diff --git a/.github/workflows/arti-provenance.yml b/.github/workflows/arti-provenance.yml deleted file mode 100644 index 5d50564e..00000000 --- a/.github/workflows/arti-provenance.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Arti Binary Provenance - -# The Arti xcframework is a vendored binary; these checks turn the policy in -# docs/ARTI-BINARY-PROVENANCE.md into an enforced gate: -# 1. The checked-in binary must match the hash manifest in the provenance doc. -# 2. A PR that changes the binary must also change at least one provenance -# input (Rust source, lockfile, build script, or the doc itself). - -on: - push: - branches: - - main - paths: - - "localPackages/Arti/**" - - "docs/ARTI-BINARY-PROVENANCE.md" - pull_request: - paths: - - "localPackages/Arti/**" - - "docs/ARTI-BINARY-PROVENANCE.md" - -jobs: - verify-hashes: - name: Verify xcframework hashes against provenance doc - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Compare artifact hashes with manifest - run: | - set -euo pipefail - doc="docs/ARTI-BINARY-PROVENANCE.md" - - # Extract the manifest: lines of " " from the doc. - grep -E '^[0-9a-f]{64} localPackages/Arti/Frameworks/arti\.xcframework/' "$doc" \ - | sort -k2 > expected.txt - - if [ ! -s expected.txt ]; then - echo "::error::No hash manifest found in $doc" - exit 1 - fi - - # Hash the same file set the doc documents. - find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 \ - | sort -z | xargs -0 sha256sum | sed 's/ \.\// /' | sort -k2 > actual.txt - - if ! diff -u expected.txt actual.txt; then - echo "::error::Checked-in arti.xcframework does not match the manifest in $doc. If the binary change is intentional, rebuild per the doc and update the manifest in the same PR." - exit 1 - fi - echo "All $(wc -l < actual.txt) artifact hashes match the provenance manifest." - - require-provenance-evidence: - name: Binary changes must ship with provenance inputs - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Check changed files - run: | - set -euo pipefail - base="origin/${{ github.base_ref }}" - git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" - changed=$(git diff --name-only "$base"...HEAD) - echo "Changed files:" - echo "$changed" - - if ! echo "$changed" | grep -q '^localPackages/Arti/Frameworks/arti\.xcframework/'; then - echo "No binary artifact changes; nothing to verify." - exit 0 - fi - - if echo "$changed" | grep -Eq '^(localPackages/Arti/(Cargo\.(toml|lock)|build-ios\.sh|arti-bitchat/)|docs/ARTI-BINARY-PROVENANCE\.md)'; then - echo "Binary change is accompanied by provenance inputs." - exit 0 - fi - - echo "::error::arti.xcframework changed without matching source, lockfile, build-script, or provenance-doc changes. See docs/ARTI-BINARY-PROVENANCE.md (\"Do not accept an xcframework-only update\")." - exit 1 diff --git a/.github/workflows/fetch_georelays.yml b/.github/workflows/fetch_georelays.yml index ad5f0bb4..6aaf6693 100644 --- a/.github/workflows/fetch_georelays.yml +++ b/.github/workflows/fetch_georelays.yml @@ -1,228 +1,42 @@ -name: Propose GeoRelay Data Update +name: Fetch GeoRelays Data on: schedule: - - cron: "0 6 * * 0" + - cron: '0 6 * * 0' workflow_dispatch: -# Default to read-only. The publishing job receives only the scopes required -# to push its branch and publish either a PR or a tracking issue. permissions: - contents: read - -concurrency: - group: georelay-data-update - cancel-in-progress: false - -env: - SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git - UPDATE_BRANCH: automation/georelay-data - TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request + contents: write + pull-requests: write jobs: - propose-relay-data: - name: Validate and propose relay data + update-relay-data: runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - issues: write steps: - - name: Checkout reviewed base - # Pinned actions/checkout v5 so a mutable action tag cannot change the - # code that receives this job's write-capable token. - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - name: Checkout repository + uses: actions/checkout@v4 with: - ref: main + token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - # Do not expose the write token to fetch/validation subprocesses. - persist-credentials: false - - name: Test GeoRelay validator + - name: Fetch GeoRelays run: | - set -euo pipefail - python3 -m unittest discover -s scripts/tests -p "test_*.py" -v + wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv + mv nostr_relays.csv ./relays/online_relays_gps.csv - - name: Fetch candidate over pinned HTTPS policy - id: upstream + - name: Check for changes + id: git-check run: | - set -euo pipefail - source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }') - if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then - echo "::error::Could not resolve an immutable upstream commit" - exit 1 - fi - source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv" - effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url") - if [[ "$effective_url" != "$source_url" ]]; then - echo "::error::Unexpected GeoRelay redirect target: $effective_url" - exit 1 - fi - echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" - echo "source_url=$source_url" >> "$GITHUB_OUTPUT" - - - name: Validate candidate against reviewed baseline - id: validation + git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT + + - name: Commit and push changes + if: steps.git-check.outputs.changes == 'true' run: | - set -euo pipefail - python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT" - - - name: Check for a reviewed-file change - id: changes - run: | - set -euo pipefail - if git diff --quiet -- relays/online_relays_gps.csv; then - echo "changed=false" >> "$GITHUB_OUTPUT" - echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - git diff --stat -- relays/online_relays_gps.csv - fi - - - name: Push automation branch and publish review request - if: steps.changes.outputs.changed == 'true' + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add relays/online_relays_gps.csv + git commit -m "Automated update of relay data - $(date -u)" + git push env: - GH_TOKEN: ${{ github.token }} - SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }} - SOURCE_URL: ${{ steps.upstream.outputs.source_url }} - DATA_ROWS: ${{ steps.validation.outputs.data_rows }} - UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }} - DATA_SHA256: ${{ steps.validation.outputs.sha256 }} - run: | - set -euo pipefail - - # Scope credential exposure to this final publishing step. - gh auth setup-git - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git switch -C "$UPDATE_BRANCH" - git add -- relays/online_relays_gps.csv - git diff --cached --quiet && { - echo "::error::Expected a staged GeoRelay data change" - exit 1 - } - git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT" - - remote_ref="refs/remotes/origin/$UPDATE_BRANCH" - if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then - remote_sha=$(git rev-parse "$remote_ref") - git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH" - else - git push origin "HEAD:refs/heads/$UPDATE_BRANCH" - fi - - body_file="$RUNNER_TEMP/georelay-pr-body.md" - { - echo "## Automated GeoRelay data proposal" - echo - echo "- Source: $SOURCE_URL" - echo "- Upstream commit: $SOURCE_COMMIT" - echo "- Data rows: $DATA_ROWS" - echo "- Unique normalized relays: $UNIQUE_RELAYS" - echo "- SHA-256: $DATA_SHA256" - echo - echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation." - echo - echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging." - } > "$body_file" - - existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty') - pr_error="$RUNNER_TEMP/georelay-pr-error.txt" - pr_url="" - if [[ -n "$existing_pr" ]]; then - if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then - pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url) - fi - else - if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then - pr_url="$created_pr_url" - fi - fi - - tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number") - tracking_issues=() - if [[ -n "$tracking_issue_numbers" ]]; then - mapfile -t tracking_issues <<< "$tracking_issue_numbers" - fi - - if [[ -n "$pr_url" ]]; then - for issue_number in "${tracking_issues[@]}"; do - gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue." - done - echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback." - if [[ -s "$pr_error" ]]; then - cat "$pr_error" >&2 - fi - - compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1" - issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md" - { - echo "## Validated GeoRelay update awaiting review" - echo - echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually." - echo - echo "- Compare and create PR: $compare_url" - echo "- Automation branch: $UPDATE_BRANCH" - echo "- Source: $SOURCE_URL" - echo "- Upstream commit: $SOURCE_COMMIT" - echo "- Data rows: $DATA_ROWS" - echo "- Unique normalized relays: $UNIQUE_RELAYS" - echo "- SHA-256: $DATA_SHA256" - echo - echo "The snapshot passed the repository's strict validator before the branch was pushed." - } > "$issue_body_file" - - if (( ${#tracking_issues[@]} > 0 )); then - primary_issue="${tracking_issues[0]}" - gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file" - issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url) - for duplicate_issue in "${tracking_issues[@]:1}"; do - gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical." - done - else - issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file") - fi - - # Do not claim success until the fallback issue was confirmed. - [[ -n "$issue_url" ]] - echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY" - - - name: Clean obsolete automation review state - if: steps.changes.outputs.changed == 'false' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh auth setup-git - - existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty') - if [[ -n "$existing_pr" ]]; then - gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal." - echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY" - fi - - tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number") - if [[ -n "$tracking_issue_numbers" ]]; then - while IFS= read -r issue_number; do - gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker." - echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY" - done <<< "$tracking_issue_numbers" - fi - - if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then - git push origin --delete "$UPDATE_BRANCH" - echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY" - else - ls_remote_status=$? - if (( ls_remote_status != 2 )); then - echo "::error::Could not inspect the obsolete automation branch" - exit "$ls_remote_status" - fi - fi + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/periphery.yml b/.github/workflows/periphery.yml deleted file mode 100644 index b5367338..00000000 --- a/.github/workflows/periphery.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Dead Code - -on: - push: - branches: - - main - pull_request: - -jobs: - periphery: - name: Periphery scan - runs-on: macos-latest - timeout-minutes: 30 - # Blocking, like SwiftLint since #1646. The baseline (committed July 8) - # has been stable across a month of merges — the "drop continue-on-error - # once the baseline proves stable" condition this job shipped with is met. - # Known macOS-scan false positives stay suppressed by the baseline; only - # NEW dead code fails the job (--strict below). - - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Install Periphery - # homebrew-core formula; the peripheryapp tap lags years behind. - run: brew install periphery - - - name: Scan for dead code - # Config comes from .periphery.yml; known findings (mostly iOS-only - # code invisible to a macOS scan) are suppressed by the committed - # baseline. --strict fails the step when NEW dead code appears. - run: periphery scan --strict --disable-update-check diff --git a/.github/workflows/source-manifest.yml b/.github/workflows/source-manifest.yml deleted file mode 100644 index e05ae34f..00000000 --- a/.github/workflows/source-manifest.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Source manifest - -# Publishes a hash manifest for every tagged release so a copy of the source -# obtained from somewhere other than this repository can be checked against it. -# -# This exists because the repository has been the target of takedown demands. -# When that succeeds, mirrors appear, and without a manifest there is no way to -# tell a faithful mirror from a modified one. The manifest is attested to this -# workflow run, so its own provenance is verifiable with `gh attestation verify`. -# -# Scope, stated plainly: this verifies SOURCE. It does not verify any compiled -# app. See docs/VERIFYING-A-BUILD.md. - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - ref: - description: 'Tag or commit to produce a manifest for' - required: true - -permissions: - contents: read - -jobs: - manifest: - runs-on: ubuntu-latest - permissions: - contents: write # attach the manifest to the release - id-token: write # provenance attestation - attestations: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.ref || github.ref }} - # Full history so the commit the tag names can be recorded exactly. - fetch-depth: 0 - - - name: Build manifest - id: build - run: | - set -euo pipefail - ref_name="${{ github.event.inputs.ref || github.ref_name }}" - commit="$(git rev-parse HEAD)" - tree="$(git rev-parse HEAD^{tree})" - - # Hash every tracked file, in a stable order, with NUL separation so - # paths containing spaces or newlines cannot shift the columns. - git ls-files -z \ - | sort -z \ - | xargs -0 sha256sum \ - > files.sha256 - - { - echo "# bitchat source manifest" - echo "#" - echo "# ref: ${ref_name}" - echo "# commit: ${commit}" - echo "# tree: ${tree}" - echo "# files: $(wc -l < files.sha256 | tr -d ' ')" - echo "#" - echo "# Verify a checkout of this ref with:" - echo "# shasum -a 256 -c files.sha256" - echo "# Hash checking alone ignores files this manifest does not list, and" - echo "# the Xcode project compiles any source file present in the tree. So" - echo "# also confirm nothing extra is present:" - echo "# git status --porcelain --ignored # git checkout: must print nothing" - echo "# or, for a tarball, diff this manifest's path list against find(1)." - echo "# Full instructions: docs/VERIFYING-A-BUILD.md" - echo "# The git tree hash above is the single value covering all tracked content:" - echo "# git rev-parse HEAD^{tree}" - echo "#" - } > SOURCE-MANIFEST.txt - cat files.sha256 >> SOURCE-MANIFEST.txt - - echo "commit=${commit}" >> "$GITHUB_OUTPUT" - echo "tree=${tree}" >> "$GITHUB_OUTPUT" - - - name: Self-check the manifest - run: | - set -euo pipefail - # A manifest that does not validate against the tree it was made from - # is worse than none, so fail loudly rather than publishing it. - grep -v '^#' SOURCE-MANIFEST.txt > check.sha256 - sha256sum -c check.sha256 > /dev/null - echo "manifest validates against this checkout" - - - name: Attest the manifest - uses: actions/attest-build-provenance@v1 - with: - subject-path: SOURCE-MANIFEST.txt - - - uses: actions/upload-artifact@v4 - with: - name: source-manifest - path: SOURCE-MANIFEST.txt - - - name: Attach to release - if: startsWith(github.ref, 'refs/tags/') - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - # A tag may be pushed before its release exists; only attach when - # there is a release to attach to, and never fail the run over it. - if gh release view "${{ github.ref_name }}" >/dev/null 2>&1; then - gh release upload "${{ github.ref_name }}" SOURCE-MANIFEST.txt --clobber - else - echo "no release for ${{ github.ref_name }} yet; manifest is available as a workflow artifact" - fi diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index e64683de..202b0cd2 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -10,12 +10,6 @@ jobs: test: name: Run Swift Tests (${{ matrix.name }}) runs-on: macos-latest - # A hung test must fail fast, not hold a runner for GitHub's 360-minute - # default (observed: intermittent app-suite hangs starving the queue). - # The long steps carry tighter individual bounds (5-minute test watchdog, - # 6-minute benchmark step, 10-minute floor gate that may re-run the - # benchmarks up to twice on a noisy runner); this is the backstop. - timeout-minutes: 25 strategy: fail-fast: false # Don't cancel other matrix jobs when one fails @@ -32,259 +26,17 @@ jobs: - name: Checkout code uses: actions/checkout@v5 - # Use the Xcode-bundled Swift toolchain: it always matches the SDK on - # the runner image. A standalone swift.org toolchain (setup-swift) broke - # whenever the image's Xcode moved ahead of it ("this SDK is not - # supported by the compiler"). - - name: Note toolchain version (cache key) - id: swift-version - run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT" + - name: Set up Swift + uses: swift-actions/setup-swift@v3 - name: Cache build artifacts - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ matrix.path }}/.build - key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }} + key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }} restore-keys: | - ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }} - ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}- - - - name: Build tests - # Built separately so the hang watchdog below times only test - # execution: a cold-cache coverage build on a slow runner can - # legitimately take several minutes, and is already bounded by the - # 15-minute job timeout. - run: swift build --build-tests --enable-code-coverage --package-path ${{ matrix.path }} + ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }} + ${{ runner.os }}-${{ matrix.name }}- - name: Run Tests - # Perf benchmarks are excluded here and run in their own serial step - # below: measuring while parallel test processes contend for cores - # produces noisy numbers, and the XCTest measure machinery has hung - # intermittently under parallel workers on loaded runners. Excluded - # via --skip (not just the env guard): every app run since the - # baselines landed timed out at the 15-minute job limit with the - # baseline tests dispatched into the parallel phase. - # - # The watchdog samples any still-running test processes after 5 - # minutes (the suite passes in seconds when healthy; the build is - # done by this step) and kills the run, so a hang fails fast with - # stacks in the log instead of a silent timeout. - env: - BITCHAT_SKIP_PERF_BASELINES: "1" - run: | - swift test --skip-build --parallel --quiet --enable-code-coverage \ - --skip PerformanceBaselineTests \ - --package-path ${{ matrix.path }} & - test_pid=$! - ( - sleep 300 - if kill -0 "$test_pid" 2>/dev/null; then - echo "::group::Tests still running after 5 minutes — sampling before kill" - for pid in $(pgrep -if 'swiftpm-testing|xctest|PackageTests' || true); do - echo "--- sample of pid $pid ---" - sample "$pid" 5 2>/dev/null || true - done - echo "::endgroup::" - pkill -KILL -P "$test_pid" 2>/dev/null || true - kill -KILL "$test_pid" 2>/dev/null || true - fi - ) & - watchdog_pid=$! - wait "$test_pid" && status=0 || status=$? - kill "$watchdog_pid" 2>/dev/null || true - exit "$status" - - # Read coverage before the serial benchmark command below rebuilds the - # test binary without instrumentation. Reporting against that newer - # binary makes llvm-cov reject the profile as out of date. - # Informational only: there is deliberately no percentage threshold, but - # a broken/missing report is a CI configuration error and must be visible. - - name: Coverage summary - run: | - BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }}) - PROF="$BIN_PATH/codecov/default.profdata" - XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1) - BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)" - if [ ! -f "$PROF" ] || [ ! -f "$BINARY" ]; then - echo "::error::Coverage profile or test binary is missing" - exit 1 - fi - xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \ - -ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' - - # Benchmarks run serially on an otherwise idle runner for stable - # numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate. - - name: Run performance benchmarks (serial) - if: matrix.name == 'app' - timeout-minutes: 6 - env: - BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log - run: swift test --quiet --filter PerformanceBaselineTests - - # Order-of-magnitude performance regression gate. Floors are deliberately - # generous (see bitchatTests/Performance/perf-floors.json) so this - # catches algorithmic regressions, never runner variance. If a metric - # still lands below floor (a saturated runner can dip one), the script - # re-runs the benchmarks — appending to the same log and keeping each - # benchmark's best value per metric — so noise clears on retry while a - # real regression fails every attempt. Floors are never lowered by this. - - name: Performance floor gate - if: matrix.name == 'app' - timeout-minutes: 10 - run: ./scripts/check-perf-floors.sh perf-output.log - - # SPM tests do not link the shipping app targets. This job covers the - # iOS-conditional paths and both universal Release link configurations. - ios-build: - name: Build Release apps (universal) - runs-on: macos-latest - timeout-minutes: 15 - - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Check clean recipe safety - run: bash scripts/check-just-clean-safety.sh - - - name: Build iOS (simulator, no signing) - # Build both simulator architectures so CI validates every vendored - # Arti simulator slice and the configuration that ships. - run: | - set -o pipefail - xcodebuild -project bitchat.xcodeproj \ - -scheme "bitchat (iOS)" \ - -configuration Release \ - -sdk iphonesimulator \ - -destination 'generic/platform=iOS Simulator' \ - ARCHS='arm64 x86_64' \ - ONLY_ACTIVE_ARCH=NO \ - CODE_SIGNING_ALLOWED=NO \ - build - - - name: Build macOS (universal, no signing) - run: | - set -o pipefail - xcodebuild -project bitchat.xcodeproj \ - -scheme "bitchat (macOS)" \ - -configuration Release \ - -destination 'generic/platform=macOS' \ - ARCHS='arm64 x86_64' \ - ONLY_ACTIVE_ARCH=NO \ - CODE_SIGNING_ALLOWED=NO \ - build - - # The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth - # conditional tests. Build the shared iOS test target and run it on the first - # available iPhone simulator from the runner image instead of hard-coding a - # model that changes when GitHub updates Xcode. The suite intentionally runs - # in one test runner: a number of integration tests exercise process-global - # stores and notification centers, so overlapping workers can corrupt each - # other's fixtures and turn sub-second tests into multi-minute timeouts. - ios-tests: - name: Run iOS simulator tests - runs-on: macos-latest - timeout-minutes: 20 - - steps: - - name: Checkout code - uses: actions/checkout@v5 - - # Some runner images list only placeholder destinations (rows carrying - # "error:" or "unavailable") or ship the selected Xcode without a - # matching iOS simulator runtime, so this walks three paths in order: - # a usable xcodebuild destination, an existing simctl device, and - # finally creating a device from the newest installed iOS runtime. - - name: Select available iPhone simulator - id: destination - run: | - set -uo pipefail - destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations 2>/dev/null || true) - destination_id=$(awk -F'id:' ' - /platform:iOS Simulator/ && /name:iPhone/ \ - && !/error/ && !/unavailable/ && !found { - value=$2 - sub(/,.*/, "", value) - gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) - print value - found=1 - } - ' <<< "$destinations") - - if [ -n "$destination_id" ]; then - echo "Selected destination via xcodebuild -showdestinations: $destination_id" - else - echo "No usable iPhone destination in -showdestinations output; falling back to simctl" - destination_id=$(xcrun simctl list devices available --json | jq -r ' - [.devices | to_entries[] - | select(.key | contains("iOS")) - | .value[] - | select(.isAvailable and (.name | startswith("iPhone")))] - | first.udid // empty') - if [ -n "$destination_id" ]; then - echo "Selected existing simctl device: $destination_id" - else - echo "No available iPhone simulator device; creating one" - # Newest installed iOS runtime plus an iPhone device type that - # runtime itself reports as supported, so the pair always match. - create_spec=$(xcrun simctl list runtimes --json | jq -r ' - [.runtimes[] | select(.platform == "iOS" and .isAvailable)] - | sort_by(.version | split(".") | map(tonumber)) - | last // empty - | .identifier as $runtime - | ([(.supportedDeviceTypes // [])[] - | select(.productFamily == "iPhone" - or (.name // "" | startswith("iPhone")))] - | first.identifier // empty) as $devicetype - | "\($devicetype) \($runtime)"') - read -r devicetype runtime <<< "$create_spec" || true - if [ -z "${devicetype:-}" ] || [ -z "${runtime:-}" ]; then - echo "::error::No iPhone simulator destination found and none creatable (no installed iOS runtime with an iPhone device type)" - exit 1 - fi - destination_id=$(xcrun simctl create ci-iphone "$devicetype" "$runtime") || { - echo "::error::simctl create failed for $devicetype on $runtime" - exit 1 - } - echo "Created simulator $destination_id ($devicetype, $runtime)" - fi - fi - - echo "Using iPhone simulator destination id: $destination_id" - echo "id=$destination_id" >> "$GITHUB_OUTPUT" - - - name: Run iOS tests - run: | - set -o pipefail - xcodebuild -project bitchat.xcodeproj \ - -scheme "bitchat (iOS)" \ - -sdk iphonesimulator \ - -destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \ - -parallel-testing-enabled NO \ - CODE_SIGNING_ALLOWED=NO \ - test - - # Blocking since the backlog reached zero (all violations fixed; rules the - # codebase deliberately breaks are disabled in .swiftlint.yml). --strict - # promotes warnings to errors so new violations fail the job. Runs in a - # pinned container (no Xcode plugin, no pbxproj changes) so it can never - # break the documented xcodebuild path. - lint: - name: SwiftLint - runs-on: ubuntu-latest - timeout-minutes: 15 - # This job runs a third-party container image, so give it the least - # privilege we can: a read-only token, and no credentials left in the - # checkout for the container to find. - permissions: - contents: read - container: - # Tag for readability, digest for immutability (tags can be repointed). - # Bump both together, deliberately — never a floating tag. - image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - name: Run SwiftLint - run: swiftlint lint --strict --reporter github-actions-logging + run: swift test --parallel --quiet --package-path ${{ matrix.path }} diff --git a/.gitignore b/.gitignore index ac9e4d89..df91a858 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,3 @@ build.log # Local configs Local.xcconfig -*.profraw diff --git a/.periphery.baseline.json b/.periphery.baseline.json deleted file mode 100644 index 9400fdf1..00000000 --- a/.periphery.baseline.json +++ /dev/null @@ -1 +0,0 @@ -{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC18logBluetoothStatusyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file diff --git a/.periphery.yml b/.periphery.yml deleted file mode 100644 index fab8b45f..00000000 --- a/.periphery.yml +++ /dev/null @@ -1,21 +0,0 @@ -# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery) -# -# CI runs the macOS scheme only (an iOS scan needs a device destination and -# doubles the build time). macOS-only scans falsely flag iOS-only code — -# state restoration, screenshot handlers, background BLE sampling — so those -# findings live in .periphery.baseline.json rather than being "fixed". -# When auditing by hand, scan BOTH schemes and intersect: -# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64 -project: bitchat.xcodeproj -schemes: - - bitchat (macOS) -retain_swift_ui_previews: true -# Codable properties are (de)serialized via synthesized conformances the -# indexer doesn't always attribute reads to: PrekeyBundleStore.StoredBundle -# .noiseKey flaked CI as "assign-only" even while read in loadFromDisk — -# and slipped past its baselined USR. Retaining Codable properties outright -# is deterministic; a truly-dead Codable field is a persisted-format change -# anyway, never a safe mechanical delete. -retain_codable_properties: true -relative_results: true -baseline: .periphery.baseline.json diff --git a/.swiftlint.yml b/.swiftlint.yml deleted file mode 100644 index 8d916f35..00000000 --- a/.swiftlint.yml +++ /dev/null @@ -1,36 +0,0 @@ -# Build artifacts and generated sources; keeps local `swiftlint` runs clean -# (CI checkouts are fresh, so this only matters in a working tree). -excluded: - - .build - - .claude - - .device-lab - - .swiftpm - - .DerivedData - - DerivedData - - build - - localPackages/*/.build - -disabled_rules: - - line_length - - type_name - - identifier_name - - statement_position - - implicit_optional_initialization - - force_try - - vertical_whitespace - - for_where - - control_statement - - void_function_in_ternary - - redundant_discardable_let # SwiftUI breaks without it - - todo # TODOs that cite a tracked issue are deliberate markers, not lint debt - # To be enabled as we fix the issues - - trailing_whitespace - - cyclomatic_complexity - - function_body_length - - function_parameter_count - - type_body_length - - file_length - - large_tuple - - force_cast - - multiple_closures_with_trailing_closure - - nesting diff --git a/Configs/Local.xcconfig.example b/Configs/Local.xcconfig.example index a934cb56..899c5a62 100644 --- a/Configs/Local.xcconfig.example +++ b/Configs/Local.xcconfig.example @@ -3,6 +3,3 @@ DEVELOPMENT_TEAM = ABC123 // Unique bundle id to be able to register and run locally PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM) - -// App and share extension must use an App Group registered to your team. -APP_GROUP_ID = group.chat.bitchat.$(DEVELOPMENT_TEAM) diff --git a/Configs/Release.xcconfig b/Configs/Release.xcconfig index f018b882..988ce02d 100644 --- a/Configs/Release.xcconfig +++ b/Configs/Release.xcconfig @@ -1,4 +1,4 @@ -MARKETING_VERSION = 1.7.1 +MARKETING_VERSION = 1.5.1 CURRENT_PROJECT_VERSION = 1 IPHONEOS_DEPLOYMENT_TARGET = 16.0 diff --git a/Justfile b/Justfile index a3d6f6b6..88cb3895 100644 --- a/Justfile +++ b/Justfile @@ -1,66 +1,107 @@ -# BitChat developer commands -# -# Builds use a repository-local, ignored DerivedData directory. No recipe -# patches, restores, or removes tracked project/configuration files. - -project := "bitchat.xcodeproj" -macos_scheme := "bitchat (macOS)" -ios_scheme := "bitchat (iOS)" -derived_data := ".DerivedData" +# BitChat macOS Build Justfile +# Handles temporary modifications needed to build and run on macOS +# Default recipe - shows available commands default: - @echo "BitChat developer commands:" - @echo " just run Build and run the macOS app" - @echo " just build Build the macOS app without signing" - @echo " just test Run the SwiftPM test suite" - @echo " just test-ios Run tests on the iPhone 17 simulator" - @echo " just clean Remove repo-local build artifacts only" - @echo " just nuke Also remove nested package build caches" - @echo " just check Validate the development environment" + @echo "BitChat macOS Build Commands:" + @echo " just run - Build and run the macOS app" + @echo " just build - Build the macOS app only" + @echo " just clean - Clean build artifacts and restore original files" + @echo " just check - Check prerequisites" + @echo "" + @echo "Original files are preserved - modifications are temporary for builds only" -# Static guard against reintroducing source-restoring or source-deleting clean -# behavior. CI runs the same script directly. -check-clean-safety: - @bash scripts/check-just-clean-safety.sh - -check: check-clean-safety +# Check prerequisites +check: @echo "Checking prerequisites..." - @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1) - @developer_dir="$(xcode-select -p 2>/dev/null)"; case "$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac - @xcodebuild -version - @echo "✅ Development environment ready (a signing identity is not required for just build)" + @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1) + @xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1) + @test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1) + @xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1) + @security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0) + @echo "✅ All prerequisites met" -build: check +# Backup original files +backup: + @echo "Backing up original project configuration..." + @if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi + @if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi + +# Restore original files +restore: + @echo "Restoring original project configuration..." + @if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi + @# Restore iOS-specific files + @if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi + @# Use git to restore all modified files except Justfile + @git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Could not restore some files with git" + @# Remove any backup files + @rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true + +# Apply macOS-specific modifications +patch-for-macos: backup + @echo "Temporarily hiding iOS-specific files for macOS build..." + @# Move iOS-specific files out of the way temporarily + @if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi + +# Build the macOS app +build: #check generate @echo "Building BitChat for macOS..." - @xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build + @xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build +# Run the macOS app run: build - @app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$app" || (echo "❌ Built app not found at $app" && exit 1); open "$app" + @echo "Launching BitChat..." + @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}" -# Backward-compatible alias for the old quick-run recipe. -dev-run: run +# Clean build artifacts and restore original files +clean: restore + @echo "Cleaning build artifacts..." + @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true + @# Only remove the generated project if we have a backup, otherwise use git + @if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \ + rm -rf bitchat.xcodeproj; \ + else \ + git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \ + fi + @rm -f project-macos.yml 2>/dev/null || true + @echo "✅ Cleaned and restored original files" -test: - @swift test - -test-ios: check - @xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test - -# Artifact-only cleanup. In particular, this recipe never invokes Git and -# never writes, moves, restores, or removes source/configuration files. -clean: - @echo "Cleaning repo-local build artifacts..." - @rm -rf -- "{{derived_data}}" ".build" - @echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched" - -# Retain the familiar command, but keep it artifact-only as well. -nuke: clean - @echo "Cleaning nested package build caches..." - @find localPackages -type d -name .build -prune -exec rm -rf -- {} + - @rm -rf -- ".cache" - @echo "✅ Removed repository build caches; tracked files were untouched" +# Quick run without cleaning (for development) +dev-run: check + @echo "Quick development build..." + @xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build + @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}" +# Show app info info: - @echo "BitChat - decentralized mesh messaging" - @echo "macOS 13+ and iOS 16+" - @echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices" + @echo "BitChat - Decentralized Mesh Messaging" + @echo "======================================" + @echo "• Native macOS SwiftUI app" + @echo "• Bluetooth LE mesh networking" + @echo "• End-to-end encryption" + @echo "• No internet required" + @echo "• Works offline with nearby devices" + @echo "" + @echo "Requirements:" + @echo "• macOS 13.0+ (Ventura)" + @echo "• Bluetooth LE capable Mac" + @echo "• Physical device (no simulator support)" + @echo "" + @echo "Usage:" + @echo "• Set nickname and start chatting" + @echo "• Use /join #channel for group chats" + @echo "• Use /msg @user for private messages" + @echo "• Triple-tap logo for emergency wipe" + +# Force clean everything (nuclear option) +nuke: + @echo "🧨 Nuclear clean - removing all build artifacts and backups..." + @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true + @rm -rf bitchat.xcodeproj 2>/dev/null || true + @rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true + @rm -f bitchat/Info.plist.backup 2>/dev/null || true + @# Restore iOS-specific files if they were moved + @if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi + @git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore" + @echo "✅ Nuclear clean complete" diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 74285c0b..e27866bb 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,160 +1,139 @@ # bitchat Privacy Policy -*Last updated: July 2026* +*Last updated: January 2025* ## Our Commitment -bitchat is designed for private, account-free communication. This policy describes what the app keeps on your device, what it sends when you use mesh or optional internet features, and how long local data can remain. +bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy. ## Summary -- **No project-operated accounts or messaging servers** — Bluetooth mesh is peer-to-peer; optional internet features use public or user-selected Nostr relays. -- **No analytics, advertising, telemetry, or tracking** — the app does not contain an analytics or advertising SDK. -- **No sale of data** — the project does not sell user data or build advertising profiles. -- **Open source** — the storage, networking, and cryptography described here can be inspected in the source code. +- **No personal data collection** - We don't collect names, emails, or phone numbers +- **No servers** - Everything happens on your device and through peer-to-peer connections +- **No tracking** - We have no analytics, telemetry, or user tracking +- **Open source** - You can verify these claims by reading our code -## What bitchat Stores on Your Device +## What Information bitchat Stores -1. **Identity and cryptographic keys** - - Noise, signing, group, prekey, and optional Nostr identity material is generated locally. - - Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events. - - Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall. +### On Your Device Only -2. **Nickname, preferences, and relationships** - - Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally. - - The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires. +1. **Identity Key** + - A cryptographic key generated on first launch + - Stored locally in your device's secure storage + - Allows you to maintain "favorite" relationships across app restarts + - Never leaves your device -3. **Private group state** - - Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support. - - Current group keys are stored in the keychain. Group state remains until you leave or remove the group, panic-wipe the app, or remove the app. +2. **Nickname** + - The display name you choose (or auto-generated) + - Stored only on your device + - Shared with peers you communicate with -4. **Queued and carried private messages** - - An outgoing private message that has not been acknowledged may remain for up to 24 hours in a bounded, encrypted outbox. The outbox is sealed with ChaCha20-Poly1305 and its key is stored in the keychain. - - A device acting as a courier may store a bounded opaque end-to-end encrypted envelope for another user for up to 24 hours. The courier cannot read its message content. - - A panic wipe deletes both stores. +3. **Message History** (if enabled) + - When room owners enable retention, messages are saved locally + - Stored encrypted on your device + - You can delete this at any time -5. **Recent public mesh messages and notices** - - Signed public mesh messages may be kept in a protected local gossip archive for up to 6 hours so they can cross mesh partitions and survive a relaunch. - - Public bulletin-board posts and deletion tombstones persist until the post's author-selected expiry, at most seven days. Both stores are bounded and panic-wipeable. - - These items are public to the mesh or board where they are posted; they are not confidential messages. +4. **Favorite Peers** + - Public keys of peers you mark as favorites + - Stored only on your device + - Allows you to recognize these peers in future sessions -6. **Media attachments** - - Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app. - - Incoming media is subject to a 100 MB quota with oldest-file eviction. All stored media, sent and received, is also deleted once it is more than seven days old, and immediately by panic wipe or app removal. +### Temporary Session Data -7. **Optional location-channel state** - - Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them. - - Per-geohash Nostr identities are derived locally from a device seed stored in the keychain. - - bitchat does not persist exact latitude or longitude and does not include exact coordinates in mesh or Nostr messages. +During each session, bitchat temporarily maintains: +- Active peer connections (forgotten when app closes) +- Routing information for message delivery +- Cached messages for offline peers (12 hours max) -## Temporary Session Data +## What Information is Shared -While running, bitchat maintains active connections, routing state, deduplication state, and bounded in-memory conversation timelines. Closing the app clears the in-memory timelines and active connections, but it does not erase the persistent stores listed above. +### With Other bitchat Users -## What Is Shared +When you use bitchat, nearby peers can see: +- Your chosen nickname +- Your ephemeral public key (changes each session) +- Messages you send to public rooms or directly to them +- Your approximate Bluetooth signal strength (for connection quality) -### With Nearby Mesh Users +### With Room Members -Depending on the feature you use, nearby peers can receive: +When you join a password-protected room: +- Your messages are visible to others with the password +- Your nickname appears in the member list +- Room owners can see you've joined -- Your chosen nickname and public Noise/signing identity material. -- Announce metadata such as supported capability flags and a bounded list of short direct-neighbor identifiers. When the bridge is enabled, an announce can also include its coarse rendezvous geohash cell. -- Public mesh messages, public notices, and group-control packets you intentionally send. -- Private ciphertext addressed to them, or opaque courier ciphertext they agree to carry. -- Radio metadata available to the receiver, such as approximate Bluetooth signal strength. +## What We DON'T Do -Noise identity keys can persist across sessions; do not treat them as anonymous identifiers. Panic wipe rotates local identity state. +bitchat **never**: +- Collects personal information +- Tracks your location +- Stores data on servers +- Shares data with third parties +- Uses analytics or telemetry +- Creates user profiles +- Requires registration -### With Private Group Members +## Encryption -Private group members receive the group's name, roster, key epoch, and encrypted group traffic needed to participate. Group messages are confidential to devices holding the current group key, subject to the security of those devices and members. +All private messages use end-to-end encryption: +- **X25519** for key exchange +- **AES-256-GCM** for message encryption +- **Ed25519** for digital signatures +- **Argon2id** for password-protected rooms -### With Nostr Relays and Internet Gateways +## Your Rights -Internet-backed features are optional. When enabled or used: +You have complete control: +- **Delete Everything**: Triple-tap the logo to instantly wipe all data +- **Leave Anytime**: Close the app and your presence disappears +- **No Account**: Nothing to delete from servers because there are none +- **Portability**: Your data never leaves your device unless you export it -- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity. -- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area. -- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge. -- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata. -- A device with gateway features enabled may relay signed bridge/location traffic or opaque courier envelopes for nearby mesh devices. +## Bluetooth & Permissions -Nostr relays are operated by third parties. Their retention, logging, availability, and privacy practices are outside the project's control. Public events and encrypted events may remain on relays according to each relay's policy. - -You can add relays yourself in settings, including `.onion` addresses. Added relays are stored locally, are limited in number, and are erased by panic wipe. Tor routing is on by default; while it is off, every relay you connect to can see your IP address, including relays carrying your private messages. - -## Location and Apple Services - -Location permission is optional and requested as when-in-use access. It is used to compute geohash channels, bridge rendezvous cells, and nearby place labels. - -- Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat. -- A selected geohash can still reveal an approximate area to peers and relays. -- When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms. -- Revoking location permission stops live location sampling. Saved bookmarks remain until you remove them, panic-wipe the app, or remove the app. - -## Microphone, Camera, and Media Permissions - -- Microphone access is used only while you record a voice note or actively hold live push-to-talk. The resulting audio is sent to the mesh conversation you selected; public-conversation audio is public to that mesh, while private-conversation audio uses the private transport protections described below. -- Voice-note and live-audio files can remain in Application Support under the media retention rules above. -- Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send. -- These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive. - -## Cryptography - -Private and public features use different protections: - -- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256. -- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures. -- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. -- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM. -- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential. - -No cryptographic system can protect content after a recipient reads, copies, screenshots, or exports it. - -## Data Retention Summary - -- **In-memory chat timelines and active connections:** until the app closes or state is cleared. -- **Queued outgoing private messages:** until acknowledged, dropped by bounded policy, or 24 hours, whichever comes first. -- **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first. -- **Recent public mesh gossip:** up to 6 hours. -- **Public board posts and tombstones:** until expiry, at most seven days. -- **Media:** seven days, or sooner by quota eviction, panic wipe, or app removal. -- **Groups, favorites, preferences, identity keys, and bookmarks:** until removed by the feature, panic wipe, or app removal. -- **Nostr data:** according to the policies of the relays that receive it. - -## Your Controls - -- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app. -- **Notification previews:** Hidden by default, so lock-screen alerts do not show message text, sender names, or geohashes. Full previews can be turned on in settings. -- **Clearing a conversation:** Clearing the mesh timeline also deletes the recent public gossip this device had stored on disk. -- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled. -- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings. -- **No account:** The project operates no account record for you to request or export. - -## What the Project Does Not Do - -bitchat does not: - -- Operate an account database or project-owned messaging backend. -- Include advertising, analytics, or tracking SDKs. -- Sell user data or create advertising profiles. -- Include exact GPS coordinates in bitchat mesh or Nostr message payloads. +bitchat requires Bluetooth permission to function: +- Used only for peer-to-peer communication +- No location data is accessed or stored +- Bluetooth is not used for tracking +- You can revoke this permission at any time in system settings ## Children's Privacy -The project does not knowingly operate a service that collects children's personal data. The app has no account registration or age-verification system. Users and guardians should understand that public mesh, board, bridge, and location-channel posts are visible to other participants and may be relayed. +bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. + +## Data Retention + +- **Messages**: Deleted from memory when app closes (unless room retention is enabled) +- **Identity Key**: Persists until you delete the app +- **Favorites**: Persist until you remove them or delete the app +- **Everything Else**: Exists only during active sessions + +## Security Measures + +- All communication is encrypted +- No data transmitted to servers (there are none) +- Open source code for public audit +- Regular security updates +- Cryptographic signatures prevent tampering ## Changes to This Policy -Material behavior changes will be reflected in this document and its “Last updated” date. Updating this policy cannot retroactively retrieve data that remained only on a user's device. +If we update this policy: +- The "Last updated" date will change +- The updated policy will be included in the app +- No retroactive changes can affect data (since we don't collect any) ## Contact bitchat is an open source project. For privacy questions: +- View our source code: [https://github.com/permissionlesstech/bitchat/tree/main](https://github.com/permissionlesstech/bitchat/tree/main) +- Open an issue on GitHub +- Join the discussion in public rooms -- View the source: [https://github.com/permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat) -- Open an issue on GitHub. +## Philosophy + +Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely. --- -*This policy is released into the public domain under The Unlicense, like the project itself.* +*This policy is released into the public domain under The Unlicense, just like bitchat itself.* diff --git a/Package.swift b/Package.swift index 2bb83521..d7bc2cb6 100644 --- a/Package.swift +++ b/Package.swift @@ -13,9 +13,9 @@ let package = Package( .executable( name: "bitchat", targets: ["bitchat"] - ) + ), ], - dependencies: [ + dependencies:[ .package(path: "localPackages/Arti"), .package(path: "localPackages/BitFoundation"), .package(path: "localPackages/BitLogger"), @@ -53,21 +53,11 @@ let package = Package( path: "bitchatTests", exclude: [ "Info.plist", - "README.md", - // CI perf gate data (read by scripts/check-perf-floors.sh), - // not a test resource. - "Performance/perf-floors.json" + "README.md" ], resources: [ .process("Localization"), - // Only the vector fixture: declaring the whole "Noise" - // directory would claim its .swift test files as resources - // and silently drop them from compilation. - .process("Noise/NoiseTestVectors.json"), - // Frozen envelopes produced by the released iOS (733098bb) - // and Android (b7f0b33d) private-DM implementations; prove - // receive compatibility independently of the local generator. - .process("Nostr/Fixtures") + .process("Noise") ] ) ] diff --git a/README.md b/README.md index 36f50be2..eef7bb40 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,6 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) -📲 [Play Store](https://play.google.com/store/apps/details?id=com.bitchat.droid) - -### Getting a copy you can trust - -Install from the App Store, or build from source you have verified. A compiled build from anywhere else cannot be verified — see [Verifying bitchat](docs/VERIFYING-A-BUILD.md) for how to check source against the per-release hash manifest, and for what to do if that is the only build you can get. - -This matters more than it usually would: this repository has been the target of takedown demands, and when a repository or releases page disappears, mirrors appear that nobody can check. - ## License This project is released into the public domain. See the [LICENSE](LICENSE) file for details. @@ -26,8 +18,8 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file - **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays - **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback) - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE -- **Privacy First**: No accounts, no phone numbers, no servers. Note that the mesh does use a persistent per-device identifier derived from your identity key — see [the whitepaper](WHITEPAPER.md) on identity and metadata for what a nearby radio can observe -- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback +- **Privacy First**: No accounts, no phone numbers, no persistent identifiers +- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface - **Universal App**: Native support for iOS and macOS - **Emergency Wipe**: Triple-tap to instantly clear all data @@ -42,7 +34,7 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor - **Local Communication**: Direct peer-to-peer within Bluetooth range - **Multi-hop Relay**: Messages route through nearby devices (max 7 hops) - **No Internet Required**: Works completely offline in disaster scenarios -- **Noise Protocol Encryption**: End-to-end encryption, with forward secrecy for live sessions (store-and-forward mail is sealed without it — see the whitepaper) +- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy - **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints - **Automatic Discovery**: Peer discovery and connection management - **Adaptive Power**: Battery-optimized duty cycling @@ -51,16 +43,10 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor - **Global Reach**: Connect with users worldwide via internet relays - **Location Channels**: Geographic chat rooms using geohash coordinates -- **440+ Relay Network**: Distributed across the globe for reliability -- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays +- **290+ Relay Network**: Distributed across the globe for reliability +- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy - **Ephemeral Keys**: Fresh cryptographic identity per geohash area -BitChat's private-envelope format is proprietary and is **not** NIP-17, -NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only -interoperates with BitChat clients: private payloads travel inside kind-1059 -events whose `v2:`-prefixed content is a BitChat-specific XChaCha20-Poly1305 -construction, not NIP-44 encryption. - ### Channel Types #### `mesh #bluetooth` @@ -94,7 +80,7 @@ Private messages use **intelligent transport selection**: 2. **Nostr Fallback** (when Bluetooth unavailable) - Uses recipient's Nostr public key - - BitChat's app-specific private-envelope encryption + - NIP-17 gift-wrapping for privacy - Routes through global relay network 3. **Smart Queuing** (when neither available) @@ -107,62 +93,30 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m ### Option 1: Using Xcode -```bash -open bitchat.xcodeproj -``` + ```bash + cd bitchat + open bitchat.xcodeproj + ``` -For a signed device build, create your ignored local configuration and replace -the example team ID with your Apple Developer Team ID: - -```bash -cp Configs/Local.xcconfig.example Configs/Local.xcconfig -``` - -`Local.xcconfig.example` derives unique app and App Group identifiers from that -team ID. The entitlement files already reference `$(APP_GROUP_ID)`, so tracked -project or entitlement files do not need to be edited. - -Useful command-line checks from the repository root: - -```bash -# macOS Debug build without signing -xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \ - -configuration Debug CODE_SIGNING_ALLOWED=NO build - -# Full SwiftPM test suite -swift test - -# iOS simulator tests -xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" \ - -sdk iphonesimulator \ - -destination 'platform=iOS Simulator,name=iPhone 17' test -``` - -If `iPhone 17` is unavailable, choose an installed simulator from: - -```bash -xcodebuild -showdestinations -project bitchat.xcodeproj -scheme "bitchat (iOS)" -``` + To run on a device there're a few steps to prepare the code: + - Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig` + - Add your Developer Team ID into the newly created `Configs/Local.xcconfig` + - Bundle ID would be set to `chat.bitchat.` (unless you set to something else) + - Entitlements need to be updated manually (TODO: Automate): + - Search and replace `group.chat.bitchat` with `group.` (e.g. `group.chat.bitchat.ABC123`) ### Option 2: Using `just` -```bash -brew install just -just check -just run -``` + ```bash + brew install just + ``` -`just build` and `just run` use the current `bitchat (macOS)` scheme and keep -Xcode output in the ignored `.DerivedData/` directory. They never patch source, -project, configuration, or entitlement files. - -`just clean` removes only `.DerivedData/` and `.build/`. It does not invoke Git -or restore tracked files, so uncommitted work is preserved. `just test` runs the -SwiftPM suite and `just test-ios` runs the iPhone 17 simulator suite. +Want to try this on macos: `just run` will set it up and run from source. +Run `just clean` afterwards to restore things to original state for mobile app building and development. ## Localization -- App localizations live in `bitchat/Localizable.xcstrings`. -- Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`. +- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`. +- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`. - Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible. - Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index c53c92c5..00000000 --- a/SECURITY.md +++ /dev/null @@ -1,45 +0,0 @@ -# Security policy - -bitchat is a security-focused messenger, and reports about its security are taken seriously. This page says how to report, what counts as a vulnerability here, and what to expect. - -## Reporting a vulnerability - -**Use GitHub's private vulnerability reporting:** [Report a vulnerability](https://github.com/permissionlesstech/bitchat/security/advisories/new) (Security tab → "Report a vulnerability"). - -Please do not open a public issue for anything that could put people at risk before a fix ships. bitchat is used by people in hostile network environments; a public proof-of-concept can be acted on faster than a patch can reach them. - -A useful report says what an attacker can do, against which build (App Store version or commit hash), and how to reproduce it. A failing test or a packet capture is worth more than speculation about impact. - -## What to expect - -This is a volunteer-maintained project. The aim is to acknowledge reports within a week and to move on confirmed vulnerabilities immediately — historically, confirmed protocol and key-handling issues have been fixed within days. You'll be kept in the loop in the advisory thread, and credited in the fix unless you'd rather not be. There is no bug bounty. - -## Supported versions - -Fixes ship to the latest App Store release and `main`. Older releases are not patched; the fix is to update. - -## Scope - -In scope — the properties the app promises: - -- Confidentiality and integrity of private messages and media (Noise sessions over BLE; over Nostr, bitchat's own ephemeral private-envelope format — a proprietary scheme, *not* NIP-17/NIP-44/NIP-59, see `WHITEPAPER.md`) -- Identity: key handling, verification, impersonation, session binding -- The panic wipe actually destroying what it claims to destroy -- Metadata exposure beyond what the documentation already discloses (see `PRIVACY_POLICY.md` and `docs/privacy-assessment.md`) -- Downgrade paths: anything that silently moves traffic from an encrypted path to a plaintext one -- Tor routing: anything that makes traffic bypass Tor while the Tor preference is on -- Supply-chain integrity of the source and its vendored binaries (see `docs/VERIFYING-A-BUILD.md`) - -Out of scope — documented design properties, not vulnerabilities: - -- Public visibility of mesh announces and geohash channels: broadcast content, nicknames, and public keys are public by design -- Bluetooth proximity being observable: anyone in radio range can tell a BLE device is present -- Mesh flooding/relay behavior inherent to a broadcast mesh (rate limits exist; the topology is what it is) -- Behavior of third-party Nostr relays -- Denial of service requiring physical proximity, and battery-drain attacks in general - -If you're unsure whether something is in scope, report it privately anyway — a false alarm costs a few minutes; a real issue reported publicly can cost much more. - -## Verifying what you're running - -If your concern is that the app or source you have has been tampered with, that has its own document: `docs/VERIFYING-A-BUILD.md`. diff --git a/WHITEPAPER.md b/WHITEPAPER.md index 75e346fb..6ad839d7 100644 --- a/WHITEPAPER.md +++ b/WHITEPAPER.md @@ -1,150 +1,309 @@ -# bitchat Protocol Whitepaper +# BitChat Protocol Whitepaper -**Version 2.0** +**Version 1.1** -**Date: July 6, 2026** +**Date: July 25, 2025** --- ## Abstract -bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented. +BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network. --- -## 1. Design Goals +## 1. Introduction -* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext. -* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified. -* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership. -* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window. -* **Ephemerality by default:** conversation timelines live in memory only. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe. Media is the exception: accepted images and voice notes are written to disk unsealed, protected by the platform's data-protection class rather than by app-layer encryption, and bounded by a storage quota. +In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE). -## 2. Architecture Overview +The design goals of the BitChat Protocol are: -Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`: +* **Confidentiality:** All communication must be unreadable to third parties. +* **Authentication:** Users must be able to verify the identity of their correspondents. +* **Integrity:** Messages cannot be tampered with in transit. +* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys. +* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message. +* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments. -* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts. -* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet. +This paper specifies the technical details of the protocol designed to meet these goals. -The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly. +--- -## 3. Identity +## 2. Protocol Stack -Each device holds two long-term key pairs in the Keychain: +The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility. -* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and -* an **Ed25519 signing key** for packet signatures. +```mermaid +graph TD + A[Application Layer] --> B[Session Layer]; + B --> C[Encryption Layer]; + C --> D[Transport Layer]; -On the mesh, peers appear under a short 8-byte peer ID. That ID is **not ephemeral**: it is the first 8 bytes of the SHA-256 fingerprint of the device's Noise static key, so it is stable across sessions, reboots, and reinstalls that preserve the keychain, and it changes only when the identity itself is replaced by a panic wipe. Favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person. + subgraph "BitChat Application" + A + end -Signed announcements additionally carry the nickname, the Noise static public key, and the Ed25519 signing public key in cleartext (§4.5), so a passive receiver in radio range can link a device across time and place regardless of the peer ID. Unlinkable presence is not a property this protocol currently provides; see §9. + subgraph "Message Framing & State" + B + end -## 4. BLE Mesh Layer + subgraph "Noise Protocol Framework" + C + end -### 4.1 Packet Format + subgraph "BLE, Wi-Fi Direct, etc." + D + end -A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. + style A fill:#cde4ff + style B fill:#b5d8ff + style C fill:#9ac2ff + style D fill:#7eadff +``` -Only `noiseEncrypted` and `noiseHandshake` packets are padded, toward 256/512/1024/2048-byte buckets; every other type — public messages, announcements, board posts, group messages, fragments, files, and voice frames — goes out at its natural length. Padding is PKCS#7-style with pad bytes equal to the pad length, and because that length must fit one byte, a frame needing more than 255 bytes to reach its bucket is emitted unpadded. Payload length is therefore observable for most traffic. +* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data. +* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format. +* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption. +* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol. -### 4.2 Flood Control +--- -Relaying is a deterministic controlled flood tuned by local connection degree: +## 3. Identity and Key Management -* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth. -* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay. -* **Jitter:** relays wait a random 10–220 ms (wider when dense) so duplicate suppression wins often. -* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon). -* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset. +A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain. -### 4.3 Routing +1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions. +2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname. -Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding. +### 3.1. Fingerprint -### 4.4 Fragmentation +A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code). -Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap). +`Fingerprint = SHA256(StaticPublicKey_Curve25519)` -### 4.5 Presence +### 3.2. Identity Management -Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain. +The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key. -## 5. Encryption +--- -### 5.1 Live Sessions: Noise XX +## 4. The Social Trust Layer -Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets. +Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`. -### 5.2 Offline Seals: Noise X +### 4.1. Peer Verification -Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work. +While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations. -### 5.3 Nostr Path +### 4.2. Favorites and Blocking -Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 14) is encrypted and placed in a sender-signed seal (kind 13); that seal is encrypted again inside a public envelope (kind 1059) signed by a one-time key, so relays learn neither the stable sender identity nor the content. Each encrypted content field is `v2:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 (the derivation reuses a "nip44-v2" info label but is not the NIP-44 key schedule). +To improve the user experience and provide control over interactions, the protocol supports: +* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently. +* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer. -This format reuses NIP-17/NIP-59 kind numbers but is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the plaintext and stable sender identity remain inside authenticated ciphertext. Public seal and envelope timestamps are randomized by up to ±15 minutes, while the actual message timestamp is encrypted. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes addressed to that key. +--- -## 6. Store and Forward +## 5. The Noise Protocol Layer -Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode. +BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption. -### 6.1 Sender Outbox +### 5.1. Protocol Name -Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext. +The specific Noise protocol implemented is: -### 6.2 Couriers +**`Noise_XX_25519_ChaChaPoly_SHA256`** -When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient: +* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment. +* **`25519`:** The Diffie-Hellman function used is Curve25519. +* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305. +* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256. -* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days. -* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first. -* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires. -* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection). -* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes. -* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time. +### 5.2. The `XX` Handshake -### 6.3 Public History (Gossip Sync) +The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys. -Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window. +```mermaid +sequenceDiagram + participant I as Initiator + participant R as Responder -### 6.4 Nostr Mailboxes + Note over I, R: Pre-computation: h = SHA256(protocol_name) -BitChat private envelopes rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet. + I->>R: -> e + Note right of I: I generates ephemeral key `e_i`.
h = SHA256(h + e_i.pub) -### 6.5 Delivery Metrics + R->>I: <- e, ee, s, es + Note left of R: R generates ephemeral key `e_r`.
h = SHA256(h + e_r.pub)
MixKey(DH(e_i, e_r))
R sends static key `s_r`, encrypted.
h = SHA256(h + ciphertext)
MixKey(DH(e_i, s_r)) -Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe. + I->>R: -> s, se + Note right of I: I decrypts and verifies `s_r`.
I sends static key `s_i`, encrypted.
h = SHA256(h + ciphertext)
MixKey(DH(s_i, e_r)) -## 7. Application Layer + Note over I, R: Handshake complete. Transport keys derived. +``` -* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above. -* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr. -* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range. -* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota. -* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only. -* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics. +**Handshake Flow:** + +1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder. +2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`). +3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`). + +Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding. + +### 5.3. Session Management + +The `NoiseSessionManager` class manages all active Noise sessions. It handles: +* Creating sessions for new peers. +* Coordinating the handshake process to prevent race conditions. +* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`). +* Periodically checking if sessions need to be re-keyed for enhanced security. + +--- + +## 6. The BitChat Session and Application Protocol + +Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages. + +### 6.1. Binary Packet Format (`BitchatPacket`) + +To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis. + +| Field | Size (bytes) | Description | +|-----------------|--------------|---------------------------------------------------------------------------------------------------------| +| **Header** | **13** | **Fixed-size header** | +| Version | 1 | Protocol version (currently `1`). | +| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. | +| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. | +| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. | +| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). | +| Payload Length | 2 | `UInt16` length of the payload field. | +| **Variable** | **...** | **Variable-size fields** | +| Sender ID | 8 | 8-byte truncated peer ID of the sender. | +| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. | +| Payload | Variable | The actual content of the packet, as defined by the `Type` field. | +| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. | + +**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers. + +```mermaid +--- +config: + theme: dark +--- +--- +title: "BitchatPacket" +--- +packet ++8: "Version" ++8: "Type" ++8: "TTL" ++64: "Timestamp" ++8: "Flags" ++16: "Payload Length" ++64: "Sender ID" ++64: "Recipient ID (optional)" ++48: "Payload (variable)" ++64: "Signature (optional)" +``` +_A representation of the sizes of the fields in `BitchatPacket`_ + +### 6.2. Application Message Format (`BitchatMessage`) + +For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content. + +| Field | Size (bytes) | Description | +|---------------------|--------------|--------------------------------------------------------------------------| +| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). | +| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. | +| ID | 1 + len | `UUID` string for the message. | +| Sender | 1 + len | Nickname of the sender. | +| Content | 2 + len | The UTF-8 encoded message content. | +| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. | +| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. | + +```mermaid +--- +config: + theme: dark +--- +--- +title: "BitchatMessage" +--- +packet ++8: "Flags" ++64: "Timestamp" ++24: "ID (variable)" ++32: "Sender (variable)" ++32: "Content (variable)" ++32: "Original Sender (variable) (optional)" ++32: "Recipient Nickname (variable) (optional)" +``` +_A representation of the sizes of the fields in `BitchatMessage`_ + +--- + +## 7. Message Routing and Propagation + +BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery. + +### 7.1. Direct Connection + +This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake. + +### 7.2. Efficient Gossip with Bloom Filters + +To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs. + +The logic is as follows: + +1. A peer receives a packet. +2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers. +3. If the packet is new, its ID is added to the Bloom filter. +4. The peer decrements the packet's Time-To-Live (TTL) field. +5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet. + +This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops. + +### 7.3. Time-To-Live (TTL) + +Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh. + +### 7.4. Private vs. Broadcast Messages + +The routing logic respects the confidentiality of private messages: + +* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet. +* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network. + +### 7.5. Message Reliability and Lifecycle + +To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery. + +* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message. +* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen. +* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience. + +### 7.6. Fragmentation + +Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol. + +* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments. +* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data. +* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly. + +Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer. + +--- ## 8. Security Considerations -* **Relay nodes** cannot read private traffic; they forward opaque ciphertext. Padding applies to Noise frames only (§4.1), so other packet types relay at their natural length. -* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit. -* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting. -* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces. -* **Metadata is the weakest part of this design, and the peer ID does not help.** The 8-byte sender ID in every packet header is derived from a never-rotating key (§3), and announcements publish the static keys and nickname in cleartext, so a passive listener can enumerate participants and follow a device between places. Announcements also carry up to ten direct-neighbor IDs (§4.3), which hands a single sniffer the local adjacency graph. Origin packets leave at the default TTL, so hop distance identifies the originator. Daily-rotating courier tags do limit correlation of carried mail, and Nostr traffic can ride Tor. Addressing the radio-layer exposure is future work (§9). -* **No forward secrecy for sealed mail or Nostr private envelopes** (§5.2–5.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key. - -## 9. Future Work - -* Prekey-based forward secrecy for courier envelopes. -* Couriered media beyond the 16 KiB text cap. -* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs. -* Multi-hop courier routing informed by encounter history. -* **Rotating on-air identity.** Epoch-rotating peer IDs, with static-key disclosure moved inside the encrypted handshake and mutual favorites recognising each other through a tag derived from their shared secret, so presence stops being linkable across sessions (§3, §8). -* **Padding for non-Noise packet types**, and closing the gap where a frame needing more than 255 bytes of padding is emitted unpadded (§4.1). -* Making the neighbor list in announcements optional, or restricted to authenticated links (§4.3). +* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages. +* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer. +* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other. +* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks. +* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size. --- -*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.* +## 9. Conclusion + +The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments. diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index d9239d66..a648a09f 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -70,7 +70,6 @@ A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Services/SharedContentHandoff.swift, Services/TransportConfig.swift, ); target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; @@ -93,7 +92,7 @@ A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Info.plist, + ShareViewController.swift, ); target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; }; @@ -259,13 +258,9 @@ buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */; buildPhases = ( 0A08E70F08F55FD5BA8C7EF3 /* Sources */, - 7E9B64F63F93443FB7BA12DF /* Resources */, ); buildRules = ( ); - fileSystemSynchronizedGroups = ( - A6E32D212E762EAB0032EA8A /* bitchatShareExtension */, - ); name = bitchatShareExtension; productName = bitchatShareExtension; productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; @@ -326,7 +321,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 2650; + LastUpgradeCheck = 1640; }; buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */; developmentRegion = en; @@ -337,7 +332,6 @@ es, ar, de, - fa, fr, he, id, @@ -378,11 +372,6 @@ E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */, ); }; - 7E9B64F63F93443FB7BA12DF /* Resources */ = { - isa = PBXResourcesBuildPhase; - files = ( - ); - }; C5E027A42ECCDFD700BD6012 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( @@ -457,6 +446,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; LD_RUNPATH_SEARCH_PATHS = ( @@ -481,6 +471,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; LD_RUNPATH_SEARCH_PATHS = ( @@ -507,6 +498,7 @@ CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -531,6 +523,7 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; @@ -539,6 +532,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -562,6 +556,7 @@ CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = NO; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -571,6 +566,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -594,6 +590,7 @@ CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -620,6 +617,7 @@ CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -629,6 +627,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -654,6 +653,7 @@ CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -663,6 +663,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -675,7 +676,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -709,7 +709,6 @@ CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -723,10 +722,10 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; + MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = "$(SWIFT_VERSION)"; @@ -746,6 +745,7 @@ CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = NO; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -755,6 +755,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -767,7 +768,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -801,7 +801,6 @@ CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -821,11 +820,11 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; + MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = "$(SWIFT_VERSION)"; @@ -842,6 +841,7 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; @@ -850,6 +850,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme b/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme index 234d9a1d..8000e27d 100644 --- a/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme +++ b/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme @@ -1,6 +1,6 @@ .Continuation] = [:] - - func emit(_ event: AppEvent) { - for continuation in continuations.values { - continuation.yield(event) - } - } - -} - -/// Identity key for a direct conversation. Equality and hashing use the -/// canonical `id` only; `routingPeerID` carries the transport-level peer ID -/// the conversation is keyed under (see `ConversationID.directPeer`). -struct PeerHandle: Sendable, Identifiable { - let id: String - let routingPeerID: PeerID -} - -extension PeerHandle: Equatable { - static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool { - lhs.id == rhs.id - } -} - -extension PeerHandle: Hashable { - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - -enum ConversationID: Hashable, Sendable { - case mesh - case geohash(String) - case direct(PeerHandle) - - init(channelID: ChannelID) { - switch channelID { - case .mesh: - self = .mesh - case .location(let channel): - self = .geohash(channel.geohash.lowercased()) - } - } -} diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift deleted file mode 100644 index 51cadfb0..00000000 --- a/bitchat/App/AppChromeModel.swift +++ /dev/null @@ -1,184 +0,0 @@ -import BitFoundation -import Combine -import CoreBluetooth -import Foundation - -@MainActor -final class AppChromeModel: ObservableObject { - @Published private(set) var hasUnreadPrivateMessages = false - @Published var nickname: String - @Published var showingFingerprintFor: PeerID? - @Published var isAppInfoPresented = false - @Published var isLocationChannelsSheetPresented = false - @Published var isNoticesSheetPresented = false - /// When the sheet is opened for "notes left here" (empty mesh timeline), - /// it should land on the geo tab instead of the channel-derived default. - @Published var noticesSheetPrefersGeoTab = false - @Published var showBluetoothAlert = false - @Published var bluetoothAlertMessage = "" - @Published var bluetoothState: CBManagerState = .unknown - /// Tor bootstrap has stalled (network likely blocks it); drives the - /// connectivity banner. Mirrored from `ChatViewModel.torBlocked`. - @Published private(set) var torBlocked = false - @Published var showScreenshotPrivacyWarning = false - /// Triple-tapping the logo asks first; the dialog lives on the header. - @Published var showPanicConfirmation = false - /// Mirrors `ChatViewModel.panicRecoveryBlocked` for the chrome: a wipe - /// that did not commit must be visible, not just logged — the person who - /// triggered it needs to know data may remain on the device. - @Published private(set) var panicWipeBlocked = false - - private let chatViewModel: ChatViewModel - private let onPanicWipe: () -> Void - private var cancellables = Set() - /// The composer owns capture state above ChatViewModel. ContentView - /// installs this hook so both panic entry points synchronously stop it. - private var prepareForPanic: (@MainActor () -> Void)? - - /// Bulletin-board coordinator, created on first use of the board sheet. - private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService) - - init( - chatViewModel: ChatViewModel, - privateInboxModel: PrivateInboxModel, - onPanicWipe: @escaping () -> Void = {} - ) { - self.chatViewModel = chatViewModel - self.onPanicWipe = onPanicWipe - self.nickname = chatViewModel.nickname - - bind(privateInboxModel: privateInboxModel) - } - - var shouldSuppressScreenshotNotification: Bool { - isLocationChannelsSheetPresented || isAppInfoPresented - } - - func setNickname(_ nickname: String) { - self.nickname = nickname - if chatViewModel.nickname != nickname { - chatViewModel.nickname = nickname - } - } - - func validateAndSaveNickname() { - chatViewModel.validateAndSaveNickname() - if nickname != chatViewModel.nickname { - nickname = chatViewModel.nickname - } - } - - func openMostRelevantPrivateChat() { - chatViewModel.openMostRelevantPrivateChat() - } - - func showFingerprint(for peerID: PeerID) { - showingFingerprintFor = peerID - } - - func clearFingerprint() { - showingFingerprintFor = nil - } - - func presentAppInfo() { - isAppInfoPresented = true - } - - func presentNotices(geoTab: Bool = false) { - noticesSheetPrefersGeoTab = geoTab - isNoticesSheetPresented = true - } - - /// Builds the mesh topology map model from the transport's gossiped - /// graph plus the live nickname table. Unknown nodes (heard about via a - /// neighbor claim but never announced to us) fall back to a short ID. - func meshTopologyDisplayModel() -> MeshTopologyDisplayModel { - let mesh = chatViewModel.meshService - guard let diagnostics = mesh as? MeshDiagnosing, - let snapshot = diagnostics.currentMeshTopology() else { return .empty } - let nicknames = mesh.getPeerNicknames() - - let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in - let isSelf = peerID == snapshot.localPeerID - let label: String - if isSelf { - label = chatViewModel.nickname - } else { - label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…" - } - return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf) - } - let edges = snapshot.edges.map { ($0.a.id, $0.b.id) } - return MeshTopologyDisplayModel(nodes: nodes, edges: edges) - } - - func triggerScreenshotPrivacyWarning() { - showScreenshotPrivacyWarning = true - } - - func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) { - prepareForPanic = preparation - } - - /// Entry point for the header triple-tap: confirm before destroying. - /// The Settings-pane button has always confirmed; the gesture now goes - /// through the same dialog so a mis-tap can't wipe the device. - func requestPanicWipe() { - showPanicConfirmation = true - } - - func panicClearAllData() { - // A wipe invalidates everything on screen, and its outcome must be - // visible: the success message and the failed-wipe banner both live - // on the root timeline, so a sheet left up (the App Info danger-zone - // path keeps its sheet presented) would hide the one signal that says - // whether the wipe worked. - isAppInfoPresented = false - isLocationChannelsSheetPresented = false - isNoticesSheetPresented = false - showingFingerprintFor = nil - - prepareForPanic?() - onPanicWipe() - chatViewModel.panicClearAllData() - } - - private func bind(privateInboxModel: PrivateInboxModel) { - privateInboxModel.$unreadPeerIDs - .receive(on: DispatchQueue.main) - .sink { [weak self] unreadPeerIDs in - self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty - } - .store(in: &cancellables) - - chatViewModel.$nickname - .receive(on: DispatchQueue.main) - .sink { [weak self] nickname in - guard let self, self.nickname != nickname else { return } - self.nickname = nickname - } - .store(in: &cancellables) - - chatViewModel.$showBluetoothAlert - .receive(on: DispatchQueue.main) - .assign(to: &$showBluetoothAlert) - - chatViewModel.$bluetoothAlertMessage - .receive(on: DispatchQueue.main) - .assign(to: &$bluetoothAlertMessage) - - chatViewModel.$bluetoothState - .receive(on: DispatchQueue.main) - .assign(to: &$bluetoothState) - - chatViewModel.$torBlocked - .receive(on: DispatchQueue.main) - .assign(to: &$torBlocked) - - chatViewModel.$panicRecoveryBlocked - .receive(on: DispatchQueue.main) - .assign(to: &$panicWipeBlocked) - - hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty - } -} diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift deleted file mode 100644 index 3d5bc5bc..00000000 --- a/bitchat/App/AppRuntime.swift +++ /dev/null @@ -1,497 +0,0 @@ -import BitFoundation -import Combine -import Foundation -import SwiftUI -import Tor -import UserNotifications -#if os(iOS) -import UIKit -#elseif os(macOS) -import AppKit -#endif - -@MainActor -final class AppRuntime: ObservableObject { - let chatViewModel: ChatViewModel - let events = AppEventStream() - /// Single source of truth for conversation message state and selection - /// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models - /// and `ChatViewModel` observe and mutate it through its intent API. - let conversations: ConversationStore - let publicChatModel: PublicChatModel - let privateInboxModel: PrivateInboxModel - let privateConversationModel: PrivateConversationModel - let verificationModel: VerificationModel - let conversationUIModel: ConversationUIModel - let locationChannelsModel: LocationChannelsModel - let peerListModel: PeerListModel - let appChromeModel: AppChromeModel - let boardAlertsModel: BoardAlertsModel - let sharedContentImportModel: SharedContentImportModel - - private let idBridge: NostrIdentityBridge - private var cancellables = Set() - private var started = false - private var lastNostrRelayConnectedState = false - private var didHandleInitialNostrConnection = false - - #if os(iOS) - private var didHandleInitialActive = false - private var didEnterBackground = false - #endif - - init( - keychain: KeychainManagerProtocol = KeychainManager.makeDefault(), - idBridge: NostrIdentityBridge = NostrIdentityBridge(), - sharedContentStore: SharedContentStore? = nil - ) { - self.idBridge = idBridge - let conversations = ConversationStore() - let peerIdentityStore = PeerIdentityStore() - let locationPresenceStore = LocationPresenceStore() - let locationManager = LocationChannelManager.shared - self.conversations = conversations - self.chatViewModel = ChatViewModel( - keychain: keychain, - idBridge: idBridge, - identityManager: SecureIdentityStateManager(keychain), - conversations: conversations, - peerIdentityStore: peerIdentityStore, - locationPresenceStore: locationPresenceStore, - locationManager: locationManager - ) - self.publicChatModel = PublicChatModel(conversations: conversations) - self.privateInboxModel = PrivateInboxModel(conversations: conversations) - self.locationChannelsModel = LocationChannelsModel(manager: locationManager) - self.privateConversationModel = PrivateConversationModel( - chatViewModel: self.chatViewModel, - conversations: conversations, - locationChannelsModel: self.locationChannelsModel, - peerIdentityStore: peerIdentityStore - ) - self.verificationModel = VerificationModel( - chatViewModel: self.chatViewModel, - privateConversationModel: self.privateConversationModel, - peerIdentityStore: peerIdentityStore - ) - self.conversationUIModel = ConversationUIModel( - chatViewModel: self.chatViewModel, - privateConversationModel: self.privateConversationModel, - conversations: conversations - ) - self.peerListModel = PeerListModel( - chatViewModel: self.chatViewModel, - conversations: conversations, - locationChannelsModel: self.locationChannelsModel, - peerIdentityStore: peerIdentityStore, - locationPresenceStore: locationPresenceStore - ) - let resolvedSharedContentStore: SharedContentStore? - if let sharedContentStore { - resolvedSharedContentStore = sharedContentStore - } else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) { - resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults) - } else { - resolvedSharedContentStore = nil - } - let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore) - self.sharedContentImportModel = sharedContentImportModel - self.appChromeModel = AppChromeModel( - chatViewModel: self.chatViewModel, - privateInboxModel: self.privateInboxModel, - onPanicWipe: { sharedContentImportModel.discardAll() } - ) - let chatViewModel = self.chatViewModel - self.boardAlertsModel = BoardAlertsModel( - arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(), - wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(), - dependencies: BoardAlertsModel.Dependencies( - isOwnPost: { post in - let key = chatViewModel.meshService.noiseSigningPublicKeyData() - return !key.isEmpty && key == post.authorSigningKey - }, - emitSystemLine: { content, geohash in - if geohash.isEmpty { - chatViewModel.addMeshOnlySystemMessage(content) - } else { - chatViewModel.addGeohashSystemMessage(content, geohash: geohash) - } - } - ) - ) - if chatViewModel.networkActivationAllowed { - GeoRelayDirectory.shared.prefetchIfNeeded() - } - bindRuntimeObservers() - NotificationDelegate.shared.runtime = self - } - - func start() { - guard chatViewModel.networkActivationAllowed else { return } - guard !started else { - checkForSharedContent() - return - } - - started = true - NotificationDelegate.shared.runtime = self - VerificationService.shared.configure(with: chatViewModel.meshService) - announceInitialTorStatusIfNeeded() - - Task(priority: .utility) { [weak self] in - guard let self else { return } - let nickname = await MainActor.run { self.chatViewModel.nickname } - let npub = await MainActor.run { - try? self.idBridge.getCurrentNostrIdentity()?.npub - } - await MainActor.run { - _ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub) - } - } - - NetworkActivationService.shared.start() - GeohashPresenceService.shared.start() - checkForSharedContent() - performMediaMaintenance() - - record(.launched) - record(.startupCompleted) - } - - /// Drops media that has outlived the retention window, then applies the - /// explicit protection class to files that older builds wrote without - /// one. Expiry runs first so the migration never touches files the - /// sweep is about to delete. Detached because `AppRuntime` is - /// main-actor and both passes go file by file through the media tree; - /// best-effort, nothing at launch depends on their results. - private func performMediaMaintenance() { - Task.detached(priority: .utility) { - let store = BLEIncomingFileStore() - store.expireAgedMedia() - store.migrateFileProtectionIfNeeded() - } - } - - func handleOpenURL(_ url: URL) { - record(.openedURL(url.absoluteString)) - - if url.scheme == "bitchat", url.host == "share" { - checkForSharedContent() - } - } - - func handleDidBecomeActiveNotification() { - guard chatViewModel.networkActivationAllowed else { return } - chatViewModel.handleDidBecomeActive() - checkForSharedContent() - } - - #if os(macOS) - func handleMacDidBecomeActiveNotification() { - guard chatViewModel.networkActivationAllowed else { return } - record(.scenePhaseChanged(.active)) - chatViewModel.handleDidBecomeActive() - checkForSharedContent() - } - #endif - - #if os(iOS) - func handleScenePhaseChange(_ newPhase: ScenePhase) { - switch newPhase { - case .background: - record(.scenePhaseChanged(.background)) - TorManager.shared.setAppForeground(false) - TorManager.shared.goDormantOnBackground() - chatViewModel.endGeohashSampling() - NostrRelayManager.shared.disconnect() - didEnterBackground = true - - case .active: - guard chatViewModel.networkActivationAllowed else { return } - record(.scenePhaseChanged(.active)) - chatViewModel.meshService.startServices() - TorManager.shared.setAppForeground(true) - let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground - - if didHandleInitialActive && didEnterBackground { - if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady { - TorManager.shared.ensureRunningOnForeground() - } - } else { - didHandleInitialActive = true - } - - didEnterBackground = false - - if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() { - Task.detached { - let _ = await TorManager.shared.awaitReady(timeout: 60) - await MainActor.run { - TorURLSession.shared.rebuild() - NostrRelayManager.shared.resetAllConnections() - } - } - } - - chatViewModel.handleDidBecomeActive() - checkForSharedContent() - - case .inactive: - record(.scenePhaseChanged(.inactive)) - - @unknown default: - break - } - } - #endif - - func applicationWillTerminate() { - record(.terminationRequested) - chatViewModel.applicationWillTerminate() - } - - func handleNotificationResponse( - identifier: String, - actionIdentifier: String = UNNotificationDefaultActionIdentifier, - userInfo: [AnyHashable: Any] - ) { - guard chatViewModel.networkActivationAllowed else { return } - if actionIdentifier == NotificationService.waveActionID { - chatViewModel.sendMeshWave() - return - } - - if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) { - record(.notificationOpened(peerID: peerID)) - chatViewModel.startPrivateChat(with: peerID) - } - - if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) { - record(.deepLinkOpened(deepLink)) - openExternalURL(url) - } - } - - func presentationOptions( - forNotificationIdentifier identifier: String, - userInfo: [AnyHashable: Any] - ) async -> UNNotificationPresentationOptions { - if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) { - if conversations.selectedPrivatePeerID == peerID { - return [] - } - return [.banner, .sound] - } - - if identifier.hasPrefix("geo-activity-"), - let deepLink = userInfo["deeplink"] as? String, - let geohash = deepLink.components(separatedBy: "/").last, - case .location(let channel) = locationChannelsModel.selectedChannel, - channel.geohash == geohash { - return [] - } - - return [.banner, .sound] - } -} - -private extension AppRuntime { - func bindRuntimeObservers() { - NostrRelayManager.shared.$isConnected - .receive(on: DispatchQueue.main) - .sink { [weak self] isConnected in - self?.handleNostrRelayConnectionChanged(isConnected) - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .TorWillRestart) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard self?.chatViewModel.networkActivationAllowed == true - else { return } - self?.record(.torLifecycleChanged(.willRestart)) - self?.chatViewModel.handleTorWillRestart() - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .TorDidBecomeReady) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard self?.chatViewModel.networkActivationAllowed == true - else { return } - self?.record(.torLifecycleChanged(.didBecomeReady)) - self?.chatViewModel.handleTorDidBecomeReady() - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .TorWillStart) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard self?.chatViewModel.networkActivationAllowed == true - else { return } - self?.record(.torLifecycleChanged(.willStart)) - self?.chatViewModel.handleTorWillStart() - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .TorBootstrapDidStall) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard self?.chatViewModel.networkActivationAllowed == true - else { return } - self?.record(.torLifecycleChanged(.bootstrapDidStall)) - self?.chatViewModel.handleTorBootstrapDidStall() - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .TorUserPreferenceChanged) - .receive(on: DispatchQueue.main) - .sink { [weak self] notification in - guard self?.chatViewModel.networkActivationAllowed == true - else { return } - self?.record(.torLifecycleChanged(.preferenceChanged)) - self?.chatViewModel.handleTorPreferenceChanged(notification) - } - .store(in: &cancellables) - - #if os(iOS) - NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.handleScreenshotCaptured() - } - .store(in: &cancellables) - #endif - } - - func checkForSharedContent() { - let previousID = sharedContentImportModel.offer?.id - guard let payload = sharedContentImportModel.refresh( - destination: currentSharedContentDestination - ) else { return } - - if previousID != payload.id { - record(.sharedContentReadyForReview(payload.kind)) - } - } - - var currentSharedContentDestination: SharedContentDestination { - SharedContentDestination.resolve( - selectedPrivatePeerID: privateConversationModel.selectedPeerID, - privateDisplayName: privateConversationModel.selectedHeaderState?.displayName, - activeChannel: locationChannelsModel.selectedChannel - ) - } - - func handleNostrRelayConnectionChanged(_ isConnected: Bool) { - record(.nostrRelayConnectionChanged(isConnected)) - - let becameConnected = isConnected && !lastNostrRelayConnectedState - lastNostrRelayConnectedState = isConnected - - guard chatViewModel.networkActivationAllowed, - started, - becameConnected else { return } - - let isInitialConnection = !didHandleInitialNostrConnection - didHandleInitialNostrConnection = true - - if !chatViewModel.nostrHandlersSetup { - chatViewModel.setupNostrMessageHandling() - chatViewModel.nostrHandlersSetup = true - } - - guard !isInitialConnection else { return } - - chatViewModel.resubscribeCurrentGeohash() - chatViewModel.geoChannelCoordinator?.refreshSampling() - } - - func announceInitialTorStatusIfNeeded() { - if TorManager.shared.torEnforced && - !chatViewModel.torStatusAnnounced && - TorManager.shared.isAutoStartAllowed() { - chatViewModel.torStatusAnnounced = true - chatViewModel.addGeohashOnlySystemMessage( - String(localized: "system.tor.starting", comment: "System message when Tor is starting") - ) - } else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced { - chatViewModel.torStatusAnnounced = true - chatViewModel.addGeohashOnlySystemMessage( - String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development") - ) - } - } - - func handleScreenshotCaptured() { - let isLocationChannelActive: Bool = { - if case .location = chatViewModel.activeChannel { return true } - return false - }() - - switch Self.resolveScreenshotResponse( - isLocationChannelsSheetPresented: appChromeModel.isLocationChannelsSheetPresented, - isAppInfoPresented: appChromeModel.isAppInfoPresented, - hasPrivateChatOpen: chatViewModel.selectedPrivateChatPeer != nil, - isLocationChannelActive: isLocationChannelActive - ) { - case .warnLocally: - appChromeModel.triggerScreenshotPrivacyWarning() - case .ignore: - break - case .forwardToChat: - chatViewModel.handleScreenshotCaptured() - } - } - - func openExternalURL(_ url: URL) { - #if os(iOS) - UIApplication.shared.open(url) - #else - NSWorkspace.shared.open(url) - #endif - } - - func record(_ event: AppEvent) { - Task { - await events.emit(event) - } - } -} - -// MARK: - Screenshot routing - -extension AppRuntime { - /// What a screenshot triggers. Nothing on this table sends anything to - /// a public channel (see `ChatLifecycleCoordinator.handleScreenshotCaptured`). - enum ScreenshotCaptureResponse: Equatable { - /// Show the local location-privacy alert; nothing is sent anywhere. - case warnLocally - /// Do nothing. App Info holds no conversation or location content. - case ignore - /// Hand to the chat layer: a DM notice goes to the peer when a - /// secure session exists; public timelines stay silent. Mesh - /// deliberately gets no local alert either — a mesh screenshot - /// reveals no place and triggers no send, so there is nothing to - /// warn about, and alerting on every screenshot would train people - /// to dismiss the one alert that matters (the location one). - case forwardToChat - } - - /// Pure decision table so the screenshot routing is testable without a - /// runtime. - nonisolated static func resolveScreenshotResponse( - isLocationChannelsSheetPresented: Bool, - isAppInfoPresented: Bool, - hasPrivateChatOpen: Bool, - isLocationChannelActive: Bool - ) -> ScreenshotCaptureResponse { - if isLocationChannelsSheetPresented { return .warnLocally } - if isAppInfoPresented { return .ignore } - // A geohash timeline screenshot still reveals a place — warn the - // person taking it, locally, with the same alert the channel sheet - // uses. - if !hasPrivateChatOpen, isLocationChannelActive { return .warnLocally } - return .forwardToChat - } -} diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift deleted file mode 100644 index 76e71813..00000000 --- a/bitchat/App/ConversationStore.swift +++ /dev/null @@ -1,978 +0,0 @@ -// -// ConversationStore.swift -// bitchat -// -// Single source of truth for conversation message state (see -// docs/CONVERSATION-STORE-DESIGN.md). One `Conversation` object per -// `ConversationID`; all mutations flow through the store's intent API and -// every mutation emits a `ConversationChange` after state is consistent. -// -// The store also owns conversation selection: the active public channel and -// the selected private peer (the two UI selection axes) plus the derived -// `selectedConversationID`. -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import Combine -import Foundation - -// MARK: - Conversation - -/// A single conversation timeline (`.mesh`, `.geohash`, or `.direct`). -/// -/// Publishing granularity is per conversation: views observe ONE -/// `Conversation` object, so an append to chat A never invalidates observers -/// of chat B. -/// -/// Mutations are `fileprivate` by design — only `ConversationStore`'s intent -/// API may mutate a conversation, keeping the store the sole writer. -@MainActor -final class Conversation: ObservableObject, Identifiable { - let id: ConversationID - /// Maximum retained messages; oldest are trimmed on overflow. - let cap: Int - - @Published private(set) var messages: [BitchatMessage] = [] - @Published private(set) var isUnread: Bool = false - - /// Incrementally-maintained message-ID → logical-index map for O(1) - /// dedup and delivery-status lookup. Logical indexes are physical array - /// indexes plus `indexOffset`; trimming from the head advances the offset - /// instead of rewriting every surviving dictionary entry. This matters - /// after the 1337-message cap is reached, when every steady-state tail - /// append evicts one old row. - /// - /// Out-of-order inserts and middle removals still reindex only the - /// affected suffix. Full filtering resets the offset while rebuilding. - private var indexByMessageID: [String: Int] = [:] - private var indexOffset = 0 - - fileprivate init(id: ConversationID, cap: Int) { - self.id = id - self.cap = max(1, cap) - } - - // MARK: Reads - - func containsMessage(withID messageID: String) -> Bool { - indexByMessageID[messageID] != nil - } - - func message(withID messageID: String) -> BitchatMessage? { - guard let index = physicalIndex(forMessageID: messageID) else { return nil } - return messages[index] - } - - /// All message IDs currently in this conversation (unordered). - var messageIDs: Dictionary.Keys { - indexByMessageID.keys - } - - // MARK: Store-internal mutations - - /// Result of an ordered insert. `trimmedMessageIDs` reports messages - /// evicted by the cap so the store can keep its message-ID → - /// conversation map exact. - fileprivate struct InsertResult { - let inserted: Bool - let trimmedMessageIDs: [String] - - static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: []) - } - - fileprivate enum UpsertOutcome { - case appended(trimmedMessageIDs: [String]) - case updated - } - - /// Inserts a message in timestamp order, deduplicating by message ID. - /// Fast path appends when the timestamp is >= the current tail; - /// otherwise a binary search finds the upper-bound insertion point so - /// arrival order is preserved among equal timestamps. - /// Reports `inserted: false` if a message with the same ID already exists. - fileprivate func insert(_ message: BitchatMessage) -> InsertResult { - guard indexByMessageID[message.id] == nil else { return .duplicate } - - if let last = messages.last, message.timestamp < last.timestamp { - let index = insertionIndex(for: message.timestamp) - messages.insert(message, at: index) - reindex(from: index) - } else { - messages.append(message) - indexByMessageID[message.id] = indexOffset + messages.count - 1 - } - - return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded()) - } - - /// Replace-or-append by message ID. An existing message keeps its - /// timeline position (in-place updates like media progress reuse the - /// original timestamp); a new message goes through ordered insertion. - fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome { - if let index = physicalIndex(forMessageID: message.id) { - messages[index] = message - return .updated - } - let result = insert(message) - return .appended(trimmedMessageIDs: result.trimmedMessageIDs) - } - - /// Applies a delivery status keyed by message ID, honoring the - /// no-downgrade rule (the SOLE enforcement point — every delivery - /// update flows through the store): equal statuses are skipped, and - /// `.read` is never downgraded to `.delivered` or `.sent`. - /// Returns `true` when the status was applied. - fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { - guard let index = physicalIndex(forMessageID: messageID) else { return false } - let message = messages[index] - guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false } - - message.deliveryStatus = status - // BitchatMessage is a reference type; write back through the - // subscript so the @Published wrapper emits. - messages[index] = message - return true - } - - /// Republishes a message without changing state. Used for mirrored - /// copies that share a BitchatMessage instance: the first conversation's - /// status apply mutated the shared object, so this conversation's - /// observers still need an @Published emission to re-render. - @discardableResult - fileprivate func republishMessage(withID messageID: String) -> Bool { - guard let index = physicalIndex(forMessageID: messageID) else { return false } - messages[index] = messages[index] - return true - } - - @discardableResult - fileprivate func setUnread(_ unread: Bool) -> Bool { - guard isUnread != unread else { return false } - isUnread = unread - return true - } - - /// Removes a single message by ID. Returns the removed message, or - /// `nil` when no message with that ID exists. - fileprivate func remove(messageID: String) -> BitchatMessage? { - guard let index = physicalIndex(forMessageID: messageID) else { return nil } - let removed = messages.remove(at: index) - indexByMessageID.removeValue(forKey: messageID) - if index == 0 { - indexOffset += 1 - } else { - reindex(from: index) - } - return removed - } - - /// Removes every message matching `predicate`. Returns the removed - /// message IDs (empty when nothing matched). - fileprivate func removeAll(where predicate: (BitchatMessage) -> Bool) -> [String] { - var removedIDs: [String] = [] - messages.removeAll { message in - guard predicate(message) else { return false } - removedIDs.append(message.id) - return true - } - guard !removedIDs.isEmpty else { return [] } - for id in removedIDs { - indexByMessageID.removeValue(forKey: id) - } - indexOffset = 0 - reindex(from: 0) - return removedIDs - } - - fileprivate func clearMessages() { - messages.removeAll() - indexByMessageID.removeAll() - indexOffset = 0 - } - - // MARK: Diagnostics - - /// Appends human-readable invariant violations for this conversation - /// (empty when healthy): the ID index must be the exact inverse of the - /// messages array, the cap must hold, and timestamps must be - /// non-decreasing (equal timestamps keep arrival order, so only strict - /// inversions are violations). O(messages); allocates only on violation. - fileprivate func collectInvariantViolations(into violations: inout [String], label: String) { - if indexByMessageID.count != messages.count { - violations.append("\(label): index has \(indexByMessageID.count) entries for \(messages.count) messages") - } - if messages.count > cap { - violations.append("\(label): \(messages.count) messages exceeds cap \(cap)") - } - var previousTimestamp: Date? - for position in messages.indices { - let message = messages[position] - // Count equality + every message resolving to its own position - // proves the index is exactly the inverse map (no stale extras). - if let logicalIndex = indexByMessageID[message.id] { - let expectedIndex = indexOffset + position - if logicalIndex != expectedIndex { - violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)") - } - } else { - violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index") - } - if let previousTimestamp, message.timestamp < previousTimestamp { - violations.append("\(label): timestamp order violated at \(position)") - } - previousTimestamp = message.timestamp - } - } - - // MARK: Internals - - static func shouldSkipStatusUpdate(current: DeliveryStatus, new: DeliveryStatus) -> Bool { - if current == new { return true } - - // Never downgrade to a weaker delivery state. Ordering of certainty: - // sending < sent < carried < delivered < read. A late `.sent` write - // (e.g. the optimistic stamp after routing) must not clobber the - // `.carried` the router already set when it handed a copy to a - // courier/bridge, nor a `.delivered`/`.read` ack. A late asynchronous - // failure is weaker than a confirmed recipient receipt too, so it may - // not replace `.delivered`/`.read`. Same for the - // `.sending` stamp a pre-handshake resend emits asynchronously: it - // can land after the message already reached `.sent`, and "Sent" was - // already truthful. (`.failed` → `.sending` stays allowed so a real - // failure retry is visible.) - switch (current, new) { - case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending), (.read, .failed): - return true - case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending), (.delivered, .failed): - return true - case (.carried, .sent), (.carried, .sending): - return true - case (.sent, .sending): - return true - case (_, .notSentYet): - // .notSentYet is the pre-transport initial state; once a message - // has any real status, resetting to it is always a downgrade. - return true - default: - return false - } - } - - /// Upper-bound binary search: first index whose timestamp is strictly - /// greater than `timestamp`, so equal-timestamp messages keep arrival - /// order. - private func insertionIndex(for timestamp: Date) -> Int { - var low = 0 - var high = messages.count - while low < high { - let mid = (low + high) / 2 - if messages[mid].timestamp <= timestamp { - low = mid + 1 - } else { - high = mid - } - } - return low - } - - private func reindex(from start: Int) { - for index in start.. Int? { - guard let logicalIndex = indexByMessageID[messageID] else { return nil } - let index = logicalIndex - indexOffset - guard messages.indices.contains(index) else { return nil } - return index - } - - /// Trims oldest messages over the cap; returns the trimmed message IDs. - private func trimIfNeeded() -> [String] { - guard messages.count > cap else { return [] } - let overflow = messages.count - cap - let trimmedIDs = messages.prefix(overflow).map(\.id) - for id in trimmedIDs { - indexByMessageID.removeValue(forKey: id) - } - messages.removeFirst(overflow) - indexOffset += overflow - return trimmedIDs - } -} - -// MARK: - ConversationChange - -/// Typed mutation events for non-UI consumers (delivery tracking, -/// notifications, sync) that need "something changed in conversation X" -/// without subscribing to whole message arrays. Emitted on the store's -/// `changes` subject AFTER the corresponding state is consistent. -enum ConversationChange { - case appended(ConversationID, BitchatMessage) - case updated(ConversationID, messageID: String) - case statusChanged(ConversationID, messageID: String, DeliveryStatus) - case messageRemoved(ConversationID, messageID: String) - case cleared(ConversationID) - case removed(ConversationID) - case migrated(from: ConversationID, to: ConversationID) - case unreadChanged(ConversationID, isUnread: Bool) -} - -// MARK: - ConversationStore - -/// Sole writer and sole holder of conversation message state. All mutations -/// go through the intent API below; backing collections are `private(set)`. -/// Reads are synchronous — writers and readers share the main actor, so -/// after an intent returns every observer sees the result. -@MainActor -final class ConversationStore: ObservableObject { - /// Conversation creation order; published so list-style consumers can - /// observe conversations appearing/disappearing without rebuilding from - /// the dictionary. - @Published private(set) var conversationIDs: [ConversationID] = [] - @Published private(set) var selectedConversationID: ConversationID? - @Published private(set) var unreadConversations: Set = [] - - // MARK: Selection state - // The two UI selection axes: which public channel is active, and which - // private chat (if any) is open on top of it. `selectedConversationID` - // is derived: the open private chat wins, otherwise the active public - // channel's conversation. Mutate via `setActiveChannel` / - // `setSelectedPrivatePeer` only. - - @Published private(set) var activeChannel: ChannelID = .mesh - @Published private(set) var selectedPrivatePeerID: PeerID? - - private(set) var conversationsByID: [ConversationID: Conversation] = [:] - - /// Store-level message-ID → conversation-membership map for ID-only - /// lookups (delivery receipts arrive with a message ID, not a - /// conversation). Maintained incrementally at every mutation point — - /// all mutation is centralized in the intent API below, so the map is - /// exact, never scanned or rebuilt. - /// - /// The value is a `Set` because a private message can legitimately live - /// in TWO direct conversations: step 2's raw per-peer keying mirrors a - /// message into both the stable-key and ephemeral-peer chats - /// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both - /// copies. - private var conversationIDsByMessageID: [String: Set] = [:] - - /// Monotonic count of messages inserted into any conversation (appends, - /// upsert-appends, migration inserts). Field-observability only: the - /// periodic store audit folds the delta into its heartbeat line so logs - /// carry throughput context. Never read on a hot path. - private(set) var appendCount: Int = 0 - - /// Sample counter for the mirrored-republish debug log in the ID-only - /// `setDeliveryStatus` fan-out (first + every Nth occurrence). - private var mirroredRepublishLogCount = 0 - - let changes = PassthroughSubject() - - // MARK: Intent API - - /// Returns the conversation for `id`, creating it (with the cap policy - /// for its kind) on first access. - @discardableResult - func conversation(for id: ConversationID) -> Conversation { - if let existing = conversationsByID[id] { - return existing - } - let conversation = Conversation(id: id, cap: Self.cap(for: id)) - conversationsByID[id] = conversation - conversationIDs.append(id) - return conversation - } - - /// Appends a message in timestamp order. Returns `false` (and emits - /// nothing) if a message with the same ID is already present. - @discardableResult - func append(_ message: BitchatMessage, to id: ConversationID) -> Bool { - let conversation = conversation(for: id) - let result = conversation.insert(message) - guard result.inserted else { return false } - registerMessageID(message.id, in: id) - unregisterMessageIDs(result.trimmedMessageIDs, from: id) - changes.send(.appended(id, message)) - return true - } - - /// Replace-or-append by message ID (media progress, edits). - func upsertByID(_ message: BitchatMessage, in id: ConversationID) { - let conversation = conversation(for: id) - switch conversation.upsert(message) { - case .appended(let trimmedMessageIDs): - registerMessageID(message.id, in: id) - unregisterMessageIDs(trimmedMessageIDs, from: id) - changes.send(.appended(id, message)) - case .updated: - changes.send(.updated(id, messageID: message.id)) - } - } - - /// Applies a delivery status keyed by message ID. Returns `false` when - /// the message is unknown or the update would downgrade the status - /// (read beats delivered beats sent). - @discardableResult - func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, in id: ConversationID) -> Bool { - guard let conversation = conversationsByID[id], - conversation.applyDeliveryStatus(status, forMessageID: messageID) else { - return false - } - changes.send(.statusChanged(id, messageID: messageID, status)) - return true - } - - /// Applies a delivery status to EVERY conversation containing - /// `messageID` (ID-only — delivery receipts don't know conversations; - /// mirrored private copies live in two direct chats). Returns `false` - /// when the message is unknown or no copy changed (equal status or - /// downgrade — read beats delivered beats sent). - /// - /// `BitchatMessage` is a reference type, so mirrored copies sharing one - /// instance are mutated by the first conversation's apply. The skipped - /// conversations still hold the changed message, so they get an explicit - /// republish and `.statusChanged` event - otherwise a view observing the - /// mirrored conversation would render stale status. Distinct copies whose - /// update was genuinely rejected (downgrade) are left untouched, guarded - /// by status equality. - @discardableResult - func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { - guard let ids = conversationIDsByMessageID[messageID] else { return false } - return applyDeliveryStatus(status, forMessageID: messageID, among: ids) - } - - /// Applies an authenticated delivery/read receipt only to the supplied - /// direct-conversation aliases. A colliding message ID in another peer's - /// conversation (or a public timeline) must not inherit the receipt. - /// - /// Stable and ephemeral aliases can temporarily hold the same message - /// instance during handoff. The shared helper republishes every targeted - /// alias even when the first mutation already changed that instance. - @discardableResult - func setDeliveryStatus( - _ status: DeliveryStatus, - forMessageID messageID: String, - inDirectPeerAliases peerIDs: Set - ) -> Bool { - guard !peerIDs.isEmpty, - let indexedIDs = conversationIDsByMessageID[messageID] else { - return false - } - let allowedIDs = Set(peerIDs.map { ConversationID.directPeer($0) }) - return applyDeliveryStatus( - status, - forMessageID: messageID, - among: indexedIDs.intersection(allowedIDs) - ) - } - - private func applyDeliveryStatus( - _ status: DeliveryStatus, - forMessageID messageID: String, - among ids: Set - ) -> Bool { - guard !ids.isEmpty else { return false } - var applied = false - var skipped: [ConversationID] = [] - for id in ids { - if setDeliveryStatus(status, forMessageID: messageID, in: id) { - applied = true - } else { - skipped.append(id) - } - } - guard applied else { return false } - for id in skipped { - guard let conversation = conversationsByID[id], - conversation.message(withID: messageID)?.deliveryStatus == status, - conversation.republishMessage(withID: messageID) else { continue } - // Field proof the mirrored-copy republish path actually fires; - // sampled (first + every Nth) so mirrored chats can't spam logs. - mirroredRepublishLogCount += 1 - if mirroredRepublishLogCount == 1 - || mirroredRepublishLogCount.isMultiple(of: TransportConfig.conversationStoreMirroredRepublishLogInterval) { - SecureLogger.debug( - "mirrored republish #\(mirroredRepublishLogCount) for \(messageID.prefix(8))… in \(id.auditDescription)", - category: .session - ) - } - changes.send(.statusChanged(id, messageID: messageID, status)) - } - return true - } - - /// Current delivery status of `messageID` in whichever conversation - /// holds it (mirrored copies share status — see `setDeliveryStatus`). - func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { - guard let ids = conversationIDsByMessageID[messageID] else { return nil } - for id in ids { - if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus { - return status - } - } - return nil - } - - /// Every conversation currently containing `messageID` (empty when the - /// message is unknown). - func conversationIDs(forMessageID messageID: String) -> Set { - conversationIDsByMessageID[messageID] ?? [] - } - - func markRead(_ id: ConversationID) { - guard unreadConversations.contains(id) else { return } - unreadConversations.remove(id) - conversationsByID[id]?.setUnread(false) - changes.send(.unreadChanged(id, isUnread: false)) - } - - func markUnread(_ id: ConversationID) { - guard !unreadConversations.contains(id) else { return } - let conversation = conversation(for: id) - unreadConversations.insert(id) - conversation.setUnread(true) - changes.send(.unreadChanged(id, isUnread: true)) - } - - /// Selects a conversation (creating it if needed) or clears the - /// selection with `nil`. - func select(_ id: ConversationID?) { - if let id { - conversation(for: id) - } - guard selectedConversationID != id else { return } - selectedConversationID = id - } - - /// Switches the active public channel. While no private chat is open - /// the selection follows the channel. - func setActiveChannel(_ channelID: ChannelID) { - if activeChannel != channelID { - activeChannel = channelID - } - refreshDerivedSelection() - } - - /// Opens a private chat (`nil` closes it, returning the selection to the - /// active public channel's conversation). - func setSelectedPrivatePeer(_ peerID: PeerID?) { - if selectedPrivatePeerID != peerID { - selectedPrivatePeerID = peerID - } - refreshDerivedSelection() - } - - private func refreshDerivedSelection() { - if let peerID = selectedPrivatePeerID { - select(.directPeer(peerID)) - } else { - select(ConversationID(channelID: activeChannel)) - } - } - - /// Moves all messages from `source` into `destination` (the - /// ephemeral↔stable peer-ID handoff): dedups by message ID, preserves - /// timestamp order, carries unread state over, and hands off the - /// selection — mirroring `ChatPrivateConversationCoordinator`'s - /// migration semantics. The source conversation is removed. Emits a - /// single `.migrated(from:to:)` once the whole move is consistent. - func migrateConversation(from source: ConversationID, to destination: ConversationID) { - guard source != destination, let sourceConversation = conversationsByID[source] else { return } - - let destinationConversation = conversation(for: destination) - for message in sourceConversation.messages { - let result = destinationConversation.insert(message) - guard result.inserted else { continue } - registerMessageID(message.id, in: destination) - unregisterMessageIDs(result.trimmedMessageIDs, from: destination) - } - for messageID in sourceConversation.messageIDs { - unregisterMessageID(messageID, from: source) - } - - let wasUnread = unreadConversations.contains(source) - let wasSelected = selectedConversationID == source - - conversationsByID.removeValue(forKey: source) - conversationIDs.removeAll { $0 == source } - unreadConversations.remove(source) - - if wasUnread, !unreadConversations.contains(destination) { - unreadConversations.insert(destination) - destinationConversation.setUnread(true) - } - if wasSelected { - selectedConversationID = destination - // Keep the private-peer selection axis consistent with the - // handed-off selection. - if let peerID = selectedPrivatePeerID, - source == .directPeer(peerID), - case .direct(let destinationHandle) = destination { - selectedPrivatePeerID = destinationHandle.routingPeerID - } - } - - changes.send(.migrated(from: source, to: destination)) - } - - /// Removes a single message by ID from a conversation. Returns the - /// removed message, or `nil` (emitting nothing) when the conversation or - /// message is unknown. - @discardableResult - func removeMessage(withID messageID: String, from id: ConversationID) -> BitchatMessage? { - guard let conversation = conversationsByID[id], - let removed = conversation.remove(messageID: messageID) else { - return nil - } - unregisterMessageID(messageID, from: id) - changes.send(.messageRemoved(id, messageID: messageID)) - return removed - } - - /// Removes every message matching `predicate` from a conversation, - /// emitting one `.messageRemoved` per removed message after the - /// conversation is consistent. No-op for unknown conversations. - func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) { - guard let conversation = conversationsByID[id] else { return } - let removedIDs = conversation.removeAll(where: predicate) - unregisterMessageIDs(removedIDs, from: id) - for messageID in removedIDs { - changes.send(.messageRemoved(id, messageID: messageID)) - } - } - - /// Empties a conversation's timeline but keeps the conversation (and - /// its unread/selection state) alive. - func clear(_ id: ConversationID) { - guard let conversation = conversationsByID[id] else { return } - for messageID in conversation.messageIDs { - unregisterMessageID(messageID, from: id) - } - conversation.clearMessages() - changes.send(.cleared(id)) - } - - /// Removes a conversation entirely, including unread state; clears the - /// selection if it pointed at the removed conversation. - func removeConversation(_ id: ConversationID) { - guard let conversation = conversationsByID.removeValue(forKey: id) else { return } - for messageID in conversation.messageIDs { - unregisterMessageID(messageID, from: id) - } - conversationIDs.removeAll { $0 == id } - unreadConversations.remove(id) - if selectedConversationID == id { - selectedConversationID = nil - } - changes.send(.removed(id)) - } - - func clearAll() { - let removedIDs = conversationIDs - guard !removedIDs.isEmpty || selectedConversationID != nil else { return } - - conversationsByID.removeAll() - conversationIDs.removeAll() - unreadConversations.removeAll() - conversationIDsByMessageID.removeAll() - if selectedConversationID != nil { - selectedConversationID = nil - } - - for id in removedIDs { - changes.send(.removed(id)) - } - } - - // MARK: Diagnostics - - /// Total messages across all conversations. O(#conversations) — heartbeat - /// logging only, never a hot path. - var totalMessageCount: Int { - conversationsByID.values.reduce(0) { $0 + $1.messages.count } - } - - /// Number of distinct message IDs in the store-level membership map. - var messageIDMapCount: Int { - conversationIDsByMessageID.count - } - - /// Verifies the store's correctness invariants and returns human-readable - /// violations (empty = healthy). Intended for a periodic field audit: - /// O(total messages) and allocation-free while healthy. Checks: - /// - the `conversationIDs` ordering array matches `conversationsByID` - /// - per conversation: ID index exact, cap held, timestamp order - /// (see `Conversation.collectInvariantViolations`) - /// - the message-ID → conversation map matches reality exactly: every - /// mapped membership points at a live conversation actually holding - /// the message, and total memberships equal total messages (with the - /// forward check, equality proves no conversation message is missing - /// from the map) - /// - `unreadConversations` only references existing conversations - /// - `selectedConversationID`, when set, references an existing - /// conversation (`select(_:)` creates on selection and - /// `removeConversation`/`clearAll` clear it, so existence is the - /// invariant for both the channel-derived and direct-peer cases) - func auditInvariants() -> [String] { - var violations: [String] = [] - - if conversationIDs.count != conversationsByID.count { - violations.append("conversationIDs lists \(conversationIDs.count) conversations but dictionary holds \(conversationsByID.count)") - } - for id in conversationIDs where conversationsByID[id] == nil { - violations.append("conversationIDs lists \(id.auditDescription) but no conversation exists") - } - - var totalMessages = 0 - for (id, conversation) in conversationsByID { - totalMessages += conversation.messages.count - conversation.collectInvariantViolations(into: &violations, label: id.auditDescription) - } - - var totalMappedMemberships = 0 - for (messageID, ids) in conversationIDsByMessageID { - totalMappedMemberships += ids.count - if ids.isEmpty { - violations.append("message map: \(messageID.prefix(8))… has an empty membership set") - } - for id in ids { - guard let conversation = conversationsByID[id] else { - violations.append("message map: \(messageID.prefix(8))… claims unknown conversation \(id.auditDescription)") - continue - } - if !conversation.containsMessage(withID: messageID) { - violations.append("message map: \(messageID.prefix(8))… not present in claimed conversation \(id.auditDescription)") - } - } - } - if totalMappedMemberships != totalMessages { - violations.append("message map holds \(totalMappedMemberships) memberships but conversations hold \(totalMessages) messages") - } - - for id in unreadConversations where conversationsByID[id] == nil { - violations.append("unreadConversations contains unknown conversation \(id.auditDescription)") - } - - if let selected = selectedConversationID, conversationsByID[selected] == nil { - violations.append("selectedConversationID \(selected.auditDescription) has no conversation") - } - - return violations - } - - // MARK: Internals - - private func registerMessageID(_ messageID: String, in id: ConversationID) { - conversationIDsByMessageID[messageID, default: []].insert(id) - // Single choke point for every successful insertion (append, upsert - // append, migration insert) — the audit heartbeat's throughput delta. - appendCount += 1 - } - - private func unregisterMessageID(_ messageID: String, from id: ConversationID) { - guard var ids = conversationIDsByMessageID[messageID] else { return } - ids.remove(id) - if ids.isEmpty { - conversationIDsByMessageID.removeValue(forKey: messageID) - } else { - conversationIDsByMessageID[messageID] = ids - } - } - - private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) { - for messageID in messageIDs { - unregisterMessageID(messageID, from: id) - } - } - - private static func cap(for id: ConversationID) -> Int { - switch id { - case .mesh: - return TransportConfig.meshTimelineCap - case .geohash: - return TransportConfig.geoTimelineCap - case .direct: - return TransportConfig.privateChatCap - } - } -} - -// MARK: - Direct-conversation keying + derived views - -extension ConversationID { - /// Direct-conversation ID keyed by the *raw* routing peer ID. - /// - /// Direct conversations are deliberately keyed per `PeerID`, not per - /// resolved identity: the private-chat coordinators mirror messages into - /// both the ephemeral and stable peer's conversations - /// (`mirrorToEphemeralIfNeeded`) and consolidate/migrate between them - /// explicitly, so a raw lookup by whichever peer ID is selected always - /// finds the right timeline without an identity-resolution layer. - static func directPeer(_ peerID: PeerID) -> ConversationID { - .direct(PeerHandle(id: "peer:\(peerID.id)", routingPeerID: peerID)) - } -} - -extension ConversationStore { - /// All direct conversations' messages keyed by routing peer ID — the - /// shape `ChatViewModel.privateChats` exposes to the coordinators. - /// Values are the conversations' backing arrays (COW), so building this - /// is O(#conversations), not O(#messages). - func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] { - var messagesByPeerID: [PeerID: [BitchatMessage]] = [:] - messagesByPeerID.reserveCapacity(conversationsByID.count) - for (id, conversation) in conversationsByID { - guard case .direct(let handle) = id else { continue } - messagesByPeerID[handle.routingPeerID] = conversation.messages - } - return messagesByPeerID - } - - /// Unread direct conversations as routing peer IDs — the shape - /// `ChatViewModel.unreadPrivateMessages` exposes to the coordinators. - func unreadDirectRoutingPeerIDs() -> Set { - var peerIDs = Set() - for id in unreadConversations { - guard case .direct(let handle) = id else { continue } - peerIDs.insert(handle.routingPeerID) - } - return peerIDs - } - - /// `true` when any direct conversation contains a message with `messageID` - /// (O(1) via the store-level message-ID → conversation map). - func directConversationsContainMessage(withID messageID: String) -> Bool { - conversationIDs(forMessageID: messageID).contains { id in - if case .direct = id { return true } - return false - } - } - - /// Message IDs across all direct conversations (read-receipt pruning - /// keeps only receipts whose messages still exist). - func directMessageIDs() -> Set { - var messageIDs = Set() - for (id, conversation) in conversationsByID { - guard case .direct = id else { continue } - messageIDs.formUnion(conversation.messageIDs) - } - return messageIDs - } - -} - -// MARK: - Diagnostics support - -extension ConversationID { - /// Short, log-safe description for audit/diagnostic lines. Direct - /// conversations truncate the handle so full peer keys never hit logs. - fileprivate var auditDescription: String { - switch self { - case .mesh: - return "mesh" - case .geohash(let geohash): - return "geo:\(geohash)" - case .direct(let handle): - return "direct:\(handle.id.prefix(13))…" - } - } -} - -#if DEBUG -// Test-only corruption hooks for `auditInvariants()` tests. The store is the -// sole writer by design — `Conversation`'s mutators are fileprivate and the -// store's backing collections are private — so the inconsistent states the -// audit exists to catch CANNOT be manufactured through the intent API. These -// DEBUG-only hooks deliberately bypass that lockdown to inject exactly those -// impossible states. Never call them outside tests. -extension Conversation { - /// Points an existing message's index entry at the wrong position - /// (positions 0 and 1 swap their index entries). Requires >= 2 messages. - func _testCorruptIndexEntries() { - guard messages.count >= 2 else { return } - indexByMessageID[messages[0].id] = indexOffset + 1 - indexByMessageID[messages[1].id] = indexOffset - } - - /// Drops a message's index entry entirely (count mismatch + missing). - func _testRemoveIndexEntry(forMessageID messageID: String) { - indexByMessageID.removeValue(forKey: messageID) - } - - /// Swaps the first and last messages while keeping the index consistent, - /// so ONLY the timestamp-order invariant is violated (requires the two - /// messages to have distinct timestamps). - func _testCorruptOrderingPreservingIndex() { - guard messages.count >= 2 else { return } - messages.swapAt(0, messages.count - 1) - indexByMessageID[messages[0].id] = indexOffset - indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1 - } -} - -extension ConversationStore { - /// Adds a map membership that the conversation does not actually hold. - func _testRegisterPhantomMessageID(_ messageID: String, in id: ConversationID) { - conversationIDsByMessageID[messageID, default: []].insert(id) - } - - /// Drops a real map membership (conversation message missing from map). - func _testUnregisterMessageID(_ messageID: String, from id: ConversationID) { - conversationIDsByMessageID[messageID]?.remove(id) - if conversationIDsByMessageID[messageID]?.isEmpty == true { - conversationIDsByMessageID.removeValue(forKey: messageID) - } - } - - /// Appends past the conversation cap, bypassing trim (map kept exact so - /// only the cap invariant is violated). - func _testAppendBypassingCap(_ message: BitchatMessage, to id: ConversationID) { - let conversation = conversation(for: id) - conversation._testAppendBypassingTrim(message) - conversationIDsByMessageID[message.id, default: []].insert(id) - } - - /// Marks a nonexistent conversation unread without creating it. - func _testInsertUnreadConversationID(_ id: ConversationID) { - unreadConversations.insert(id) - } - - /// Sets the selection directly, without `select(_:)`'s create-on-select. - func _testSetSelectedConversationID(_ id: ConversationID?) { - selectedConversationID = id - } -} - -extension Conversation { - fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) { - messages.append(message) - indexByMessageID[message.id] = indexOffset + messages.count - 1 - } -} -#endif - -// MARK: - Public timeline derived views - -extension ConversationStore { - /// Removes a message by ID from whichever public (mesh/geohash) - /// conversation contains it. Returns the removed message, if any. - @discardableResult - func removePublicMessage(withID messageID: String) -> BitchatMessage? { - for id in conversationIDs(forMessageID: messageID) { - switch id { - case .mesh, .geohash: - return removeMessage(withID: messageID, from: id) - case .direct: - continue - } - } - return nil - } -} diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift deleted file mode 100644 index 4883a04c..00000000 --- a/bitchat/App/ConversationUIModel.swift +++ /dev/null @@ -1,291 +0,0 @@ -import BitFoundation -import Combine -import SwiftUI -#if os(iOS) -import UIKit -#endif - -@MainActor -final class ConversationUIModel: ObservableObject { - @Published private(set) var showAutocomplete = false - @Published private(set) var autocompleteSuggestions: [String] = [] - @Published private(set) var selectedAutocompleteIndex = 0 - @Published private(set) var currentNickname: String - @Published private(set) var isBatchingPublic = false - @Published private(set) var canSendMediaInCurrentContext = true - @Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? - /// Who is talking live in the public mesh channel right now (floor - /// courtesy: the composer mic tints "busy" while someone holds the floor). - @Published private(set) var activeLiveVoiceTalker: String? - - private let chatViewModel: ChatViewModel - private let privateConversationModel: PrivateConversationModel - private let conversations: ConversationStore - private var activeChannel: ChannelID - private var cancellables = Set() - - init( - chatViewModel: ChatViewModel, - privateConversationModel: PrivateConversationModel, - conversations: ConversationStore - ) { - self.chatViewModel = chatViewModel - self.privateConversationModel = privateConversationModel - self.conversations = conversations - self.activeChannel = conversations.activeChannel - self.currentNickname = chatViewModel.nickname - self.isBatchingPublic = chatViewModel.isBatchingPublic - self.showAutocomplete = chatViewModel.showAutocomplete - self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions - self.selectedAutocompleteIndex = chatViewModel.selectedAutocompleteIndex - self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext - - bind() - } - - func setCurrentColorScheme(_ colorScheme: ColorScheme) { - chatViewModel.currentColorScheme = colorScheme - } - - func setCurrentTheme(_ theme: AppTheme) { - chatViewModel.currentTheme = theme - } - - func sendMessage(_ message: String) { - chatViewModel.sendMessage(message) - } - - /// Resends a failed private message through the normal send path, - /// removing the failed original so the re-submission replaces it - /// instead of stacking a duplicate under the red bubble. - func resendFailedPrivateMessage(_ message: BitchatMessage) { - chatViewModel.removePrivateMessage(withID: message.id) - chatViewModel.sendMessage(message.content) - } - - func clearCurrentConversation() { - chatViewModel.sendMessage("/clear") - } - - func sendHug(to sender: String) { - chatViewModel.sendMessage("/hug @\(sender)") - } - - func sendSlap(to sender: String) { - chatViewModel.sendMessage("/slap @\(sender)") - } - - func block(peerID: PeerID?, displayName: String?) { - guard let displayName else { return } - - if let peerID, peerID.isGeoChat, - let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) { - chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName) - } else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat { - // Mesh: block the peer's stable Noise identity resolved from the - // tapped peerID rather than re-resolving a display-name string. - chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName) - } else { - chatViewModel.sendMessage("/block \(displayName)") - } - } - - /// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by - /// the tapped peer's stable identity so the exact row is unblocked — this - /// also works for offline peers, which the `/unblock ` command - /// cannot resolve. - func unblock(peerID: PeerID, displayName: String) { - chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName) - } - - func updateAutocomplete(for text: String, cursorPosition: Int) { - chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition) - } - - func completeNickname(_ nickname: String, in text: inout String) -> Int { - chatViewModel.completeNickname(nickname, in: &text) - } - - /// Accept the currently highlighted mention suggestion, if any. - func completeSelectedSuggestion(in text: inout String) -> Bool { - guard showAutocomplete, - autocompleteSuggestions.indices.contains(selectedAutocompleteIndex) - else { return false } - _ = completeNickname(autocompleteSuggestions[selectedAutocompleteIndex], in: &text) - return true - } - - /// Dismiss the mention suggestion panel without inserting (Escape). - func dismissAutocomplete() { - guard showAutocomplete else { return } - chatViewModel.showAutocomplete = false - chatViewModel.autocompleteSuggestions = [] - chatViewModel.autocompleteRange = nil - chatViewModel.selectedAutocompleteIndex = 0 - } - - func moveAutocompleteSelection(by delta: Int) { - guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return } - let count = min(4, autocompleteSuggestions.count) - let next = (selectedAutocompleteIndex + delta + count) % count - chatViewModel.selectedAutocompleteIndex = next - } - - func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { - chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme) - } - - func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { - chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme) - } - - func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? { - message.mediaAttachment(for: currentNickname) - } - - func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool { - chatViewModel.isSelfSender(peerID: peerID, displayName: displayName) - } - - func isSentByCurrentUser(_ message: BitchatMessage) -> Bool { - message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#") - } - - func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool { - message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID - } - - /// Whether a private-message row should show the filled verification seal - /// next to the sender name (#1439). Scoped to DMs only — public timelines - /// have different trust semantics and stay undressed. - func showsVerifiedSeal(for message: BitchatMessage) -> Bool { - guard message.isPrivate, - message.sender != "system", - !isSentByCurrentUser(message), - let peerID = message.senderPeerID else { return false } - guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false } - return chatViewModel.peerIdentityStore.isVerified(fingerprint) - } - - func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? { - if peerID.isGeoDM || peerID.isGeoChat { - return chatViewModel.geohashDisplayName(for: peerID) - } - if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) { - return nickname - } - return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender - } - - #if os(iOS) - func processSelectedImage(_ image: UIImage?) { - chatViewModel.processThenSendImage(image) - } - #endif - - func processSelectedImage(from url: URL?) { - #if os(macOS) - chatViewModel.processThenSendImage(from: url) - #endif - } - - func sendVoiceNote(at url: URL) { - chatViewModel.sendVoiceNote(at: url) - } - - func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) { - chatViewModel.resolveLegacyPrivateMediaConsent( - requestID: requestID, - approved: approved - ) - } - - /// Capture backend for the mic gesture: live PTT when the current DM - /// peer can hear it now, classic voice note otherwise. - func makeVoiceCaptureSession() -> VoiceCaptureSession { - chatViewModel.makeVoiceCaptureSession() - } - - /// Whether this message is a live voice burst still streaming in. - func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool { - chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message) - } - - func cancelMediaSend(messageID: String) { - chatViewModel.cancelMediaSend(messageID: messageID) - } - - func deleteMediaMessage(messageID: String) { - chatViewModel.deleteMediaMessage(messageID: messageID) - } - - private func bind() { - chatViewModel.$nickname - .receive(on: DispatchQueue.main) - .assign(to: &$currentNickname) - - chatViewModel.$showAutocomplete - .receive(on: DispatchQueue.main) - .assign(to: &$showAutocomplete) - - chatViewModel.$autocompleteSuggestions - .receive(on: DispatchQueue.main) - .assign(to: &$autocompleteSuggestions) - - chatViewModel.$selectedAutocompleteIndex - .receive(on: DispatchQueue.main) - .assign(to: &$selectedAutocompleteIndex) - - chatViewModel.$isBatchingPublic - .receive(on: DispatchQueue.main) - .assign(to: &$isBatchingPublic) - - chatViewModel.$activePublicVoiceTalker - .receive(on: DispatchQueue.main) - .assign(to: &$activeLiveVoiceTalker) - - chatViewModel.$legacyPrivateMediaConsentRequest - .receive(on: DispatchQueue.main) - .assign(to: &$legacyPrivateMediaConsentRequest) - - conversations.$activeChannel - .receive(on: DispatchQueue.main) - .sink { [weak self] channel in - self?.activeChannel = channel - self?.refreshComputedState() - } - .store(in: &cancellables) - - privateConversationModel.$selectedPeerID - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshComputedState() - } - .store(in: &cancellables) - - // Verify/unverify while a DM is open must repaint existing rows — - // showsVerifiedSeal is computed per render, so forward the store change. - chatViewModel.peerIdentityStore.$verifiedFingerprints - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - } - - private func refreshComputedState() { - if let selectedPeerID = privateConversationModel.selectedPeerID { - // Media transfer is not wired for groups in v1; keep it off so the - // composer can't strand a media placeholder that never sends. - canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup) - return - } - - switch activeChannel { - case .mesh: - canSendMediaInCurrentContext = true - case .location: - canSendMediaInCurrentContext = false - } - } -} diff --git a/bitchat/App/LocationChannelsModel.swift b/bitchat/App/LocationChannelsModel.swift deleted file mode 100644 index 25cb2b9f..00000000 --- a/bitchat/App/LocationChannelsModel.swift +++ /dev/null @@ -1,184 +0,0 @@ -import Combine -import Foundation - -@MainActor -final class LocationChannelsModel: ObservableObject { - @Published private(set) var permissionState: LocationChannelManager.PermissionState - @Published private(set) var availableChannels: [GeohashChannel] - @Published private(set) var selectedChannel: ChannelID - @Published private(set) var teleported: Bool - @Published private(set) var bookmarks: [String] - @Published private(set) var bookmarkNames: [String: String] - @Published private(set) var locationNames: [GeohashChannelLevel: String] - @Published private(set) var userTorEnabled: Bool - @Published private(set) var gatewayEnabled: Bool - - private let manager: LocationChannelManager - private let network: NetworkActivationService - private let gateway: GatewayService - - init( - manager: LocationChannelManager? = nil, - network: NetworkActivationService? = nil, - gateway: GatewayService? = nil - ) { - let manager = manager ?? .shared - let network = network ?? .shared - let gateway = gateway ?? .shared - - self.manager = manager - self.network = network - self.gateway = gateway - self.gatewayEnabled = gateway.isEnabled - self.permissionState = manager.permissionState - self.availableChannels = manager.availableChannels - self.selectedChannel = manager.selectedChannel - self.teleported = manager.teleported - self.bookmarks = manager.bookmarks - self.bookmarkNames = manager.bookmarkNames - self.locationNames = manager.locationNames - self.userTorEnabled = network.userTorEnabled - - bind() - } - - var currentBuildingGeohash: String? { - availableChannels.first(where: { $0.level == .building })?.geohash - } - - func isSelected(_ channel: GeohashChannel) -> Bool { - guard case .location(let selected) = selectedChannel else { return false } - return selected == channel - } - - func isBookmarked(_ geohash: String) -> Bool { - manager.isBookmarked(geohash) - } - - func enableLocationChannels() { - manager.enableLocationChannels() - } - - func refreshChannels() { - manager.refreshChannels() - } - - func enableAndRefresh() { - manager.enableLocationChannels() - manager.refreshChannels() - } - - func beginLiveRefresh() { - manager.beginLiveRefresh() - } - - func endLiveRefresh() { - manager.endLiveRefresh() - } - - func select(_ channel: ChannelID) { - manager.select(channel) - } - - func markTeleported(for geohash: String, _ flag: Bool) { - manager.markTeleported(for: geohash, flag) - } - - func toggleBookmark(_ geohash: String) { - manager.toggleBookmark(geohash) - } - - func resolveBookmarkNameIfNeeded(for geohash: String) { - manager.resolveBookmarkNameIfNeeded(for: geohash) - } - - func locationName(for level: GeohashChannelLevel) -> String? { - locationNames[level] - } - - func setUserTorEnabled(_ enabled: Bool) { - network.setUserTorEnabled(enabled) - } - - func refreshMeshChannelsIfNeeded() { - guard case .mesh = selectedChannel, - permissionState == .authorized, - availableChannels.isEmpty else { - return - } - refreshChannels() - } - - func openLocationChannel(for geohash: String) { - let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") - guard (2...12).contains(normalized.count), - normalized.allSatisfy({ allowed.contains($0) }) else { - return - } - - let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized) - let isRegional = availableChannels.contains { $0.geohash == normalized } - if !isRegional && !availableChannels.isEmpty { - markTeleported(for: normalized, true) - } - select(.location(channel)) - } - - func teleport(to geohash: String) { - let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized) - markTeleported(for: normalized, true) - select(.location(channel)) - } - - private func bind() { - manager.$permissionState - .receive(on: DispatchQueue.main) - .assign(to: &$permissionState) - - manager.$availableChannels - .receive(on: DispatchQueue.main) - .assign(to: &$availableChannels) - - manager.$selectedChannel - .receive(on: DispatchQueue.main) - .assign(to: &$selectedChannel) - - manager.$teleported - .receive(on: DispatchQueue.main) - .assign(to: &$teleported) - - manager.$bookmarks - .receive(on: DispatchQueue.main) - .assign(to: &$bookmarks) - - manager.$bookmarkNames - .receive(on: DispatchQueue.main) - .assign(to: &$bookmarkNames) - - manager.$locationNames - .receive(on: DispatchQueue.main) - .assign(to: &$locationNames) - - network.$userTorEnabled - .receive(on: DispatchQueue.main) - .assign(to: &$userTorEnabled) - - gateway.$isEnabled - .receive(on: DispatchQueue.main) - .assign(to: &$gatewayEnabled) - } - - private func level(forLength length: Int) -> GeohashChannelLevel { - switch length { - case 0...2: return .region - case 3...4: return .province - case 5: return .city - case 6: return .neighborhood - case 7: return .block - case 8...12: return .building - default: return .block - } - } -} diff --git a/bitchat/App/LocationPresenceStore.swift b/bitchat/App/LocationPresenceStore.swift deleted file mode 100644 index b67731ba..00000000 --- a/bitchat/App/LocationPresenceStore.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Combine -import Foundation - -@MainActor -final class LocationPresenceStore: ObservableObject { - @Published private(set) var currentGeohash: String? - @Published private(set) var geoNicknames: [String: String] = [:] - @Published private(set) var teleportedGeo: Set = [] - - private let teleportedGeoCapacity: Int - private var teleportedGeoOrder: [String] = [] - private let geoNicknameCapacity: Int - private var geoNicknameOrder: [String] = [] - - init( - teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap, - geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap - ) { - self.teleportedGeoCapacity = max(0, teleportedGeoCapacity) - self.geoNicknameCapacity = max(0, geoNicknameCapacity) - } - - func setCurrentGeohash(_ geohash: String?) { - let normalized = geohash?.lowercased() - if currentGeohash != normalized { - // Presence markers are scoped to the active geohash channel. - clearTeleportedGeo() - clearGeoNicknames() - } - currentGeohash = normalized - } - - func setNickname(_ nickname: String, for pubkeyHex: String) { - guard geoNicknameCapacity > 0 else { - clearGeoNicknames() - return - } - - let nickname = nickname.normalizedNickname - let key = pubkeyHex.lowercased() - if geoNicknames[key] != nil { - geoNicknames[key] = nickname - return - } - - while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first { - geoNicknameOrder.removeFirst() - geoNicknames.removeValue(forKey: oldest) - } - - geoNicknames[key] = nickname - geoNicknameOrder.append(key) - } - - func replaceGeoNicknames(_ nicknames: [String: String]) { - guard geoNicknameCapacity > 0 else { - clearGeoNicknames() - return - } - - var seen: Set = [] - var ordered: [String] = [] - var normalized: [String: String] = [:] - for (key, value) in nicknames { - let lower = key.lowercased() - guard seen.insert(lower).inserted else { continue } - ordered.append(lower) - normalized[lower] = value.normalizedNickname - } - if ordered.count > geoNicknameCapacity { - let kept = Array(ordered.suffix(geoNicknameCapacity)) - ordered = kept - normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in - normalized[key].map { (key, $0) } - }) - } - geoNicknameOrder = ordered - geoNicknames = normalized - } - - func clearGeoNicknames() { - geoNicknames.removeAll() - geoNicknameOrder.removeAll() - } - - func retainGeoNicknames(keeping pubkeys: Set) { - let allowed = Set(pubkeys.map { $0.lowercased() }) - geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) } - geoNicknames = geoNicknames.filter { allowed.contains($0.key) } - } - - func markTeleported(_ pubkeyHex: String) { - guard teleportedGeoCapacity > 0 else { - clearTeleportedGeo() - return - } - - let key = pubkeyHex.lowercased() - guard !teleportedGeo.contains(key) else { return } - - while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first { - teleportedGeoOrder.removeFirst() - teleportedGeo.remove(oldest) - } - - teleportedGeo.insert(key) - teleportedGeoOrder.append(key) - } - - func clearTeleported(_ pubkeyHex: String) { - let key = pubkeyHex.lowercased() - teleportedGeo.remove(key) - teleportedGeoOrder.removeAll { $0 == key } - } - - func replaceTeleportedGeo(_ pubkeys: Set) { - guard teleportedGeoCapacity > 0 else { - clearTeleportedGeo() - return - } - - var seen: Set = [] - var ordered: [String] = [] - for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) { - seen.insert(key) - ordered.append(key) - } - if ordered.count > teleportedGeoCapacity { - ordered = Array(ordered.suffix(teleportedGeoCapacity)) - } - teleportedGeoOrder = ordered - teleportedGeo = Set(ordered) - } - - func retainTeleportedGeo(keeping pubkeys: Set) { - let allowed = Set(pubkeys.map { $0.lowercased() }) - teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) } - teleportedGeo = teleportedGeo.intersection(allowed) - } - - func clearTeleportedGeo() { - teleportedGeo.removeAll() - teleportedGeoOrder.removeAll() - } - - func reset() { - currentGeohash = nil - geoNicknames.removeAll() - geoNicknameOrder.removeAll() - teleportedGeo.removeAll() - teleportedGeoOrder.removeAll() - } -} diff --git a/bitchat/App/NearbyNotesCounter.swift b/bitchat/App/NearbyNotesCounter.swift deleted file mode 100644 index d1863970..00000000 --- a/bitchat/App/NearbyNotesCounter.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// NearbyNotesCounter.swift -// bitchat -// -// Counts unexpired location notes left at the user's current building-level -// geohash so the empty mesh timeline can say "📍 3 notes left here". Only -// subscribes while a view holds it active, and only when location notes are -// enabled and location permission is already granted (it never prompts). -// This is free and unencumbered software released into the public domain. -// - -import Combine -import Foundation - -@MainActor -final class NearbyNotesCounter: ObservableObject { - static let shared = NearbyNotesCounter() - - @Published private(set) var noteCount = 0 - /// Whether an explicit notes act (the empty-timeline "check for notes" - /// tap, opening the notices sheet's geo tab, or a successful /drop) has - /// unlocked the counter this session. Until then nothing subscribes: - /// merely looking at the mesh timeline must not open a building-precision - /// relay REQ that leaks location passively. - @Published private(set) var revealed = false - - private var manager: LocationNotesManager? - private var managerCancellable: AnyCancellable? - private var channelsCancellable: AnyCancellable? - private var permissionCancellable: AnyCancellable? - private var settingCancellable: AnyCancellable? - private var activeHolders = 0 - private let locationManager: LocationChannelManager - private let managerFactory: @MainActor (String) -> LocationNotesManager - private let releaseManager: @MainActor (LocationNotesManager?) -> Void - private let locationNotesEnabled: @MainActor () -> Bool - private let locationNotesSettingsPublisher: AnyPublisher - - init( - locationManager: LocationChannelManager = .shared, - managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) }, - releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) }, - locationNotesEnabled: @escaping @MainActor () -> Bool = { LocationNotesSettings.enabled }, - locationNotesSettings: AnyPublisher? = nil - ) { - self.locationManager = locationManager - self.managerFactory = managerFactory - self.releaseManager = releaseManager - self.locationNotesEnabled = locationNotesEnabled - self.locationNotesSettingsPublisher = locationNotesSettings - ?? NotificationCenter.default - .publisher(for: LocationNotesSettings.didChangeNotification) - .map { _ in () } - .eraseToAnyPublisher() - } - - /// Whether the empty-timeline "check for notes" hint should render. - /// The permission gate matters: `retarget()` never subscribes without - /// location authorization, so offering the hint to an unauthorized - /// install would be a silent dead-end — tap, `revealed` flips, the hint - /// vanishes, and nothing else happens for the session. The hint never - /// prompts; it simply stays hidden until permission exists. The caller - /// passes its own observed permission state so the hint re-renders when - /// authorization changes. - func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool { - !revealed && locationNotesEnabled() && permissionState == .authorized - } - - /// Marks the one explicit act that lets the counter subscribe. Sticky for - /// the rest of the session (the singleton's lifetime); `deactivate()` - /// deliberately does not reset it. - func reveal() { - guard !revealed else { return } - revealed = true - retarget() - } - - /// Begins (or keeps) the notes subscription for the current building - /// geohash. Balanced by `deactivate()`; ref-counted so multiple views can - /// hold it. - func activate() { - activeHolders += 1 - guard activeHolders == 1 else { return } - channelsCancellable = locationManager.$availableChannels - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in self?.retarget() } - // CoreLocation can revoke authorization while the view remains - // mounted. `availableChannels` deliberately retains its last value, - // so permission must be an independent invalidation signal or the - // building REQ survives on stale coordinates. - permissionCancellable = locationManager.$permissionState - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in self?.retarget() } - // The app-info kill switch must take effect immediately, not on the - // next location change or remount. - settingCancellable = locationNotesSettingsPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in self?.retarget() } - retarget() - } - - func deactivate() { - activeHolders = max(0, activeHolders - 1) - guard activeHolders == 0 else { return } - channelsCancellable = nil - permissionCancellable = nil - settingCancellable = nil - managerCancellable = nil - releaseManager(manager) - manager = nil - noteCount = 0 - } - - private func retarget() { - guard activeHolders > 0, - revealed, - locationNotesEnabled(), - locationManager.permissionState == .authorized, - let geohash = locationManager.availableChannels - .first(where: { $0.level == .building })?.geohash - else { - managerCancellable = nil - releaseManager(manager) - manager = nil - noteCount = 0 - return - } - - if let manager { - guard manager.geohash != geohash.lowercased() else { return } - // Pooled managers are shared; never retarget one in place — - // release the old cell and acquire the new one. - managerCancellable = nil - releaseManager(manager) - self.manager = nil - } - - let fresh = managerFactory(geohash) - manager = fresh - managerCancellable = fresh.$notes - .receive(on: DispatchQueue.main) - .sink { [weak self] notes in - let now = Date() - self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count - } - } -} diff --git a/bitchat/App/PeerIdentityStore.swift b/bitchat/App/PeerIdentityStore.swift deleted file mode 100644 index e7e49f01..00000000 --- a/bitchat/App/PeerIdentityStore.swift +++ /dev/null @@ -1,117 +0,0 @@ -import BitFoundation -import Combine -import Foundation - -@MainActor -final class PeerIdentityStore: ObservableObject { - @Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:] - @Published private(set) var verifiedFingerprints: Set = [] - - private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:] - private(set) var selectedPrivateChatFingerprint: String? - - private var stablePeerIDsByShortID: [PeerID: PeerID] = [:] - private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:] - - func stablePeerID(forShortID peerID: PeerID) -> PeerID? { - stablePeerIDsByShortID[peerID] - } - - func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? { - stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key - } - - func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) { - stablePeerIDsByShortID[peerID] = stablePeerID - } - - func fingerprint(for peerID: PeerID) -> String? { - peerFingerprintsByPeerID[peerID] - } - - func setFingerprint(_ fingerprint: String?, for peerID: PeerID) { - if let fingerprint { - peerFingerprintsByPeerID[peerID] = fingerprint - } else { - peerFingerprintsByPeerID.removeValue(forKey: peerID) - } - } - - func replaceFingerprintMappings(_ mappings: [PeerID: String]) { - peerFingerprintsByPeerID = mappings - } - - @discardableResult - func migrateFingerprintMapping( - from oldPeerID: PeerID, - to newPeerID: PeerID, - fallback: String? = nil - ) -> String? { - let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback - if let fingerprint { - peerFingerprintsByPeerID[newPeerID] = fingerprint - if selectedPrivateChatFingerprint == nil { - selectedPrivateChatFingerprint = fingerprint - } - } - return fingerprint - } - - func setSelectedPrivateChatFingerprint(_ fingerprint: String?) { - selectedPrivateChatFingerprint = fingerprint - } - - func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? { - encryptionStatusCache[peerID] - } - - func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) { - encryptionStatusCache[peerID] = status - } - - func invalidateEncryptionCache(for peerID: PeerID? = nil) { - if let peerID { - encryptionStatusCache.removeValue(forKey: peerID) - } else { - encryptionStatusCache.removeAll() - } - } - - func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? { - encryptionStatuses[peerID] - } - - func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { - if let status { - encryptionStatuses[peerID] = status - } else { - encryptionStatuses.removeValue(forKey: peerID) - } - invalidateEncryptionCache(for: peerID) - } - - func setVerifiedFingerprints(_ fingerprints: Set) { - verifiedFingerprints = fingerprints - } - - func setVerified(_ fingerprint: String, verified: Bool) { - if verified { - verifiedFingerprints.insert(fingerprint) - } else { - verifiedFingerprints.remove(fingerprint) - } - } - - func isVerified(_ fingerprint: String) -> Bool { - verifiedFingerprints.contains(fingerprint) - } - - func clearAll() { - encryptionStatuses.removeAll() - verifiedFingerprints.removeAll() - peerFingerprintsByPeerID.removeAll() - selectedPrivateChatFingerprint = nil - stablePeerIDsByShortID.removeAll() - encryptionStatusCache.removeAll() - } -} diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift deleted file mode 100644 index 4f1fc633..00000000 --- a/bitchat/App/PeerListModel.swift +++ /dev/null @@ -1,418 +0,0 @@ -import BitFoundation -import Combine -import SwiftUI - -struct MeshPeerRow: Identifiable, Equatable { - let peerID: PeerID - let displayName: String - let isMe: Bool - let hasUnread: Bool - let isBlocked: Bool - let isFavorite: Bool - let isConnected: Bool - let isReachable: Bool - let isMutualFavorite: Bool - let encryptionStatus: EncryptionStatus - let showsVerifiedBadgeWhenOffline: Bool - /// Vouched-for by someone I verified, without an explicit verification of - /// mine — rendered as the unfilled seal (verified gets the filled one). - let showsVouchedBadge: Bool - - var id: String { peerID.id } -} - -struct GeohashPersonRow: Identifiable, Equatable { - let id: String - let displayName: String - let isMe: Bool - let isTeleported: Bool - let isBlocked: Bool -} - -struct GroupChatRow: Identifiable, Equatable { - let peerID: PeerID - let name: String - let memberCount: Int - let isCreator: Bool - let hasUnread: Bool - - var id: String { peerID.id } -} - -/// A direct conversation whose person is NOT in any roster above it. Without -/// this section, a DM from a passerby became unreachable the moment they went -/// offline: the roster filters to connected/reachable/mutual-favorite, the -/// header envelope only exists while unread, and /msg can't resolve offline -/// strangers — the thread was still in memory with no row anywhere in the UI. -struct RecentChatRow: Identifiable, Equatable { - let peerID: PeerID - let displayName: String - let hasUnread: Bool - let lastActivity: Date - - var id: String { peerID.id } -} - -@MainActor -final class PeerListModel: ObservableObject { - @Published private(set) var allPeers: [BitchatPeer] = [] - @Published private(set) var meshRows: [MeshPeerRow] = [] - @Published private(set) var geohashPeople: [GeohashPersonRow] = [] - @Published private(set) var groupRows: [GroupChatRow] = [] - @Published private(set) var recentChatRows: [RecentChatRow] = [] - @Published private(set) var reachableMeshPeerCount = 0 - @Published private(set) var connectedMeshPeerCount = 0 - @Published private(set) var visibleGeohashPeerCount = 0 - @Published private(set) var renderID = "" - - private let chatViewModel: ChatViewModel - private let conversations: ConversationStore - private let locationChannelsModel: LocationChannelsModel - private let peerIdentityStore: PeerIdentityStore - private let locationPresenceStore: LocationPresenceStore - private var cancellables = Set() - - init( - chatViewModel: ChatViewModel, - conversations: ConversationStore, - locationChannelsModel: LocationChannelsModel? = nil, - peerIdentityStore: PeerIdentityStore? = nil, - locationPresenceStore: LocationPresenceStore? = nil - ) { - self.chatViewModel = chatViewModel - self.conversations = conversations - self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel() - self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore - self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore - self.allPeers = chatViewModel.allPeers - - bind() - refresh() - } - - func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color { - chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark) - } - - func colorForGeohashPerson(id: String, isDark: Bool) -> Color { - chatViewModel.colorForNostrPubkey(id, isDark: isDark) - } - - func participantCount(for geohash: String) -> Int { - chatViewModel.geohashParticipantCount(for: geohash) - } - - func startConversation(with peerID: PeerID) { - chatViewModel.startPrivateChat(with: peerID) - } - - func toggleFavorite(peerID: PeerID) { - chatViewModel.toggleFavorite(peerID: peerID) - } - - func openGeohashDirectMessage(with pubkeyHex: String) { - chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex) - } - - func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { - chatViewModel.blockGeohashUser( - pubkeyHexLowercased: pubkeyHexLowercased, - displayName: displayName - ) - } - - func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { - chatViewModel.unblockGeohashUser( - pubkeyHexLowercased: pubkeyHexLowercased, - displayName: displayName - ) - } - - private func bind() { - chatViewModel.$allPeers - .receive(on: DispatchQueue.main) - .sink { [weak self] peers in - self?.allPeers = peers - self?.refresh() - } - .store(in: &cancellables) - - chatViewModel.$nickname - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - - locationPresenceStore.$teleportedGeo - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - - conversations.$unreadConversations - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - - // Direct-conversation changes reorder or create recent-chat rows. - // Filtered to `.direct` so public-timeline traffic (every mesh or - // geohash message emits a change) doesn't rebuild the sheet. - conversations.changes - .receive(on: DispatchQueue.main) - .sink { [weak self] change in - guard case .direct = Self.changedConversationID(change) else { return } - self?.refresh() - } - .store(in: &cancellables) - - chatViewModel.groupStore.$groups - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - - peerIdentityStore.$encryptionStatuses - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - - peerIdentityStore.$verifiedFingerprints - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - - chatViewModel.participantTracker.$visiblePeople - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - - locationChannelsModel.$selectedChannel - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - - locationChannelsModel.$teleported - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - - locationChannelsModel.$availableChannels - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refresh() - } - .store(in: &cancellables) - } - - private func refresh() { - let myPeerID = chatViewModel.meshService.myPeerID - let meshRows = allPeers.map { peer in - let isMe = peer.peerID == myPeerID - let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID) - let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false - let verifiedBadge = !peer.isConnected && isVerifiedFingerprint - // Vouched is subordinate to verified: never show both seals. - let vouchedBadge = !isVerifiedFingerprint - && (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false) - - return MeshPeerRow( - peerID: peer.peerID, - displayName: isMe ? chatViewModel.nickname : peer.displayName, - isMe: isMe, - hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID), - isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID), - isFavorite: peer.favoriteStatus?.isFavorite ?? false, - isConnected: peer.isConnected, - isReachable: peer.isReachable, - isMutualFavorite: peer.isMutualFavorite, - encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID), - showsVerifiedBadgeWhenOffline: verifiedBadge, - showsVouchedBadge: vouchedBadge - ) - } - - let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in - guard !row.isMe else { return } - if row.isConnected { - counts.connected += 1 - counts.reachable += 1 - } else if row.isReachable { - counts.reachable += 1 - } - } - - let geohashPeople = buildGeohashPeople() - let groupRows = buildGroupRows() - let recentChatRows = buildRecentChatRows(meshRows: meshRows, geohashPeople: geohashPeople) - - self.meshRows = meshRows - reachableMeshPeerCount = meshCounts.reachable - connectedMeshPeerCount = meshCounts.connected - self.geohashPeople = geohashPeople - visibleGeohashPeerCount = geohashPeople.count - self.groupRows = groupRows - self.recentChatRows = recentChatRows - renderID = ( - meshRows.map { - "\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" - } + - geohashPeople.map { - "geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)" - } + - groupRows.map { - "group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)" - } + - recentChatRows.map { - "chat:\($0.id)-\($0.displayName)-\($0.hasUnread)" - } - ).joined(separator: "|") - } - - /// Direct conversations with people absent from the rosters above: - /// the offline passerby DM, the geoDM from a channel since left. Rows - /// mirrored under both an ephemeral and a stable peer ID collapse to - /// one row per identity (newest activity wins), matching how the - /// private-chat coordinators consolidate on open. - private func buildRecentChatRows( - meshRows: [MeshPeerRow], - geohashPeople: [GeohashPersonRow] - ) -> [RecentChatRow] { - // A conversation can be keyed by the stable Noise peer ID while the - // roster lists the ephemeral one — compare fingerprints too. - var visibleIdentities = Set() - for row in meshRows { - visibleIdentities.insert(row.peerID.id) - if let fingerprint = chatViewModel.getFingerprint(for: row.peerID) { - visibleIdentities.insert(fingerprint) - } - } - // Someone visible in the geohash section must not also get a chat - // row: their GeoDM conversation is keyed by the nostr_ form of the - // same pubkey the roster lists. - for person in geohashPeople { - visibleIdentities.insert(PeerID(nostr_: person.id).id) - } - - struct Candidate { - let peerID: PeerID - let lastActivity: Date - } - var bestByIdentity: [String: Candidate] = [:] - - for (id, conversation) in conversations.conversationsByID { - guard case .direct(let handle) = id else { continue } - let peerID = handle.routingPeerID - // Groups have their own section; blocked people get no row. - guard !peerID.isGroup else { continue } - guard let lastMessage = conversation.messages.last else { continue } - guard !chatViewModel.isPeerBlocked(peerID) else { continue } - - let fingerprint = chatViewModel.getFingerprint(for: peerID) - if visibleIdentities.contains(peerID.id) { continue } - if let fingerprint, visibleIdentities.contains(fingerprint) { continue } - - let identityKey = fingerprint ?? peerID.id - if let existing = bestByIdentity[identityKey], - existing.lastActivity >= lastMessage.timestamp { - continue - } - bestByIdentity[identityKey] = Candidate(peerID: peerID, lastActivity: lastMessage.timestamp) - } - - return bestByIdentity.values - .sorted { $0.lastActivity > $1.lastActivity } - .map { candidate in - RecentChatRow( - peerID: candidate.peerID, - displayName: displayName(for: candidate.peerID), - hasUnread: chatViewModel.hasUnreadMessages(for: candidate.peerID), - lastActivity: candidate.lastActivity - ) - } - } - - /// GeoDM conversations resolve through the Nostr mapping — - /// `resolveNickname` only consults mesh identity sources and would - /// render a `nostr_…` key as an opaque `anonnost` fallback. - private func displayName(for peerID: PeerID) -> String { - if peerID.isGeoDM { - return chatViewModel.geohashDisplayName(for: peerID) - } - return chatViewModel.resolveNickname(for: peerID) - } - - private static func changedConversationID(_ change: ConversationChange) -> ConversationID { - switch change { - case .appended(let id, _), - .updated(let id, _), - .statusChanged(let id, _, _), - .messageRemoved(let id, _), - .cleared(let id), - .removed(let id), - .unreadChanged(let id, _): - return id - case .migrated(_, let to): - return to - } - } - - private func buildGroupRows() -> [GroupChatRow] { - let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint() - return chatViewModel.groupStore.groups.map { group in - GroupChatRow( - peerID: group.peerID, - name: group.name, - memberCount: group.members.count, - isCreator: group.creatorFingerprint == myFingerprint, - hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID) - ) - } - } - - private func buildGeohashPeople() -> [GeohashPersonRow] { - let myHex = currentGeohashIdentityHex() - let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() }) - - return chatViewModel.visibleGeohashPeople().map { person in - let isMe = person.id == myHex - return GeohashPersonRow( - id: person.id, - displayName: person.displayName, - isMe: isMe, - isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported), - isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) - ) - } - } - - private func currentGeohashIdentityHex() -> String? { - guard case .location(let channel) = locationChannelsModel.selectedChannel, - let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else { - return nil - } - - return identity.publicKeyHex.lowercased() - } -} diff --git a/bitchat/App/PrivacyScreen.swift b/bitchat/App/PrivacyScreen.swift deleted file mode 100644 index 7e422057..00000000 --- a/bitchat/App/PrivacyScreen.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// PrivacyScreen.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -#if os(iOS) -import UIKit - -/// Covers the window while the app is not frontmost, so the snapshot iOS takes -/// for the app switcher shows a placeholder instead of the open conversation. -/// -/// The cover is added on `willResignActive` and removed on `didBecomeActive`. -/// Both are deliberately UIKit notifications rather than SwiftUI's `scenePhase`: -/// the snapshot is captured shortly after `willResignActive`, and adding an -/// opaque subview to the window synchronously in that callback is the only way -/// to guarantee it is in the render tree before the capture. A SwiftUI overlay -/// driven by state may not have been laid out yet. -/// -/// Panic wipe separately deletes any snapshots already on disk; this keeps new -/// ones from containing anything worth deleting. -final class PrivacyScreen { - static let shared = PrivacyScreen() - - private var cover: UIView? - private var observers: [NSObjectProtocol] = [] - - private init() {} - - /// Idempotent: repeated calls do not stack observers. - /// - /// `queue: nil` is required, not incidental. Passing an `OperationQueue` - /// would enqueue the handler to run in a later runloop turn, which the - /// snapshot can beat; with no queue the block runs synchronously on the - /// thread that posted the notification — the main thread, for UIApplication - /// lifecycle notifications. - func install() { - guard observers.isEmpty else { return } - let center = NotificationCenter.default - observers = [ - center.addObserver( - forName: UIApplication.willResignActiveNotification, - object: nil, - queue: nil - ) { _ in - PrivacyScreen.shared.show() - }, - center.addObserver( - forName: UIApplication.didBecomeActiveNotification, - object: nil, - queue: nil - ) { _ in - PrivacyScreen.shared.hide() - } - ] - } - - private func show() { - guard cover == nil, let window = Self.activeWindow() else { return } - - // Opaque rather than a blur: blurred large text can stay partly - // legible, and the snapshot is stored on disk. - let view = UIView(frame: window.bounds) - view.backgroundColor = .systemBackground - view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - let label = UILabel() - label.text = "bitchat" - label.font = .monospacedSystemFont(ofSize: 22, weight: .medium) - label.textColor = .secondaryLabel - label.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(label) - NSLayoutConstraint.activate([ - label.centerXAnchor.constraint(equalTo: view.centerXAnchor), - label.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - - window.addSubview(view) - cover = view - } - - private func hide() { - cover?.removeFromSuperview() - cover = nil - } - - private static func activeWindow() -> UIWindow? { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first { $0.isKeyWindow } ?? - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first - } -} -#endif diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift deleted file mode 100644 index 80d5cf29..00000000 --- a/bitchat/App/PrivateConversationModels.swift +++ /dev/null @@ -1,372 +0,0 @@ -import BitFoundation -import Combine -import Foundation - -/// Feature model for private (direct) conversations. -/// -/// Reads the single-writer `ConversationStore` directly: `messages(for:)` -/// returns the peer's conversation backing array (no mirror dictionary), and -/// the store's typed `changes` subject drives invalidation — a change in the -/// SELECTED peer's conversation republishes this model, while appends to -/// other private chats only surface through the unread set. Direct -/// conversations are keyed by raw routing peer ID; the coordinators' -/// ephemeral/stable mirroring guarantees the selected peer's key always -/// holds the full timeline (see `ConversationID.directPeer`). -@MainActor -final class PrivateInboxModel: ObservableObject { - @Published private(set) var selectedPeerID: PeerID? - @Published private(set) var unreadPeerIDs: Set = [] - - private let conversations: ConversationStore - private var cancellables = Set() - - init(conversations: ConversationStore) { - self.conversations = conversations - self.selectedPeerID = conversations.selectedPrivatePeerID - self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs() - - bind() - } - - func messages(for peerID: PeerID?) -> [BitchatMessage] { - guard let peerID else { return [] } - return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? [] - } - - private func bind() { - conversations.$selectedPrivatePeerID - .dropFirst() - .sink { [weak self] peerID in - guard let self, self.selectedPeerID != peerID else { return } - self.selectedPeerID = peerID - } - .store(in: &cancellables) - - conversations.changes - .sink { [weak self] change in - self?.apply(change) - } - .store(in: &cancellables) - } - - private func apply(_ change: ConversationChange) { - switch change { - case .appended(let id, _), - .updated(let id, _), - .statusChanged(let id, _, _), - .messageRemoved(let id, _), - .cleared(let id): - republishIfSelected(id) - - case .unreadChanged(let id, _): - guard isDirect(id) else { return } - refreshUnreadPeerIDs() - - case .removed(let id): - guard isDirect(id) else { return } - refreshUnreadPeerIDs() - republishIfSelected(id) - - case .migrated(let source, let destination): - guard isDirect(source) || isDirect(destination) else { return } - refreshUnreadPeerIDs() - republishIfSelected(source) - republishIfSelected(destination) - } - } - - private func republishIfSelected(_ id: ConversationID) { - guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return } - objectWillChange.send() - } - - private func refreshUnreadPeerIDs() { - let next = conversations.unreadDirectRoutingPeerIDs() - guard unreadPeerIDs != next else { return } - unreadPeerIDs = next - } - - private func isDirect(_ id: ConversationID) -> Bool { - if case .direct = id { return true } - return false - } -} - -enum PrivateConversationAvailability: Equatable { - case bluetoothConnected - case meshReachable - case nostrAvailable - case offline -} - -struct PrivateConversationHeaderState: Equatable { - let conversationPeerID: PeerID - let headerPeerID: PeerID - let displayName: String - let availability: PrivateConversationAvailability - let isFavorite: Bool - let encryptionStatus: EncryptionStatus? - - var supportsFavoriteToggle: Bool { - !conversationPeerID.isGeoDM && !conversationPeerID.isGroup - } - - /// Group chats have no single peer identity behind the header: no - /// fingerprint screen, no per-peer encryption badge. - var isGroupConversation: Bool { - conversationPeerID.isGroup - } -} - -@MainActor -final class PrivateConversationModel: ObservableObject { - @Published private(set) var selectedPeerID: PeerID? - @Published private(set) var selectedHeaderState: PrivateConversationHeaderState? - - private let chatViewModel: ChatViewModel - private let conversations: ConversationStore - private let locationChannelsModel: LocationChannelsModel - private let peerIdentityStore: PeerIdentityStore - private var cancellables = Set() - - init( - chatViewModel: ChatViewModel, - conversations: ConversationStore, - locationChannelsModel: LocationChannelsModel? = nil, - peerIdentityStore: PeerIdentityStore? = nil - ) { - self.chatViewModel = chatViewModel - self.conversations = conversations - self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel() - self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore - let initialPeerID = conversations.selectedPrivatePeerID - self.selectedPeerID = initialPeerID - self.selectedHeaderState = initialPeerID.flatMap { peerID in - makeHeaderState(for: peerID) - } - - bind() - } - - func startConversation(with peerID: PeerID) { - chatViewModel.startPrivateChat(with: peerID) - refreshSelectedConversation() - } - - func openConversation(for peerID: PeerID) { - if peerID.isGeoChat { - guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return } - chatViewModel.startGeohashDM(withPubkeyHex: full) - } else { - chatViewModel.startPrivateChat(with: peerID) - } - - refreshSelectedConversation() - } - - func endConversation() { - chatViewModel.endPrivateChat() - refreshSelectedConversation() - } - - func toggleFavorite(peerID: PeerID) { - chatViewModel.toggleFavorite(peerID: peerID) - refreshSelectedConversation() - } - - func toggleFavoriteForSelectedConversation() { - guard let headerPeerID = selectedHeaderState?.headerPeerID else { return } - toggleFavorite(peerID: headerPeerID) - } - - func markMessagesAsRead(from peerID: PeerID) { - chatViewModel.markPrivateMessagesAsRead(from: peerID) - } - - private func bind() { - conversations.$selectedPrivatePeerID - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshSelectedConversation() - } - .store(in: &cancellables) - - chatViewModel.$allPeers - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshSelectedConversation() - } - .store(in: &cancellables) - - peerIdentityStore.$encryptionStatuses - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshSelectedConversation() - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .favoriteStatusChanged) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshSelectedConversation() - } - .store(in: &cancellables) - - chatViewModel.groupStore.$groups - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshSelectedConversation() - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshSelectedConversation() - } - .store(in: &cancellables) - - locationChannelsModel.$selectedChannel - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshSelectedConversation() - } - .store(in: &cancellables) - } - - private func refreshSelectedConversation() { - selectedPeerID = conversations.selectedPrivatePeerID - selectedHeaderState = selectedPeerID.flatMap { peerID in - makeHeaderState(for: peerID) - } - } - - private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState { - // Group chats: the "peer" is the whole crew. Name + member count in - // the header; availability reads as mesh since group traffic floods - // the local mesh, and the per-peer encryption badge does not apply. - if conversationPeerID.isGroup { - let displayName: String - if let group = chatViewModel.groupStore.group(for: conversationPeerID) { - displayName = "#\(group.name) (\(group.members.count))" - } else { - displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer") - } - return PrivateConversationHeaderState( - conversationPeerID: conversationPeerID, - headerPeerID: conversationPeerID, - displayName: displayName, - availability: .meshReachable, - isFavorite: false, - encryptionStatus: nil - ) - } - - let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) - let peer = chatViewModel.getPeer(byID: headerPeerID) - let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) - // Geo DMs are always routed through BitChat private envelopes over - // Nostr; their nostr_ keys never resolve to a reachable mesh peer, so - // resolveAvailability would report .offline. Report .nostrAvailable - // so the header shows the globe instead of a misleading "offline" tag. - let availability = conversationPeerID.isGeoDM - ? .nostrAvailable - : resolveAvailability(for: headerPeerID, peer: peer) - let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM - ? nil - : chatViewModel.getEncryptionStatus(for: headerPeerID) - - return PrivateConversationHeaderState( - conversationPeerID: conversationPeerID, - headerPeerID: headerPeerID, - displayName: displayName, - availability: availability, - isFavorite: chatViewModel.isFavorite(peerID: headerPeerID), - encryptionStatus: encryptionStatus - ) - } - - private func resolveDisplayName( - for conversationPeerID: PeerID, - headerPeerID: PeerID, - peer: BitchatPeer? - ) -> String { - if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel { - return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))" - } - // Local alias wins over a live peer row's announced nickname. - if headerPeerID.id.count == 16 { - let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) - if let identity = candidates.first, - let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint), - let pet = social.localPetname, !pet.isEmpty { - return pet - } - } else if let noiseKey = headerPeerID.noiseKey { - let fingerprint = noiseKey.sha256Fingerprint() - if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint), - let pet = social.localPetname, !pet.isEmpty { - return pet - } - } - if let displayName = peer?.displayName { - return displayName - } - if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) { - return nickname - } - if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus( - for: Data(hexString: headerPeerID.id) ?? Data() - ), !favorite.peerNickname.isEmpty { - return favorite.peerNickname - } - if headerPeerID.id.count == 16 { - let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) - if let identity = candidates.first, - let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint), - !social.claimedNickname.isEmpty { - return social.claimedNickname - } - } else if let noiseKey = headerPeerID.noiseKey { - let fingerprint = noiseKey.sha256Fingerprint() - if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint), - !social.claimedNickname.isEmpty { - return social.claimedNickname - } - } - - return String(localized: "common.unknown", comment: "Fallback label for unknown peer") - } - - private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability { - if let connectionState = peer?.connectionState { - switch connectionState { - case .bluetoothConnected: - return .bluetoothConnected - case .meshReachable: - return .meshReachable - case .nostrAvailable: - return .nostrAvailable - case .offline: - return .offline - } - } - - if chatViewModel.meshService.isPeerReachable(headerPeerID) { - return .meshReachable - } - if let noiseKey = Data(hexString: headerPeerID.id), - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - favoriteStatus.isMutual, - // No stored recipient key means no Nostr delivery — and the - // "end-to-end encrypted" caption keys off .nostrAvailable. - favoriteStatus.peerNostrPublicKey != nil { - return .nostrAvailable - } - if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) { - return .bluetoothConnected - } - - return .offline - } -} diff --git a/bitchat/App/PublicChatModel.swift b/bitchat/App/PublicChatModel.swift deleted file mode 100644 index fc9040ac..00000000 --- a/bitchat/App/PublicChatModel.swift +++ /dev/null @@ -1,77 +0,0 @@ -import BitFoundation -import Combine -import SwiftUI - -/// Feature model for the active public (mesh/geohash) timeline. -/// -/// Observes ONE `Conversation` object in the single-writer -/// `ConversationStore` — the active channel's — so appends to background -/// conversations (other geohashes, private chats) never invalidate it. -/// `messages` reads the observed conversation's backing array directly; -/// there is no mirror copy. -@MainActor -final class PublicChatModel: ObservableObject { - @Published private(set) var activeChannel: ChannelID - - /// The active public conversation's timeline. - var messages: [BitchatMessage] { activeConversation.messages } - - private let conversations: ConversationStore - private var activeConversation: Conversation - private var activeConversationCancellable: AnyCancellable? - private var cancellables = Set() - - init(conversations: ConversationStore) { - let channel = conversations.activeChannel - self.conversations = conversations - self.activeChannel = channel - self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel)) - - observeActiveConversation() - bind() - } - - private func bind() { - conversations.$activeChannel - .dropFirst() - .sink { [weak self] channel in - guard let self else { return } - self.activeChannel = channel - self.retargetActiveConversation(to: channel) - } - .store(in: &cancellables) - - // The store replaces a conversation's object when it is removed - // (panic clear); retarget to the fresh instance so the observation - // never goes stale. - conversations.changes - .sink { [weak self] change in - guard let self, - case .removed(let id) = change, - id == self.activeConversation.id else { return } - self.retargetActiveConversation(to: self.activeChannel) - } - .store(in: &cancellables) - } - - private func retargetActiveConversation(to channel: ChannelID) { - let conversation = conversations.conversation(for: ConversationID(channelID: channel)) - guard conversation !== activeConversation else { - // Same object (e.g. re-selected channel): keep the existing - // observation, but `messages` may still differ from what views - // last rendered, so republish. - objectWillChange.send() - return - } - objectWillChange.send() - activeConversation = conversation - observeActiveConversation() - } - - private func observeActiveConversation() { - activeConversationCancellable = activeConversation.objectWillChange - .sink { [weak self] _ in - self?.objectWillChange.send() - } - } -} diff --git a/bitchat/App/SharedContentImportModel.swift b/bitchat/App/SharedContentImportModel.swift deleted file mode 100644 index 68e20aa1..00000000 --- a/bitchat/App/SharedContentImportModel.swift +++ /dev/null @@ -1,119 +0,0 @@ -import BitFoundation -import Combine -import Foundation - -enum SharedContentDestination: Sendable, Equatable { - case mesh - case geohash(String) - case privateConversation(peerID: PeerID, displayName: String) - - static func resolve( - selectedPrivatePeerID: PeerID?, - privateDisplayName: String?, - activeChannel: ChannelID - ) -> SharedContentDestination { - if let selectedPrivatePeerID { - let fallback = String(selectedPrivatePeerID.id.prefix(12)) - return .privateConversation( - peerID: selectedPrivatePeerID, - displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback - ) - } - - switch activeChannel { - case .mesh: - return .mesh - case .location(let channel): - return .geohash(channel.geohash.lowercased()) - } - } - - var displayName: String { - switch self { - case .mesh: - return "#mesh" - case .geohash(let geohash): - return "#\(geohash)" - case .privateConversation(_, let displayName): - return displayName - } - } -} - -struct SharedContentOffer: Identifiable, Sendable, Equatable { - let payload: SharedContentPayload - let destination: SharedContentDestination - - var id: UUID { payload.id } -} - -/// Holds a pending extension handoff until the user chooses a destination and -/// explicitly adds it to the composer. This type has no send dependency by -/// design: confirming an import can never transmit a message. -@MainActor -final class SharedContentImportModel: ObservableObject { - @Published private(set) var offer: SharedContentOffer? - - private let store: SharedContentStore? - - init(store: SharedContentStore?) { - self.store = store - } - - @discardableResult - func refresh( - destination: SharedContentDestination, - now: Date = Date() - ) -> SharedContentPayload? { - guard let payload = store?.pending(now: now) else { - offer = nil - return nil - } - - let nextOffer = SharedContentOffer(payload: payload, destination: destination) - if offer != nextOffer { - offer = nextOffer - } - return payload - } - - func updateDestination(_ destination: SharedContentDestination) { - guard let offer, offer.destination != destination else { return } - self.offer = SharedContentOffer(payload: offer.payload, destination: destination) - } - - /// Returns composer text only when the currently displayed destination is - /// still current and the reviewed envelope is still the stored envelope. - /// A destination change updates the prompt and requires another tap. - func confirm( - destination: SharedContentDestination, - now: Date = Date() - ) -> String? { - guard let offer else { return nil } - guard offer.destination == destination else { - updateDestination(destination) - return nil - } - guard let payload = store?.consume(id: offer.id, now: now) else { - _ = refresh(destination: destination, now: now) - return nil - } - - self.offer = nil - return payload.composerText - } - - func cancel(destination: SharedContentDestination, now: Date = Date()) { - guard let offer else { return } - store?.discard(id: offer.id) - self.offer = nil - // If a newer share replaced the reviewed envelope, surface it rather - // than losing it with the older cancellation. - _ = refresh(destination: destination, now: now) - } - - func discardAll() { - store?.discardAll() - offer = nil - } -} diff --git a/bitchat/App/VerificationModel.swift b/bitchat/App/VerificationModel.swift deleted file mode 100644 index 4e1de457..00000000 --- a/bitchat/App/VerificationModel.swift +++ /dev/null @@ -1,242 +0,0 @@ -import BitFoundation -import Combine -import Foundation - -struct FingerprintPresentationState: Equatable { - let peerNickname: String - let encryptionStatus: EncryptionStatus - let theirFingerprint: String? - let myFingerprint: String - let isVerified: Bool - /// User-assigned local alias (petname), if any — distinct from the - /// peer-claimed nickname. - let localPetname: String? - /// Number of currently-valid vouches from peers the user verified - /// (0 when the peer is explicitly verified — the stronger badge wins). - let voucherCount: Int - /// Display names of the (verified) vouchers, where known. - let voucherNames: [String] - - /// Vouched for by ≥1 peer the user verified (and not explicitly verified). - var isVouched: Bool { voucherCount > 0 } - - var canToggleVerification: Bool { - encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified - } - - /// Alias field is editable once we know who we're looking at. - var canEditLocalAlias: Bool { - theirFingerprint != nil - } -} - -enum VerificationScanOutcome: Equatable { - case requested(String) - case notFound - case invalid -} - -@MainActor -final class VerificationModel: ObservableObject { - @Published private(set) var currentNickname: String - @Published private(set) var selectedPeerID: PeerID? - - private let chatViewModel: ChatViewModel - private let peerIdentityStore: PeerIdentityStore - private var cancellables = Set() - - init( - chatViewModel: ChatViewModel, - privateConversationModel: PrivateConversationModel, - peerIdentityStore: PeerIdentityStore? = nil - ) { - self.chatViewModel = chatViewModel - self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore - self.currentNickname = chatViewModel.nickname - self.selectedPeerID = privateConversationModel.selectedPeerID - - bind(privateConversationModel: privateConversationModel) - } - - func myQRString() -> String { - let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub - return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? "" - } - - func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome { - guard let qr = VerificationService.shared.verifyScannedQR(payload) else { - return .invalid - } - - guard chatViewModel.beginQRVerification(with: qr) else { - return .notFound - } - - return .requested(qr.nickname) - } - - func verifyFingerprint(for peerID: PeerID) { - chatViewModel.verifyFingerprint(for: peerID) - } - - func unverifyFingerprint(for peerID: PeerID) { - chatViewModel.unverifyFingerprint(for: peerID) - } - - /// Persist a local alias for this peer. Empty/whitespace clears it so the - /// claimed nickname shows again. Display paths prefer `localPetname` - /// when set (#1439). - func setLocalPetname(_ petname: String?, for peerID: PeerID) { - let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID) - guard let fingerprint = chatViewModel.getFingerprint(for: statusPeerID) else { return } - - let trimmed = petname?.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized: String? = (trimmed?.isEmpty == false) ? trimmed : nil - - let existing = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) - let claimed = existing?.claimedNickname - ?? chatViewModel.meshService.peerNickname(peerID: statusPeerID) - ?? chatViewModel.resolveNickname(for: statusPeerID) - var identity = existing ?? SocialIdentity( - fingerprint: fingerprint, - localPetname: nil, - claimedNickname: claimed, - trustLevel: .unknown, - isFavorite: false, - isBlocked: false, - notes: nil - ) - identity.localPetname = normalized - // Prefer the mesh-announced name for claimedNickname so we don't - // persist a previous alias as the "claimed" identity. - if let announced = chatViewModel.meshService.peerNickname(peerID: statusPeerID), - !announced.isEmpty { - identity.claimedNickname = announced - } else if identity.claimedNickname.isEmpty { - identity.claimedNickname = claimed - } - chatViewModel.identityManager.updateSocialIdentity(identity) - // Rebuild peer rows so PeerList / DM header pick up the new display name - // without waiting for an unrelated mesh event. - chatViewModel.unifiedPeerService.refreshPeers() - NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) - objectWillChange.send() - } - - func isVerified(peerID: PeerID) -> Bool { - guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false } - return peerIdentityStore.isVerified(fingerprint) - } - - func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState { - let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID) - let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID) - let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID) - let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID) - let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false - let localPetname = theirFingerprint - .flatMap { chatViewModel.identityManager.getSocialIdentity(for: $0)?.localPetname } - - // Vouch state is recomputed on read: only vouchers still in the - // verified set count, so removing a verification silently retires the - // vouches that peer gave. - let vouchers: [VouchRecord] - if !isVerified, let theirFingerprint { - vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint) - } else { - vouchers = [] - } - let voucherNames = vouchers.compactMap { record -> String? in - guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else { - return nil - } - if let petname = social.localPetname, !petname.isEmpty { return petname } - return social.claimedNickname.isEmpty ? nil : social.claimedNickname - } - - return FingerprintPresentationState( - peerNickname: peerNickname, - encryptionStatus: encryptionStatus, - theirFingerprint: theirFingerprint, - myFingerprint: chatViewModel.getMyFingerprint(), - isVerified: isVerified, - localPetname: localPetname, - voucherCount: vouchers.count, - voucherNames: voucherNames - ) - } - - private func bind(privateConversationModel: PrivateConversationModel) { - chatViewModel.$nickname - .receive(on: DispatchQueue.main) - .assign(to: &$currentNickname) - - privateConversationModel.$selectedPeerID - .receive(on: DispatchQueue.main) - .assign(to: &$selectedPeerID) - - peerIdentityStore.$encryptionStatuses - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - - peerIdentityStore.$verifiedFingerprints - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - - chatViewModel.$allPeers - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - - // Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged) - // are signalled via this notification rather than a published - // property, so an open fingerprint sheet refreshes its vouched badge - // live when a vouch batch is accepted. - NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - } - - private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String { - // Prefer an explicit local alias even when a live peer row exists — - // peer.displayName already does this once UnifiedPeerService rebuilds, - // but read social identity directly so the fingerprint sheet header - // updates before that rebuild lands. - if let fingerprint = chatViewModel.getFingerprint(for: statusPeerID), - let pet = chatViewModel.identityManager.getSocialIdentity(for: fingerprint)?.localPetname, - !pet.isEmpty { - return pet - } - if let peer = chatViewModel.getPeer(byID: statusPeerID) { - return peer.displayName - } - if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) { - return name - } - if let data = peerID.noiseKey { - if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), - !favorite.peerNickname.isEmpty { - return favorite.peerNickname - } - let fingerprint = data.sha256Fingerprint() - if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) { - if !social.claimedNickname.isEmpty { - return social.claimedNickname - } - } - } - - return String(localized: "common.unknown", comment: "Label for an unknown peer") - } -} diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128.png index c9298e1a..f85ced2c 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png index 7dfe0762..6516c53c 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16.png index e20339bf..fa6a8cf0 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png index cf0a709f..84b3ea2c 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256.png index 7dfe0762..6516c53c 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png index 930a4bb2..5c73d4e6 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32.png index cf0a709f..84b3ea2c 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png index 500352c4..58e93947 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512.png index 930a4bb2..5c73d4e6 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png index 46801070..9bf9b82a 100644 Binary files a/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png and b/bitchat/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/Contents.json b/bitchat/Assets.xcassets/AppIconDebug.appiconset/Contents.json index 72bc51f7..b74e0441 100644 --- a/bitchat/Assets.xcassets/AppIconDebug.appiconset/Contents.json +++ b/bitchat/Assets.xcassets/AppIconDebug.appiconset/Contents.json @@ -27,66 +27,6 @@ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" - }, - { - "filename" : "mac_16x16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "mac_16x16@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "mac_32x32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "mac_32x32@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "mac_128x128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "mac_128x128@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "mac_256x256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "mac_256x256@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "mac_512x512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "mac_512x512@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" } ], "info" : { diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128.png deleted file mode 100644 index 9915a654..00000000 Binary files a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128.png and /dev/null differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128@2x.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128@2x.png deleted file mode 100644 index b5458dca..00000000 Binary files a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_128x128@2x.png and /dev/null differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16.png deleted file mode 100644 index c5a0c8e1..00000000 Binary files a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16.png and /dev/null differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16@2x.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16@2x.png deleted file mode 100644 index 5a292f43..00000000 Binary files a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_16x16@2x.png and /dev/null differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256.png deleted file mode 100644 index b5458dca..00000000 Binary files a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256.png and /dev/null differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256@2x.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256@2x.png deleted file mode 100644 index 95912d2f..00000000 Binary files a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_256x256@2x.png and /dev/null differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32.png deleted file mode 100644 index 5a292f43..00000000 Binary files a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32.png and /dev/null differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32@2x.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32@2x.png deleted file mode 100644 index 1cea92dd..00000000 Binary files a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_32x32@2x.png and /dev/null differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512.png deleted file mode 100644 index 95912d2f..00000000 Binary files a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512.png and /dev/null differ diff --git a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512@2x.png b/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512@2x.png deleted file mode 100644 index 691785a8..00000000 Binary files a/bitchat/Assets.xcassets/AppIconDebug.appiconset/mac_512x512@2x.png and /dev/null differ diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index cf2e1bd1..128ea1b7 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -6,59 +6,128 @@ // For more information, see // +import Tor import SwiftUI +import BitFoundation import UserNotifications @main struct BitchatApp: App { static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat" static let groupID = "group.\(bundleID)" - - @StateObject private var runtime: AppRuntime - @AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue + + @StateObject private var chatViewModel: ChatViewModel #if os(iOS) @Environment(\.scenePhase) var scenePhase @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + // Skip the very first .active-triggered Tor restart on cold launch + @State private var didHandleInitialActive: Bool = false + @State private var didEnterBackground: Bool = false #elseif os(macOS) @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate #endif - + + private let idBridge = NostrIdentityBridge() + init() { - _runtime = StateObject(wrappedValue: AppRuntime()) + let keychain = KeychainManager() + let idBridge = self.idBridge + _chatViewModel = StateObject( + wrappedValue: ChatViewModel( + keychain: keychain, + idBridge: idBridge, + identityManager: SecureIdentityStateManager(keychain) + ) + ) + UNUserNotificationCenter.current().delegate = NotificationDelegate.shared + // Warm up georelay directory and refresh if stale (once/day) + GeoRelayDirectory.shared.prefetchIfNeeded() } - + var body: some Scene { WindowGroup { ContentView() - .environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix) - .environmentObject(runtime.publicChatModel) - .environmentObject(runtime.privateInboxModel) - .environmentObject(runtime.privateConversationModel) - .environmentObject(runtime.verificationModel) - .environmentObject(runtime.conversationUIModel) - .environmentObject(runtime.locationChannelsModel) - .environmentObject(runtime.peerListModel) - .environmentObject(runtime.appChromeModel) - .environmentObject(runtime.boardAlertsModel) - .environmentObject(runtime.sharedContentImportModel) + .environmentObject(chatViewModel) .onAppear { - appDelegate.runtime = runtime - runtime.start() + NotificationDelegate.shared.chatViewModel = chatViewModel + // Inject live Noise service into VerificationService to avoid creating new BLE instances + VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) + // Prewarm Nostr identity and QR to make first VERIFY sheet fast + let nickname = chatViewModel.nickname + DispatchQueue.global(qos: .utility).async { + let npub = try? idBridge.getCurrentNostrIdentity()?.npub + _ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub) + } + + appDelegate.chatViewModel = chatViewModel + + // Initialize network activation policy; will start Tor/Nostr only when allowed + NetworkActivationService.shared.start() + + // Start presence service (will wait for Tor readiness) + GeohashPresenceService.shared.start() + + // Check for shared content + checkForSharedContent() } .onOpenURL { url in - runtime.handleOpenURL(url) + handleURL(url) } #if os(iOS) .onChange(of: scenePhase) { newPhase in - runtime.handleScenePhaseChange(newPhase) + switch newPhase { + case .background: + // Keep BLE mesh running in background; BLEService adapts scanning automatically + // Always send Tor to dormant on background for a clean restart later. + TorManager.shared.setAppForeground(false) + TorManager.shared.goDormantOnBackground() + // Stop geohash sampling while backgrounded + Task { @MainActor in + chatViewModel.endGeohashSampling() + } + // Proactively disconnect Nostr to avoid spurious socket errors while Tor is down + NostrRelayManager.shared.disconnect() + didEnterBackground = true + case .active: + // Restart services when becoming active + chatViewModel.meshService.startServices() + TorManager.shared.setAppForeground(true) + // On initial cold launch, Tor was just started in onAppear. + // Skip the deterministic restart the first time we become active. + if didHandleInitialActive && didEnterBackground { + if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady { + TorManager.shared.ensureRunningOnForeground() + } + } else { + didHandleInitialActive = true + } + didEnterBackground = false + if TorManager.shared.isAutoStartAllowed() { + Task.detached { + let _ = await TorManager.shared.awaitReady(timeout: 60) + await MainActor.run { + // Rebuild proxied sessions to bind to the live Tor after readiness + TorURLSession.shared.rebuild() + // Reconnect Nostr via fresh sessions; will gate until Tor 100% + NostrRelayManager.shared.resetAllConnections() + } + } + } + checkForSharedContent() + case .inactive: + break + @unknown default: + break + } } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - runtime.handleDidBecomeActiveNotification() + // Check for shared content when app becomes active + checkForSharedContent() } #elseif os(macOS) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - runtime.handleMacDidBecomeActiveNotification() + // App became active } #endif } @@ -67,21 +136,66 @@ struct BitchatApp: App { .windowResizability(.contentSize) #endif } + + private func handleURL(_ url: URL) { + if url.scheme == "bitchat" && url.host == "share" { + // Handle shared content + checkForSharedContent() + } + } + + private func checkForSharedContent() { + // Check app group for shared content from extension + guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { + return + } + + guard let sharedContent = userDefaults.string(forKey: "sharedContent"), + let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else { + return + } + + // Only process if shared within configured window + if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds { + let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text" + + // Clear the shared content + userDefaults.removeObject(forKey: "sharedContent") + userDefaults.removeObject(forKey: "sharedContentType") + userDefaults.removeObject(forKey: "sharedContentDate") + // No need to force synchronize here + + // Send the shared content immediately on the main queue + DispatchQueue.main.async { + if contentType == "url" { + // Try to parse as JSON first + if let data = sharedContent.data(using: .utf8), + let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String], + let url = urlData["url"] { + // Send plain URL + self.chatViewModel.sendMessage(url) + } else { + // Fallback to simple URL + self.chatViewModel.sendMessage(sharedContent) + } + } else { + self.chatViewModel.sendMessage(sharedContent) + } + } + } + } } #if os(iOS) final class AppDelegate: NSObject, UIApplicationDelegate { - weak var runtime: AppRuntime? - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - // Installed before the first resign-active so the app-switcher snapshot - // never captures an open conversation. - PrivacyScreen.shared.install() + weak var chatViewModel: ChatViewModel? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { return true } - + func applicationWillTerminate(_ application: UIApplication) { - runtime?.applicationWillTerminate() + chatViewModel?.applicationWillTerminate() } } #endif @@ -90,50 +204,79 @@ final class AppDelegate: NSObject, UIApplicationDelegate { import AppKit final class MacAppDelegate: NSObject, NSApplicationDelegate { - weak var runtime: AppRuntime? - + weak var chatViewModel: ChatViewModel? + func applicationWillTerminate(_ notification: Notification) { - runtime?.applicationWillTerminate() + chatViewModel?.applicationWillTerminate() } - + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - true + return true } } #endif final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { static let shared = NotificationDelegate() - weak var runtime: AppRuntime? - + weak var chatViewModel: ChatViewModel? + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let identifier = response.notification.request.identifier - let actionIdentifier = response.actionIdentifier let userInfo = response.notification.request.content.userInfo - - // Complete only after the response is handled: for a background - // action (👋 wave) the system may suspend the app the moment the - // completion handler runs, which would drop the queued send. - Task { @MainActor in - self.runtime?.handleNotificationResponse( - identifier: identifier, - actionIdentifier: actionIdentifier, - userInfo: userInfo - ) - completionHandler() + + // Check if this is a private message notification + if identifier.hasPrefix("private-") { + // Get peer ID from userInfo + if let peerID = userInfo["peerID"] as? String { + DispatchQueue.main.async { + self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID)) + } + } } + // Handle deeplink (e.g., geohash activity) + if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) { + #if os(iOS) + DispatchQueue.main.async { UIApplication.shared.open(url) } + #else + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + #endif + } + + completionHandler() } - + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let identifier = notification.request.identifier let userInfo = notification.request.content.userInfo - - Task { - let options = await self.runtime?.presentationOptions( - forNotificationIdentifier: identifier, - userInfo: userInfo - ) ?? [.banner, .sound] - completionHandler(options) + + // Check if this is a private message notification + if identifier.hasPrefix("private-") { + // Get peer ID from userInfo + if let peerID = userInfo["peerID"] as? String { + // Don't show notification if the private chat is already open + // Access main-actor-isolated property via Task + Task { @MainActor in + if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) { + completionHandler([]) + } else { + completionHandler([.banner, .sound]) + } + } + return + } } + // Suppress geohash activity notification if we're already in that geohash channel + if identifier.hasPrefix("geo-activity-"), + let deep = userInfo["deeplink"] as? String, + let gh = deep.components(separatedBy: "/").last { + if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh { + completionHandler([]) + return + } + } + + // Show notification in all other cases + completionHandler([.banner, .sound]) } } + diff --git a/bitchat/Features/media/ImageUtils.swift b/bitchat/Features/media/ImageUtils.swift index a6eb25d1..af6a37af 100644 --- a/bitchat/Features/media/ImageUtils.swift +++ b/bitchat/Features/media/ImageUtils.swift @@ -15,39 +15,30 @@ enum ImageUtilsError: Error { enum ImageUtils { private static let compressionQuality: CGFloat = 0.82 private static let targetImageBytes: Int = 45_000 - private static let maxSourceImageBytes: Int = 10 * 1024 * 1024 - static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { - try validateImageSource(at: url) + static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL { + // Security H1: Check file size BEFORE reading into memory + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + guard let fileSize = attrs[.size] as? Int else { + throw ImageUtilsError.invalidImage + } + // Allow up to 10MB source images (will be scaled down) + guard fileSize <= 10 * 1024 * 1024 else { + throw ImageUtilsError.invalidImage + } let data = try Data(contentsOf: url) #if os(iOS) guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage } - return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory) + return try processImage(image, maxDimension: maxDimension) #else guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage } - return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory) + return try processImage(image, maxDimension: maxDimension) #endif } - static func validateImageSource(at url: URL) throws { - // Security H1: Check file size BEFORE reading into memory. - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - guard let fileSize = attrs[.size] as? Int, - fileSize > 0, - fileSize <= maxSourceImageBytes else { - throw ImageUtilsError.invalidImage - } - - let options = [kCGImageSourceShouldCache: false] as CFDictionary - guard let source = CGImageSourceCreateWithURL(url as CFURL, options), - CGImageSourceGetType(source) != nil else { - throw ImageUtilsError.invalidImage - } - } - #if os(iOS) - static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { + static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL { return try autoreleasepool { // Scale the image first let scaled = scaledImage(image, maxDimension: maxDimension) @@ -73,7 +64,7 @@ enum ImageUtils { } } - let outputURL = try makeOutputURL(outputDirectory: outputDirectory) + let outputURL = try makeOutputURL() try jpegData.write(to: outputURL, options: .atomic) return outputURL } @@ -115,7 +106,7 @@ enum ImageUtils { return data as Data } #else - static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { + static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL { return try autoreleasepool { let scaled = scaledImage(image, maxDimension: maxDimension) guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else { @@ -151,7 +142,7 @@ enum ImageUtils { } } } - let outputURL = try makeOutputURL(outputDirectory: outputDirectory) + let outputURL = try makeOutputURL() try jpegData.write(to: outputURL, options: .atomic) return outputURL } @@ -195,18 +186,13 @@ enum ImageUtils { } #endif - private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL { + private static func makeOutputURL() throws -> URL { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" - let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg" + let fileName = "img_\(formatter.string(from: Date())).jpg" - let directory: URL - if let outputDirectory { - directory = outputDirectory - } else { - directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true) - } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes) + let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) return directory.appendingPathComponent(fileName) } diff --git a/bitchat/Features/voice/AudioSessionCoordinator.swift b/bitchat/Features/voice/AudioSessionCoordinator.swift deleted file mode 100644 index b92b1549..00000000 --- a/bitchat/Features/voice/AudioSessionCoordinator.swift +++ /dev/null @@ -1,472 +0,0 @@ -// -// AudioSessionCoordinator.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import AVFoundation -import BitLogger -import Foundation - -/// The raw audio-session calls the coordinator makes, abstracted so the -/// state machine is unit-testable with a mock (and compiles on the macOS -/// test host, where `AVAudioSession` doesn't exist). -/// -/// Calls arrive on the coordinator's private serial queue — never the main -/// thread. `setCategory`/`setActive` block on IPC to the audio server -/// (observed >1 s under contention on device, tripping the system gesture -/// gate), and Apple explicitly recommends activating the session off the -/// main thread. -protocol SessionApplying: Sendable { - func setCategory(_ category: AudioSessionCoordinator.Category) throws - func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws -} - -/// Sole owner of `AVAudioSession` category/activation for voice features. -/// -/// Talk-over means capture (push-to-talk) and playback (inbound bursts, -/// voice notes) can be live simultaneously; letting each engine configure -/// the shared session directly made them stomp each other's category and -/// route mid-flight (the AURemoteIO -10851 dead-input class). Instead every -/// client acquires a `Token` and the coordinator: -/// -/// - reference-counts activation: `setActive(true)` only on the first -/// holder, `setActive(false, notifyOthersOnDeactivation:)` only when the -/// last one releases — no client can deactivate another's session; -/// - keeps one escalating category: playback-only holders get `.playback`, -/// any capture holder escalates to `.playAndRecord`, and the category is -/// never downgraded while anyone still holds a token (capture ending must -/// not yank the route out from under live playback); -/// - fans out `onInterrupted` on system interruptions and when the active -/// route's device disappears (no auto-resume: bursts are transient, the -/// next press or burst simply re-acquires). The escalating category change -/// fans out separately as `onCategoryEscalated` — the session stays live, -/// so holders that can rebuild their engine against the new configuration -/// keep playing (talk-over is bidirectional); holders that don't provide -/// it fall back to `onInterrupted`. -/// -/// Threading: all state lives on a private serial queue, which both -/// serializes rapid acquire/release pairs and keeps the blocking session IPC -/// off the main thread (`acquire` is `async` for exactly that hop; `release` -/// is fire-and-forget onto the queue). Holder callbacks always run on the -/// main actor. -/// -/// Microphone *permission* queries stay with their callers; this type owns -/// only category and activation. -/// -/// `@unchecked Sendable`: every mutable property is confined to `queue`. -final class AudioSessionCoordinator: @unchecked Sendable { - enum Use { - case playback - case capture - } - - /// The session category the coordinator has applied (the `SessionApplying` - /// adapter maps these to concrete `AVAudioSession` category/mode/options). - enum Category { - case playback - case playAndRecord - } - - /// Opaque handle for one client's hold on the session. Release exactly - /// once when done (extra releases are ignored). - /// - /// `@unchecked` because the stored callbacks are `@MainActor`-isolated - /// closures (non-Sendable as stored types). Lifecycle state is protected - /// by `stateLock`, and callbacks are only ever invoked on the main actor. - final class Token: @unchecked Sendable { - fileprivate enum CallbackKind: Sendable { - case interrupted - case categoryEscalated - } - - /// A callback snapshot is only valid for the lifecycle epoch in which - /// it was captured. `release` advances the epoch synchronously before - /// its queue work, so a callback already headed to the main actor can't - /// reach a client that has since released this token and reacquired a - /// different one. - fileprivate struct CallbackTicket: Sendable { - let token: Token - let kind: CallbackKind - let lifecycleEpoch: UInt64 - } - - private enum Lifecycle { - /// Registered on the session queue, but `acquire` has not yet - /// returned into the client's main-actor call frame. - case acquiring - case ready - case released - } - - fileprivate let onInterrupted: @MainActor () -> Void - fileprivate let onCategoryEscalated: (@MainActor () -> Void)? - private let stateLock = NSLock() - private var lifecycle = Lifecycle.acquiring - private var lifecycleEpoch: UInt64 = 0 - /// A terminal event that lands while the token is registered but not - /// yet handed off invalidates the acquire before its caller can start. - private var terminalEventPendingHandoff = false - - fileprivate init( - onInterrupted: @escaping @MainActor () -> Void, - onCategoryEscalated: (@MainActor () -> Void)? - ) { - self.onInterrupted = onInterrupted - self.onCategoryEscalated = onCategoryEscalated - } - - /// Records an event at the same linearization point at which the - /// coordinator snapshots its holders. An acquiring token cannot safely - /// receive a callback yet: terminal events invalidate the acquire, - /// while category escalation needs no callback because its engine will - /// start against the already-escalated configuration. - fileprivate func record(_ kind: CallbackKind) -> CallbackTicket? { - stateLock.withLock { - switch lifecycle { - case .acquiring: - switch kind { - case .interrupted: - terminalEventPendingHandoff = true - case .categoryEscalated: - break - } - return nil - case .ready: - return CallbackTicket(token: self, kind: kind, lifecycleEpoch: lifecycleEpoch) - case .released: - return nil - } - } - } - - /// Completes the main-actor ownership handoff if no terminal event - /// invalidated it. Because `acquire` itself is main-actor isolated, a - /// successful handoff returns directly into the caller without another - /// actor hop; no callback can interleave before the caller stores the - /// returned token. - fileprivate func completeHandoff() -> Bool { - stateLock.withLock { - guard lifecycle == .acquiring, - !terminalEventPendingHandoff - else { return false } - lifecycle = .ready - return true - } - } - - /// Marks the token dead synchronously, before the asynchronous holder - /// removal. Returns false for an already-released token. - fileprivate func markReleased() -> Bool { - stateLock.withLock { - guard lifecycle != .released else { return false } - lifecycle = .released - lifecycleEpoch &+= 1 - terminalEventPendingHandoff = false - return true - } - } - - /// Revalidates a queue snapshot at the main-actor delivery boundary. - /// The lock is deliberately released before invoking client code: real - /// callbacks commonly call `release` on this same token. - @MainActor - fileprivate func deliver(_ ticket: CallbackTicket) { - let isLive = stateLock.withLock { - lifecycle == .ready && lifecycleEpoch == ticket.lifecycleEpoch - } - guard isLive else { return } - switch ticket.kind { - case .interrupted: - onInterrupted() - case .categoryEscalated: - (onCategoryEscalated ?? onInterrupted)() - } - } - } - - /// Deterministic suspension points for lifecycle race tests. Production - /// instances use the nil defaults; the hooks never move session calls off - /// the coordinator queue or callback execution off the main actor. - struct TestingHooks: Sendable { - let beforeAcquireHandoff: (@Sendable () async -> Void)? - let beforeCallbackDelivery: (@Sendable () async -> Void)? - - init( - beforeAcquireHandoff: (@Sendable () async -> Void)? = nil, - beforeCallbackDelivery: (@Sendable () async -> Void)? = nil - ) { - self.beforeAcquireHandoff = beforeAcquireHandoff - self.beforeCallbackDelivery = beforeCallbackDelivery - } - } - - static let shared = AudioSessionCoordinator(session: SystemAudioSession()) - - private let session: SessionApplying - private let testingHooks: TestingHooks - /// Confines all mutable state, serializes whole acquire/release - /// operations (two rapid presses can't interleave their category and - /// activation calls), and hosts the blocking session IPC off main. - private let queue = DispatchQueue(label: "chat.bitchat.audio-session", qos: .userInitiated) - - // Queue-confined state. - private var holders: [ObjectIdentifier: Token] = [:] - private var currentCategory: Category? - private var sessionActive = false - /// Written once in init, read in deinit — never touched concurrently. - private var observers: [NSObjectProtocol] = [] - - init(session: SessionApplying, testingHooks: TestingHooks = TestingHooks()) { - self.session = session - self.testingHooks = testingHooks - observeSystemNotifications() - } - - deinit { - for observer in observers { - NotificationCenter.default.removeObserver(observer) - } - } - - /// Configures + activates the session for `use` and registers the caller - /// as a holder. The blocking `AVAudioSession` calls run on the session - /// queue — the caller suspends instead of stalling its thread (a PTT - /// press used to block main >1 s in `setActive`, tripping the system - /// gesture gate). `onInterrupted` fires (on the main actor) when the - /// client must stop using the session: a system interruption began or - /// its route's device went away. The client should stop its engine, - /// finalize any artifacts, and release — resuming means acquiring again. - /// - /// `onCategoryEscalated` fires instead when the session category - /// escalated underneath the holder (a capture client joined): the session - /// stays active, so a holder that can rebuild its engine against the new - /// configuration should restart and keep going. Holders that pass `nil` - /// get `onInterrupted` for escalation too. Escalation is delivered before - /// `acquire` returns, so the new holder starts its engine strictly after - /// existing ones were told to rebuild. Main-actor isolation is also the - /// ownership handoff boundary: if interruption or route loss lands after - /// queue registration but before that boundary, the provisional holder is - /// removed and `acquire` throws `CancellationError` instead of returning a - /// token whose callback already fired. - @MainActor - func acquire( - _ use: Use, - onInterrupted: @escaping @MainActor () -> Void, - onCategoryEscalated: (@MainActor () -> Void)? = nil - ) async throws -> Token { - let token = Token(onInterrupted: onInterrupted, onCategoryEscalated: onCategoryEscalated) - let reconfigured: [Token.CallbackTicket] = try await withCheckedThrowingContinuation { continuation in - queue.async { - do { - continuation.resume(returning: try self.activateOnQueue(use, registering: token)) - } catch { - continuation.resume(throwing: error) - } - } - } - - // Escalating playback -> playAndRecord reconfigures the hardware - // route; engines started against the old configuration must restart. - if !reconfigured.isEmpty { - SecureLogger.info("AudioSession: category escalated to playAndRecord with \(reconfigured.count) live holder(s)", category: .session) - await deliver(reconfigured) - } - if let beforeAcquireHandoff = testingHooks.beforeAcquireHandoff { - await beforeAcquireHandoff() - } - guard token.completeHandoff() else { - // A call/Siri interruption or route loss landed after registration - // but before ownership handoff. Remove the provisional holder and - // fail instead of starting a client engine after the stop event. - release(token) - throw CancellationError() - } - return token - } - - /// Drops one holder. Deactivates the session (notifying other apps) only - /// when the last holder releases. Safe to call more than once, from any - /// thread (including `deinit` paths): the work is fire-and-forget onto - /// the session queue, so the blocking deactivation IPC never runs on the - /// caller. - func release(_ token: Token) { - guard token.markReleased() else { return } - queue.async { - self.releaseOnQueue(token) - } - } - - // MARK: - Queue-confined core - - /// Returns callback tickets for pre-existing live holders whose engines - /// must restart because this acquire escalated the category. - private func activateOnQueue(_ use: Use, registering token: Token) throws -> [Token.CallbackTicket] { - let target: Category = (use == .capture || currentCategory == .playAndRecord) ? .playAndRecord : .playback - let categoryChanged = target != currentCategory - let previousCategory = currentCategory - if categoryChanged { - try session.setCategory(target) - currentCategory = target - } - if !sessionActive { - do { - try session.setActive(true, notifyOthersOnDeactivation: false) - } catch { - // Activation failed (e.g. a phone call owns the hardware): - // with no holder registered, an escalated category recorded - // here would stick and pin later playback-only acquires to - // .playAndRecord. Existing holders keep the category the - // hardware really has. - if categoryChanged, holders.isEmpty { - currentCategory = previousCategory - } - throw error - } - sessionActive = true - } - - let reconfigured = categoryChanged - ? holders.values.compactMap { $0.record(.categoryEscalated) } - : [] - holders[ObjectIdentifier(token)] = token - return reconfigured - } - - private func releaseOnQueue(_ token: Token) { - guard holders.removeValue(forKey: ObjectIdentifier(token)) != nil else { return } - guard holders.isEmpty else { return } - currentCategory = nil - guard sessionActive else { return } - sessionActive = false - do { - try session.setActive(false, notifyOthersOnDeactivation: true) - } catch { - SecureLogger.error("AudioSession: deactivation failed: \(error)", category: .session) - } - } - - private func onQueue(_ body: @escaping @Sendable () -> T) async -> T { - await withCheckedContinuation { continuation in - queue.async { - continuation.resume(returning: body()) - } - } - } - - @MainActor - private func deliver(_ tickets: [Token.CallbackTicket]) async { - guard !tickets.isEmpty else { return } - if let beforeCallbackDelivery = testingHooks.beforeCallbackDelivery { - await beforeCallbackDelivery() - } - for ticket in tickets { - ticket.token.deliver(ticket) - } - } - - // MARK: - System events (internal so tests can drive them directly) - - /// A system interruption began: the session is already deactivated by the - /// OS, so just mark it inactive and tell every ready holder (on the main - /// actor) to stop. A provisional acquiring holder is invalidated instead. - /// No auto-resume — the next acquire re-activates. - func handleInterruptionBegan() async { - let tickets = await onQueue { () -> [Token.CallbackTicket] in - self.sessionActive = false - return self.holders.values.compactMap { $0.record(.interrupted) } - } - await deliver(tickets) - } - - /// The active route's input/output device disappeared (e.g. BT headset - /// off): ready holders' engines are wedged against a dead route — stop - /// them; invalidate a holder whose acquire has not returned yet. - func handleRouteDeviceUnavailable() async { - let tickets = await onQueue { - self.holders.values.compactMap { $0.record(.interrupted) } - } - await deliver(tickets) - } - - /// Test hook: suspends until every session operation enqueued before this - /// call — including fire-and-forget `release`s — has completed. - func drain() async { - await onQueue {} - } - - private func observeSystemNotifications() { - #if os(iOS) - let center = NotificationCenter.default - observers.append(center.addObserver( - forName: AVAudioSession.interruptionNotification, - object: AVAudioSession.sharedInstance(), - queue: .main - ) { [weak self] note in - guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, - AVAudioSession.InterruptionType(rawValue: raw) == .began, - let self - else { return } - SecureLogger.info("AudioSession: interruption began", category: .session) - Task { await self.handleInterruptionBegan() } - }) - observers.append(center.addObserver( - forName: AVAudioSession.routeChangeNotification, - object: AVAudioSession.sharedInstance(), - queue: .main - ) { [weak self] note in - guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, - AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable, - let self - else { return } - SecureLogger.info("AudioSession: route device became unavailable", category: .session) - Task { await self.handleRouteDeviceUnavailable() } - }) - #endif - } -} - -// MARK: - Production adapter - -#if os(iOS) -private struct SystemAudioSession: SessionApplying { - func setCategory(_ category: AudioSessionCoordinator.Category) throws { - let session = AVAudioSession.sharedInstance() - switch category { - case .playback: - try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers]) - case .playAndRecord: - // allowBluetoothHFP is not available on iOS Simulator - #if targetEnvironment(simulator) - try session.setCategory( - .playAndRecord, - mode: .default, - options: [.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers] - ) - #else - try session.setCategory( - .playAndRecord, - mode: .default, - options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP, .mixWithOthers] - ) - #endif - } - } - - func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { - try AVAudioSession.sharedInstance().setActive( - active, - options: notifyOthersOnDeactivation ? [.notifyOthersOnDeactivation] : [] - ) - } -} -#else -/// macOS has no app-level audio session; the coordinator still runs its -/// bookkeeping so client code is identical across platforms. -private struct SystemAudioSession: SessionApplying { - func setCategory(_ category: AudioSessionCoordinator.Category) throws {} - func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {} -} -#endif diff --git a/bitchat/Features/voice/PTTAudioCodec.swift b/bitchat/Features/voice/PTTAudioCodec.swift deleted file mode 100644 index 0b8ed6f3..00000000 --- a/bitchat/Features/voice/PTTAudioCodec.swift +++ /dev/null @@ -1,175 +0,0 @@ -// -// PTTAudioCodec.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import AVFoundation -import BitLogger -import Foundation - -/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder -/// carries a bit reservoir across frames); one instance per burst. -/// Not thread-safe — confine to one queue. -final class PTTFrameEncoder { - private let converter: AVAudioConverter - private var pendingInput: [AVAudioPCMBuffer] = [] - - init?() { - guard let pcm = PTTAudioFormat.pcmFormat, - let aac = PTTAudioFormat.aacFormat, - let converter = AVAudioConverter(from: pcm, to: aac) - else { return nil } - converter.bitRate = PTTAudioFormat.bitRate - self.converter = converter - } - - /// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the - /// encoder produced. Frames come out ~130 bytes each at 16 kbps. - func encode(_ buffer: AVAudioPCMBuffer) -> [Data] { - pendingInput.append(buffer) - return drainConverter() - } - - private func drainConverter() -> [Data] { - var frames: [Data] = [] - while true { - let output = AVAudioCompressedBuffer( - format: converter.outputFormat, - packetCapacity: 8, - maximumPacketSize: max(converter.maximumOutputPacketSize, 1) - ) - var error: NSError? - let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in - guard let self, let next = self.pendingInput.first else { - outStatus.pointee = .noDataNow - return nil - } - self.pendingInput.removeFirst() - outStatus.pointee = .haveData - return next - } - if status == .error { - SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session) - return frames - } - frames.append(contentsOf: Self.extractPackets(from: output)) - // .haveData means the output buffer filled and more may be ready; - // anything else means the converter wants more input. - if status != .haveData { return frames } - } - } - - private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] { - guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] } - var frames: [Data] = [] - frames.reserveCapacity(Int(buffer.packetCount)) - for index in 0.. 0 else { continue } - let start = buffer.data.advanced(by: Int(description.mStartOffset)) - frames.append(Data(bytes: start, count: Int(description.mDataByteSize))) - } - return frames - } -} - -/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per -/// inbound burst. Not thread-safe — confine to one queue/actor. -final class PTTFrameDecoder { - private let converter: AVAudioConverter - private let pcmFormat: AVAudioFormat - private let aacFormat: AVAudioFormat - - init?() { - guard let pcm = PTTAudioFormat.pcmFormat, - let aac = PTTAudioFormat.aacFormat, - let converter = AVAudioConverter(from: aac, to: pcm) - else { return nil } - self.converter = converter - self.pcmFormat = pcm - self.aacFormat = aac - } - - /// Decodes one raw AAC frame to PCM. Returns nil for malformed input or - /// while the decoder is still priming (the first frame of a stream). - func decode(_ frame: Data) -> AVAudioPCMBuffer? { - guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil } - - let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count) - frame.withUnsafeBytes { raw in - guard let base = raw.baseAddress else { return } - input.data.copyMemory(from: base, byteCount: frame.count) - } - input.byteLength = UInt32(frame.count) - input.packetCount = 1 - input.packetDescriptions?.pointee = AudioStreamPacketDescription( - mStartOffset: 0, - mVariableFramesInPacket: 0, - mDataByteSize: UInt32(frame.count) - ) - - guard let output = AVAudioPCMBuffer( - pcmFormat: pcmFormat, - frameCapacity: PTTAudioFormat.samplesPerFrame * 2 - ) else { return nil } - - var consumed = false - var error: NSError? - let status = converter.convert(to: output, error: &error) { _, outStatus in - if consumed { - outStatus.pointee = .noDataNow - return nil - } - consumed = true - outStatus.pointee = .haveData - return input - } - guard status != .error else { - SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session) - return nil - } - return output.frameLength > 0 ? output : nil - } -} - -/// Sample-rate/channel converter from the microphone's native format to the -/// 16 kHz mono processing format. Stateful; not thread-safe. -final class PTTInputResampler { - private let converter: AVAudioConverter - private let outputFormat: AVAudioFormat - private let ratio: Double - - init?(inputFormat: AVAudioFormat) { - guard let pcm = PTTAudioFormat.pcmFormat, - let converter = AVAudioConverter(from: inputFormat, to: pcm) - else { return nil } - self.converter = converter - self.outputFormat = pcm - self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate - } - - func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? { - let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64 - guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil } - - var consumed = false - var error: NSError? - let status = converter.convert(to: output, error: &error) { _, outStatus in - if consumed { - outStatus.pointee = .noDataNow - return nil - } - consumed = true - outStatus.pointee = .haveData - return buffer - } - guard status != .error else { - SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session) - return nil - } - return output.frameLength > 0 ? output : nil - } -} diff --git a/bitchat/Features/voice/PTTAudioFormat.swift b/bitchat/Features/voice/PTTAudioFormat.swift deleted file mode 100644 index 39e5a61a..00000000 --- a/bitchat/Features/voice/PTTAudioFormat.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// PTTAudioFormat.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import AVFoundation -import Foundation - -/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono, -/// ~16 kbps — deliberately identical to `VoiceRecorder`'s voice-note settings -/// so a burst's finalized `.m4a` and its live frames sound the same. -enum PTTAudioFormat { - static let sampleRate: Double = 16_000 - static let channelCount: AVAudioChannelCount = 1 - static let bitRate = 16_000 - /// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz. - static let samplesPerFrame: AVAudioFrameCount = 1024 - static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate } - - /// Uncompressed processing format (deinterleaved float PCM). - static var pcmFormat: AVAudioFormat? { - AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount) - } - - /// Compressed wire format. - static var aacFormat: AVAudioFormat? { - var description = AudioStreamBasicDescription( - mSampleRate: sampleRate, - mFormatID: kAudioFormatMPEG4AAC, - mFormatFlags: 0, - mBytesPerPacket: 0, - mFramesPerPacket: samplesPerFrame, - mBytesPerFrame: 0, - mChannelsPerFrame: channelCount, - mBitsPerChannel: 0, - mReserved: 0 - ) - return AVAudioFormat(streamDescription: &description) - } - - /// Voice-note container settings for the finalized `.m4a`, mirroring - /// `VoiceRecorder.startRecording()`. - static var voiceNoteFileSettings: [String: Any] { - [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: sampleRate, - AVNumberOfChannelsKey: Int(channelCount), - AVEncoderBitRateKey: bitRate - ] - } -} - -/// Builds ADTS-framed AAC so a receiver can persist a burst progressively: -/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac` -/// stream is playable at any prefix — a partially received burst is still a -/// replayable voice note. -enum ADTSFramer { - private static let headerSize = 7 - /// MPEG-4 sampling frequency index for 16 kHz. - private static let samplingFrequencyIndex: UInt8 = 8 - private static let channelConfiguration: UInt8 = 1 - - /// Wraps one raw AAC-LC frame in an ADTS header. - static func frame(_ aacFrame: Data) -> Data { - let frameLength = aacFrame.count + headerSize - var data = Data(capacity: frameLength) - // Syncword 0xFFF, MPEG-4, layer 00, no CRC. - data.append(0xFF) - data.append(0xF1) - // Profile AAC-LC (audio object type 2 -> bits 01), frequency index, - // private bit 0, channel config high bit. - data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1)) - data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3)) - data.append(UInt8((frameLength >> 3) & 0xFF)) - data.append(UInt8((frameLength & 0x7) << 5) | 0x1F) - // Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame. - data.append(0xFC) - data.append(aacFrame) - return data - } -} diff --git a/bitchat/Features/voice/PTTBurstPlayer.swift b/bitchat/Features/voice/PTTBurstPlayer.swift deleted file mode 100644 index 272906f4..00000000 --- a/bitchat/Features/voice/PTTBurstPlayer.swift +++ /dev/null @@ -1,509 +0,0 @@ -// -// PTTBurstPlayer.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -@preconcurrency import AVFoundation -import BitLogger -import Foundation - -/// The engine operations behind live-burst playback, abstracted so the -/// player's lifecycle (jitter start, category-escalation restart, stop) is -/// unit-testable without real audio hardware. -@MainActor -protocol PTTPlaybackEngine: AnyObject { - /// The object `AVAudioEngineConfigurationChange` notifications are posted - /// for (nil for mocks — no observer is registered). - var configChangeObject: AnyObject? { get } - func start() throws - func play() - func stop() - func schedule( - _ buffer: AVAudioPCMBuffer, - completionType: PTTPlaybackCompletionType, - completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void - ) -} - -/// The lifecycle point requested from `AVAudioPlayerNode` for a scheduled -/// buffer. `dataConsumed` only means the node no longer needs the bytes; it -/// may arrive before the render pipeline has made the audio audible. -enum PTTPlaybackCompletionType: Equatable, Sendable { - case dataConsumed - case dataPlayedBack -} - -enum PTTPlaybackCompletionEvent: Equatable, Sendable { - case dataConsumed - case dataPlayedBack - /// AVAudioPlayerNode invokes the requested callback when the node is - /// stopped too. That is not audible completion and must remain replayable. - case playbackStopped -} - -/// One `AVAudioEngine` + `AVAudioPlayerNode` pair. Created fresh per (re)start: -/// an engine instantiated against an earlier audio-session configuration keeps -/// rendering to the stale route (same class of failure as the capture side's -/// fresh-engine-per-press rule). -@MainActor -private final class SystemPTTPlaybackEngine: PTTPlaybackEngine { - private let engine = AVAudioEngine() - private let node = AVAudioPlayerNode() - - init(format: AVAudioFormat) { - engine.attach(node) - engine.connect(node, to: engine.mainMixerNode, format: format) - } - - var configChangeObject: AnyObject? { engine } - - func start() throws { - engine.prepare() - try engine.start() - } - - func play() { - node.play() - } - - func stop() { - node.stop() - engine.stop() - } - - func schedule( - _ buffer: AVAudioPCMBuffer, - completionType: PTTPlaybackCompletionType, - completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void - ) { - let callbackType: AVAudioPlayerNodeCompletionCallbackType = switch completionType { - case .dataConsumed: .dataConsumed - case .dataPlayedBack: .dataPlayedBack - } - let scheduledEngine = engine - node.scheduleBuffer(buffer, completionCallbackType: callbackType) { [weak scheduledEngine] callbackType in - // The API invokes this callback when the player is stopped as - // well. A configuration change can stop the engine before its - // notification reaches MainActor, so do not misclassify that - // flushed tail as audible playback. - guard scheduledEngine?.isRunning == true else { - completionHandler(.playbackStopped) - return - } - switch callbackType { - case .dataConsumed: - completionHandler(.dataConsumed) - case .dataRendered: - completionHandler(.dataConsumed) - case .dataPlayedBack: - completionHandler(.dataPlayedBack) - @unknown default: - completionHandler(.playbackStopped) - } - } - } -} - -/// Completion callbacks arrive off the main actor, while engine rebuilds are -/// serialized on it. This small lock-backed latch lets a rebuild atomically -/// claim only buffers whose completion has not already fired — even when the -/// callback's hop back to the main actor is still queued. -private final class PTTPlaybackCompletionState: @unchecked Sendable { - private enum State { - case scheduled - case completed - case retired - } - - private let lock = NSLock() - private var state: State = .scheduled - - /// Returns true exactly once when playback completion wins the race with - /// an engine rebuild or stop. - func complete() -> Bool { - lock.withLock { - guard case .scheduled = state else { return false } - state = .completed - return true - } - } - - /// Returns true exactly once when a rebuild or stop claims this - /// still-pending schedule. Later callbacks from that engine are stale. - func retireIfPending() -> Bool { - lock.withLock { - guard case .scheduled = state else { return false } - state = .retired - return true - } - } -} - -/// Plays one inbound live voice burst with a small jitter buffer. -/// -/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`; -/// an underrun (missing/late packets) simply pauses output until the next -/// buffer arrives, which self-heals timing without explicit silence -/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds` -/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed. -/// -/// Talk-over is bidirectional: when push-to-talk capture starts while this -/// burst plays, the session category escalates underneath the engine — the -/// player rebuilds a fresh engine against the new configuration and keeps -/// streaming instead of dying. Real interruptions (phone call, route device -/// gone) still stop it; the burst keeps assembling to file either way. -@MainActor -final class PTTBurstPlayer { - /// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a - /// handful of category/route changes is plenty — beyond it something is - /// thrashing and stopping cleanly beats an engine-rebuild loop. - private static let maxEngineRestarts = 8 - - private let makeEngine: @MainActor () -> PTTPlaybackEngine - private var engine: PTTPlaybackEngine - private let decoder: PTTFrameDecoder - private let coordinator: AudioSessionCoordinator - /// Injectable so tests don't fight over the app-wide exclusive-playback - /// slot (a parallel test's `play()` would stop this player mid-test). - private let exclusivity: VoiceNotePlaybackCoordinator - - private var queuedBuffers: [AVAudioPCMBuffer] = [] - private var queuedDuration: TimeInterval = 0 - private struct ScheduledBuffer { - let id: UInt64 - let buffer: AVAudioPCMBuffer - let completionState: PTTPlaybackCompletionState - } - /// Buffers handed to the current engine whose completion has not yet - /// been processed on the main actor. Keeping the buffers themselves lets - /// a category-escalation rebuild replay the unfinished tail in order. - private var scheduledBuffers: [ScheduledBuffer] = [] - private var nextScheduledBufferID: UInt64 = 0 - /// Bumped on every engine rebuild or stop so completion tasks from a - /// torn-down engine cannot mutate the current generation's pending list. - private var engineGeneration = 0 - private var engineRestarts = 0 - private var engineStarted = false - private var finished = false - /// Latched off (internal read so tests can await the async failure path). - private(set) var stopped = false - /// A session acquire is in flight (it suspends off-main for the blocking - /// session IPC); gates `startIfReady` against double acquisition. - private var acquiringSession = false - private var deadlineTask: Task? - private var sessionToken: AudioSessionCoordinator.Token? - /// Reserved before the session acquire suspends. Activation succeeds only - /// if no newer playback request claimed the floor in the meantime. - private var playbackReservation: VoiceNotePlaybackCoordinator.Reservation? - private var configChangeObserver: NSObjectProtocol? - - private(set) var isPlaying = false - - /// Fires exactly once when the player stops for good (drain-out, cancel, - /// interruption, failure). `ChatLiveVoiceCoordinator` uses it to unpark - /// the draining player it keeps alive after the assembly — the player's - /// only long-lived owner — is discarded on burst END. - var onStopped: (() -> Void)? - - init?( - coordinator: AudioSessionCoordinator? = nil, - exclusivity: VoiceNotePlaybackCoordinator? = nil, - makeEngine: (@MainActor () -> PTTPlaybackEngine)? = nil - ) { - guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil } - self.decoder = decoder - self.coordinator = coordinator ?? .shared - self.exclusivity = exclusivity ?? .shared - let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) } - self.makeEngine = factory - self.engine = factory() - - deadlineTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000)) - self?.startIfReady(force: true) - } - } - - deinit { - // Backstop for an owner dropping the player before it stopped: the - // session coordinator retains registered tokens strongly, so a token - // leaked here would keep the session active (and pin any escalated - // category) for the app's lifetime. `release` is fire-and-forget - // onto the coordinator's queue, so it is deinit-safe. - if let token = sessionToken { - coordinator.release(token) - } - if let observer = configChangeObserver { - NotificationCenter.default.removeObserver(observer) - } - deadlineTask?.cancel() - } - - /// Decodes and queues frames (in burst order). Starts playback when the - /// jitter buffer fills. - func enqueue(_ frames: [Data]) { - guard !stopped else { return } - for frame in frames { - guard let pcm = decoder.decode(frame) else { continue } - if engineStarted { - schedule(pcm) - } else { - queuedBuffers.append(pcm) - queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate - } - } - startIfReady(force: false) - } - - /// The burst ended: stop once everything scheduled has played out. - func finishAfterDrain() { - finished = true - // The complete burst is queued — no jitter left to wait for. This - // also matters when END lands while the async session acquire is - // still in flight: the queued audio must play out, not be treated - // as already drained. - startIfReady(force: true) - stopIfDrained() - } - - /// Immediate stop (cancel, another playback taking over, interruption, - /// teardown). - func stop() { - guard !stopped else { return } - stopped = true - deadlineTask?.cancel() - removeConfigObserver() - queuedBuffers = [] - retireScheduledBuffers() - if engineStarted { - engine.stop() - } - isPlaying = false - releaseSessionToken() - exclusivity.deactivate(self) - onStopped?() - } - - private func startIfReady(force: Bool) { - guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return } - guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return } - - // Acquiring the session suspends for its blocking IPC (off the main - // actor); frames arriving meanwhile keep queueing and are flushed - // onto the engine once it starts. - acquiringSession = true - playbackReservation = exclusivity.reserve(self) - Task { [weak self] in - await self?.acquireSessionAndStart() - } - } - - private func acquireSessionAndStart() async { - let token: AudioSessionCoordinator.Token - do { - token = try await coordinator.acquire( - .playback, - onInterrupted: { [weak self] in self?.stop() }, - onCategoryEscalated: { [weak self] in self?.restartEngine() } - ) - } catch { - acquiringSession = false - SecureLogger.error("PTT playback session activation failed: \(error)", category: .session) - // Playing unregistered would leave the engine exposed: another - // holder's last release deactivates the session mid-play, and no - // interruption/escalation fan-out ever reaches us. Bail like the - // engine-start failure below; the burst still assembles to file. - // (stop() also fires onStopped so a parked draining player is - // unparked instead of leaking.) - stop() - return - } - acquiringSession = false - // stop() (cancel, exclusivity, teardown) may have landed while the - // session was activating: hand the token straight back. - guard !stopped else { - coordinator.release(token) - return - } - sessionToken = token - guard let playbackReservation, - exclusivity.isCurrent(playbackReservation, for: self) - else { - // The request was superseded while audio-session activation was - // suspended. Do not even start the retired engine. - stop() - return - } - - // Observe reconfiguration before starting so nothing lands between. - registerConfigObserver() - do { - try engine.start() - } catch { - // A capture racing this start can reconfigure the session while - // the engine spins up (its escalation fan-out no-ops on a player - // that never started): rebuild once against the settled - // configuration — counted against the restart cap — before - // giving up. - SecureLogger.warning("PTT playback engine failed to start (\(error)) — rebuilding once", category: .session) - removeConfigObserver() - engineRestarts += 1 - engine = makeEngine() - registerConfigObserver() - do { - try engine.start() - } catch { - SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session) - // stop() removes the observer, hands the token back, and - // fires onStopped for any parked draining owner. - stop() - return - } - } - engineStarted = true - guard exclusivity.activate(self, reservation: playbackReservation) - else { - // A newer user-initiated playback reserved the floor while this - // older PTT request was suspended in audio-session activation. - // Never let the late completion steal playback back. - stop() - return - } - isPlaying = true - engine.play() - - let buffered = queuedBuffers - queuedBuffers = [] - queuedDuration = 0 - for buffer in buffered { - schedule(buffer) - } - } - - /// The audio session was reconfigured underneath the running engine - /// (category escalation for talk-over, or an engine configuration - /// change): rebuild a fresh engine against the new configuration and - /// keep streaming. Buffers already completed stay completed; the - /// unfinished scheduled tail is replayed in order on the fresh engine, - /// and frames still arriving continue scheduling after it. - private func restartEngine() { - guard engineStarted, !stopped else { return } - engineRestarts += 1 - guard engineRestarts <= Self.maxEngineRestarts else { - SecureLogger.warning("PTT playback: engine reconfigured \(engineRestarts) times in one burst — stopping", category: .session) - stop() - return - } - - removeConfigObserver() - // Claim the unfinished tail before stopping the old engine. Stopping - // a player node may itself invoke its completion handlers; retiring - // the claimed entries first makes those callbacks unambiguously stale. - // A completion that fired just before this rebuild wins the latch and - // is excluded even if its MainActor task has not run yet. - let buffersToReplay = scheduledBuffers.compactMap { scheduled in - scheduled.completionState.retireIfPending() ? scheduled.buffer : nil - } - scheduledBuffers = [] - engineGeneration += 1 - engine.stop() - engine = makeEngine() - registerConfigObserver() - do { - try engine.start() - } catch { - SecureLogger.error("PTT playback engine failed to restart after session reconfigure: \(error)", category: .session) - stop() - return - } - engine.play() - for buffer in buffersToReplay { - schedule(buffer) - } - SecureLogger.info("PTT playback: engine restarted after session reconfigure", category: .session) - // If every old buffer completed before the rebuild, a finished burst - // can stop now. Otherwise the replayed tail keeps it alive until its - // new-generation completions arrive. - stopIfDrained() - } - - private func registerConfigObserver() { - guard let object = engine.configChangeObject else { return } - configChangeObserver = NotificationCenter.default.addObserver( - forName: .AVAudioEngineConfigurationChange, - object: object, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.restartEngine() - } - } - } - - private func removeConfigObserver() { - if let observer = configChangeObserver { - NotificationCenter.default.removeObserver(observer) - configChangeObserver = nil - } - } - - private func schedule(_ buffer: AVAudioPCMBuffer) { - let id = nextScheduledBufferID - nextScheduledBufferID &+= 1 - let completionState = PTTPlaybackCompletionState() - scheduledBuffers.append(ScheduledBuffer( - id: id, - buffer: buffer, - completionState: completionState - )) - let generation = engineGeneration - engine.schedule(buffer, completionType: .dataPlayedBack) { [weak self, completionState] event in - guard event == .dataPlayedBack else { return } - // Mark completion before hopping to MainActor. A rebuild can then - // distinguish already-completed audio from an unfinished tail - // even when this task has not run yet. - guard completionState.complete() else { return } - Task { @MainActor [weak self] in - guard let self, self.engineGeneration == generation else { return } - self.scheduledBuffers.removeAll { $0.id == id } - self.stopIfDrained() - } - } - } - - private func retireScheduledBuffers() { - engineGeneration += 1 - for scheduled in scheduledBuffers { - _ = scheduled.completionState.retireIfPending() - } - scheduledBuffers = [] - } - - private func stopIfDrained() { - guard finished, scheduledBuffers.isEmpty else { return } - // Started: everything scheduled has played out. Never started with - // nothing queued or in flight (e.g. no decodable frames): nothing - // will ever play. Otherwise the engine start is still pending (the - // async session acquire) and the queued audio must play out first. - guard engineStarted || (!acquiringSession && queuedBuffers.isEmpty) else { return } - stop() - } - - private func releaseSessionToken() { - sessionToken.map(coordinator.release) - sessionToken = nil - } -} - -extension PTTBurstPlayer: ExclusivePlayback { - /// A live stream can't meaningfully pause; yielding the floor stops it. - /// The burst keeps assembling to file, so nothing is lost. - nonisolated func pauseForExclusivity() { - Task { @MainActor [weak self] in - self?.stop() - } - } -} diff --git a/bitchat/Features/voice/PTTCaptureEngine.swift b/bitchat/Features/voice/PTTCaptureEngine.swift deleted file mode 100644 index 4d29181d..00000000 --- a/bitchat/Features/voice/PTTCaptureEngine.swift +++ /dev/null @@ -1,326 +0,0 @@ -// -// PTTCaptureEngine.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import AVFoundation -import BitLogger -import Foundation - -/// Owns one capture token and returns it even when the capture engine's owner -/// disappears without reaching its normal stop/cancel path. The coordinator -/// retains registered tokens strongly, so relying on `Token.deinit` cannot -/// reclaim an abandoned hold. -final class PTTCaptureSessionLease: @unchecked Sendable { - private let coordinator: AudioSessionCoordinator - private let lock = NSLock() - private var token: AudioSessionCoordinator.Token? - - init(coordinator: AudioSessionCoordinator) { - self.coordinator = coordinator - } - - func install(_ token: AudioSessionCoordinator.Token) { - let previous = lock.withLock { - let previous = self.token - self.token = token - return previous - } - previous.map(coordinator.release) - } - - func release() { - let token = lock.withLock { - let token = self.token - self.token = nil - return token - } - token.map(coordinator.release) - } - - deinit { - release() - } -} - -/// Monotonic capture identity shared by main-actor lifecycle code and queued -/// engine callbacks. Removing a notification observer does not cancel a block -/// already enqueued on the main queue, so every callback must also prove it -/// still belongs to the current hold before mutating capture state. -final class PTTCaptureGeneration: @unchecked Sendable { - private let lock = NSLock() - private var value: UInt = 0 - - func begin() -> UInt { - lock.withLock { - value &+= 1 - return value - } - } - - func invalidate() { - lock.withLock { value &+= 1 } - } - - func invalidate(ifCurrent generation: UInt) -> Bool { - lock.withLock { - guard value == generation else { return false } - value &+= 1 - return true - } - } - - func isCurrent(_ generation: UInt) -> Bool { - lock.withLock { value == generation } - } -} - -/// Captures microphone audio for a live push-to-talk burst, producing both: -/// - live AAC frames via `onFrames` (called on the capture queue), and -/// - a finalized `.m4a` voice note on `stop()` — the same artifact -/// `VoiceRecorder` produces, so the existing voice-note send pipeline -/// handles delivery to receivers that missed the live stream. -/// `@unchecked Sendable`: every mutable property is confined to one executor — -/// the capture `queue` (resampler/encoder/file/counters) or the main actor -/// (`engine`, `engineStarted`, `sessionLease`, `configChangeObserver`) — so -/// weak references may cross the `@Sendable` tap/notification closures, which -/// immediately hop back to the owning executor. -final class PTTCaptureEngine: @unchecked Sendable { - /// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the - /// engine keeps running (the UI owns the gesture) but stops encoding. - private static let maxCaptureDuration: TimeInterval = 120 - - /// Recreated on every `start()`: an engine whose input unit was - /// instantiated against an earlier (playback-only or inactive) audio - /// session keeps reporting a dead 0 Hz / 2 ch input format and fails to - /// enable the mic (AURemoteIO -10851, observed on iPhone field tests). - private var engine = AVAudioEngine() - private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated) - private let coordinator: AudioSessionCoordinator - private let sessionLease: PTTCaptureSessionLease - private let captureGeneration = PTTCaptureGeneration() - - // Capture-queue-confined state. - private var resampler: PTTInputResampler? - private var encoder: PTTFrameEncoder? - private var file: AVAudioFile? - private var fileURL: URL? - private var encodedFrameCount = 0 - private var running = false - private var captureStart = Date() - /// Whether `engine.start()` succeeded for the current capture - /// (see `stopEngineIfStarted`). - @MainActor private var engineStarted = false - @MainActor private var configChangeObserver: NSObjectProtocol? - /// Called on the capture queue with each batch of encoded AAC frames. - var onFrames: (([Data]) -> Void)? - - enum CaptureError: Error { - case inputUnavailable - case audioSetupFailed - } - - init(coordinator: AudioSessionCoordinator = .shared) { - self.coordinator = coordinator - self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator) - } - - deinit { - sessionLease.release() - } - - /// Async because acquiring the session hops its blocking IPC off the main - /// actor (a PTT press used to stall main >1 s in `setActive`); the engine - /// itself still starts back on main once the session is configured. - @MainActor - func start(outputURL: URL) async throws { - let generation = captureGeneration.begin() - let token = try await coordinator.acquire(.capture) { [weak self] in - self?.handleInterruption(for: generation) - } - // The hold ended (stop/cancel) while the session was activating: - // starting the engine now would leave a hot mic after release. - guard captureGeneration.isCurrent(generation) else { - coordinator.release(token) - throw CancellationError() - } - sessionLease.install(token) - do { - try beginCapture(outputURL: outputURL, generation: generation) - } catch { - releaseSessionToken() - throw error - } - } - - @MainActor - private func beginCapture(outputURL: URL, generation: UInt) throws { - // Fresh engine per capture so its input unit binds to the session - // that is active *now* (see `engine` doc comment). - engine = AVAudioEngine() - let inputFormat = engine.inputNode.outputFormat(forBus: 0) - guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { - SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session) - throw CaptureError.inputUnavailable - } - guard let resampler = PTTInputResampler(inputFormat: inputFormat), - let encoder = PTTFrameEncoder(), - let pcmFormat = PTTAudioFormat.pcmFormat - else { throw CaptureError.audioSetupFailed } - - let file = try AVAudioFile( - forWriting: outputURL, - settings: PTTAudioFormat.voiceNoteFileSettings, - commonFormat: pcmFormat.commonFormat, - interleaved: pcmFormat.isInterleaved - ) - - queue.sync { - self.resampler = resampler - self.encoder = encoder - self.file = file - self.fileURL = outputURL - self.encodedFrameCount = 0 - self.captureStart = Date() - self.running = true - } - - engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in - self?.queue.async { self?.process(buffer, generation: generation) } - } - // Route/category changes reconfigure the engine underneath the tap; - // stop and finalize cleanly — the .m4a captured so far still sends. - // Registered before start() so no reconfigure lands unobserved - // (handleInterruption also validates this capture generation). - configChangeObserver = NotificationCenter.default.addObserver( - forName: .AVAudioEngineConfigurationChange, - object: engine, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.handleInterruption(for: generation) - } - } - engine.prepare() - do { - try engine.start() - } catch { - SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session) - if let observer = configChangeObserver { - NotificationCenter.default.removeObserver(observer) - configChangeObserver = nil - } - engine.inputNode.removeTap(onBus: 0) - queue.sync { self.teardown(deleteFile: true) } - throw error - } - engineStarted = true - SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session) - } - - /// Stops capture and finalizes the `.m4a`. Returns the file URL and the - /// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long). - @MainActor - func stop() -> (url: URL?, encodedFrames: Int) { - captureGeneration.invalidate() - stopEngineIfStarted() - let result: (URL?, Int) = queue.sync { - let url = fileURL - let frames = encodedFrameCount - teardown(deleteFile: false) - return (url, frames) - } - releaseSessionToken() - return result - } - - @MainActor - func cancel() { - captureGeneration.invalidate() - stopEngineIfStarted() - queue.sync { teardown(deleteFile: true) } - releaseSessionToken() - } - - /// Audio session interrupted (call, Siri) or the engine was reconfigured - /// mid-capture: behave like `stop()` — finalize the `.m4a` container but - /// keep `fileURL`/`encodedFrameCount` so the caller's pending `stop()` - /// still returns the note for delivery. - @MainActor - private func handleInterruption(for generation: UInt) { - // Also invalidate a start whose acquire has registered its token but - // has not returned to this actor yet. Without this bump the callback - // is lost while `engineStarted == false`, and the resumed start can - // open the mic after the stop signal. - guard captureGeneration.invalidate(ifCurrent: generation) else { return } - guard engineStarted else { - releaseSessionToken() - return - } - stopEngineIfStarted() - queue.sync { - running = false - // Releasing the AVAudioFile finalizes the .m4a container. - file = nil - encoder = nil - resampler = nil - } - releaseSessionToken() - SecureLogger.info("PTT: capture interrupted — burst finalized early", category: .session) - } - - /// Touching `inputNode` on an engine that never started instantiates its - /// input unit against whatever session is active and spams AURemoteIO - /// errors — a canceled-before-start hold must not touch the engine. - @MainActor - private func stopEngineIfStarted() { - if let observer = configChangeObserver { - NotificationCenter.default.removeObserver(observer) - configChangeObserver = nil - } - guard engineStarted else { return } - engineStarted = false - engine.inputNode.removeTap(onBus: 0) - engine.stop() - } - - @MainActor - private func releaseSessionToken() { - sessionLease.release() - } - - // MARK: - Capture queue - - private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) { - guard captureGeneration.isCurrent(generation), - running, - Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration, - let resampled = resampler?.resample(buffer) - else { return } - - do { - try file?.write(from: resampled) - } catch { - SecureLogger.error("PTT capture file write failed: \(error)", category: .session) - } - - guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return } - encodedFrameCount += frames.count - onFrames?(frames) - } - - private func teardown(deleteFile: Bool) { - running = false - // Releasing the AVAudioFile finalizes the .m4a container. - file = nil - encoder = nil - resampler = nil - if deleteFile, let url = fileURL { - try? FileManager.default.removeItem(at: url) - } - fileURL = nil - } -} diff --git a/bitchat/Features/voice/PTTSettings.swift b/bitchat/Features/voice/PTTSettings.swift deleted file mode 100644 index c3b56510..00000000 --- a/bitchat/Features/voice/PTTSettings.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// PTTSettings.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -#if os(iOS) -import UIKit -#elseif os(macOS) -import AppKit -#endif - -/// User preference for live push-to-talk voice. One switch controls both -/// directions: streaming your holds live, and auto-playing inbound bursts. -/// Off means voice messages behave exactly like classic voice notes. -enum PTTSettings { - private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled" - - static var liveVoiceEnabled: Bool { - get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true } - set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) } - } - - /// Autoplay is foreground-only: audio must never start from the - /// background. - @MainActor - static var isAppActive: Bool { - #if os(iOS) - return UIApplication.shared.applicationState == .active - #elseif os(macOS) - return NSApplication.shared.isActive - #else - return true - #endif - } -} diff --git a/bitchat/Features/voice/VoiceCaptureSession.swift b/bitchat/Features/voice/VoiceCaptureSession.swift deleted file mode 100644 index 75451552..00000000 --- a/bitchat/Features/voice/VoiceCaptureSession.swift +++ /dev/null @@ -1,250 +0,0 @@ -// -// VoiceCaptureSession.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import Foundation - -/// Capture backend behind the composer's hold-to-record gesture. -/// `VoiceRecordingViewModel` drives one session per press; the concrete type -/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a -/// note delivered on release (today's behavior), `PTTLiveVoiceSession` -/// additionally streams frames live while the button is held. -@MainActor -protocol VoiceCaptureSession: AnyObject { - /// Whether audio is leaving the device in real time while recording — - /// drives the composer's LIVE treatment. - var isLive: Bool { get } - func requestPermission() async -> Bool - func start() async throws - /// Stops capture and returns the finalized voice-note file, or nil when - /// nothing valid was captured. - func finish() async -> URL? - func cancel() async - /// Stops capture and suppresses every later send before returning. - func panicCancelSynchronously() -} - -/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`. -@MainActor -final class VoiceNoteCaptureSession: VoiceCaptureSession { - private let recorder: VoiceRecorder - private let owner = VoiceRecorder.RecordingOwner() - - var isLive: Bool { false } - - init(recorder: VoiceRecorder = .shared) { - self.recorder = recorder - } - - func requestPermission() async -> Bool { - await recorder.requestPermission() - } - - func start() async throws { - try await recorder.startRecording(owner: owner) - } - - func finish() async -> URL? { - await recorder.stopRecording(owner: owner) - } - - func cancel() async { - await recorder.cancelRecording(owner: owner) - } - - func panicCancelSynchronously() { - recorder.panicCancelSynchronously(owner: owner) - } -} - -/// Testable surface of the live capture engine. Production uses -/// `PTTCaptureEngine`; tests can supply captured-frame counts without opening -/// real audio hardware. -@MainActor -protocol PTTCapturing: AnyObject { - var onFrames: (([Data]) -> Void)? { get set } - func start(outputURL: URL) async throws - func stop() -> (url: URL?, encodedFrames: Int) - func cancel() -} - -extension PTTCaptureEngine: PTTCapturing {} - -/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while -/// recording, then finalizes the same audio as a standard voice note whose -/// file name carries the burst ID (`voice_.m4a`) so receivers that -/// heard the live stream absorb the note silently instead of seeing a -/// duplicate. -@MainActor -final class PTTLiveVoiceSession: VoiceCaptureSession { - let burstID: Data - - private let sendPacket: (Data) -> Void - private let capture: any PTTCapturing - private let now: () -> Date - /// Capture-queue-confined stream state: packetizes frames and lazily - /// emits START so packet order is guaranteed by queue serialization. - private final class StreamState { - var packetizer: VoiceBurstPacketizer - var sentStart = false - init(burstID: Data) { - packetizer = VoiceBurstPacketizer(burstID: burstID) - } - } - private let stream: StreamState - private var startDate: Date? - private var completed = false - - var isLive: Bool { true } - - /// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the - /// target peer; must be safe to call from any queue (BLEService hops to - /// its own message queue internally). - init( - sendPacket: @escaping (Data) -> Void, - capture: (any PTTCapturing)? = nil, - now: @escaping () -> Date = Date.init, - burstID: Data? = nil - ) { - self.burstID = burstID ?? VoiceBurstPacket.makeBurstID() - self.sendPacket = sendPacket - self.capture = capture ?? PTTCaptureEngine() - self.now = now - self.stream = StreamState(burstID: self.burstID) - } - - func requestPermission() async -> Bool { - await VoiceRecorder.shared.requestPermission() - } - - func start() async throws { - let outputURL = try Self.makeOutputURL(burstID: burstID) - let sendPacket = sendPacket - let stream = stream - capture.onFrames = { frames in - if !stream.sentStart { - stream.sentStart = true - if let start = VoiceBurstPacket( - burstID: stream.packetizer.burstID, - seq: 0, - kind: .start(codec: .aacLC16kMono) - ) { - sendPacket(start.encode()) - } - } - for frame in frames { - for packet in stream.packetizer.add(frame) { - sendPacket(packet) - } - } - // Flush per callback batch: at ~130-byte frames the budget fits - // one frame per packet anyway, and holding residue would add - // ~100 ms of avoidable latency. - for packet in stream.packetizer.flush() { - sendPacket(packet) - } - } - do { - try await capture.start(outputURL: outputURL) - } catch is CancellationError { - // The hold was released/canceled while the session acquire was - // in flight: the engine never started and the capture already - // handed its token back — nothing to retry. A coordinator-side - // interruption during handoff also cancels acquire, but that is - // not a successful start and must propagate to the view model. - guard completed else { throw CancellationError() } - return - } catch { - // The HAL can briefly report a dead input right after the audio - // session (re)activates while the route settles; one retry after - // a short pause covers it (observed on iPhone field tests). - SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session) - try? await Task.sleep(nanoseconds: 150_000_000) - // The hold may have been released/canceled during the retry pause. - // Starting the mic now would leave it live and streaming after the - // user let go, so bail instead of opening a hot mic. - guard !completed else { - capture.cancel() - return - } - try await capture.start(outputURL: outputURL) - } - startDate = now() - SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session) - } - - func finish() async -> URL? { - guard !completed else { return nil } - completed = true - - let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0 - let (url, encodedFrames) = capture.stop() - // stop() drained the capture queue, so touching `stream` is safe now. - - let capturedDuration = Double(encodedFrames) * PTTAudioFormat.frameDuration - - guard elapsed >= VoiceRecorder.minRecordingDuration, - capturedDuration >= VoiceRecorder.minRecordingDuration, - let url - else { - sendControlPacket(.canceled) - if let url { - try? FileManager.default.removeItem(at: url) - } - return nil - } - - for packet in stream.packetizer.flush() { - sendPacket(packet) - } - let durationMs = UInt32((capturedDuration * 1000).rounded()) - sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs)) - SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session) - return url - } - - func cancel() async { - let alreadyCompleted = completed - completed = true - // Always tear down the capture, even if a quick-release already marked - // us completed: the engine can start late (during start()'s retry - // pause), and only capture.cancel() stops the mic and deactivates the - // session. It is idempotent, so a redundant call is harmless. - capture.cancel() - if !alreadyCompleted { - sendControlPacket(.canceled) - } - } - - func panicCancelSynchronously() { - // Do not emit a canceled packet: it would itself be pre-panic - // conversation data racing the emergency transport reset. - completed = true - capture.cancel() - } - - private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) { - guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return } - sendPacket(packet.encode()) - } - - private static func makeOutputURL(burstID: Data) throws -> URL { - let base = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let directory = base - .appendingPathComponent("files", isDirectory: true) - .appendingPathComponent("voicenotes/outgoing", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes) - return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a") - } -} diff --git a/bitchat/Features/voice/VoiceNotePlaybackController.swift b/bitchat/Features/voice/VoiceNotePlaybackController.swift index 3818128c..79b9af6c 100644 --- a/bitchat/Features/voice/VoiceNotePlaybackController.swift +++ b/bitchat/Features/voice/VoiceNotePlaybackController.swift @@ -9,9 +9,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay @Published private(set) var duration: TimeInterval = 0 @Published private(set) var progress: Double = 0 - /// Internal lifecycle visibility for deterministic acquisition tests. - var isPlaybackStartPending: Bool { sessionAcquireInFlight } - /// rounded so 4.9s shows "00:05" var roundedDuration: Int { guard duration.isFinite else { return 0 } @@ -27,24 +24,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay private var player: AVAudioPlayer? private var timer: Timer? private var url: URL - /// Test seam; `AudioSessionCoordinator.shared` when nil. - private let sessionCoordinatorOverride: AudioSessionCoordinator? - /// Injectable so tests don't fight over the app-wide exclusive-playback - /// slot (a parallel test's `play()` would pause this controller mid-test). - private let exclusivity: VoiceNotePlaybackCoordinator - private var sessionToken: AudioSessionCoordinator.Token? - /// A session acquire is in flight (it suspends off-main for the blocking - /// session IPC); gates against double acquisition on rapid play taps. - private var sessionAcquireInFlight = false - init( - url: URL, - sessionCoordinator: AudioSessionCoordinator? = nil, - exclusivity: VoiceNotePlaybackCoordinator? = nil - ) { + init(url: URL) { self.url = url - self.sessionCoordinatorOverride = sessionCoordinator - self.exclusivity = exclusivity ?? .shared super.init() // Don't load anything eagerly - wait until user interaction or view is fully displayed } @@ -69,16 +51,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay deinit { timer?.invalidate() - player?.stop() - // A per-row @StateObject can be discarded mid-playback (navigating - // away). Leaking the token here would hold the session forever — - // never deactivating it, and pinning any escalated category for the - // app's lifetime. `release` is fire-and-forget onto the coordinator's - // queue, so it is deinit-safe: only the Sendable token crosses. - if let token = sessionToken { - sessionToken = nil - (sessionCoordinatorOverride ?? .shared).release(token) - } } func replaceURL(_ url: URL) { @@ -96,15 +68,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay func play() { guard ensurePlayerReady() else { return } - exclusivity.activate(self) - isPlaying = true + VoiceNotePlaybackCoordinator.shared.activate(self) + player?.play() startTimer() updateProgress() - // Acquired here (not in ensurePlayerReady): scrubbing a paused note - // must not hold the session while nothing is audible. The session - // calls block on audio-server IPC, so they run off the main thread; - // the player starts once the session is configured. - startPlayerAfterAcquiringSession() + isPlaying = true } func pause() { @@ -112,7 +80,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay stopTimer() updateProgress() isPlaying = false - releaseSession() } func stop() { @@ -121,8 +88,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay stopTimer() updateProgress() isPlaying = false - releaseSession() - exclusivity.deactivate(self) + VoiceNotePlaybackCoordinator.shared.deactivate(self) } func seek(to fraction: Double) { @@ -130,11 +96,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay let clamped = max(0, min(1, fraction)) if let player = player { player.currentTime = clamped * player.duration - // While the session acquire is still in flight, don't start - // audio pre-activation — the pending acquire's completion starts - // playback (from the new position) once the session resolves. - if isPlaying, !sessionAcquireInFlight { - startPreparedPlayer() + if isPlaying { + player.play() } updateProgress() } @@ -149,20 +112,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay self.stopTimer() self.updateProgress() self.isPlaying = false - self.releaseSession() - self.exclusivity.deactivate(self) + VoiceNotePlaybackCoordinator.shared.deactivate(self) } } // MARK: - Private Helpers private func preparePlayer(for url: URL) { - // Load metadata synchronously, but do not call prepareToPlay here: - // paused scrubbing reaches this path and must not acquire playback - // hardware outside the AudioSessionCoordinator token lifetime. + // Prepare player synchronously (only called when playback is requested) do { let player = try AVAudioPlayer(contentsOf: url) player.delegate = self + player.prepareToPlay() self.player = player duration = player.duration currentTime = player.currentTime @@ -180,81 +141,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay if player == nil { preparePlayer(for: url) } + #if os(iOS) + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers]) + try session.setActive(true, options: []) + } catch { + SecureLogger.error("Failed to activate audio session: \(error)", category: .session) + } + #endif return player != nil } - /// All entry points (SwiftUI actions, `pauseForExclusivity`, the - /// delegate's main-queue hop) run on the main thread; the acquire itself - /// suspends while the blocking session IPC runs on the coordinator's - /// queue, and the player starts when it resolves. An acquire failure - /// leaves playback stopped: starting without a registered token would - /// bypass interruption fan-out and the coordinator's refcount. A - /// pause/stop landing mid-acquire hands the token straight back. - private func startPlayerAfterAcquiringSession() { - if sessionToken != nil { - startPreparedPlayer() - return - } - guard !sessionAcquireInFlight else { return } - sessionAcquireInFlight = true - let coordinator = sessionCoordinatorOverride ?? AudioSessionCoordinator.shared - Task { @MainActor [weak self] in - var token: AudioSessionCoordinator.Token? - do { - token = try await coordinator.acquire(.playback) { [weak self] in - self?.pause() - } - } catch { - SecureLogger.error("Failed to activate audio session: \(error)", category: .session) - } - guard let self else { - // The row was discarded while acquiring; deinit had no token - // to release yet. - token.map(coordinator.release) - return - } - self.sessionAcquireInFlight = false - guard self.isPlaying else { - // Paused/stopped while the session was activating. - token.map(coordinator.release) - return - } - guard let token else { - self.failPlaybackStart() - return - } - self.sessionToken = token - self.startPreparedPlayer() - } - } - - @discardableResult - private func startPreparedPlayer() -> Bool { - guard let player, - player.prepareToPlay(), - player.play() - else { - SecureLogger.error("Voice note player refused to start " + url.lastPathComponent, category: .session) - failPlaybackStart() - return false - } - return true - } - - private func failPlaybackStart() { - player?.pause() - stopTimer() - updateProgress() - isPlaying = false - releaseSession() - exclusivity.deactivate(self) - } - - private func releaseSession() { - sessionToken.map((sessionCoordinatorOverride ?? .shared).release) - sessionToken = nil - } - private func startTimer() { if timer != nil { return } timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in @@ -283,75 +181,25 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay } } -/// Something that can hold the app's single audio-playback slot and yield it -/// when another playback starts (voice notes pause; live bursts stop). -protocol ExclusivePlayback: AnyObject { - func pauseForExclusivity() -} - -extension VoiceNotePlaybackController: ExclusivePlayback { - func pauseForExclusivity() { - pause() - } -} - -/// Ensures only one voice playback (note or live burst) runs at a time. +/// Ensures only one voice note plays at a time. final class VoiceNotePlaybackCoordinator { static let shared = VoiceNotePlaybackCoordinator() - struct Reservation: Equatable { - fileprivate let generation: UInt64 - } + private weak var activeController: VoiceNotePlaybackController? - private weak var activeController: (any ExclusivePlayback)? - private weak var latestReservedController: (any ExclusivePlayback)? - private var latestReservation = Reservation(generation: 0) + private init() {} - /// Internal so tests can isolate their own exclusivity slot; the app - /// uses `shared`. - init() {} - - /// Records playback intent without interrupting audio that is already - /// audible. Async starters reserve before suspension, then activate only - /// after their audio resource is ready. - func reserve(_ controller: any ExclusivePlayback) -> Reservation { - latestReservation = Reservation(generation: latestReservation.generation &+ 1) - latestReservedController = controller - return latestReservation - } - - /// Immediate activation for synchronous/user-initiated playback. - @discardableResult - func activate(_ controller: any ExclusivePlayback) -> Reservation { - let reservation = reserve(controller) - _ = activate(controller, reservation: reservation) - return reservation - } - - /// Commits an earlier reservation only when it is still the newest - /// playback request. This prevents an older async acquire from stealing - /// the floor after a newer play gesture. - @discardableResult - func activate(_ controller: any ExclusivePlayback, reservation: Reservation) -> Bool { - guard isCurrent(reservation, for: controller) else { return false } + func activate(_ controller: VoiceNotePlaybackController) { if activeController === controller { - return true + return } - activeController?.pauseForExclusivity() + activeController?.pause() activeController = controller - return true } - func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool { - latestReservation == reservation && latestReservedController === controller - } - - func deactivate(_ controller: any ExclusivePlayback) { + func deactivate(_ controller: VoiceNotePlaybackController) { if activeController === controller { activeController = nil } - if latestReservedController === controller { - latestReservedController = nil - } } } diff --git a/bitchat/Features/voice/VoiceRecorder.swift b/bitchat/Features/voice/VoiceRecorder.swift index 909879cd..b7b4b91f 100644 --- a/bitchat/Features/voice/VoiceRecorder.swift +++ b/bitchat/Features/voice/VoiceRecorder.swift @@ -1,95 +1,22 @@ import Foundation import AVFoundation -/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping -/// it behind a protocol lets lifecycle races be tested without opening the -/// microphone on the test host. -protocol VoiceAudioRecording: AnyObject { - var isRecording: Bool { get } - var isMeteringEnabled: Bool { get set } - func prepareToRecord() -> Bool - func record(forDuration duration: TimeInterval) -> Bool - func stop() -} - -extension AVAudioRecorder: VoiceAudioRecording {} - -protocol VoiceAudioRecorderCreating { - func makeRecorder(url: URL) throws -> any VoiceAudioRecording -} - -private struct SystemVoiceAudioRecorderFactory: VoiceAudioRecorderCreating { - func makeRecorder(url: URL) throws -> any VoiceAudioRecording { - let settings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 16_000, - AVNumberOfChannelsKey: 1, - AVEncoderBitRateKey: 16_000 - ] - return try AVAudioRecorder(url: url, settings: settings) - } -} - /// Manages audio capture for mesh voice notes with predictable encoding settings. actor VoiceRecorder { - enum RecorderError: Error, Equatable { + enum RecorderError: Error { case microphoneAccessDenied + case recorderInitializationFailed case recordingInProgress - case failedToStartRecording } static let shared = VoiceRecorder() + private let paddingInterval: TimeInterval = 0.5 + private let maxRecordingDuration: TimeInterval = 120 static let minRecordingDuration: TimeInterval = 1 - /// Identity of one press/hold. Every lifecycle mutation must present the - /// same owner that started the recorder, so a stale finish or cancel from - /// another hold cannot stop or delete the current recording. - final class RecordingOwner: @unchecked Sendable {} - - /// Test-only scheduling seams for lifecycle boundaries that otherwise rely - /// on wall-clock sleeps. Production uses the real padding delay. - struct TestingHooks: Sendable { - let waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? - - init(waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? = nil) { - self.waitForStopPadding = waitForStopPadding - } - } - - private let sessionCoordinator: AudioSessionCoordinator - private let recorderFactory: any VoiceAudioRecorderCreating - private let permissionGranted: () -> Bool - private let paddingInterval: TimeInterval - private let maxRecordingDuration: TimeInterval - private let outputDirectory: URL? - private let testingHooks: TestingHooks - - private var recorder: (any VoiceAudioRecording)? + private var recorder: AVAudioRecorder? private var currentURL: URL? - private var sessionToken: AudioSessionCoordinator.Token? - private var activeOwner: RecordingOwner? - /// True only while `startRecording()` is suspended in session acquire. - /// A second start is rejected instead of superseding the first one. - private var startInFlight = false - - init( - sessionCoordinator: AudioSessionCoordinator = .shared, - recorderFactory: any VoiceAudioRecorderCreating = SystemVoiceAudioRecorderFactory(), - permissionGranted: (() -> Bool)? = nil, - paddingInterval: TimeInterval = 0.5, - maxRecordingDuration: TimeInterval = 120, - outputDirectory: URL? = nil, - testingHooks: TestingHooks = TestingHooks() - ) { - self.sessionCoordinator = sessionCoordinator - self.recorderFactory = recorderFactory - self.permissionGranted = permissionGranted ?? Self.hasSystemPermission - self.paddingInterval = paddingInterval - self.maxRecordingDuration = maxRecordingDuration - self.outputDirectory = outputDirectory - self.testingHooks = testingHooks - } // MARK: - Permissions @@ -115,130 +42,82 @@ actor VoiceRecorder { // MARK: - Recording Lifecycle @discardableResult - func startRecording(owner: RecordingOwner) async throws -> URL { - if activeOwner != nil { + func startRecording() throws -> URL { + if recorder?.isRecording == true { throw RecorderError.recordingInProgress } - guard permissionGranted() else { + #if os(iOS) + let session = AVAudioSession.sharedInstance() + guard session.recordPermission == .granted else { throw RecorderError.microphoneAccessDenied } - - activeOwner = owner - startInFlight = true - - // The acquire suspends while the blocking session IPC runs on the - // coordinator's queue (never this actor's thread or main). - let token: AudioSessionCoordinator.Token - do { - token = try await sessionCoordinator.acquire(.capture) { [weak self] in - Task { await self?.handleSessionInterruption(for: owner) } - } - } catch { - guard activeOwner === owner else { - throw CancellationError() - } - startInFlight = false - activeOwner = nil - throw error + #if targetEnvironment(simulator) + // allowBluetoothHFP is not available on iOS Simulator + try session.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetoothA2DP] + ) + #else + try session.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP] + ) + #endif + try session.setActive(true, options: .notifyOthersOnDeactivation) + #endif + #if os(macOS) + guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { + throw RecorderError.microphoneAccessDenied } + #endif - // Actor reentrancy: release/cancel may have ended this hold while the - // blocking session activation was still in progress. - guard activeOwner === owner, startInFlight else { - sessionCoordinator.release(token) - throw CancellationError() - } - startInFlight = false - sessionToken = token + let outputURL = try makeOutputURL() + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 16_000, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 16_000 + ] - var outputURL: URL? - do { - let newURL = try makeOutputURL() - outputURL = newURL - let audioRecorder = try recorderFactory.makeRecorder(url: newURL) - audioRecorder.isMeteringEnabled = true - guard audioRecorder.prepareToRecord() else { - throw RecorderError.failedToStartRecording - } - guard audioRecorder.record(forDuration: maxRecordingDuration) else { - throw RecorderError.failedToStartRecording - } + let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings) + audioRecorder.isMeteringEnabled = true + audioRecorder.prepareToRecord() + audioRecorder.record(forDuration: maxRecordingDuration) - recorder = audioRecorder - currentURL = newURL - return newURL - } catch { - releaseSessionToken() - recorder = nil - currentURL = nil - activeOwner = nil - if let outputURL { - try? FileManager.default.removeItem(at: outputURL) - } - throw error - } + recorder = audioRecorder + currentURL = outputURL + return outputURL } - func stopRecording(owner: RecordingOwner) async -> URL? { - guard activeOwner === owner else { return nil } - - // `finish()` can race a still-suspended start on a direct caller even - // though the UI normally routes quick releases through cancel(). - if startInFlight { - activeOwner = nil - startInFlight = false - return nil - } - - guard let activeRecorder = recorder else { - let sessionURL = currentURL - releaseSessionToken() - currentURL = nil - activeOwner = nil - return sessionURL + func stopRecording() async -> URL? { + guard let recorder, recorder.isRecording else { + return currentURL } let sessionURL = currentURL - if activeRecorder.isRecording, paddingInterval > 0 { - if let waitForStopPadding = testingHooks.waitForStopPadding { - await waitForStopPadding(paddingInterval) - } else { - try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000)) - } - } + try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000)) - // Cancellation or interruption may have run during the padding sleep. - // Only the recorder whose stop began here may be finalized by it. - guard activeOwner === owner, - let recorder = self.recorder, - recorder === activeRecorder - else { return nil } + recorder.stop() - if activeRecorder.isRecording { - activeRecorder.stop() + // A new session may have started during the sleep — don't touch its state + if self.recorder === recorder { + cleanupSession() + self.recorder = nil + currentURL = nil } - releaseSessionToken() - self.recorder = nil - currentURL = nil - activeOwner = nil return sessionURL } - func cancelRecording(owner: RecordingOwner) async { - guard activeOwner === owner else { return } - - // Invalidate ownership before cleanup. An actor-reentrant start whose - // session acquire resumes later will observe the mismatch and release - // its token without opening the microphone. - activeOwner = nil - startInFlight = false + func cancelRecording() { if let recorder, recorder.isRecording { recorder.stop() } - releaseSessionToken() + cleanupSession() if let currentURL { try? FileManager.default.removeItem(at: currentURL) } @@ -246,61 +125,15 @@ actor VoiceRecorder { currentURL = nil } - /// Panic is a synchronous security boundary: the caller must know the - /// microphone, audio-session lease, and partial file are gone before it - /// rotates identities or deletes the media tree. VoiceRecorder is an - /// independent actor and this cleanup path never hops to MainActor, so a - /// short semaphore join is safe even when invoked by the UI actor. - nonisolated - func panicCancelSynchronously(owner: RecordingOwner) { - let finished = DispatchSemaphore(value: 0) - Task { - await cancelRecording(owner: owner) - finished.signal() - } - finished.wait() - } - - /// The audio session was interrupted (call, Siri) or reconfigured: stop - /// the recorder but keep `recorder`/`currentURL` so the caller's pending - /// `stopRecording()` still returns the partial note. - private func handleSessionInterruption(for owner: RecordingOwner) async { - // A callback captured for a released token must never stop a newer - // recording. Conversely, an interruption delivered while acquire is - // still suspended invalidates that acquire before it can open the mic. - guard activeOwner === owner else { return } - if startInFlight { - activeOwner = nil - startInFlight = false - return - } - startInFlight = false - if let recorder, recorder.isRecording { - recorder.stop() - } - releaseSessionToken() - } - // MARK: - Helpers - private static func hasSystemPermission() -> Bool { - #if os(iOS) - AVAudioSession.sharedInstance().recordPermission == .granted - #elseif os(macOS) - AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - #else - true - #endif - } - private func makeOutputURL() throws -> URL { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" - let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a" + let fileName = "voice_\(formatter.string(from: Date())).m4a" - let baseDirectory = try outputDirectory - ?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true) - try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes) + let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true) + try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil) return baseDirectory.appendingPathComponent(fileName) } @@ -314,11 +147,9 @@ actor VoiceRecorder { #endif } - /// Fire-and-forget: the coordinator hops the blocking deactivation IPC - /// onto its own queue. - private func releaseSessionToken() { - guard let token = sessionToken else { return } - sessionToken = nil - sessionCoordinator.release(token) + private func cleanupSession() { + #if os(iOS) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + #endif } } diff --git a/bitchat/Features/voice/Waveform.swift b/bitchat/Features/voice/Waveform.swift index cfc79310..e33c72bd 100644 --- a/bitchat/Features/voice/Waveform.swift +++ b/bitchat/Features/voice/Waveform.swift @@ -56,6 +56,12 @@ final class WaveformCache { } } + func purgeAll() { + queue.async(flags: .barrier) { [weak self] in + self?.cache.removeAll() + } + } + private func computeWaveform(url: URL, bins: Int) -> [Float]? { guard bins > 0 else { return nil } // Use autoreleasepool to manage memory from audio buffer allocations diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 8a72fcba..29f036c6 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -14,22 +14,18 @@ /// /// ## Overview /// BitChat's identity system separates concerns across three distinct layers: -/// 1. **Network Identity**: the 8-byte peer ID seen on air +/// 1. **Ephemeral Identity**: Short-lived, rotatable peer IDs for privacy /// 2. **Cryptographic Identity**: Long-term Noise static keys for security -/// 3. **Social Identity**: assigned names and trust relationships +/// 3. **Social Identity**: User-assigned names and trust relationships /// -/// The layers are separate concerns, but they are not independent: the network -/// identity is *derived* from the cryptographic one, so it does not provide -/// unlinkability. Rotating peer IDs would be a change to this model, not a -/// description of it — see the note below. +/// This separation allows users to maintain stable cryptographic identities +/// while frequently rotating their network identifiers for privacy. /// /// ## Three-Layer Architecture /// -/// ### Layer 1: Network Identity -/// - 8-byte peer ID = first 8 bytes of the Noise static key fingerprint -/// - **Not ephemeral and not rotating.** It is stable across sessions and -/// reboots, and changes only when the underlying identity is replaced by a -/// panic wipe. A passive observer can use it to track a device. +/// ### Layer 1: Ephemeral Identity +/// - Random 8-byte peer IDs that rotate periodically +/// - Provides network-level privacy and prevents tracking /// - Changes don't affect cryptographic relationships /// - Includes handshake state tracking /// @@ -37,7 +33,7 @@ /// - Based on Noise Protocol static key pairs /// - Fingerprint derived from SHA256 of public key /// - Enables end-to-end encryption and authentication -/// - The root of the peer ID above, and never rotated on a schedule +/// - Persists across peer ID rotations /// /// ### Layer 3: Social Identity /// - User-assigned names (petnames) for contacts @@ -48,13 +44,10 @@ /// ## Privacy Design /// The model is designed with privacy-first principles: /// - No mandatory persistent storage -/// - Optional identity caching with explicit consent +/// - Optional identity caching with user consent +/// - Ephemeral IDs prevent long-term tracking /// - Social mappings stored locally only /// -/// It does **not** currently prevent long-term tracking by a passive radio -/// observer: the peer ID is stable (Layer 1) and signed announcements carry the -/// static keys and nickname in cleartext. -/// /// ## Trust Model /// Four levels of trust: /// 1. **Unknown**: New or unverified peers @@ -63,17 +56,17 @@ /// 4. **Verified**: Cryptographic verification completed /// /// ## Identity Resolution -/// When a peer's ID changes (a panic wipe on their side, or a future rotation): +/// When a peer rotates their ephemeral ID: /// 1. Cryptographic handshake reveals their fingerprint /// 2. System looks up social identity by fingerprint -/// 3. UI seamlessly maintains existing relationships +/// 3. UI seamlessly maintains user relationships /// 4. Historical messages remain properly attributed /// /// ## Conflict Resolution /// Handles edge cases like: /// - Multiple peers claiming same nickname /// - Nickname changes and conflicts -/// - Identity replacement during active chats +/// - Identity rotation during active chats /// - Network partitions and rejoins /// /// ## Usage Example @@ -92,13 +85,11 @@ import BitFoundation // MARK: - Three-Layer Identity Model -/// Represents the network layer of identity — the peer ID seen on air, plus the -/// handshake state tracked against it. -/// -/// Named "ephemeral" for historical reasons; the peer ID is in fact stable, -/// being derived from the Noise static key fingerprint. It does not rotate and -/// does not prevent tracking. +/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. +/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships. struct EphemeralIdentity { + let peerID: PeerID // 8 random bytes + let sessionStart: Date var handshakeState: HandshakeState } @@ -107,18 +98,19 @@ enum HandshakeState { case initiated case inProgress case completed(fingerprint: String) + case failed(reason: String) } /// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair. -/// This identity outlives any change to a peer's network ID and enables secure communication. -/// The fingerprint serves as the permanent identifier for a peer's cryptographic identity, and -/// its first 8 bytes are the peer ID broadcast on the mesh. +/// This identity persists across ephemeral ID rotations and enables secure communication. +/// The fingerprint serves as the permanent identifier for a peer's cryptographic identity. struct CryptographicIdentity: Codable { let fingerprint: String // SHA256 of public key let publicKey: Data // Noise static public key // Optional Ed25519 signing public key (used to authenticate public messages) var signingPublicKey: Data? = nil let firstSeen: Date + let lastHandshake: Date? } /// Represents the social layer of identity - user-assigned names and trust relationships. @@ -134,35 +126,11 @@ struct SocialIdentity: Codable { var notes: String? } -/// Trust ladder: unknown → casual → vouched → trusted → verified. -/// -/// Persistence compatibility: `TrustLevel` is stored by its *String* raw -/// value ("unknown", "casual", …), not by ordinal position, so inserting -/// `vouched` mid-ladder cannot corrupt previously persisted values — every -/// pre-existing case keeps the exact raw value it was written with. The -/// `vouched` tier is additionally never persisted into `SocialIdentity` -/// (it's recomputed on read from stored vouches), so downgraded builds never -/// encounter the unfamiliar raw value. enum TrustLevel: String, Codable { - case unknown - case casual - /// Transitively trusted: vouched for by at least one peer *I* verified. - /// Derived at read time — never written to persistent storage. - case vouched - case trusted - case verified -} - -// MARK: - Vouching (transitive verification) - -/// One accepted vouch: a peer I verified (the voucher) attested that they -/// verified the vouchee. Validity is recomputed on read — a record only -/// counts while its voucher remains in `verifiedFingerprints` and its -/// timestamp is within `VouchAttestation.maxAge` — so unverifying a voucher -/// silently invalidates the vouches they gave without a cascade delete. -struct VouchRecord: Codable, Equatable { - let voucherFingerprint: String - let timestamp: Date + case unknown = "unknown" + case casual = "casual" + case trusted = "trusted" + case verified = "verified" } // MARK: - Identity Cache @@ -173,78 +141,22 @@ struct VouchRecord: Codable, Equatable { struct IdentityCache: Codable { // Fingerprint -> Social mapping var socialIdentities: [String: SocialIdentity] = [:] - + // Nickname -> [Fingerprints] reverse index // Multiple fingerprints can claim same nickname var nicknameIndex: [String: Set] = [:] - + // Verified fingerprints (cryptographic proof) var verifiedFingerprints: Set = [] - + // Last interaction timestamps (privacy: optional) - var lastInteractions: [String: Date] = [:] - + var lastInteractions: [String: Date] = [:] + // Blocked Nostr pubkeys (lowercased hex) for geohash chats var blockedNostrPubkeys: Set = [] - - // Vouching (transitive verification). All three fields are Optional so - // caches persisted before this feature decode cleanly — decodeIfPresent - // is used below, and a missing key must not trip the "unreadable cache" - // recovery path that discards everything. - - // Vouchee fingerprint -> accepted vouches (capped per vouchee) - var vouchesByVouchee: [String: [VouchRecord]]? = nil - - // Peer fingerprint -> when we last sent them a vouch batch (rate limit) - var vouchBatchSentAt: [String: Date]? = nil - - // Fingerprint -> when we verified it (orders outgoing vouch batches; - // entries verified before this field exists sort as oldest) - var verifiedAt: [String: Date]? = nil - - // Stable Noise fingerprints that proved encrypted private-media support - // inside an authenticated Noise session. Optional for decoding caches - // written before this migration. Entries are monotonic until a panic wipe - // so an old/replayed announce cannot silently downgrade a peer. - var privateMediaCapableFingerprints: Set? = nil - - // Noise-fingerprint -> Ed25519 announcement key, learned only from the - // authenticated peer-state payload. This prevents a self-signed announce - // containing a copied public Noise key from replacing a previously bound - // public-message signing identity. Optional for old cache compatibility. - var authenticatedSigningKeysByFingerprint: [String: Data]? = nil - - // Fingerprint -> Cryptographic identity (noise + pinned signing key). - // Persisting the signing-key pin is security-critical: it must survive - // app restarts so an attacker cannot replay a known peer's - // noiseKey/peerID with their own signing key and be treated as first - // contact (TOFU downgrade). - var cryptographicIdentities: [String: CryptographicIdentity] = [:] - + // Schema version for future migrations var version: Int = 1 - - init() {} - - // Custom decoding so caches written by older builds (missing newer keys - // such as `cryptographicIdentities` or the vouching fields) still load - // instead of being discarded. Every field uses decodeIfPresent so a - // missing key falls back to its default rather than throwing. - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:] - nicknameIndex = try container.decodeIfPresent([String: Set].self, forKey: .nicknameIndex) ?? [:] - verifiedFingerprints = try container.decodeIfPresent(Set.self, forKey: .verifiedFingerprints) ?? [] - lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:] - blockedNostrPubkeys = try container.decodeIfPresent(Set.self, forKey: .blockedNostrPubkeys) ?? [] - vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee) - vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt) - verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt) - privateMediaCapableFingerprints = try container.decodeIfPresent(Set.self, forKey: .privateMediaCapableFingerprints) - authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint) - cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:] - version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 - } } // diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 4204e940..c96aeb0e 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -108,6 +108,8 @@ protocol SecureIdentityStateManagerProtocol { func updateSocialIdentity(_ identity: SocialIdentity) // MARK: Favorites Management + func getFavorites() -> Set + func setFavorite(_ fingerprint: String, isFavorite: Bool) func isFavorite(fingerprint: String) -> Bool // MARK: Blocked Users Management @@ -121,7 +123,8 @@ protocol SecureIdentityStateManagerProtocol { // MARK: Ephemeral Session Management func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) - + func updateHandshakeState(peerID: PeerID, state: HandshakeState) + // MARK: Cleanup func clearAllIdentityData() func removeEphemeralSession(peerID: PeerID) @@ -130,24 +133,6 @@ protocol SecureIdentityStateManagerProtocol { func setVerified(fingerprint: String, verified: Bool) func isVerified(fingerprint: String) -> Bool func getVerifiedFingerprints() -> Set - - // MARK: Vouching (transitive verification) - @discardableResult - func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool - func validVouchers(for fingerprint: String) -> [VouchRecord] - func isVouched(fingerprint: String) -> Bool - func lastVouchBatchSent(to fingerprint: String) -> Date? - func markVouchBatchSent(to fingerprint: String, at date: Date) - func signingPublicKey(forFingerprint fingerprint: String) -> Data? - func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] - - // MARK: Noise-authenticated announcement identity - func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) - func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? - - // MARK: Private-media downgrade protection - func markPrivateMediaCapable(fingerprint: String) - func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool } /// Singleton manager for secure identity state persistence and retrieval. @@ -160,106 +145,48 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // In-memory state private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:] - // Cryptographic identities (including pinned signing keys) live inside - // `cache` so they persist across app restarts; see IdentityCache. + private var cryptographicIdentities: [String: CryptographicIdentity] = [:] private var cache: IdentityCache = IdentityCache() // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) - private let queueSpecificKey = DispatchSpecificKey() - // Pending-save coalescing flag. Reads/writes are serialized on `queue`. - // - // Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt - // + keychain write inside `queue.sync(flags: .barrier)`, so when the call - // returns the write is already complete and NOTHING is left scheduled on - // the queue. This is deliberate — a retained DispatchSourceTimer (the - // original design) kept the dispatch machinery alive and prevented the - // unit-test process from exiting, and fire-and-forget `queue.async(.barrier)` - // (a later design) left a backlog of instrumented barrier saves still - // draining when LLVM's `--enable-code-coverage` `atexit` handler dumped - // `.profraw`, deadlocking the process at teardown on the constrained CI - // runner. Synchronous persistence has zero outstanding dispatch at exit, so - // neither failure mode is possible. `pendingSave` is now effectively always - // false after any mutation (saveIdentityCache persists inline and clears - // it); it remains only as a belt-and-suspenders flag read by `forceSave` - // and `deinit`. + // Debouncing for keychain saves + private var saveTimer: Timer? + private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds private var pendingSave = false - + // Encryption key private let encryptionKey: SymmetricKey - /// True when `encryptionKey` is a throwaway generated this session because the - /// persisted key could not be read (device locked / access denied). In that - /// state we must NOT persist (it would overwrite the real cache with data the - /// next launch can't decrypt) and must NOT delete the existing cache. - private let encryptionKeyIsEphemeral: Bool init(_ keychain: KeychainManagerProtocol) { self.keychain = keychain - - // Retrieve (or, only on genuine first run, generate) the cache - // encryption key. We MUST distinguish "key doesn't exist yet" from a - // transient failure (device locked / access denied): the legacy - // getIdentityKey(forKey:) collapses both to nil, and generating+saving a - // new key deletes the existing one first — permanently orphaning the - // encrypted cache on a launch that merely couldn't read the key. + + // Generate or retrieve encryption key from keychain let loadedKey: SymmetricKey - let keyIsEphemeral: Bool - - switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) { - case .success(let keyData): + + // Try to load from keychain + if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) { loadedKey = SymmetricKey(data: keyData) - keyIsEphemeral = false SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true) - - case .itemNotFound: - // Genuine first run: generate and persist a new key. - let newKey = SymmetricKey(size: .bits256) - let keyData = newKey.withUnsafeBytes { Data($0) } - let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName) - loadedKey = newKey - // If even the save failed, treat the key as ephemeral so we don't - // later try to persist a cache the next launch can't read. - keyIsEphemeral = !saved - SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved) - - case .deviceLocked, .authenticationFailed, .accessDenied, .otherError: - // Transient/critical read failure. Do NOT overwrite the persisted - // key. Use a session-only ephemeral key; the real key and cache are - // left intact for a healthy launch. - SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security) + } + // Generate new key if needed + else { loadedKey = SymmetricKey(size: .bits256) - keyIsEphemeral = true + let keyData = loadedKey.withUnsafeBytes { Data($0) } + // Save to keychain + let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName) + SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved) } - + self.encryptionKey = loadedKey - self.encryptionKeyIsEphemeral = keyIsEphemeral - queue.setSpecific(key: queueSpecificKey, value: 1) - - // Only read the persisted cache when we hold the real key; with an - // ephemeral key the decrypt would fail and discard the real cache. - if !keyIsEphemeral { - loadIdentityCache() - } + + // Load identity cache on init + loadIdentityCache() } deinit { - // Do NOT dispatch onto `queue` here. `deinit` can run on any thread - // (including one draining `queue`), and the object is being - // deallocated: a `queue.sync` risks a re-entrant same-queue wait - // (deadlock) and a `queue.async` schedules work that resurrects `self` - // and may not drain before process exit. - // - // A flush here is redundant anyway: every mutating API already - // persists inline within its own barrier, so the keychain is already - // up to date. As a queue-free best-effort belt-and-suspenders, only - // flush if something is still pending. This is a direct read of - // in-hand state — safe because a deallocating object has no other - // live references, so nothing can be mutating `cache` concurrently. - if pendingSave { - pendingSave = false - persist(snapshot: cache) - } + forceSave() } // MARK: - Secure Loading/Saving @@ -275,45 +202,30 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey) cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData) } catch { - cache = IdentityCache() - let deleted = keychain.deleteIdentityKey(forKey: cacheKey) - SecureLogger.warning( - "Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))", - category: .security - ) + // Log error but continue with empty cache + SecureLogger.error(error, context: "Failed to load identity cache", category: .security) } } - /// Persists the cache. Always invoked on `queue` under a barrier (its - /// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read - /// while serialized. The encode + keychain write are done here (already on - /// the exclusive barrier context), synchronously, so no separate hop is - /// scheduled and nothing is left to keep the process alive. private func saveIdentityCache() { + // Mark that we need to save pendingSave = true - // On the barrier context already: snapshot is trivially consistent. - persist(snapshot: cache) - pendingSave = false - } - - /// Encodes, seals, and writes a *snapshot* of the cache to the keychain. - /// - /// Takes the cache by value so callers can capture a consistent snapshot - /// under `queue` and then encode without holding it. Reading `cache` - /// concurrently with a barrier writer would be a data race on the - /// dictionary storage, which — because `JSONEncoder` walks that storage — - /// can spin forever (observed as a CI test-suite hang), so the snapshot - /// must be taken on `queue`, never off it. - private func persist(snapshot: IdentityCache) { - // Never persist under an ephemeral key — it would overwrite the real - // cache with data the next launch cannot decrypt. - guard !encryptionKeyIsEphemeral else { - SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security) - return + + // Cancel any existing timer + saveTimer?.invalidate() + + // Schedule a new save after the debounce interval + saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in + self?.performSave() } - + } + + private func performSave() { + guard pendingSave else { return } + pendingSave = false + do { - let data = try JSONEncoder().encode(snapshot) + let data = try JSONEncoder().encode(cache) let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) if saved { @@ -323,27 +235,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { SecureLogger.error(error, context: "Failed to save identity cache", category: .security) } } - - // Force a flush (for app-termination / lifecycle events — NOT from - // `deinit`, which persists inline; see the deinit note). Every mutating - // API already persists inline inside its own barrier via - // `saveIdentityCache`, so by the time this is called the keychain is - // already up to date and this is normally a no-op; it exists as a - // belt-and-suspenders flush of any `pendingSave` left set. - // - // Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier - // makes the `cache` read race-free (a plain off-queue read races in-flight - // barrier writers — JSONEncoder walking a concurrently-mutated dictionary - // can spin forever, which surfaced as a CI hang), and being synchronous it - // leaves nothing scheduled to keep the process alive at teardown. Safe - // against re-entrant deadlock because this is never invoked from `deinit` - // (the only path that can run *on* `queue`). + + // Force immediate save (for app termination) func forceSave() { - queue.sync(flags: .barrier) { - guard pendingSave else { return } - pendingSave = false - persist(snapshot: cache) - } + saveTimer?.invalidate() + performSave() } // MARK: - Social Identity Management @@ -357,46 +253,36 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // MARK: - Cryptographic Identities /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. - /// - /// TOFU signing-key pinning: once a signing key has been persisted for a - /// fingerprint, an update carrying a *different* signing key is refused in - /// full (including the claimed-nickname update) and security-logged. This - /// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` — without it, an - /// attacker replaying a victim's noiseKey/peerID with their own signing - /// key could overwrite the victim's persisted identity while the victim is - /// offline or after an app restart. The refusal is permanent: there is - /// currently no targeted in-app way to reset the pin (`setVerified` does - /// not touch it). Recovering from a legitimate signing re-key requires the - /// peer to establish a new noise identity (new peerID) or the local user - /// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe). /// - Parameters: /// - fingerprint: SHA-256 hex of the Noise static public key /// - noisePublicKey: Noise static public key data /// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages /// - claimedNickname: Optional latest claimed nickname to persist into social identity func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { - queue.sync(flags: .barrier) { + queue.async(flags: .barrier) { let now = Date() - if var existing = self.cache.cryptographicIdentities[fingerprint] { - if let pinnedSigningKey = existing.signingPublicKey, - let announcedSigningKey = signingPublicKey, - pinnedSigningKey != announcedSigningKey { - SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security) - return - } + if var existing = self.cryptographicIdentities[fingerprint] { // Update keys if changed if existing.publicKey != noisePublicKey { existing = CryptographicIdentity( fingerprint: fingerprint, publicKey: noisePublicKey, signingPublicKey: signingPublicKey ?? existing.signingPublicKey, - firstSeen: existing.firstSeen + firstSeen: existing.firstSeen, + lastHandshake: now ) - self.cache.cryptographicIdentities[fingerprint] = existing + self.cryptographicIdentities[fingerprint] = existing } else { - // Update signing key + // Update signing key and lastHandshake existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey - self.cache.cryptographicIdentities[fingerprint] = existing + let updated = CryptographicIdentity( + fingerprint: existing.fingerprint, + publicKey: existing.publicKey, + signingPublicKey: existing.signingPublicKey, + firstSeen: existing.firstSeen, + lastHandshake: now + ) + self.cryptographicIdentities[fingerprint] = updated } // Persist updated state (already assigned in branches above) } else { @@ -405,9 +291,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { fingerprint: fingerprint, publicKey: noisePublicKey, signingPublicKey: signingPublicKey, - firstSeen: now + firstSeen: now, + lastHandshake: now ) - self.cache.cryptographicIdentities[fingerprint] = entry + self.cryptographicIdentities[fingerprint] = entry } // Optionally persist claimed nickname into social identity @@ -439,72 +326,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { queue.sync { // Defensive: ensure hex and correct length guard peerID.isShort else { return [] } - return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } - } - } - - // MARK: - Private-media downgrade protection - - func markPrivateMediaCapable(fingerprint: String) { - guard !fingerprint.isEmpty else { return } - let insertAndPersist = { - var pinned = self.cache.privateMediaCapableFingerprints ?? [] - guard pinned.insert(fingerprint).inserted else { return } - self.cache.privateMediaCapableFingerprints = pinned - self.saveIdentityCache() - } - // Downgrade decisions can run immediately after an authenticated - // announce. Make the pin visible before returning; merely enqueueing a - // barrier leaves a cross-queue window where a replay can look legacy. - // The queue-specific fast path prevents self-deadlock if a future - // identity-state mutation records the capability from inside `queue`. - if DispatchQueue.getSpecific(key: queueSpecificKey) != nil { - insertAndPersist() - } else { - queue.sync(flags: .barrier, execute: insertAndPersist) - } - } - - func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { - guard !fingerprint.isEmpty else { return false } - return queue.sync { - cache.privateMediaCapableFingerprints?.contains(fingerprint) == true - } - } - - // MARK: - Noise-authenticated announcement identity - - func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) { - guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength, - !fingerprint.isEmpty else { return } - let bindAndPersist = { - var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:] - let bindingChanged = bindings[fingerprint] != signingPublicKey - bindings[fingerprint] = signingPublicKey - self.cache.authenticatedSigningKeysByFingerprint = bindings - if var cryptoIdentity = self.cache.cryptographicIdentities[fingerprint] { - cryptoIdentity.signingPublicKey = signingPublicKey - self.cache.cryptographicIdentities[fingerprint] = cryptoIdentity - } - guard bindingChanged else { return } - self.saveIdentityCache() - } - if DispatchQueue.getSpecific(key: queueSpecificKey) != nil { - bindAndPersist() - } else { - queue.sync(flags: .barrier, execute: bindAndPersist) - } - } - - func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { - guard !fingerprint.isEmpty else { return nil } - return queue.sync { - cache.authenticatedSigningKeysByFingerprint?[fingerprint] + return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } } } func updateSocialIdentity(_ identity: SocialIdentity) { - queue.sync(flags: .barrier) { + queue.async(flags: .barrier) { let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname self.cache.socialIdentities[identity.fingerprint] = identity @@ -540,7 +367,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } func setFavorite(_ fingerprint: String, isFavorite: Bool) { - queue.sync(flags: .barrier) { + queue.async(flags: .barrier) { if var identity = self.cache.socialIdentities[fingerprint] { identity.isFavorite = isFavorite self.cache.socialIdentities[fingerprint] = identity @@ -578,7 +405,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func setBlocked(_ fingerprint: String, isBlocked: Bool) { SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security) - queue.sync(flags: .barrier) { + queue.async(flags: .barrier) { if var identity = self.cache.socialIdentities[fingerprint] { identity.isBlocked = isBlocked if isBlocked { @@ -612,7 +439,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { let key = pubkeyHexLowercased.lowercased() - queue.sync(flags: .barrier) { + queue.async(flags: .barrier) { if isBlocked { self.cache.blockedNostrPubkeys.insert(key) } else { @@ -630,12 +457,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) { queue.async(flags: .barrier) { - self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState) + self.ephemeralSessions[peerID] = EphemeralIdentity( + peerID: peerID, + sessionStart: Date(), + handshakeState: handshakeState + ) } } func updateHandshakeState(peerID: PeerID, state: HandshakeState) { - queue.sync(flags: .barrier) { + queue.async(flags: .barrier) { self.ephemeralSessions[peerID]?.handshakeState = state // If handshake completed, update last interaction @@ -651,10 +482,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func clearAllIdentityData() { SecureLogger.warning("Clearing all identity data", category: .security) - queue.sync(flags: .barrier) { + queue.async(flags: .barrier) { self.cache = IdentityCache() self.ephemeralSessions.removeAll() - + self.cryptographicIdentities.removeAll() + // Delete from keychain let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted) @@ -662,8 +494,8 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } func removeEphemeralSession(peerID: PeerID) { - queue.sync(flags: .barrier) { - _ = self.ephemeralSessions.removeValue(forKey: peerID) + queue.async(flags: .barrier) { + self.ephemeralSessions.removeValue(forKey: peerID) } } @@ -672,23 +504,19 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func setVerified(fingerprint: String, verified: Bool) { SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security) - queue.sync(flags: .barrier) { + queue.async(flags: .barrier) { if verified { self.cache.verifiedFingerprints.insert(fingerprint) - var verifiedAt = self.cache.verifiedAt ?? [:] - verifiedAt[fingerprint] = Date() - self.cache.verifiedAt = verifiedAt } else { self.cache.verifiedFingerprints.remove(fingerprint) - self.cache.verifiedAt?.removeValue(forKey: fingerprint) } - + // Update trust level if social identity exists if var identity = self.cache.socialIdentities[fingerprint] { identity.trustLevel = verified ? .verified : .casual self.cache.socialIdentities[fingerprint] = identity } - + self.saveIdentityCache() } } @@ -705,159 +533,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } - // MARK: - Vouching (transitive verification) - - /// Maximum vouchers retained per vouchee (most recent kept). - static let maxVouchersPerVouchee = 8 - - /// Records an accepted vouch, enforcing every accept-policy gate that can - /// be evaluated against stored state (signature verification is the - /// caller's job — it needs the sender's announce-bound signing key): - /// - the voucher must be a fingerprint *I* verified - /// - self-vouches are ignored - /// - vouches for peers I already verified are ignored (nothing to add) - /// - attestations outside the validity window are ignored - /// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee - /// - /// Returns true when the vouch was stored (or refreshed). - @discardableResult - func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { - recordVouch( - voucheeFingerprint: voucheeFingerprint, - voucherFingerprint: voucherFingerprint, - timestamp: timestamp, - now: Date() - ) - } - - @discardableResult - func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool { - queue.sync(flags: .barrier) { - guard voucheeFingerprint != voucherFingerprint, - self.cache.verifiedFingerprints.contains(voucherFingerprint), - !self.cache.verifiedFingerprints.contains(voucheeFingerprint) else { - return false - } - let age = now.timeIntervalSince(timestamp) - guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else { - return false - } - - var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? [] - if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) { - let newest = max(records[index].timestamp, timestamp) - records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest) - } else { - records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp)) - } - // Keep the most recent vouchers up to the cap. - records.sort { $0.timestamp > $1.timestamp } - let capped = Array(records.prefix(Self.maxVouchersPerVouchee)) - guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else { - return false // Full of fresher vouches; nothing changed. - } - - var vouches = self.cache.vouchesByVouchee ?? [:] - vouches[voucheeFingerprint] = capped - self.cache.vouchesByVouchee = vouches - self.saveIdentityCache() - return true - } - } - - /// The vouches that currently count for `fingerprint`. Validity is - /// recomputed here rather than maintained by cascade deletes: a record - /// only counts while its voucher is still verified-by-me and its - /// timestamp is within the expiry window. - func validVouchers(for fingerprint: String) -> [VouchRecord] { - validVouchers(for: fingerprint, now: Date()) - } - - func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] { - queue.sync { - self.validVouchersLocked(for: fingerprint, now: now) - } - } - - /// Requires `queue`. - private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] { - guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] } - return records.filter { record in - record.voucherFingerprint != fingerprint - && cache.verifiedFingerprints.contains(record.voucherFingerprint) - && now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge - } - } - - /// True when the peer has at least one valid vouch and no explicit - /// verification of ours. - func isVouched(fingerprint: String) -> Bool { - isVouched(fingerprint: fingerprint, now: Date()) - } - - func isVouched(fingerprint: String, now: Date) -> Bool { - queue.sync { - guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false } - return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty - } - } - - /// The trust level to display: explicit verification wins, then the - /// persisted level, with `vouched` layered in (derived, never persisted) - /// between `casual` and `trusted`. - func effectiveTrustLevel(for fingerprint: String) -> TrustLevel { - effectiveTrustLevel(for: fingerprint, now: Date()) - } - - func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel { - queue.sync { - if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified } - let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown - let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty - switch stored { - case .verified, .trusted: - return stored - case .vouched, .casual, .unknown: - if vouched { return .vouched } - // `.vouched` should never be persisted; degrade defensively. - return stored == .vouched ? .casual : stored - } - } - } - - func lastVouchBatchSent(to fingerprint: String) -> Date? { - queue.sync { cache.vouchBatchSentAt?[fingerprint] } - } - - func markVouchBatchSent(to fingerprint: String, at date: Date) { - queue.async(flags: .barrier) { - var sentAt = self.cache.vouchBatchSentAt ?? [:] - sentAt[fingerprint] = date - self.cache.vouchBatchSentAt = sentAt - self.saveIdentityCache() - } - } - - /// The peer's announce-bound Ed25519 signing key, if seen this session. - func signingPublicKey(forFingerprint fingerprint: String) -> Data? { - queue.sync { cache.cryptographicIdentities[fingerprint]?.signingPublicKey } - } - - /// Verified fingerprints ordered most recently verified first (entries - /// without a recorded verification time sort last), excluding the given - /// fingerprint. Feeds the outgoing vouch batch. - func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { - queue.sync { - let verifiedAt = cache.verifiedAt ?? [:] - let ordered = cache.verifiedFingerprints - .filter { $0 != fingerprint } - .sorted { - (verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1) - } - return Array(ordered.prefix(limit)) - } - } - var debugNicknameIndex: [String: Set] { queue.sync { cache.nicknameIndex } } diff --git a/bitchat/Info.plist b/bitchat/Info.plist index 57ddfa11..f949ecee 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -31,8 +31,6 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) - LSApplicationCategoryType - public.app-category.social-networking LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSBluetoothAlwaysUsageDescription @@ -42,9 +40,9 @@ NSCameraUsageDescription bitchat uses the camera to scan QR codes to verify peers. NSLocationWhenInUseUsageDescription - bitchat uses your location to compute optional geohash channels, bridge cells, and nearby place labels. Exact coordinates are not included in bitchat messages. + bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared. NSMicrophoneUsageDescription - bitchat uses the microphone while you record voice notes or hold live push-to-talk, then sends that audio to your selected mesh conversation. + bitchat uses the microphone to record voice notes that relay across the mesh. NSPhotoLibraryUsageDescription bitchat lets you pick images from your photo library to share with nearby peers. UIBackgroundModes diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e498a20e..8cbbf031 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -1,19213 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "board.accessibility.post" : { - "comment" : "Accessibility label for the board post button", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "نشر إعلان" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নোটিশ পোস্ট করো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aushang posten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Post notice" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Publicar aviso" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال اطلاعیه" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mag-post ng abiso" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Publier une annonce" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פרסום מודעה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "नोटिस पोस्ट करो" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Posting pengumuman" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pubblica avviso" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お知らせを投稿" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공지 올리기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Siarkan notis" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचना पोस्ट गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mededeling plaatsen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Opublikuj ogłoszenie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Publicar aviso" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Publicar aviso" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Опубликовать объявление" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Publicera anslag" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அறிவிப்பை வெளியிடு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "โพสต์ประกาศ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Duyuru paylaş" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Опублікувати оголошення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اعلان پوسٹ کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đăng thông báo" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "发布公告" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "張貼公告" - } - } - } - }, - "board.accessibility.post_row" : { - "comment" : "Accessibility label for a board post row", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إعلان من %@: %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-এর নোটিশ: %@" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aushang von %@: %@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notice from %@: %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aviso de %@: %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اطلاعیه از %@: %@" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abiso mula kay %@: %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annonce de %@ : %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מודעה מאת %@: %@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ का नोटिस: %@" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pengumuman dari %@: %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avviso da %@: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@さんからのお知らせ:%@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@님의 공지: %@" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notis daripada %@: %@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@बाट सूचना: %@" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mededeling van %@: %@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ogłoszenie od %@: %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aviso de %@: %@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aviso de %@: %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Объявление от %@: %@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anslag från %@: %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ இடமிருந்து அறிவிப்பு: %@" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ประกาศจาก %@: %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ kişisinden duyuru: %@" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Оголошення від %@: %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کی طرف سے اعلان: %@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thông báo từ %@: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "来自 %@ 的公告:%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "來自 %@ 的公告:%@" - } - } - } - }, - "board.action.delete" : { - "comment" : "Delete action for own board posts", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "حذف" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মুছে ফেলো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "löschen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "delete" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "borrar" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حذف" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "burahin" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "supprimer" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מחיקה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "हटाओ" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "hapus" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "elimina" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "削除" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "삭제" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "padam" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "मेटाउनुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "verwijderen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "usuń" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "apagar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "excluir" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "удалить" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ta bort" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நீக்கு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ลบ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "sil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "видалити" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "حذف کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "xoá" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "删除" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "刪除" - } - } - } - }, - "board.compose.expiry" : { - "comment" : "Label for the board post expiry picker", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تنتهي الصلاحية خلال" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মেয়াদ শেষ হবে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "läuft ab in" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "expires in" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "caduca en" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "منقضی می‌شود پس از" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mag-e-expire sa loob ng" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "expire dans" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פג תוקף בעוד" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "समाप्त होगा" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "kedaluwarsa dalam" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "scade tra" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "有効期限" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "만료까지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "luput dalam" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "म्याद सकिने" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "verloopt over" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wygasa za" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "expira em" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "expira em" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "истекает через" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "går ut om" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "காலாவதி நேரம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "หมดอายุใน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "geçerlilik süresi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "зникає через" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "میعاد ختم ہونے میں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "hết hạn sau" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "有效期" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "有效期限" - } - } - } - }, - "board.compose.expiry_days" : { - "comment" : "Expiry picker option, number of days abbreviated", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld ي" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld দি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldT" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldd" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldd" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld روز" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldd" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldj" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld י׳" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld दि" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld hr" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldg" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld日" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld일" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld दिन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldd" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldd" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldd" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld д" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldd" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld நா" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld วัน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldg" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldд" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld دن" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld ngày" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld天" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld天" - } - } - } - }, - "board.compose.placeholder" : { - "comment" : "Placeholder for the board composer text field", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "انشر إعلانًا…" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "একটা নোটিশ পোস্ট করো…" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "einen aushang posten…" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "post a notice…" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "publica un aviso…" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "یک اطلاعیه بگذار…" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mag-post ng abiso…" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "publie une annonce…" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פרסמו מודעה…" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "कोई नोटिस लिखो…" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "posting pengumuman…" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "pubblica un avviso…" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お知らせを投稿…" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공지를 올려보세요…" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "siarkan notis…" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचना पोस्ट गर्नुहोस्…" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "plaats een mededeling…" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dodaj ogłoszenie…" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "publica um aviso…" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "publique um aviso…" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "напишите объявление…" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "publicera ett anslag…" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அறிவிப்பை வெளியிடுங்கள்…" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "โพสต์ประกาศ…" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bir duyuru paylaş…" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "напиши оголошення…" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ایک اعلان پوسٹ کریں…" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đăng một thông báo…" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "发布一条公告…" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "張貼一則公告…" - } - } - } - }, - "board.compose.urgent" : { - "comment" : "Label for the urgent toggle in the board composer", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "عاجل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "জরুরি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "dringend" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgent" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgente" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "فوری" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgent" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgent" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "דחוף" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ज़रूरी" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "mendesak" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgente" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "緊急" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "긴급" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "segera" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "जरुरी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dringend" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "pilne" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgente" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgente" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "срочно" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "akut" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அவசரம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ด่วน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "acil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "терміново" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "فوری" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "khẩn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "紧急" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "緊急" - } - } - } - }, - "board.empty_subtitle" : { - "comment" : "Subtitle shown when the board has no posts", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ثبّت أول إعلان للناس في الجوار." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আশেপাশের মানুষদের জন্য প্রথম নোটিশটা পিন করো।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "pinne den ersten aushang für die leute hier in der gegend an." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "pin the first notice for people around here." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "fija el primer aviso para la gente de por aquí." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اولین اطلاعیه را برای آدم‌های این اطراف سنجاق کن." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "i-pin ang unang abiso para sa mga tao sa paligid." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "épingle la première annonce pour les gens du coin." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הצמידו את המודעה הראשונה בשביל האנשים שבסביבה." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आस-पास के लोगों के लिए पहला नोटिस पिन करो।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "sematkan pengumuman pertama untuk orang-orang di sekitar sini." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "appunta il primo avviso per le persone qui intorno." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "この辺りにいる人のために最初のお知らせをピン留めしましょう。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 주변 사람들을 위해 첫 공지를 고정해 보세요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "sematkan notis pertama untuk orang di sekitar sini." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यहाँ वरपरका मानिसहरूका लागि पहिलो सूचना पिन गर्नुहोस्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "pin de eerste mededeling voor mensen hier in de buurt." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "przypnij pierwsze ogłoszenie dla osób w okolicy." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "afixa o primeiro aviso para as pessoas por aqui." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "fixe o primeiro aviso para as pessoas por aqui." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "закрепите первое объявление для людей поблизости." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "sätt upp det första anslaget för folk häromkring." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இங்குள்ள மக்களுக்காக முதல் அறிவிப்பைப் பொருத்துங்கள்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ปักประกาศแรกให้ผู้คนแถวนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "buradaki insanlar için ilk duyuruyu sabitle." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "закріпи перше оголошення для людей поруч." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہاں آس پاس کے لوگوں کے لیے پہلا اعلان لگائیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ghim thông báo đầu tiên cho mọi người quanh đây." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "为附近的人钉上第一条公告吧。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "為附近的人釘上第一則公告吧。" - } - } - } - }, - "board.empty_title" : { - "comment" : "Title shown when the board has no posts", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا إعلانات بعد" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এখনও কোনো নোটিশ নেই" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "noch keine aushänge" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no notices yet" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "aún no hay avisos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هنوز اطلاعیه‌ای نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "wala pang abiso" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "pas encore d'annonces" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין עדיין מודעות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अभी कोई नोटिस नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "belum ada pengumuman" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "ancora nessun avviso" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お知らせはまだありません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "아직 공지가 없어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "belum ada notis" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अहिलेसम्म कुनै सूचना छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nog geen mededelingen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie ma jeszcze ogłoszeń" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ainda não há avisos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "nenhum aviso ainda" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "объявлений пока нет" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "inga anslag än" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இன்னும் அறிவிப்புகள் இல்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ยังไม่มีประกาศ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "henüz duyuru yok" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "оголошень поки немає" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ابھی کوئی اعلان نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "chưa có thông báo nào" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "还没有公告" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "還沒有公告" - } - } - } - }, - "board.urgent_badge" : { - "comment" : "Badge shown on urgent board posts", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "عاجل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "জরুরি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "dringend" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgent" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgente" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "فوری" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgent" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgent" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "דחוף" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ज़रूरी" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "mendesak" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgente" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "緊急" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "긴급" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "segera" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "जरुरी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dringend" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "pilne" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgente" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "urgente" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "срочно" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "akut" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அவசரம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ด่วน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "acil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "терміново" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "فوری" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "khẩn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "紧急" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "緊急" - } - } - } - }, - "app_info.settings.relays.add" : { - "comment" : "Button that adds the typed relay address to the list", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إضافة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "যোগ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "hinzufügen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "add" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "añadir" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "افزودن" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "idagdag" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ajouter" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הוסף" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "जोड़ें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tambah" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "aggiungi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "追加" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "추가" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tambah" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "थप" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "toevoegen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dodaj" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "adicionar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "adicionar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "добавить" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "lägg till" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "சேர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เพิ่ม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ekle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "додати" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "شامل کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "thêm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "添加" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "加入" - } - } - } - }, - "app_info.settings.relays.built_in" : { - "comment" : "Label marking a relay as one of the built-in relays, which cannot be removed", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مدمج" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বিল্ট-ইন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "eingebaut" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "built in" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "integrado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "داخلی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "built in" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "intégré" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מובנה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अंतर्निर्मित" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "bawaan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "integrato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "組み込み" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "기본 제공" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "terbina dalam" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "पूर्वनिर्मित" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ingebouwd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wbudowany" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "integrado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "integrado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "встроенное" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "inbyggt" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "உள்ளமைந்தது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "มาพร้อมแอป" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "yerleşik" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "вбудований" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "بلٹ اِن" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tích hợp" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "内置" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "內建" - } - } - } - }, - "app_info.settings.relays.error.duplicate" : { - "comment" : "Error shown when the typed relay address is already in the list", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "هذا المرحّل موجود في القائمة بالفعل." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই রিলে আগেই তালিকায় আছে।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "dieses relay steht schon in der liste." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "that relay is already in the list." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ese relay ya está en la lista." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این رله از قبل در فهرست است." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "nasa listahan na ang relay na iyon." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ce relais est déjà dans la liste." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הממסר הזה כבר ברשימה." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "वह रिले पहले से सूची में है।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay itu sudah ada di daftar." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "quel relay è già nell'elenco." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "そのリレーはすでにリストにあります。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "그 릴레이는 이미 목록에 있습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay itu sudah ada dalam senarai." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "त्यो रिले पहिले नै सूचीमा छ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "die relay staat al in de lijst." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ten relay już jest na liście." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "esse relé já está na lista." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "esse relay já está na lista." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "это реле уже в списке." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "det reläet finns redan i listan." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அந்த ரிலே ஏற்கெனவே பட்டியலில் உள்ளது." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รีเลย์นี้อยู่ในรายการแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu röle listede zaten var." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "цей релей уже в списку." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہ ریلے پہلے ہی فہرست میں ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay đó đã có trong danh sách." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "该中继已在列表中。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "該中繼已在清單中。" - } - } - } - }, - "app_info.settings.relays.error.limit" : { - "comment" : "Error shown when the relay list is already at its maximum size; %d is that maximum", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يمكنك إضافة %d مرحّلات كحد أقصى." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আপনি সর্বোচ্চ %d টি রিলে যোগ করতে পারেন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "du kannst bis zu %d relays hinzufügen." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you can add up to %d relays." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "puedes añadir hasta %d relays." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "می‌توانید تا %d رله اضافه کنید." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hanggang %d relay lang ang maaari mong idagdag." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tu peux ajouter jusqu'à %d relais." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אפשר להוסיף עד %d ממסרים." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आप %d रिले तक जोड़ सकते हैं।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "kamu bisa menambahkan hingga %d relay." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "puoi aggiungere fino a %d relay." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "リレーは最大%d件まで追加できます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "릴레이는 최대 %d개까지 추가할 수 있습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "anda boleh menambah sehingga %d relay." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "तिमी %d रिलेसम्म थप्न सक्छौ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "je kunt tot %d relays toevoegen." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "możesz dodać maksymalnie %d relay." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "podes adicionar até %d relés." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "você pode adicionar até %d relays." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "можно добавить до %d реле." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "du kan lägga till upp till %d reläer." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d ரிலேகள் வரை சேர்க்கலாம்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เพิ่มรีเลย์ได้สูงสุด %d รายการ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "en fazla %d röle ekleyebilirsiniz." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "можна додати до %d релеїв." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "آپ %d ریلے تک شامل کر سکتے ہیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "bạn có thể thêm tối đa %d relay." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "最多可以添加 %d 个中继。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "最多可以加入 %d 個中繼。" - } - } - } - }, - "app_info.settings.relays.error.malformed" : { - "comment" : "Error shown when a typed relay address cannot be parsed", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا يبدو هذا عنوان مرحّل. جرّب wss://host." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এটি রিলের ঠিকানার মতো মনে হচ্ছে না। wss://host দিয়ে চেষ্টা করুন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "das sieht nicht wie eine relay-adresse aus. versuch wss://host." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "that doesn't look like a relay address. try wss://host." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "eso no parece una dirección de relay. prueba wss://host." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این شبیه نشانی رله نیست. wss://host را امتحان کنید." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mukhang hindi ito address ng relay. subukan ang wss://host." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ça ne ressemble pas à une adresse de relais. essaie wss://host." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "זה לא נראה כמו כתובת של ממסר. נסה wss://host." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "यह रिले का पता नहीं लगता। wss://host आज़माएँ।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "itu tidak tampak seperti alamat relay. coba wss://host." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "non sembra l'indirizzo di un relay. prova wss://host." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "リレーのアドレスではないようです。wss://host を試してください。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "릴레이 주소가 아닌 것 같습니다. wss://host 형식으로 시도하세요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "itu tidak kelihatan seperti alamat relay. cuba wss://host." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यो रिलेको ठेगाना जस्तो देखिँदैन। wss://host प्रयास गर।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dat lijkt niet op een relay-adres. probeer wss://host." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "to nie wygląda na adres relay. spróbuj wss://host." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "isso não parece um endereço de relé. tenta wss://host." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "isso não parece um endereço de relay. tente wss://host." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "это не похоже на адрес реле. попробуй wss://host." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "det ser inte ut som en reläadress. prova wss://host." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இது ரிலே முகவரி போல் தெரியவில்லை. wss://host முயற்சிக்கவும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ดูเหมือนไม่ใช่ที่อยู่รีเลย์ ลอง wss://host" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu bir röle adresine benzemiyor. wss://host deneyin." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "це не схоже на адресу релея. спробуй wss://host." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہ ریلے کا پتہ نہیں لگتا۔ wss://host آزمائیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đó không giống địa chỉ relay. hãy thử wss://host." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "这看起来不像中继地址。试试 wss://host。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "這看起來不像中繼位址。試試 wss://host。" - } - } - } - }, - "app_info.settings.relays.placeholder" : { - "comment" : "Placeholder text in the field for adding a relay address", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "wss://relay.example.com" - } - } - } - }, - "app_info.settings.relays.remove" : { - "comment" : "Accessibility label for the button that removes an added relay", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إزالة المرحّل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "রিলে সরান" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay entfernen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "remove relay" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "quitar relay" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حذف رله" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "alisin ang relay" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "retirer le relais" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הסר ממסר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "रिले हटाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "hapus relay" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "rimuovi relay" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "リレーを削除" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "릴레이 제거" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "buang relay" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "रिले हटाउ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay verwijderen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "usuń relay" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "remover relé" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "remover relay" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "удалить реле" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ta bort relä" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ரிலேயை நீக்கு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ลบรีเลย์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "röleyi kaldır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "видалити релей" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ریلے ہٹائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "xóa relay" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "移除中继" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "移除中繼" - } - } - } - }, - "app_info.settings.relays.subtitle" : { - "comment" : "Subtitle explaining what the relay list is for and why someone would add a relay", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "عندما لا تصل شبكة mesh إلى شخص، تنتقل الرسائل الخاصة عبر هذه المرحّلات. المرحّلات المدمجة عناوين معروفة يمكن لمرشّح الشبكة حجبها — لذا يمكنك إضافة مرحّلاتك الخاصة، بما في ذلك عناوين .onion." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মেশ কারও কাছে পৌঁছাতে না পারলে ব্যক্তিগত বার্তা এই রিলেগুলোর মধ্য দিয়ে যায়। বিল্ট-ইন রিলেগুলো সুপরিচিত ঠিকানা, যা নেটওয়ার্ক ফিল্টার আটকে দিতে পারে — তাই আপনি নিজের রিলে যোগ করতে পারেন, .onion ঠিকানাও।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "wenn das mesh jemanden nicht erreicht, laufen private nachrichten über diese relays. die eingebauten sind bekannte adressen, die ein netzwerkfilter blockieren kann — du kannst also eigene hinzufügen, auch .onion-adressen." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "when the mesh can't reach someone, private messages travel through these relays. the built-in ones are well-known addresses that a network filter can block, so you can add your own — including .onion addresses." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "cuando el mesh no llega a alguien, los mensajes privados viajan por estos relays. los integrados son direcciones muy conocidas que un filtro de red puede bloquear, así que puedes añadir los tuyos — incluidas direcciones .onion." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "وقتی مش به کسی نمی‌رسد، پیام‌های خصوصی از این رله‌ها می‌گذرند. رله‌های داخلی نشانی‌های شناخته‌شده‌ای هستند که یک صافی شبکه می‌تواند مسدودشان کند — پس می‌توانید رله‌های خودتان را اضافه کنید، از جمله نشانی‌های .onion." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "kapag hindi maabot ng mesh ang isang tao, dumadaan ang mga pribadong mensahe sa mga relay na ito. ang mga built-in ay kilalang-kilalang address na kayang harangin ng filter ng network — kaya maaari kang magdagdag ng sarili mo, kasama ang mga .onion address." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "quand le mesh ne joint pas quelqu'un, les messages privés passent par ces relais. ceux intégrés sont des adresses bien connues qu'un filtre réseau peut bloquer — tu peux donc ajouter les tiens, y compris des adresses .onion." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "כשה-mesh לא מגיע למישהו, הודעות פרטיות עוברות דרך הממסרים האלה. הממסרים המובנים הם כתובות מוכרות שמסנן רשת יכול לחסום — אפשר להוסיף ממסרים משלך, כולל כתובות .onion." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "जब मेश किसी तक नहीं पहुँच पाता, तब निजी संदेश इन रिले से होकर जाते हैं। अंतर्निर्मित रिले जाने-पहचाने पते हैं जिन्हें नेटवर्क फ़िल्टर रोक सकता है — इसलिए आप अपने रिले जोड़ सकते हैं, .onion पते भी।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "saat mesh tidak bisa menjangkau seseorang, pesan pribadi lewat relay ini. relay bawaan adalah alamat yang sudah dikenal luas dan bisa diblokir filter jaringan — jadi kamu bisa menambahkan relay sendiri, termasuk alamat .onion." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "quando la mesh non raggiunge qualcuno, i messaggi privati passano da questi relay. quelli integrati sono indirizzi molto noti che un filtro di rete può bloccare — puoi quindi aggiungere i tuoi, anche indirizzi .onion." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "meshで相手に届かないとき、プライベートメッセージはこれらのリレーを通ります。組み込みのリレーはよく知られたアドレスで、ネットワークのフィルターにブロックされることがあります — 自分のリレー、.onionアドレスも追加できます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh로 상대에게 닿지 않으면 비공개 메시지는 이 릴레이를 거칩니다. 기본 릴레이는 널리 알려진 주소여서 네트워크 필터가 차단할 수 있습니다 — 그래서 .onion 주소를 포함해 직접 릴레이를 추가할 수 있습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "apabila mesh tidak dapat mencapai seseorang, pesan peribadi bergerak melalui relay ini. relay terbina dalam ialah alamat yang terkenal dan boleh dihalang oleh penapis rangkaian — jadi anda boleh menambah relay sendiri, termasuk alamat .onion." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh कसैसम्म पुग्न सक्दैन भने निजी सन्देश यी रिले मार्फत जान्छन्। पूर्वनिर्मित रिले सबैलाई थाहा भएका ठेगाना हुन्, जसलाई नेटवर्क फिल्टरले रोक्न सक्छ — त्यसैले तिमी आफ्नै रिले थप्न सक्छौ, .onion ठेगाना पनि।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "als de mesh iemand niet bereikt, gaan privéberichten via deze relays. de ingebouwde relays zijn bekende adressen die een netwerkfilter kan blokkeren — je kunt dus je eigen relays toevoegen, ook .onion-adressen." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "gdy mesh nie dosięga kogoś, wiadomości prywatne idą przez te relay. wbudowane to powszechnie znane adresy, które filtr sieciowy może zablokować — możesz więc dodać własne, także adresy .onion." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "quando a mesh não chega a alguém, as mensagens privadas seguem por estes relés. os integrados são endereços bem conhecidos que um filtro de rede pode bloquear — por isso podes acrescentar os teus, incluindo endereços .onion." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "quando o mesh não alcança alguém, as mensagens privadas passam por estes relays. os integrados são endereços conhecidos que um filtro de rede pode bloquear — então você pode adicionar os seus, inclusive endereços .onion." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "когда mesh не достаёт до человека, приватные сообщения идут через эти реле. встроенные — это широко известные адреса, которые может заблокировать сетевой фильтр, поэтому можно добавить свои, в том числе .onion-адреса." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "när mesh inte når fram till någon går privata meddelanden via dessa reläer. de inbyggda är välkända adresser som ett nätverksfilter kan blockera — du kan därför lägga till egna, även .onion-adresser." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh ஒருவரை எட்ட முடியாதபோது, தனிப்பட்ட செய்திகள் இந்த ரிலேகள் வழியாகச் செல்கின்றன. உள்ளமைந்த ரிலேகள் நன்கு அறியப்பட்ட முகவரிகள், அவற்றை நெட்வொர்க் வடிகட்டி தடுக்க முடியும் — எனவே .onion முகவரிகள் உட்பட உங்கள் சொந்த ரிலேகளைச் சேர்க்கலாம்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เมื่อ mesh ไปไม่ถึงใคร ข้อความส่วนตัวจะเดินทางผ่านรีเลย์เหล่านี้ รีเลย์ที่มาพร้อมแอปเป็นที่อยู่ที่รู้จักกันดีและตัวกรองเครือข่ายปิดกั้นได้ — คุณจึงเพิ่มรีเลย์ของตัวเองได้ รวมถึงที่อยู่ .onion" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh birine ulaşamadığında özel mesajlar bu rölelerden geçer. yerleşik röleler herkesin bildiği adreslerdir ve bir ağ filtresi bunları engelleyebilir — bu yüzden .onion adresleri de dahil kendi rölelerinizi ekleyebilirsiniz." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "коли mesh не дотягується до людини, приватні повідомлення йдуть через ці релеї. вбудовані — це добре відомі адреси, які може заблокувати мережевий фільтр, тож можна додати власні, зокрема .onion-адреси." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "جب mesh کسی تک نہ پہنچ سکے تو نجی پیغامات ان ریلے سے گزرتے ہیں۔ بلٹ اِن ریلے مشہور پتے ہیں جنہیں نیٹ ورک فلٹر روک سکتا ہے — اس لیے آپ اپنے ریلے شامل کر سکتے ہیں، .onion پتے بھی۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "khi mesh không tới được ai đó, tin nhắn riêng đi qua các relay này. các relay tích hợp là những địa chỉ ai cũng biết nên bộ lọc mạng có thể chặn — vì vậy bạn có thể thêm relay của mình, kể cả địa chỉ .onion." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "当 mesh 无法触达某人时,私密消息会经这些中继传递。内置中继是众所周知的地址,网络过滤可以封锁它们 — 所以你可以添加自己的中继,包括 .onion 地址。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "當 mesh 無法觸及某人時,私密訊息會經這些中繼傳遞。內建中繼是眾所周知的位址,網路過濾可以封鎖它們 — 所以你可以加入自己的中繼,包括 .onion 位址。" - } - } - } - }, - "app_info.settings.relays.title" : { - "comment" : "Title of the relay list editor in settings", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مرحّلات الرسائل الخاصة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্যক্তিগত বার্তার রিলে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "relays für private nachrichten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "private message relays" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "relays para mensajes privados" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رله‌های پیام خصوصی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mga relay para sa pribadong mensahe" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "relais pour messages privés" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ממסרים להודעות פרטיות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "निजी संदेश रिले" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay pesan pribadi" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay per messaggi privati" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "プライベートメッセージのリレー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "비공개 메시지 릴레이" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay pesan peribadi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "निजी सन्देशका रिले" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "relays voor privéberichten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay dla wiadomości prywatnych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "relés para mensagens privadas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "relays para mensagens privadas" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "реле для приватных сообщений" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "reläer för privata meddelanden" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "தனிப்பட்ட செய்திகளுக்கான ரிலேகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รีเลย์สำหรับข้อความส่วนตัว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "özel mesaj röleleri" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "релеї для приватних повідомлень" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "نجی پیغامات کے ریلے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay cho tin nhắn riêng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "私密消息中继" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "私密訊息中繼" - } - } - } - }, - "app_info.settings.tor.off_warning" : { - "comment" : "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor متوقف: كل مرحّل تتصل به يمكنه رؤية عنوان IP الخاص بك، بما في ذلك المرحّلات التي تحمل رسائلك الخاصة." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor বন্ধ: আপনি যে রিলেতেই সংযুক্ত হন সেটি আপনার IP ঠিকানা দেখতে পায়, আপনার ব্যক্তিগত বার্তা বহনকারী রিলেগুলোও।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor ist aus: jedes relay, mit dem du dich verbindest, kann deine ip-adresse sehen, auch die relays, die deine privaten nachrichten tragen." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor está desactivado: cada relay al que te conectas puede ver tu dirección IP, incluidos los relays que llevan tus mensajes privados." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor خاموش است: هر رله‌ای که به آن وصل می‌شوید می‌تواند نشانی IP شما را ببیند، از جمله رله‌هایی که پیام‌های خصوصی شما را می‌برند." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "naka-off ang tor: nakikita ng bawat relay na kinokonekta mo ang iyong IP address, kasama ang mga relay na nagdadala ng iyong mga pribadong mensahe." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor est désactivé : chaque relais auquel tu te connectes voit ton adresse ip, y compris les relais qui transportent tes messages privés." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor כבוי: כל ממסר שאתה מתחבר אליו רואה את כתובת ה-ip שלך, כולל ממסרים שנושאים את ההודעות הפרטיות שלך." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor बंद है: आप जिस भी रिले से जुड़ते हैं वह आपका IP पता देख सकता है, उनमें वे रिले भी शामिल हैं जो आपके निजी संदेश ले जाते हैं।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor nonaktif: setiap relay yang kamu hubungi bisa melihat alamat ip-mu, termasuk relay yang membawa pesan pribadimu." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor è disattivato: ogni relay a cui ti colleghi può vedere il tuo indirizzo ip, compresi i relay che trasportano i tuoi messaggi privati." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "torはオフです: 接続するすべてのリレーがあなたのipアドレスを見られます。プライベートメッセージを運ぶリレーも同じです。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor가 꺼져 있습니다: 연결하는 모든 릴레이가 IP 주소를 볼 수 있고, 비공개 메시지를 운반하는 릴레이도 마찬가지입니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor tidak aktif: setiap relay yang anda sambung boleh melihat alamat ip anda, termasuk relay yang membawa pesan peribadi anda." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor बन्द छ: तिमी जोडिने हरेक रिलेले तिम्रो ip ठेगाना देख्न सक्छ, तिम्रा निजी सन्देश बोक्ने रिले पनि।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor staat uit: elke relay waarmee je verbindt kan je IP-adres zien, ook de relays die je privéberichten vervoeren." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor jest wyłączony: każdy relay, z którym się łączysz, widzi twój adres IP, także te przenoszące twoje wiadomości prywatne." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "o Tor está desligado: cada relé a que te ligas consegue ver o teu endereço IP, incluindo os relés que transportam as tuas mensagens privadas." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "o tor está desligado: cada relay a que você se conecta consegue ver seu endereço ip, inclusive os relays que carregam suas mensagens privadas." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor выключен: каждое реле, к которому ты подключаешься, видит твой ip-адрес, включая реле, через которые идут твои приватные сообщения." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor är av: varje relä du ansluter till kan se din IP-adress, även reläerna som bär dina privata meddelanden." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor அணைந்திருக்கிறது: நீங்கள் இணைக்கும் ஒவ்வொரு ரிலேயும் உங்கள் IP முகவரியைப் பார்க்க முடியும், உங்கள் தனிப்பட்ட செய்திகளை எடுத்துச் செல்லும் ரிலேகளும் அதில் அடங்கும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor ปิดอยู่: ทุกรีเลย์ที่คุณเชื่อมต่อเห็นที่อยู่ IP ของคุณ รวมถึงรีเลย์ที่ส่งข้อความส่วนตัวของคุณ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor kapalı: bağlandığınız her röle IP adresinizi görebilir, özel mesajlarınızı taşıyan röleler de dahil." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor вимкнено: кожен релей, до якого ти підключаєшся, бачить твою ip-адресу, включно з релеями, що несуть твої приватні повідомлення." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor بند ہے: آپ جس ریلے سے بھی جڑتے ہیں وہ آپ کا IP پتہ دیکھ سکتا ہے، اُن ریلے سمیت جو آپ کے نجی پیغامات لے جاتے ہیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor đang tắt: mọi relay bạn kết nối đều thấy địa chỉ IP của bạn, kể cả những relay mang tin nhắn riêng của bạn." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor 已关闭:你连接的每个中继都能看到你的 IP 地址,包括承载你私密消息的中继。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor 已關閉:你連線的每個中繼都能看到你的 IP 位址,包括承載你私密訊息的中繼。" - } - } - } - }, - "app_info.settings.tor.subtitle" : { - "comment" : "Subtitle for the tor routing toggle in settings, explaining what it covers", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يرسل حركة الإنترنت عبر tor، فيرى مشغّلو المرحّلات عنوان tor بدلاً من عنوانك. يشمل قنوات الموقع والرسائل الخاصة المسلَّمة عبر الإنترنت. الموصى به: تشغيل." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ইন্টারনেট ট্রাফিক Tor দিয়ে পাঠায়, তাই রিলে পরিচালকেরা আপনার ঠিকানার বদলে Tor-এর ঠিকানা দেখে। লোকেশন চ্যানেল ও ইন্টারনেটে পাঠানো ব্যক্তিগত বার্তা এর আওতায় পড়ে। সুপারিশ: চালু।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "sendet internetverkehr über tor, sodass relay-betreiber die adresse von tor statt deiner sehen. gilt für standortkanäle und private nachrichten, die über das internet zugestellt werden. empfohlen: an." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sends internet traffic through tor, so relay operators see tor's address instead of yours. covers location channels and private messages delivered over the internet. recommended: on." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "envía el tráfico de internet a través de Tor, así quienes gestionan los relays ven la dirección de Tor en lugar de la tuya. cubre los canales de ubicación y los mensajes privados entregados por internet. recomendado: activado." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ترافیک اینترنت را از طریق Tor می‌فرستد، بنابراین گردانندگان رله به‌جای نشانی شما نشانی Tor را می‌بینند. کانال‌های موقعیت و پیام‌های خصوصی که از اینترنت تحویل می‌شوند را پوشش می‌دهد. توصیه: روشن." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "ipinapadala ang trapiko sa internet sa pamamagitan ng tor, kaya nakikita ng mga nagpapatakbo ng relay ang address ng tor at hindi ang sa iyo. saklaw nito ang mga channel ng lokasyon at ang mga pribadong mensaheng ipinapadala sa internet. inirerekomenda: naka-on." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "envoie le trafic internet par tor, donc ceux qui gèrent les relais voient l'adresse de tor et pas la tienne. couvre les canaux de localisation et les messages privés livrés par internet. recommandé : activé." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שולח את תעבורת האינטרנט דרך tor, כך שמפעילי ממסרים רואים את הכתובת של tor במקום שלך. חל על ערוצי מיקום ועל הודעות פרטיות שנשלחות דרך האינטרנט. מומלץ: פעיל." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "इंटरनेट ट्रैफ़िक Tor से भेजता है, जिससे रिले चलाने वालों को आपके पते की जगह Tor का पता दिखता है। यह लोकेशन चैनलों और इंटरनेट से पहुँचाए जाने वाले निजी संदेशों पर लागू होता है। अनुशंसित: चालू।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "mengirim lalu lintas internet lewat tor, jadi pengelola relay melihat alamat tor bukan alamatmu. berlaku untuk kanal lokasi dan pesan pribadi yang dikirim lewat internet. disarankan: aktif." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "invia il traffico internet attraverso tor, così chi gestisce i relay vede l'indirizzo di tor invece del tuo. riguarda i canali posizione e i messaggi privati consegnati via internet. consigliato: attivo." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "インターネット通信をtor経由で送るため、リレーの運営者にはあなたの代わりにtorのアドレスが見えます。ロケーションチャンネルと、インターネット経由で届くプライベートメッセージが対象です。推奨: オン" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "인터넷 트래픽을 tor를 통해 보내므로 릴레이를 운영하는 쪽에는 내 주소가 아니라 tor의 주소가 보입니다. 위치 채널과 인터넷으로 전달되는 비공개 메시지에 적용됩니다. 권장: 켜기." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "menghantar trafik internet melalui tor, jadi pengendali relay melihat alamat tor dan bukan alamat anda. ia merangkumi kanal lokasi dan pesan peribadi yang dihantar melalui internet. disarankan: aktif." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "इन्टरनेट ट्राफिक tor मार्फत पठाउँछ, त्यसैले रिले चलाउनेहरूले तिम्रो ठेगानाको सट्टा tor को ठेगाना देख्छन्। यो स्थान च्यानल र इन्टरनेटबाट पुग्ने निजी सन्देशमा लागू हुन्छ। सिफारिस: अन।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "stuurt internetverkeer via tor, zodat relaybeheerders het adres van tor zien in plaats van het jouwe. geldt voor locatiekanalen en privéberichten die via internet worden bezorgd. aanbevolen: aan." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wysyła ruch internetowy przez tor, więc osoby prowadzące relay widzą adres tor, a nie twój. dotyczy kanałów lokalizacji i wiadomości prywatnych dostarczanych przez internet. zalecane: włączone." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "envia o tráfego de internet através do Tor, por isso quem opera os relés vê o endereço do Tor em vez do teu. abrange os canais de localização e as mensagens privadas entregues pela internet. recomendado: ligado." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "envia o tráfego de internet pelo tor, então quem opera os relays vê o endereço do tor em vez do seu. vale para os canais de localização e as mensagens privadas entregues pela internet. recomendado: ligado." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "отправляет интернет-трафик через tor, поэтому операторы реле видят адрес tor, а не твой. распространяется на каналы локации и приватные сообщения, доставляемые через интернет. рекомендуем включить." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "skickar internettrafik via tor, så de som driver reläer ser tors adress i stället för din. gäller platskanaler och privata meddelanden som levereras över internet. rekommenderas: på." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இணைய போக்குவரத்தை tor வழியாக அனுப்புகிறது, அதனால் ரிலேகளை நடத்துபவர்கள் உங்கள் முகவரிக்குப் பதிலாக tor இன் முகவரியைப் பார்க்கிறார்கள். இட சேனல்களுக்கும் இணையம் வழியாக வழங்கப்படும் தனிப்பட்ட செய்திகளுக்கும் இது பொருந்தும். பரிந்துரை: இயக்கவும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ส่งทราฟฟิกอินเทอร์เน็ตผ่าน tor ผู้ดูแลรีเลย์จึงเห็นที่อยู่ของ tor แทนที่อยู่ของคุณ ครอบคลุมช่องตามตำแหน่งและข้อความส่วนตัวที่ส่งผ่านอินเทอร์เน็ต แนะนำให้เปิด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "internet trafiğini Tor üzerinden gönderir; böylece röle işletenler sizin adresiniz yerine Tor'un adresini görür. konum kanallarını ve internet üzerinden iletilen özel mesajları kapsar. önerilen: açık." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "надсилає інтернет-трафік через tor, тож оператори релеїв бачать адресу tor, а не твою. охоплює канали локації та приватні повідомлення, що доставляються через інтернет. рекомендовано ввімкнути." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "انٹرنیٹ ٹریفک tor کے ذریعے بھیجتا ہے، اس لیے ریلے چلانے والوں کو آپ کے پتے کی جگہ tor کا پتہ نظر آتا ہے۔ اس میں لوکیشن چینلز اور انٹرنیٹ سے پہنچائے جانے والے نجی پیغامات شامل ہیں۔ تجویز: آن رکھیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "gửi lưu lượng internet qua tor, nên bên vận hành relay thấy địa chỉ của tor thay vì địa chỉ của bạn. áp dụng cho kênh vị trí và tin nhắn riêng được gửi qua internet. khuyến nghị: bật." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "通过 tor 发送互联网流量,中继运营方看到的是 tor 的地址而不是你的。适用于位置频道和经互联网投递的私密消息。推荐:开启。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "透過 tor 傳送網際網路流量,中繼營運方看到的是 tor 的位址而不是你的。適用於位置頻道和經網際網路投遞的私密訊息。推薦:開啟。" - } - } - } - }, - "chats.accessibility.open_hint" : { - "comment" : "Accessibility hint on a recent chat row explaining activation opens the direct conversation", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يفتح هذه المحادثة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই কথোপকথন খোলে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "öffnet diese unterhaltung" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens this conversation" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre esta conversación" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این گفتگو را باز می‌کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "binubuksan ang usapang ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ouvre cette conversation" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פותח את השיחה הזו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "यह वार्तालाप खोलता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "membuka percakapan ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "apre questa conversazione" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "この会話を開きます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 대화를 엽니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "membuka perbualan ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यो वार्तालाप खोल्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "opent dit gesprek" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "otwiera tę rozmowę" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre esta conversa" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre esta conversa" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "открывает этот разговор" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "öppnar den här konversationen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இந்த உரையாடலைத் திறக்கும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เปิดการสนทนานี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu konuşmayı açar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "відкриває цю розмову" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہ گفتگو کھولتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mở cuộc trò chuyện này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "打开此对话" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "開啟此對話" - } - } - } - }, - "chats.section.header" : { - "comment" : "Section header above recent direct conversations in the people sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الدردشات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "চ্যাট" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "chats" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "chats" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "chats" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گفتگوها" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mga chat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "discussions" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "צ'אטים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "चैट" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "obrolan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "chat" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "チャット" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "채팅" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "sembang" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "च्याटहरू" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "chats" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "czaty" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "conversas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "conversas" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "чаты" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "chattar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அரட்டைகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "แชท" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "sohbetler" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "чати" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "چیٹس" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "trò chuyện" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "聊天" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "聊天" - } - } - } - }, - "command.action.not_found" : { - "comment" : "Error when an action command can't resolve its target; placeholders are the command and the nickname", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعذّر %1$@ %2$@: غير موجود" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@-কে %1$@ করা গেল না: পাওয়া যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ %2$@ geht nicht: nicht gefunden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot %1$@ %2$@: not found" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede %1$@ a %2$@: no encontrado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمی‌توان %2$@ را %1$@ کرد: پیدا نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi ma-%1$@ si %2$@: hindi nahanap" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossible de %1$@ %2$@ : introuvable" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אי אפשר לעשות %1$@ ל-%2$@: לא נמצא" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ को %1$@ नहीं कर सकते: नहीं मिला" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa %1$@ %2$@: tidak ditemukan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile %1$@ %2$@: non trovato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ を %1$@ できない: 見つからない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@에게 %1$@ 할 수 없어요: 찾을 수 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak dapat %1$@ %2$@: tidak dijumpai" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ लाई %1$@ गर्न सकिएन: फेला परेन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan %2$@ niet %1$@: niet gevonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie można %1$@ %2$@: nie znaleziono" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível %1$@ %2$@: não encontrado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não dá para %1$@ %2$@: não encontrado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "не получается %1$@ %2$@: не найден" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan inte %1$@ %2$@: hittades inte" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ ஐ %1$@ செய்ய முடியாது: கிடைக்கவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่สามารถ %1$@ %2$@ ได้: ไม่พบ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ için %1$@ yapılamıyor: bulunamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "не вдалося %1$@ %2$@: не знайдено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ کو %1$@ نہیں کر سکتے: نہیں ملا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không thể %1$@ %2$@: không tìm thấy" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法 %1$@ %2$@:未找到" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法 %1$@ %2$@:找不到" - } - } - } - }, - "command.action.not_found_mesh" : { - "comment" : "Error when a mesh-only command can't resolve its target; placeholders are the command and the nickname", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعذّر %1$@ %2$@: غير موجود على شبكة mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@-কে %1$@ করা গেল না: mesh-এ পাওয়া যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ %2$@ geht nicht: nicht im mesh gefunden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot %1$@ %2$@: not found on mesh" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede %1$@ a %2$@: no encontrado en la mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمی‌توان %2$@ را %1$@ کرد: در mesh پیدا نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi ma-%1$@ si %2$@: hindi nahanap sa mesh" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossible de %1$@ %2$@ : introuvable sur le mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אי אפשר לעשות %1$@ ל-%2$@: לא נמצא ב-mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ को %1$@ नहीं कर सकते: mesh पर नहीं मिला" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa %1$@ %2$@: tidak ditemukan di mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile %1$@ %2$@: non trovato sulla mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ を %1$@ できない: メッシュ上に見つからない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@에게 %1$@ 할 수 없어요: 메시에서 찾을 수 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak dapat %1$@ %2$@: tidak dijumpai di mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ लाई %1$@ गर्न सकिएन: मेसमा फेला परेन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan %2$@ niet %1$@: niet gevonden op mesh" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie można %1$@ %2$@: nie znaleziono w mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível %1$@ %2$@: não encontrado na mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não dá para %1$@ %2$@: não encontrado na mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "не получается %1$@ %2$@: не найден в mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan inte %1$@ %2$@: hittades inte på meshen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ ஐ %1$@ செய்ய முடியாது: mesh இல் கிடைக்கவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่สามารถ %1$@ %2$@ ได้: ไม่พบใน mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ için %1$@ yapılamıyor: mesh'te bulunamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "не вдалося %1$@ %2$@: не знайдено в mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ کو %1$@ نہیں کر سکتے: mesh پر نہیں ملا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không thể %1$@ %2$@: không tìm thấy trên mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法 %1$@ %2$@:在 mesh 上未找到" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法 %1$@ %2$@:在 mesh 上找不到" - } - } - } - }, - "command.action.usage" : { - "comment" : "Usage hint for a command that takes a nickname; placeholder is the command name", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الاستخدام: /%@ " - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্যবহার: /%@ <ডাকনাম>" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verwendung: /%@ " - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /%@ " - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /%@ " - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کاربرد: /%@ " - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "paggamit: /%@ " - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage : /%@ " - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שימוש: /%@ " - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "उपयोग: /%@ <उपनाम>" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "penggunaan: /%@ " - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /%@ " - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "使い方: /%@ <ニックネーム>" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "사용법: /%@ <닉네임>" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "cara guna: /%@ " - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "प्रयोग: /%@ <उपनाम>" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "gebruik: /%@ " - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "użycie: /%@ " - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /%@ " - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /%@ " - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "использование: /%@ <ник>" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "användning: /%@ " - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பயன்பாடு: /%@ <புனைப்பெயர்>" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "วิธีใช้: /%@ <ชื่อเล่น>" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kullanım: /%@ " - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "використання: /%@ <нікнейм>" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "استعمال: /%@ <عرفی نام>" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cách dùng: /%@ " - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/%@ <昵称>" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/%@ <暱稱>" - } - } - } - }, - "command.block.already" : { - "comment" : "Reply when /block targets an already-blocked nickname", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ محظور بالفعل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ আগেই ব্লক করা আছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ist bereits blockiert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is already blocked" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ya está bloqueado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ از قبل مسدود است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "naka-block na si %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ est déjà bloqué" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ כבר חסום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ पहले से ब्लॉक है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ sudah diblokir" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ è già bloccato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ はすでにブロック済み" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@은(는) 이미 차단되어 있어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ sudah disekat" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ पहिले नै ब्लक गरिएको छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is al geblokkeerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ znajduje się już na liście zablokowanych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ já está bloqueado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ já está bloqueado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ уже заблокирован" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ är redan blockerad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ஏற்கனவே தடுக்கப்பட்டுள்ளார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ถูกบล็อกอยู่แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ zaten engelli" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ вже заблоковано" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ پہلے سے بلاک ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ đã bị chặn rồi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 已被屏蔽" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 已被封鎖" - } - } - } - }, - "command.block.done" : { - "comment" : "Confirmation after blocking a mesh peer", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم حظر %@. لن ترى رسائل هذا الشخص بعد الآن" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-কে ব্লক করা হলো। এই ব্যক্তির বার্তা আর দেখতে পাবে না" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ blockiert. du siehst die nachrichten dieser person nicht mehr" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked %@. you will no longer see their messages" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bloqueado. ya no verás sus mensajes" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ مسدود شد. دیگر پیام‌های این شخص را نمی‌بینی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "na-block si %@. hindi mo na makikita ang mga mensahe niya" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bloqué. tu ne verras plus ses messages" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ נחסם. ההודעות של האדם הזה לא יוצגו יותר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ब्लॉक किया गया। अब तुम्हें इस व्यक्ति के संदेश नहीं दिखेंगे" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ diblokir. kamu tidak akan melihat pesannya lagi" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bloccato. non vedrai più i suoi messaggi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ をブロックした。相手のメッセージはもう表示されない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@을(를) 차단했어요. 이제 그 사람의 메시지가 보이지 않아요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ disekat. mesejnya tidak akan kelihatan lagi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ब्लक गरियो। अब यस व्यक्तिका सन्देशहरू देखिने छैनन्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ geblokkeerd. je ziet berichten van deze persoon niet meer" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zablokowano %@. nie zobaczysz już wiadomości od tej osoby" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bloqueado. deixas de ver as mensagens dessa pessoa" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bloqueado. você não vai mais ver as mensagens dessa pessoa" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ заблокирован. сообщения этого человека больше не показываются" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "blockerade %@. du ser inte längre deras meddelanden" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ தடுக்கப்பட்டார். இனி அவர்களின் செய்திகளை நீங்கள் பார்க்க மாட்டீர்கள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "บล็อก %@ แล้ว คุณจะไม่เห็นข้อความของเขาอีก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ engellendi. artık mesajlarını görmeyeceksin" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ заблоковано. ти більше не бачитимеш повідомлень від них" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کو بلاک کر دیا گیا۔ اب آپ کو ان کے پیغامات نظر نہیں آئیں گے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đã chặn %@. bạn sẽ không thấy tin nhắn của họ nữa" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已屏蔽 %@,你不会再看到对方的消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已封鎖 %@,你不會再看到對方的訊息" - } - } - } - }, - "command.block.done_geo" : { - "comment" : "Confirmation after blocking a geohash participant", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم حظر %@ في محادثات geohash" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash চ্যাটে %@-কে ব্লক করা হলো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ in geohash-chats blockiert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked %@ in geohash chats" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bloqueado en los chats de geohash" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ در چت‌های geohash مسدود شد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "na-block si %@ sa mga geohash chat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bloqué dans les chats geohash" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ נחסם בצ'אטים של geohash" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash चैट में %@ को ब्लॉक किया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ diblokir di chat geohash" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bloccato nelle chat geohash" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash チャットで %@ をブロックした" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash 채팅에서 %@을(를) 차단했어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ disekat dalam sembang geohash" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash च्याटहरूमा %@ ब्लक गरियो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ geblokkeerd in geohash-chats" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zablokowano %@ w czatach geohash" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bloqueado nas conversas geohash" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bloqueado nos chats geohash" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ заблокирован в geohash-чатах" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "blockerade %@ i geohash-chattar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash அரட்டைகளில் %@ தடுக்கப்பட்டார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "บล็อก %@ ในแชท geohash แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ geohash sohbetlerinde engellendi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ заблоковано в geohash-чатах" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash چیٹس میں %@ کو بلاک کر دیا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đã chặn %@ trong các kênh trò chuyện geohash" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已在 geohash 聊天中屏蔽 %@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已在 geohash 聊天中封鎖 %@" - } - } - } - }, - "command.block.failed" : { - "comment" : "Error when /block can't resolve or verify the target", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعذّر حظر %@: غير موجود أو تعذّر التحقق من الهوية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-কে ব্লক করা গেল না: পাওয়া যায়নি বা পরিচয় যাচাই করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "kann %@ nicht blockieren: nicht gefunden oder identität nicht überprüfbar" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot block %@: not found or unable to verify identity" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede bloquear a %@: no encontrado o no se pudo verificar la identidad" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمی‌توان %@ را مسدود کرد: پیدا نشد یا هویتش قابل تأیید نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi ma-block si %@: hindi nahanap o hindi ma-verify ang identity" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossible de bloquer %@ : introuvable ou identité invérifiable" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אי אפשר לחסום את %@: לא נמצא או שאי אפשר לאמת את הזהות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ को ब्लॉक नहीं कर सकते: नहीं मिला या पहचान सत्यापित नहीं हो सकी" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa memblokir %@: tidak ditemukan atau identitas tidak bisa diverifikasi" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile bloccare %@: non trovato o identità non verificabile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ をブロックできない: 見つからないか身元を確認できない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@을(를) 차단할 수 없어요: 찾을 수 없거나 신원을 확인할 수 없어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak dapat menyekat %@: tidak dijumpai atau identiti tidak dapat disahkan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ लाई ब्लक गर्न सकिएन: फेला परेन वा पहिचान प्रमाणित गर्न सकिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan %@ niet blokkeren: niet gevonden of identiteit niet te verifiëren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie można zablokować %@: nie znaleziono lub nie udało się zweryfikować tożsamości" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível bloquear %@: não encontrado ou identidade impossível de verificar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não dá para bloquear %@: não encontrado ou não foi possível verificar a identidade" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "не получается заблокировать %@: не найден или не удалось проверить личность" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan inte blockera %@: hittades inte eller kunde inte verifiera identiteten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ஐ தடுக்க முடியாது: கிடைக்கவில்லை அல்லது அடையாளத்தை சரிபார்க்க முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "บล็อก %@ ไม่ได้: ไม่พบหรือยืนยันตัวตนไม่ได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ engellenemiyor: bulunamadı veya kimliği doğrulanamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "не вдалося заблокувати %@: не знайдено або неможливо перевірити особу" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کو بلاک نہیں کیا جا سکا: نہیں ملا یا شناخت کی تصدیق ممکن نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không thể chặn %@: không tìm thấy hoặc không xác minh được danh tính" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法屏蔽 %@:未找到或无法验证身份" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法封鎖 %@:找不到或無法驗證身分" - } - } - } - }, - "command.block.list" : { - "comment" : "Reply to /block with no argument; placeholders are the mesh and geohash block lists", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الأقران المحظورون: %1$@ | حظر geohash: %2$@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্লক করা পিয়ার: %1$@ | geohash ব্লক: %2$@" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "blockierte peers: %1$@ | geohash-blockierungen: %2$@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked peers: %1$@ | geohash blocks: %2$@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "peers bloqueados: %1$@ | bloqueos de geohash: %2$@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "همتاهای مسدود: %1$@ | مسدودی‌های geohash: %2$@" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mga naka-block na peer: %1$@ | mga geohash block: %2$@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "pairs bloqués : %1$@ | blocages geohash : %2$@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "עמיתים חסומים: %1$@ | חסימות geohash: %2$@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ब्लॉक किए गए पीयर: %1$@ | geohash ब्लॉक: %2$@" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer yang diblokir: %1$@ | blokir geohash: %2$@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer bloccati: %1$@ | blocchi geohash: %2$@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ブロック中のピア: %1$@ | geohash のブロック: %2$@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "차단한 피어: %1$@ | geohash 차단: %2$@" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "rakan disekat: %1$@ | sekatan geohash: %2$@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "ब्लक गरिएका पियरहरू: %1$@ | geohash ब्लकहरू: %2$@" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geblokkeerde peers: %1$@ | geohash-blokkades: %2$@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zablokowane peery: %1$@ | blokady geohash: %2$@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "peers bloqueados: %1$@ | bloqueios geohash: %2$@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "peers bloqueados: %1$@ | bloqueios geohash: %2$@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "заблокированные пиры: %1$@ | блокировки geohash: %2$@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "blockerade personer: %1$@ | geohash-blockeringar: %2$@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "தடுக்கப்பட்ட நபர்கள்: %1$@ | geohash தடைகள்: %2$@" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "คนที่ถูกบล็อก: %1$@ | บล็อกใน geohash: %2$@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "engellenen kişiler: %1$@ | geohash engelleri: %2$@" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "заблоковані люди: %1$@ | блокування geohash: %2$@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "بلاک شدہ لوگ: %1$@ | geohash بلاکس: %2$@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "người bị chặn: %1$@ | chặn geohash: %2$@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已屏蔽的人:%1$@ | geohash 屏蔽:%2$@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已封鎖的人:%1$@ | geohash 封鎖:%2$@" - } - } - } - }, - "command.drop.finding_place" : { - "comment" : "Error when /drop runs before a location fix arrives", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ما زلنا نحدد هذا المكان — حاول مجددًا بعد لحظات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই জায়গাটা এখনও খুঁজে পাচ্ছি — একটু পরে আবার চেষ্টা করো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "dieser ort wird noch gesucht — versuch es gleich nochmal" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "still finding this place — try again in a moment" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "todavía buscando este lugar — inténtalo de nuevo en un momento" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هنوز داریم این مکان را پیدا می‌کنیم — چند لحظه دیگر دوباره امتحان کن" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hinahanap pa ang lugar na ito — subukan ulit maya-maya" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "on cherche encore cet endroit — réessaie dans un instant" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "עוד מאתרים את המקום הזה — נסו שוב עוד רגע" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "यह जगह अभी ढूँढी जा रही है — थोड़ी देर में फिर कोशिश करो" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "masih mencari tempat ini — coba lagi sebentar lagi" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "sto ancora individuando questo posto — riprova tra un attimo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "この場所をまだ特定中 — 少ししてからもう一度試して" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "아직 이 장소를 찾는 중이에요 — 잠시 후 다시 시도해 주세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "masih mencari tempat ini — cuba lagi sebentar nanti" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यो ठाउँ अझै पत्ता लगाउँदै — एकछिनमा फेरि प्रयास गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "deze plek wordt nog gezocht — probeer het zo nog eens" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wciąż ustalam to miejsce — spróbuj ponownie za chwilę" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ainda a localizar este sítio — tenta outra vez daqui a pouco" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ainda localizando este lugar — tente de novo daqui a pouco" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "всё ещё определяю это место — попробуй снова чуть позже" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "letar fortfarande efter den här platsen — försök igen om en stund" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இந்த இடத்தை இன்னும் கண்டறிகிறோம் — சிறிது நேரம் கழித்து மீண்டும் முயற்சிக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ยังหาตำแหน่งของที่นี่อยู่ — ลองใหม่อีกสักครู่" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu yer hâlâ tespit ediliyor — az sonra tekrar dene" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "ще визначаємо це місце — спробуй за мить" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ابھی اس جگہ کا پتہ لگایا جا رہا ہے — تھوڑی دیر میں دوبارہ کوشش کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "vẫn đang xác định nơi này — thử lại sau một lát nhé" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "还在定位这个地方 — 请稍后再试" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "還在定位這個地方 — 請稍後再試" - } - } - } - }, - "command.drop.left" : { - "comment" : "Confirmation after /drop pins a note", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 تُركت ملاحظة هنا — تتلاشى خلال 24 ساعة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 এখানে নোট রাখা হলো — ২৪ ঘণ্টায় মিলিয়ে যাবে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 notiz hier hinterlassen — sie verblasst in 24h" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 note left here — it fades in 24h" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nota dejada aquí — se desvanece en 24h" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 یادداشت اینجا گذاشته شد — تا ۲۴ ساعت دیگر محو می‌شود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nag-iwan ng note dito — maglalaho ito sa loob ng 24h" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 note laissée ici — elle s'efface dans 24h" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 הושארה כאן הערה — היא תיעלם בעוד 24 שעות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 यहाँ नोट छोड़ा गया — यह 24 घंटे में मिट जाएगा" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 catatan ditinggalkan di sini — akan hilang dalam 24 jam" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nota lasciata qui — svanisce in 24h" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 ここにノートを残した — 24時間で消える" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 여기에 노트를 남겼어요 — 24시간 후에 사라져요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nota ditinggalkan di sini — hilang dalam 24 jam" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 यहाँ नोट छोडियो — 24 घण्टामा हराउँछ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 notitie hier achtergelaten — verdwijnt na 24 uur" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 notka zostawiona tutaj — zniknie za 24 h" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nota deixada aqui — desaparece em 24h" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nota deixada aqui — some em 24h" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 заметка оставлена здесь — исчезнет через 24 ч" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 anteckning lämnad här — den tonar bort om 24h" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 குறிப்பு இங்கே விடப்பட்டது — 24 மணி நேரத்தில் மறைந்துவிடும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 ทิ้งโน้ตไว้ที่นี่แล้ว — จะเลือนหายใน 24 ชม." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 buraya not bırakıldı — 24 saat içinde kaybolur" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 нотатку залишено тут — вона зникне за 24 год" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 نوٹ یہاں چھوڑ دیا گیا — یہ 24 گھنٹے میں مٹ جائے گا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 đã để lại ghi chú ở đây — nó sẽ mờ dần sau 24 giờ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 便签已留在这里 — 24 小时后消失" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 便箋已留在這裡 — 24 小時後會消失" - } - } - } - }, - "command.drop.needs_location" : { - "comment" : "Error when /drop is used without location permission", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ترك ملاحظة يحتاج إلى الموقع — فعّله من شاشة المعلومات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নোট রাখতে লোকেশন লাগবে — ইনফো স্ক্রিন থেকে চালু করো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "eine notiz zu hinterlassen braucht den standort — aktiviere ihn im info-bildschirm" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "leaving a note needs location — enable it in the info screen" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "dejar una nota necesita ubicación — actívala en la pantalla de info" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گذاشتن یادداشت به موقعیت مکانی نیاز دارد — از صفحه اطلاعات فعالش کن" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "kailangan ng location para mag-iwan ng note — i-enable ito sa info screen" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "laisser une note nécessite la localisation — active-la dans l'écran info" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "כדי להשאיר הערה צריך מיקום — הפעילו אותו במסך המידע" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "नोट छोड़ने के लिए लोकेशन चाहिए — इसे इन्फो स्क्रीन में चालू करो" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "meninggalkan catatan butuh lokasi — aktifkan di layar info" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "per lasciare una nota serve la posizione — attivala nella schermata info" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ノートを残すには位置情報が必要 — 情報画面で有効にして" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "노트를 남기려면 위치 정보가 필요해요 — 정보 화면에서 켜 주세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "meninggalkan nota memerlukan lokasi — hidupkannya di skrin info" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "नोट छोड्न स्थान चाहिन्छ — जानकारी स्क्रिनमा सक्रिय गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "een notitie achterlaten vereist locatie — zet die aan in het infoscherm" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zostawienie notki wymaga lokalizacji — włącz ją na ekranie informacji" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "deixar uma nota precisa de localização — ativa-a no ecrã de info" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "deixar uma nota precisa de localização — ative na tela de info" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "чтобы оставить заметку, нужна геолокация — включи её на экране информации" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "att lämna en anteckning kräver plats — slå på det i infoskärmen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "குறிப்பு விட்டுச்செல்ல இருப்பிடம் தேவை — தகவல் திரையில் அதை இயக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "การทิ้งโน้ตต้องใช้ตำแหน่งที่ตั้ง — เปิดได้ในหน้าข้อมูล" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "not bırakmak için konum gerekli — bilgi ekranından aç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "щоб залишити нотатку, потрібна геолокація — увімкни її на інформаційному екрані" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "نوٹ چھوڑنے کے لیے مقام درکار ہے — اسے معلوماتی اسکرین سے آن کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "để lại ghi chú cần vị trí — bật trong màn hình thông tin" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "留下便签需要位置信息 — 请在信息页面开启" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "留下便箋需要位置資訊 — 請在資訊畫面開啟" - } - } - } - }, - "command.drop.no_relays" : { - "comment" : "Error when /drop finds no reachable geo relays", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا يمكن الوصول إلى أي مرحّلات جغرافية — لم تُترك الملاحظة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "কোনো জিও রিলে পাওয়া যাচ্ছে না — নোট রাখা হয়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "keine geo-relays erreichbar — notiz nicht hinterlassen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no geo relays reachable — note not left" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no hay relés geo alcanzables — la nota no se dejó" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هیچ رله جغرافیایی در دسترس نیست — یادداشت گذاشته نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "walang maabot na geo relay — hindi naiwan ang note" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "aucun relais géo joignable — note non laissée" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין ממסרי geo זמינים — ההערה לא הושארה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "कोई geo रिले उपलब्ध नहीं — नोट नहीं छोड़ा गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak ada relay geo yang bisa dijangkau — catatan tidak ditinggalkan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "nessun relay geo raggiungibile — nota non lasciata" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "到達できる geo リレーがない — ノートは残せなかった" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "연결할 수 있는 geo 릴레이가 없어요 — 노트를 남기지 못했어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tiada geo relay yang dapat dihubungi — nota tidak ditinggalkan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "कुनै geo रिले उपलब्ध छैन — नोट छोडिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geen geo-relays bereikbaar — notitie niet achtergelaten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "żaden geo-przekaźnik nie jest osiągalny — notka nie została zostawiona" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "nenhum relay geo alcançável — a nota não foi deixada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "nenhum relay geo alcançável — a nota não foi deixada" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "гео-релеи недоступны — заметка не оставлена" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "inga geo-reläer nåbara — anteckningen lämnades inte" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "எந்த geo ரிலேயும் எட்டவில்லை — குறிப்பு விடப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ติดต่อรีเลย์ geo ไม่ได้เลย — ยังไม่ได้ทิ้งโน้ตไว้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ulaşılabilir geo rölesi yok — not bırakılmadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "гео-релеї недоступні — нотатку не залишено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "کوئی geo ریلے دستیاب نہیں — نوٹ نہیں چھوڑا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không kết nối được relay geo nào — chưa để lại ghi chú" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "没有可达的地理中继 — 便签没有留下" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "沒有可連上的地理中繼 — 便箋沒有留下" - } - } - } - }, - "command.drop.notes_off" : { - "comment" : "Error when /drop is used while location notes are disabled", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ملاحظات الموقع متوقفة — فعّلها من شاشة المعلومات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "লোকেশন নোট বন্ধ আছে — ইনফো স্ক্রিন থেকে চালু করো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "orts-notizen sind aus — aktiviere sie im info-bildschirm" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "location notes are off — enable them in the info screen" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "las notas de ubicación están desactivadas — actívalas en la pantalla de info" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "یادداشت‌های مکانی خاموش‌اند — از صفحه اطلاعات فعال‌شان کن" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "naka-off ang mga location note — i-enable ang mga ito sa info screen" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "les notes de lieu sont désactivées — active-les dans l'écran info" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הערות מיקום כבויות — הפעילו אותן במסך המידע" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "लोकेशन नोट बंद हैं — इन्हें इन्फो स्क्रीन में चालू करो" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "catatan lokasi sedang nonaktif — aktifkan di layar info" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "le note di posizione sono disattivate — attivale nella schermata info" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "位置ノートはオフ — 情報画面で有効にして" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "위치 노트가 꺼져 있어요 — 정보 화면에서 켜 주세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "nota lokasi dimatikan — hidupkannya di skrin info" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान नोटहरू बन्द छन् — जानकारी स्क्रिनमा सक्रिय गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "locatienotities staan uit — zet ze aan in het infoscherm" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "notki lokalizacyjne są wyłączone — włącz je na ekranie informacji" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "as notas de localização estão desligadas — ativa-as no ecrã de info" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "notas de localização estão desativadas — ative-as na tela de info" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "гео-заметки выключены — включи их на экране информации" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "platsanteckningar är av — slå på dem i infoskärmen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இருப்பிடக் குறிப்புகள் அணைந்துள்ளன — தகவல் திரையில் அவற்றை இயக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "โน้ตตามสถานที่ปิดอยู่ — เปิดได้ในหน้าข้อมูล" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "konum notları kapalı — bilgi ekranından aç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "нотатки про місце вимкнено — увімкни їх на інформаційному екрані" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "مقام کے نوٹس بند ہیں — انہیں معلوماتی اسکرین سے آن کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ghi chú vị trí đang tắt — bật trong màn hình thông tin" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "位置便签已关闭 — 请在信息页面开启" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "位置便箋已關閉 — 請在資訊畫面開啟" - } - } - } - }, - "command.drop.usage" : { - "comment" : "Usage hint for /drop", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الاستخدام: /drop " - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্যবহার: /drop <বার্তা>" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verwendung: /drop " - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /drop " - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /drop " - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کاربرد: /drop " - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "paggamit: /drop " - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage : /drop " - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שימוש: /drop " - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "उपयोग: /drop <संदेश>" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "penggunaan: /drop " - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /drop " - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "使い方: /drop <メッセージ>" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "사용법: /drop <메시지>" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "cara guna: /drop " - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "प्रयोग: /drop <सन्देश>" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "gebruik: /drop " - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "użycie: /drop " - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /drop " - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /drop " - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "использование: /drop <сообщение>" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "användning: /drop " - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பயன்பாடு: /drop <செய்தி>" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "วิธีใช้: /drop <ข้อความ>" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kullanım: /drop " - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "використання: /drop <повідомлення>" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "استعمال: /drop <پیغام>" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cách dùng: /drop " - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/drop <消息>" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/drop <訊息>" - } - } - } - }, - "command.error.favorites_mesh_only" : { - "comment" : "Error when a favorites command is used outside the mesh channel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "المفضلة متاحة فقط لأقران شبكة mesh في #mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "প্রিয় তালিকা শুধু #mesh-এ থাকা mesh পিয়ারদের জন্য" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "favoriten gibt es nur für mesh-peers in #mesh" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorites are only for mesh peers in #mesh" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "los favoritos son solo para peers de la mesh en #mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "موردعلاقه‌ها فقط برای همتاهای mesh در #mesh هستند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "para lang sa mga mesh peer sa #mesh ang mga favorite" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "les favoris sont réservés aux pairs du mesh dans #mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מועדפים זמינים רק לעמיתי mesh ב-#mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "पसंदीदा सिर्फ #mesh के mesh पीयर के लिए हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorit hanya untuk peer mesh di #mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "i preferiti sono solo per i peer mesh in #mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お気に入りは #mesh のメッシュピア専用" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "즐겨찾기는 #mesh의 메시 피어에게만 쓸 수 있어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "kegemaran hanya untuk rakan mesh di #mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "मनपर्नेहरू #mesh का मेस पियरहरूका लागि मात्र हुन्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorieten zijn alleen voor mesh-peers in #mesh" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ulubione są tylko dla peerów mesh w #mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "os favoritos são só para peers mesh no #mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "favoritos são só para peers mesh no #mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "избранное только для mesh-пиров в #mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "favoriter är bara för mesh-deltagare i #mesh" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பிடித்தவை #mesh இல் உள்ள mesh நபர்களுக்கு மட்டும்தான்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รายการโปรดมีไว้สำหรับคนในเครือข่าย mesh ใน #mesh เท่านั้น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "favoriler sadece #mesh'teki mesh kişileri için" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "обране доступне лише людям у mesh у #mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پسندیدہ صرف #mesh میں mesh والے لوگوں کے لیے ہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mục yêu thích chỉ dành cho những người trong mesh ở #mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "收藏只对 #mesh 里的 mesh 成员可用" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "最愛只限 #mesh 裡的 mesh 成員使用" - } - } - } - }, - "command.error.groups_mesh_only" : { - "comment" : "Error when a group command is used outside the mesh channel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "المجموعات متاحة فقط لأقران شبكة mesh في #mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "গ্রুপ শুধু #mesh-এ থাকা mesh পিয়ারদের জন্য" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "gruppen gibt es nur für mesh-peers in #mesh" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "groups are only for mesh peers in #mesh" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "los grupos son solo para peers de la mesh en #mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گروه‌ها فقط برای همتاهای mesh در #mesh هستند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "para lang sa mga mesh peer sa #mesh ang mga group" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "les groupes sont réservés aux pairs du mesh dans #mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "קבוצות זמינות רק לעמיתי mesh ב-#mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ग्रुप सिर्फ #mesh के mesh पीयर के लिए हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "grup hanya untuk peer mesh di #mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "i gruppi sono solo per i peer mesh in #mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "グループは #mesh のメッシュピア専用" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "그룹은 #mesh의 메시 피어끼리만 쓸 수 있어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "kumpulan hanya untuk rakan mesh di #mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "समूहहरू #mesh का मेस पियरहरूका लागि मात्र हुन्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "groepen zijn alleen voor mesh-peers in #mesh" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "grupy są tylko dla peerów mesh w #mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "os grupos são só para peers mesh no #mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "grupos são só para peers mesh no #mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "группы только для mesh-пиров в #mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "grupper är bara för mesh-deltagare i #mesh" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "குழுக்கள் #mesh இல் உள்ள mesh நபர்களுக்கு மட்டும்தான்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "กลุ่มมีไว้สำหรับคนในเครือข่าย mesh ใน #mesh เท่านั้น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "gruplar sadece #mesh'teki mesh kişileri için" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "групи доступні лише людям у mesh у #mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "گروپ صرف #mesh میں mesh والے لوگوں کے لیے ہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "nhóm chỉ dành cho những người trong mesh ở #mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "群组只对 #mesh 里的 mesh 成员开放" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "群組只限 #mesh 裡的 mesh 成員使用" - } - } - } - }, - "command.error.invalid" : { - "comment" : "Error for an empty or unparseable slash command", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "أمر غير صالح" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "অবৈধ কমান্ড" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "ungültiger befehl" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "invalid command" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "comando no válido" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دستور نامعتبر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "invalid na command" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "commande invalide" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פקודה לא חוקית" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अमान्य कमांड" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "perintah tidak valid" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "comando non valido" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "無効なコマンド" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "잘못된 명령어" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "arahan tidak sah" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अवैध कमाण्ड" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ongeldig commando" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nieprawidłowe polecenie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "comando inválido" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "comando inválido" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "неверная команда" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ogiltigt kommando" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "தவறான கட்டளை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "คำสั่งไม่ถูกต้อง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "geçersiz komut" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "неправильна команда" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "غلط کمانڈ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "lệnh không hợp lệ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无效命令" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無效的指令" - } - } - } - }, - "command.error.ping_mesh_only" : { - "comment" : "Error when /ping is used outside the mesh channel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping يعمل فقط مع أقران شبكة mesh في #mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping শুধু #mesh-এ থাকা mesh পিয়ারদের সাথে কাজ করে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping funktioniert nur für mesh-peers in #mesh" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping only works for mesh peers in #mesh" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping solo funciona con peers de la mesh en #mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping فقط برای همتاهای mesh در #mesh کار می‌کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "gumagana lang ang ping sa mga mesh peer sa #mesh" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping ne fonctionne qu'avec les pairs du mesh dans #mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping עובד רק עם עמיתי mesh ב-#mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping सिर्फ #mesh के mesh पीयर के साथ काम करता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping hanya berfungsi untuk peer mesh di #mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping funziona solo per i peer mesh in #mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping は #mesh のメッシュピアにのみ使える" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping은 #mesh의 메시 피어에게만 작동해요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping hanya berfungsi untuk rakan mesh di #mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping #mesh का मेस पियरहरूका लागि मात्र काम गर्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping werkt alleen voor mesh-peers in #mesh" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping działa tylko dla peerów mesh w #mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "o ping só funciona para peers mesh no #mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping só funciona para peers mesh no #mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping работает только для mesh-пиров в #mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping funkar bara för mesh-deltagare i #mesh" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping #mesh இல் உள்ள mesh நபர்களுக்கு மட்டுமே வேலை செய்யும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping ใช้ได้กับคนในเครือข่าย mesh ใน #mesh เท่านั้น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping sadece #mesh'teki mesh kişileri için çalışır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping працює лише для людей у mesh у #mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping صرف #mesh میں mesh والے لوگوں کے لیے کام کرتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping chỉ hoạt động với những người trong mesh ở #mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping 只对 #mesh 里的 mesh 成员有效" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping 只對 #mesh 裡的 mesh 成員有效" - } - } - } - }, - "command.error.trace_mesh_only" : { - "comment" : "Error when /trace is used outside the mesh channel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace يعمل فقط مع أقران شبكة mesh في #mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace শুধু #mesh-এ থাকা mesh পিয়ারদের সাথে কাজ করে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace funktioniert nur für mesh-peers in #mesh" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace only works for mesh peers in #mesh" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace solo funciona con peers de la mesh en #mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace فقط برای همتاهای mesh در #mesh کار می‌کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "gumagana lang ang trace sa mga mesh peer sa #mesh" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace ne fonctionne qu'avec les pairs du mesh dans #mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace עובד רק עם עמיתי mesh ב-#mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace सिर्फ #mesh के mesh पीयर के साथ काम करता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace hanya berfungsi untuk peer mesh di #mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace funziona solo per i peer mesh in #mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace は #mesh のメッシュピアにのみ使える" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace는 #mesh의 메시 피어에게만 작동해요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace hanya berfungsi untuk rakan mesh di #mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace #mesh का मेस पियरहरूका लागि मात्र काम गर्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace werkt alleen voor mesh-peers in #mesh" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace działa tylko dla peerów mesh w #mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "o trace só funciona para peers mesh no #mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace só funciona para peers mesh no #mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace работает только для mesh-пиров в #mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace funkar bara för mesh-deltagare i #mesh" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace #mesh இல் உள்ள mesh நபர்களுக்கு மட்டுமே வேலை செய்யும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace ใช้ได้กับคนในเครือข่าย mesh ใน #mesh เท่านั้น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace sadece #mesh'teki mesh kişileri için çalışır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace працює лише для людей у mesh у #mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace صرف #mesh میں mesh والے لوگوں کے لیے کام کرتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace chỉ hoạt động với những người trong mesh ở #mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace 只对 #mesh 里的 mesh 成员有效" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "trace 只對 #mesh 裡的 mesh 成員有效" - } - } - } - }, - "command.error.unknown" : { - "comment" : "Error for an unrecognized slash command; placeholder is the typed command", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "أمر غير معروف: %@ — اكتب /help لعرض الأوامر" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "অজানা কমান্ড: %@ — কমান্ডের তালিকা দেখতে /help লেখো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "unbekannter befehl: %@ — tippe /help für alle befehle" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "unknown command: %@ — type /help for commands" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "comando desconocido: %@ — escribe /help para ver los comandos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دستور ناشناخته: %@ — برای دیدن دستورها /help را بنویس" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi kilalang command: %@ — i-type ang /help para sa mga command" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "commande inconnue : %@ — tape /help pour voir les commandes" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פקודה לא מוכרת: %@ — הקלידו /help לרשימת הפקודות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अनजान कमांड: %@ — कमांड देखने के लिए /help लिखो" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "perintah tak dikenal: %@ — ketik /help untuk melihat daftar perintah" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "comando sconosciuto: %@ — digita /help per i comandi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "不明なコマンド: %@ — /help でコマンド一覧を表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "알 수 없는 명령어: %@ — /help를 입력하면 명령어 목록을 볼 수 있어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "arahan tidak dikenali: %@ — taip /help untuk senarai arahan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अज्ञात कमाण्ड: %@ — कमाण्डहरूका लागि /help टाइप गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "onbekend commando: %@ — typ /help voor commando's" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nieznane polecenie: %@ — wpisz /help, aby zobaczyć polecenia" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "comando desconhecido: %@ — escreve /help para veres os comandos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "comando desconhecido: %@ — digite /help para ver os comandos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "неизвестная команда: %@ — набери /help для списка команд" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "okänt kommando: %@ — skriv /help för kommandon" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "தெரியாத கட்டளை: %@ — கட்டளைகளுக்கு /help என தட்டச்சு செய்யவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่รู้จักคำสั่ง: %@ — พิมพ์ /help เพื่อดูคำสั่งทั้งหมด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bilinmeyen komut: %@ — komutlar için /help yaz" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "невідома команда: %@ — введи /help для списку команд" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "نامعلوم کمانڈ: %@ — کمانڈز کے لیے /help لکھیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "lệnh không xác định: %@ — gõ /help để xem các lệnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未知命令:%@ — 输入 /help 查看命令" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "未知的指令:%@ — 輸入 /help 查看指令" - } - } - } - }, - "command.fav.added" : { - "comment" : "Confirmation after /fav", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تمت إضافة %@ إلى المفضلة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-কে প্রিয় তালিকায় যোগ করা হলো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ zu den favoriten hinzugefügt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "added %@ to favorites" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ añadido a favoritos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ به موردعلاقه‌ها اضافه شد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "idinagdag si %@ sa mga favorite" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ajouté aux favoris" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ נוסף למועדפים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ को पसंदीदा में जोड़ा गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ditambahkan ke favorit" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ aggiunto ai preferiti" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ をお気に入りに追加した" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@을(를) 즐겨찾기에 추가했어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ditambah ke kegemaran" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ मनपर्नेहरूमा थपियो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ toegevoegd aan favorieten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dodano %@ do ulubionych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ adicionado aos favoritos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ adicionado aos favoritos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ добавлен в избранное" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "la till %@ i favoriter" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ பிடித்தவர்களில் சேர்க்கப்பட்டார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เพิ่ม %@ ในรายการโปรดแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ favorilere eklendi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ додано до обраного" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کو پسندیدہ میں شامل کر دیا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đã thêm %@ vào mục yêu thích" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已将 %@ 加入收藏" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已將 %@ 加入最愛" - } - } - } - }, - "command.fav.already" : { - "comment" : "Reply when /fav targets an existing favorite", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ضمن المفضلة بالفعل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ আগে থেকেই প্রিয় তালিকায় আছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ist bereits ein favorit" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is already a favorite" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ya es un favorito" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ از قبل موردعلاقه است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorite na si %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ est déjà en favori" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ כבר במועדפים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ पहले से पसंदीदा है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ sudah jadi favorit" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ è già nei preferiti" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ はすでにお気に入り" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@은(는) 이미 즐겨찾기예요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ sudah menjadi kegemaran" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ पहिले नै मनपर्ने हो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is al een favoriet" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ już jest w ulubionych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ já é um favorito" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ já é um favorito" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ уже в избранном" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ är redan en favorit" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ஏற்கனவே பிடித்தவர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ อยู่ในรายการโปรดอยู่แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ zaten favori" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ вже в обраному" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ پہلے سے پسندیدہ ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ đã có trong mục yêu thích rồi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 已在收藏中" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 已在最愛中" - } - } - } - }, - "command.fav.not_favorite" : { - "comment" : "Reply when /unfav targets someone who isn't a favorite", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ليس ضمن المفضلة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ প্রিয় তালিকায় নেই" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ist kein favorit" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is not a favorite" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ no es un favorito" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ موردعلاقه نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi favorite si %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ n'est pas en favori" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ לא במועדפים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ पसंदीदा नहीं है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bukan favorit" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ non è nei preferiti" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ はお気に入りじゃない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@은(는) 즐겨찾기가 아니에요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bukan kegemaran" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ मनपर्ने होइन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is geen favoriet" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ nie jest w ulubionych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ não é um favorito" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ não é um favorito" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ не в избранном" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ är inte en favorit" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ பிடித்தவர்களில் இல்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ไม่ได้อยู่ในรายการโปรด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ favori değil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ не в обраному" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ پسندیدہ نہیں ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ không có trong mục yêu thích" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 不在收藏中" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 不在最愛中" - } - } - } - }, - "command.fav.not_found" : { - "comment" : "Error when /fav or /unfav can't resolve the nickname", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعذّر العثور على القرين: %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "পিয়ার খুঁজে পাওয়া যাচ্ছে না: %@" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer nicht gefunden: %@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "can't find peer: %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se encuentra el peer: %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "همتا پیدا نشد: %@" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi mahanap ang peer: %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "pair introuvable : %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "לא נמצא עמית: %@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "पीयर नहीं मिला: %@" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer tidak ditemukan: %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer non trovato: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ピアが見つからない: %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "피어를 찾을 수 없어요: %@" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "rakan tidak dijumpai: %@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "पियर फेला परेन: %@" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "peer niet gevonden: %@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie można znaleźć peera: %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não encontro o peer: %@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não achei o peer: %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "пир не найден: %@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "hittar inte personen: %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நபரை கண்டுபிடிக்க முடியவில்லை: %@" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "หาคนนี้ไม่เจอ: %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kişi bulunamıyor: %@" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "не вдалося знайти людину: %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہ شخص نہیں مل رہا: %@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không tìm thấy người này: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "找不到这个人:%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "找不到這個人:%@" - } - } - } - }, - "command.fav.removed" : { - "comment" : "Confirmation after /unfav", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تمت إزالة %@ من المفضلة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-কে প্রিয় তালিকা থেকে সরানো হলো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ aus den favoriten entfernt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "removed %@ from favorites" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ eliminado de favoritos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ از موردعلاقه‌ها حذف شد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "inalis si %@ sa mga favorite" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ retiré des favoris" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ הוסר מהמועדפים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ को पसंदीदा से हटाया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ dihapus dari favorit" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ rimosso dai preferiti" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ をお気に入りから外した" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@을(를) 즐겨찾기에서 삭제했어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ dibuang daripada kegemaran" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ मनपर्नेहरूबाट हटाइयो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ verwijderd uit favorieten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "usunięto %@ z ulubionych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ removido dos favoritos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ removido dos favoritos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ удалён из избранного" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "tog bort %@ från favoriter" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ பிடித்தவர்களிலிருந்து நீக்கப்பட்டார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "นำ %@ ออกจากรายการโปรดแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ favorilerden çıkarıldı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ вилучено з обраного" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کو پسندیدہ سے نکال دیا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đã xóa %@ khỏi mục yêu thích" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已将 %@ 移出收藏" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已將 %@ 移出最愛" - } - } - } - }, - "command.group.usage" : { - "comment" : "Usage hint for /group subcommands", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الاستخدام: /group create · invite @name · remove @name · leave · list" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্যবহার: /group create <নাম> · invite @name · remove @name · leave · list" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verwendung: /group create · invite @name · remove @name · leave · list" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /group create · invite @name · remove @name · leave · list" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /group create · invite @name · remove @name · leave · list" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کاربرد: /group create · invite @name · remove @name · leave · list" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "paggamit: /group create · invite @name · remove @name · leave · list" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage : /group create · invite @name · remove @name · leave · list" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שימוש: /group create · invite @name · remove @name · leave · list" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "उपयोग: /group create <नाम> · invite @name · remove @name · leave · list" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "penggunaan: /group create · invite @name · remove @name · leave · list" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /group create · invite @name · remove @name · leave · list" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "使い方: /group create <名前> · invite @name · remove @name · leave · list" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "사용법: /group create <이름> · invite @name · remove @name · leave · list" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "cara guna: /group create · invite @name · remove @name · leave · list" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "प्रयोग: /group create <नाम> · invite @name · remove @name · leave · list" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "gebruik: /group create · invite @name · remove @name · leave · list" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "użycie: /group create · invite @name · remove @name · leave · list" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /group create · invite @name · remove @name · leave · list" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /group create · invite @name · remove @name · leave · list" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "использование: /group create <название> · invite @name · remove @name · leave · list" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "användning: /group create · invite @name · remove @name · leave · list" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பயன்பாடு: /group create <பெயர்> · invite @name · remove @name · leave · list" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "วิธีใช้: /group create <ชื่อ> · invite @name · remove @name · leave · list" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kullanım: /group create · invite @name · remove @name · leave · list" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "використання: /group create <назва> · invite @name · remove @name · leave · list" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "استعمال: /group create <نام> · invite @name · remove @name · leave · list" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cách dùng: /group create · invite @name · remove @name · leave · list" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/group create <名称> · invite @name · remove @name · leave · list" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/group create <名稱> · invite @name · remove @name · leave · list" - } - } - } - }, - "command.help.text" : { - "comment" : "The /help reference list; command syntax stays verbatim, only the descriptions after each dash are translated", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الأوامر:\n/msg @name [message] — بدء محادثة خاصة\n/who — عرض من هنا\n/clear — مسح هذه المحادثة\n/hug @name — إرسال عناق\n/slap @name — صفعة بسمكة تراوت ضخمة\n/block @name · /unblock @name\n/fav @name · /unfav @name — المفضلة (mesh فقط)\n/group create — إنشاء مجموعة مشفّرة\n/group invite @name · /group remove @name — إدارة الأعضاء (المنشئ)\n/group leave · /group list — مغادرة مجموعاتك أو عرضها\n/ping @name — قياس زمن الذهاب والإياب (mesh فقط)\n/trace @name — مسار mesh المقدّر (mesh فقط)\n/pay — إرسال رمز cashu (ecash) في هذه المحادثة\n/drop — تثبيت ملاحظة في هذا المكان لمدة 24 ساعة (يحتاج إلى الموقع)\n/help — هذه القائمة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "কমান্ড:\n/msg @name [message] — প্রাইভেট চ্যাট শুরু করো\n/who — কে কে আছে দেখো\n/clear — এই চ্যাট মুছে ফেলো\n/hug @name — একটা আলিঙ্গন পাঠাও\n/slap @name — বিশাল এক ট্রাউট মাছ দিয়ে চড় মারো\n/block @name · /unblock @name\n/fav @name · /unfav @name — প্রিয় তালিকা (শুধু mesh)\n/group create — এনক্রিপ্ট করা গ্রুপ শুরু করো\n/group invite @name · /group remove @name — সদস্য ব্যবস্থাপনা (নির্মাতা)\n/group leave · /group list — গ্রুপ ছাড়ো বা তালিকা দেখো\n/ping @name — রাউন্ড-ট্রিপ সময় মাপো (শুধু mesh)\n/trace @name — আনুমানিক mesh পথ (শুধু mesh)\n/pay — এই চ্যাটে একটি cashu ecash টোকেন পাঠাও\n/drop — এই জায়গায় ২৪ ঘণ্টার জন্য একটা নোট আটকে দাও (লোকেশন লাগবে)\n/help — এই তালিকা" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "befehle:\n/msg @name [message] — privaten chat starten\n/who — zeigen, wer hier ist\n/clear — diesen chat leeren\n/hug @name — eine umarmung senden\n/slap @name — mit einer großen forelle ohrfeigen\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoriten (nur mesh)\n/group create — eine verschlüsselte gruppe starten\n/group invite @name · /group remove @name — mitglieder verwalten (ersteller)\n/group leave · /group list — gruppen verlassen oder auflisten\n/ping @name — round-trip-zeit messen (nur mesh)\n/trace @name — geschätzter mesh-pfad (nur mesh)\n/pay — einen cashu-ecash-token in diesem chat senden\n/drop — eine notiz für 24h an diesem ort anpinnen (braucht standort)\n/help — diese liste" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "commands:\n/msg @name [message] — start a private chat\n/who — list who's here\n/clear — clear this chat\n/hug @name — send a hug\n/slap @name — slap with a large trout\n/block @name · /unblock @name\n/fav @name · /unfav @name — favorites (mesh only)\n/group create — start an encrypted group\n/group invite @name · /group remove @name — manage members (creator)\n/group leave · /group list — leave or list your groups\n/ping @name — measure round-trip time (mesh only)\n/trace @name — estimated mesh path (mesh only)\n/pay — send a cashu ecash token in this chat\n/drop — pin a note to this place for 24h (needs location)\n/help — this list" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "comandos:\n/msg @name [message] — iniciar un chat privado\n/who — listar quién está aquí\n/clear — limpiar este chat\n/hug @name — enviar un abrazo\n/slap @name — dar un golpe con una trucha enorme\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoritos (solo mesh)\n/group create — iniciar un grupo cifrado\n/group invite @name · /group remove @name — gestionar miembros (creador)\n/group leave · /group list — salir o listar tus grupos\n/ping @name — medir el tiempo de ida y vuelta (solo mesh)\n/trace @name — ruta estimada por la mesh (solo mesh)\n/pay — enviar un token ecash de cashu en este chat\n/drop — fijar una nota en este lugar por 24h (necesita ubicación)\n/help — esta lista" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دستورها:\n/msg @name [message] — شروع چت خصوصی\n/who — فهرست کسانی که اینجا هستند\n/clear — پاک کردن این چت\n/hug @name — فرستادن یک بغل\n/slap @name — سیلی با یک قزل‌آلای گنده\n/block @name · /unblock @name\n/fav @name · /unfav @name — موردعلاقه‌ها (فقط mesh)\n/group create — شروع یک گروه رمزگذاری‌شده\n/group invite @name · /group remove @name — مدیریت اعضا (سازنده)\n/group leave · /group list — ترک گروه‌هایت یا فهرست‌شان\n/ping @name — اندازه‌گیری زمان رفت‌وبرگشت (فقط mesh)\n/trace @name — مسیر تخمینی mesh (فقط mesh)\n/pay — ارسال توکن ecash از cashu در این چت\n/drop — سنجاق کردن یادداشت به این مکان برای ۲۴ ساعت (به موقعیت نیاز دارد)\n/help — همین فهرست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mga command:\n/msg @name [message] — magsimula ng pribadong chat\n/who — ilista kung sino ang narito\n/clear — i-clear ang chat na ito\n/hug @name — magpadala ng yakap\n/slap @name — sampalin gamit ang malaking trout\n/block @name · /unblock @name\n/fav @name · /unfav @name — mga favorite (mesh lang)\n/group create — magsimula ng encrypted na group\n/group invite @name · /group remove @name — pamahalaan ang mga miyembro (creator)\n/group leave · /group list — umalis o ilista ang mga group mo\n/ping @name — sukatin ang round-trip time (mesh lang)\n/trace @name — tinatayang mesh path (mesh lang)\n/pay — magpadala ng cashu ecash token sa chat na ito\n/drop — mag-pin ng note sa lugar na ito nang 24h (kailangan ng location)\n/help — ang listahang ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "commandes :\n/msg @name [message] — démarrer un chat privé\n/who — lister qui est là\n/clear — effacer ce chat\n/hug @name — envoyer un câlin\n/slap @name — mettre une claque avec une grosse truite\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoris (mesh uniquement)\n/group create — démarrer un groupe chiffré\n/group invite @name · /group remove @name — gérer les membres (créateur)\n/group leave · /group list — quitter ou lister tes groupes\n/ping @name — mesurer le temps aller-retour (mesh uniquement)\n/trace @name — chemin mesh estimé (mesh uniquement)\n/pay — envoyer un token ecash cashu dans ce chat\n/drop — épingler une note à cet endroit pour 24h (nécessite la localisation)\n/help — cette liste" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פקודות:\n/msg @name [message] — פתיחת צ'אט פרטי\n/who — מי נמצא כאן\n/clear — ניקוי הצ'אט הזה\n/hug @name — שליחת חיבוק\n/slap @name — סטירה עם פורל ענק\n/block @name · /unblock @name\n/fav @name · /unfav @name — מועדפים (mesh בלבד)\n/group create — פתיחת קבוצה מוצפנת\n/group invite @name · /group remove @name — ניהול חברים (יוצר הקבוצה)\n/group leave · /group list — עזיבה או הצגת הקבוצות שלכם\n/ping @name — מדידת זמן הלוך-חזור (mesh בלבד)\n/trace @name — נתיב mesh משוער (mesh בלבד)\n/pay — שליחת טוקן ecash של cashu בצ'אט הזה\n/drop — הצמדת הערה למקום הזה ל-24 שעות (דורש מיקום)\n/help — הרשימה הזאת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "कमांड:\n/msg @name [message] — निजी चैट शुरू करो\n/who — देखो यहाँ कौन-कौन है\n/clear — यह चैट साफ़ करो\n/hug @name — झप्पी भेजो\n/slap @name — एक बड़ी ट्राउट मछली से थप्पड़ मारो\n/block @name · /unblock @name\n/fav @name · /unfav @name — पसंदीदा (सिर्फ mesh)\n/group create — एक एन्क्रिप्टेड ग्रुप शुरू करो\n/group invite @name · /group remove @name — सदस्य प्रबंधन (निर्माता)\n/group leave · /group list — ग्रुप छोड़ो या अपने ग्रुप की सूची देखो\n/ping @name — राउंड-ट्रिप समय मापो (सिर्फ mesh)\n/trace @name — अनुमानित mesh रास्ता (सिर्फ mesh)\n/pay — इस चैट में cashu ecash टोकन भेजो\n/drop — इस जगह 24 घंटे के लिए नोट पिन करो (लोकेशन चाहिए)\n/help — यही सूची" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "perintah:\n/msg @name [message] — mulai chat pribadi\n/who — daftar siapa saja yang ada di sini\n/clear — bersihkan chat ini\n/hug @name — kirim pelukan\n/slap @name — tampar dengan ikan trout besar\n/block @name · /unblock @name\n/fav @name · /unfav @name — favorit (khusus mesh)\n/group create — mulai grup terenkripsi\n/group invite @name · /group remove @name — kelola anggota (pembuat)\n/group leave · /group list — keluar dari grup atau lihat daftar grupmu\n/ping @name — ukur waktu bolak-balik (khusus mesh)\n/trace @name — perkiraan jalur mesh (khusus mesh)\n/pay — kirim token ecash cashu di chat ini\n/drop — sematkan catatan di tempat ini selama 24 jam (butuh lokasi)\n/help — daftar ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "comandi:\n/msg @name [messaggio] — avvia una chat privata\n/who — elenca chi c'è\n/clear — pulisci questa chat\n/hug @name — manda un abbraccio\n/slap @name — schiaffeggia con una grossa trota\n/block @name · /unblock @name\n/fav @name · /unfav @name — preferiti (solo mesh)\n/group create — avvia un gruppo cifrato\n/group invite @name · /group remove @name — gestisci i membri (creatore)\n/group leave · /group list — esci dai tuoi gruppi o elencali\n/ping @name — misura il tempo di andata e ritorno (solo mesh)\n/trace @name — percorso mesh stimato (solo mesh)\n/pay — invia un token ecash cashu in questa chat\n/drop — appunta una nota in questo posto per 24h (serve la posizione)\n/help — questa lista" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "コマンド:\n/msg @name [メッセージ] — プライベートチャットを開始\n/who — 誰がいるか一覧表示\n/clear — このチャットをクリア\n/hug @name — ハグを送る\n/slap @name — 大きなマスでひっぱたく\n/block @name · /unblock @name\n/fav @name · /unfav @name — お気に入り (メッシュのみ)\n/group create <名前> — 暗号化グループを開始\n/group invite @name · /group remove @name — メンバー管理 (作成者)\n/group leave · /group list — グループから抜ける・一覧表示\n/ping @name — 往復時間を測定 (メッシュのみ)\n/trace @name — 推定メッシュ経路 (メッシュのみ)\n/pay <トークン> — このチャットで cashu ecash トークンを送る\n/drop <メッセージ> — この場所に24時間ノートをピン留め (位置情報が必要)\n/help — この一覧" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "명령어:\n/msg @name [메시지] — 개인 채팅 시작\n/who — 여기 누가 있는지 보기\n/clear — 이 채팅 지우기\n/hug @name — 포옹 보내기\n/slap @name — 큰 송어로 찰싹 때리기\n/block @name · /unblock @name\n/fav @name · /unfav @name — 즐겨찾기 (메시 전용)\n/group create <이름> — 암호화 그룹 만들기\n/group invite @name · /group remove @name — 멤버 관리 (만든 사람)\n/group leave · /group list — 그룹 나가기 또는 목록 보기\n/ping @name — 왕복 시간 측정 (메시 전용)\n/trace @name — 예상 메시 경로 (메시 전용)\n/pay <토큰> — 이 채팅에서 cashu ecash 토큰 보내기\n/drop <메시지> — 이 장소에 24시간 노트 고정 (위치 정보 필요)\n/help — 이 목록" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "arahan:\n/msg @name [mesej] — mulakan sembang peribadi\n/who — senaraikan siapa yang ada di sini\n/clear — kosongkan sembang ini\n/hug @name — hantar pelukan\n/slap @name — tampar dengan ikan trout besar\n/block @name · /unblock @name\n/fav @name · /unfav @name — kegemaran (mesh sahaja)\n/group create — mulakan kumpulan tersulit\n/group invite @name · /group remove @name — urus ahli (pencipta)\n/group leave · /group list — keluar atau senaraikan kumpulan anda\n/ping @name — ukur masa pergi balik (mesh sahaja)\n/trace @name — laluan mesh anggaran (mesh sahaja)\n/pay — hantar token ecash cashu dalam sembang ini\n/drop — sematkan nota di tempat ini selama 24 jam (perlukan lokasi)\n/help — senarai ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "कमाण्डहरू:\n/msg @name [सन्देश] — निजी च्याट सुरु गर्नुहोस्\n/who — यहाँ को छ भनेर हेर्नुहोस्\n/clear — यो च्याट खाली गर्नुहोस्\n/hug @name — अँगालो पठाउनुहोस्\n/slap @name — ठूलो ट्राउट माछाले हिर्काउनुहोस्\n/block @name · /unblock @name\n/fav @name · /unfav @name — मनपर्नेहरू (मेस मात्र)\n/group create <नाम> — इन्क्रिप्टेड समूह सुरु गर्नुहोस्\n/group invite @name · /group remove @name — सदस्य व्यवस्थापन (सर्जक)\n/group leave · /group list — समूह छोड्नुहोस् वा सूची हेर्नुहोस्\n/ping @name — राउन्ड-ट्रिप समय नाप्नुहोस् (मेस मात्र)\n/trace @name — अनुमानित मेस बाटो (मेस मात्र)\n/pay <टोकन> — यो च्याटमा cashu ecash टोकन पठाउनुहोस्\n/drop <सन्देश> — यो ठाउँमा 24 घण्टाका लागि नोट राख्नुहोस् (स्थान चाहिन्छ)\n/help — यो सूची" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "commando's:\n/msg @name [bericht] — start een privéchat\n/who — toon wie er is\n/clear — wis deze chat\n/hug @name — stuur een knuffel\n/slap @name — sla met een grote forel\n/block @name · /unblock @name\n/fav @name · /unfav @name — favorieten (alleen mesh)\n/group create — start een versleutelde groep\n/group invite @name · /group remove @name — beheer leden (maker)\n/group leave · /group list — verlaat of toon je groepen\n/ping @name — meet de rondreistijd (alleen mesh)\n/trace @name — geschat mesh-pad (alleen mesh)\n/pay — stuur een cashu-ecash-token in deze chat\n/drop — pin 24 uur lang een notitie op deze plek (vereist locatie)\n/help — deze lijst" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "polecenia:\n/msg @name [wiadomość] — rozpocznij prywatny czat\n/who — pokaż, kto tu jest\n/clear — wyczyść ten czat\n/hug @name — wyślij przytulasa\n/slap @name — trzaśnij wielkim pstrągiem\n/block @name · /unblock @name\n/fav @name · /unfav @name — ulubione (tylko mesh)\n/group create — załóż szyfrowaną grupę\n/group invite @name · /group remove @name — zarządzaj członkami (założyciel)\n/group leave · /group list — opuść grupę lub pokaż swoje grupy\n/ping @name — zmierz czas podróży w obie strony (tylko mesh)\n/trace @name — szacowana ścieżka mesh (tylko mesh)\n/pay — wyślij token ecash cashu na tym czacie\n/drop — przypnij notkę w tym miejscu na 24 h (wymaga lokalizacji)\n/help — ta lista" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "comandos:\n/msg @name [mensagem] — inicia uma conversa privada\n/who — lista quem está aqui\n/clear — limpa esta conversa\n/hug @name — envia um abraço\n/slap @name — dá uma chapada com uma truta enorme\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoritos (só mesh)\n/group create — cria um grupo encriptado\n/group invite @name · /group remove @name — gere os membros (criador)\n/group leave · /group list — sai dos teus grupos ou lista-os\n/ping @name — mede o tempo de ida e volta (só mesh)\n/trace @name — caminho mesh estimado (só mesh)\n/pay — envia um token ecash cashu nesta conversa\n/drop — fixa uma nota neste sítio por 24h (precisa de localização)\n/help — esta lista" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "comandos:\n/msg @name [mensagem] — comece um chat privado\n/who — veja quem está aqui\n/clear — limpe este chat\n/hug @name — mande um abraço\n/slap @name — dê um tapa com uma truta gigante\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoritos (só mesh)\n/group create — crie um grupo criptografado\n/group invite @name · /group remove @name — gerencie os membros (criador)\n/group leave · /group list — saia dos seus grupos ou liste-os\n/ping @name — meça o tempo de ida e volta (só mesh)\n/trace @name — caminho mesh estimado (só mesh)\n/pay — envie um token ecash cashu neste chat\n/drop — fixe uma nota neste lugar por 24h (precisa de localização)\n/help — esta lista" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "команды:\n/msg @name [сообщение] — начать личный чат\n/who — показать, кто здесь\n/clear — очистить этот чат\n/hug @name — отправить обнимашку\n/slap @name — шлёпнуть большой форелью\n/block @name · /unblock @name\n/fav @name · /unfav @name — избранное (только mesh)\n/group create <название> — создать зашифрованную группу\n/group invite @name · /group remove @name — управление участниками (создатель)\n/group leave · /group list — выйти из групп или показать их список\n/ping @name — измерить время туда-обратно (только mesh)\n/trace @name — примерный путь по mesh (только mesh)\n/pay <токен> — отправить cashu-токен (ecash) в этот чат\n/drop <сообщение> — закрепить заметку в этом месте на 24 ч (нужна геолокация)\n/help — этот список" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kommandon:\n/msg @name [message] — starta en privat chatt\n/who — lista vilka som är här\n/clear — rensa den här chatten\n/hug @name — skicka en kram\n/slap @name — daska till med en stor forell\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoriter (endast mesh)\n/group create — starta en krypterad grupp\n/group invite @name · /group remove @name — hantera medlemmar (skapare)\n/group leave · /group list — lämna eller lista dina grupper\n/ping @name — mät tur- och returtid (endast mesh)\n/trace @name — uppskattad mesh-väg (endast mesh)\n/pay — skicka en cashu ecash-token i den här chatten\n/drop — fäst en anteckning på den här platsen i 24h (kräver plats)\n/help — den här listan" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "கட்டளைகள்:\n/msg @name [message] — தனி அரட்டையைத் தொடங்கு\n/who — இங்கு யார் இருக்கிறார்கள் என்று பட்டியலிடு\n/clear — இந்த அரட்டையை அழி\n/hug @name — ஒரு அணைப்பை அனுப்பு\n/slap @name — ஒரு பெரிய ட்ரவுட் மீனால் அறை\n/block @name · /unblock @name\n/fav @name · /unfav @name — பிடித்தவை (mesh மட்டும்)\n/group create — மறைகுறியாக்கப்பட்ட குழுவைத் தொடங்கு\n/group invite @name · /group remove @name — உறுப்பினர்களை நிர்வகி (உருவாக்கியவர்)\n/group leave · /group list — உங்கள் குழுக்களிலிருந்து வெளியேறு அல்லது பட்டியலிடு\n/ping @name — சுற்றுப்பயண நேரத்தை அளவிடு (mesh மட்டும்)\n/trace @name — மதிப்பிடப்பட்ட mesh பாதை (mesh மட்டும்)\n/pay — இந்த அரட்டையில் cashu ecash டோக்கனை அனுப்பு\n/drop — இந்த இடத்தில் 24 மணி நேரத்திற்கு ஒரு குறிப்பை பொருத்து (இருப்பிடம் தேவை)\n/help — இந்த பட்டியல்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "คำสั่ง:\n/msg @name [message] — เริ่มแชทส่วนตัว\n/who — ดูว่าใครอยู่ที่นี่บ้าง\n/clear — ล้างแชทนี้\n/hug @name — ส่งอ้อมกอด\n/slap @name — ตบด้วยปลาเทราต์ตัวใหญ่\n/block @name · /unblock @name\n/fav @name · /unfav @name — รายการโปรด (เฉพาะ mesh)\n/group create — เริ่มกลุ่มที่เข้ารหัส\n/group invite @name · /group remove @name — จัดการสมาชิก (ผู้สร้าง)\n/group leave · /group list — ออกจากกลุ่มหรือดูรายชื่อกลุ่มของคุณ\n/ping @name — วัดเวลาไปกลับ (เฉพาะ mesh)\n/trace @name — เส้นทาง mesh โดยประมาณ (เฉพาะ mesh)\n/pay — ส่งโทเคน ecash ของ cashu ในแชทนี้\n/drop — ปักโน้ตไว้ที่นี่นาน 24 ชม. (ต้องใช้ตำแหน่งที่ตั้ง)\n/help — รายการนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "komutlar:\n/msg @name [message] — özel sohbet başlat\n/who — burada kimler var, listele\n/clear — bu sohbeti temizle\n/hug @name — sarılma gönder\n/slap @name — koca bir alabalıkla tokat at\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoriler (sadece mesh)\n/group create — şifreli bir grup başlat\n/group invite @name · /group remove @name — üyeleri yönet (kurucu)\n/group leave · /group list — gruplarından ayrıl veya listele\n/ping @name — gidiş-dönüş süresini ölç (sadece mesh)\n/trace @name — tahmini mesh yolu (sadece mesh)\n/pay — bu sohbette cashu ecash token'ı gönder\n/drop — buraya 24 saatliğine not sabitle (konum gerekli)\n/help — bu liste" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "команди:\n/msg @name [message] — почати приватний чат\n/who — показати, хто тут є\n/clear — очистити цей чат\n/hug @name — надіслати обійми\n/slap @name — ляснути великою фореллю\n/block @name · /unblock @name\n/fav @name · /unfav @name — обране (лише mesh)\n/group create — створити зашифровану групу\n/group invite @name · /group remove @name — керувати учасниками (творець)\n/group leave · /group list — вийти з груп або показати список\n/ping @name — виміряти час проходження туди й назад (лише mesh)\n/trace @name — орієнтовний шлях у mesh (лише mesh)\n/pay — надіслати cashu ecash-токен у цей чат\n/drop — закріпити нотатку в цьому місці на 24 год (потрібна геолокація)\n/help — цей список" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "کمانڈز:\n/msg @name [message] — نجی چیٹ شروع کریں\n/who — دیکھیں یہاں کون کون ہے\n/clear — یہ چیٹ صاف کریں\n/hug @name — جھپی بھیجیں\n/slap @name — ایک بڑی ٹراؤٹ مچھلی سے تھپڑ ماریں\n/block @name · /unblock @name\n/fav @name · /unfav @name — پسندیدہ (صرف mesh)\n/group create — ایک انکرپٹڈ گروپ بنائیں\n/group invite @name · /group remove @name — اراکین کا انتظام کریں (بنانے والا)\n/group leave · /group list — اپنے گروپ چھوڑیں یا فہرست دیکھیں\n/ping @name — راؤنڈ ٹرپ وقت ناپیں (صرف mesh)\n/trace @name — تخمینی mesh راستہ (صرف mesh)\n/pay — اس چیٹ میں cashu ای کیش ٹوکن بھیجیں\n/drop — اس جگہ 24 گھنٹے کے لیے نوٹ لگائیں (مقام درکار)\n/help — یہ فہرست" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "các lệnh:\n/msg @name [message] — bắt đầu trò chuyện riêng\n/who — xem ai đang ở đây\n/clear — xóa cuộc trò chuyện này\n/hug @name — gửi một cái ôm\n/slap @name — tát bằng một con cá hồi vân to\n/block @name · /unblock @name\n/fav @name · /unfav @name — mục yêu thích (chỉ mesh)\n/group create — tạo nhóm mã hóa\n/group invite @name · /group remove @name — quản lý thành viên (người tạo)\n/group leave · /group list — rời hoặc xem danh sách nhóm của bạn\n/ping @name — đo thời gian khứ hồi (chỉ mesh)\n/trace @name — đường đi mesh ước tính (chỉ mesh)\n/pay — gửi token ecash cashu trong cuộc trò chuyện này\n/drop — ghim ghi chú tại nơi này trong 24 giờ (cần vị trí)\n/help — danh sách này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "命令:\n/msg @name [message] — 开始私聊\n/who — 列出谁在这里\n/clear — 清空当前聊天\n/hug @name — 送出一个拥抱\n/slap @name — 用一条大鳟鱼拍过去\n/block @name · /unblock @name\n/fav @name · /unfav @name — 收藏(仅 mesh)\n/group create — 创建加密群组\n/group invite @name · /group remove @name — 管理成员(创建者)\n/group leave · /group list — 退出或列出你的群组\n/ping @name — 测量往返时间(仅 mesh)\n/trace @name — 估计的 mesh 路径(仅 mesh)\n/pay — 在当前聊天发送 cashu ecash 代币\n/drop — 在这个地方钉一条便签,保留 24 小时(需要位置)\n/help — 本列表" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "指令:\n/msg @name [message] — 開始私人聊天\n/who — 列出誰在這裡\n/clear — 清除這個聊天\n/hug @name — 送出一個擁抱\n/slap @name — 用一條大鱒魚巴下去\n/block @name · /unblock @name\n/fav @name · /unfav @name — 最愛(僅限 mesh)\n/group create — 建立加密群組\n/group invite @name · /group remove @name — 管理成員(建立者)\n/group leave · /group list — 離開或列出你的群組\n/ping @name — 測量來回時間(僅限 mesh)\n/trace @name — 估計的 mesh 路徑(僅限 mesh)\n/pay — 在這個聊天中傳送 cashu ecash 代幣\n/drop — 在這個地方釘一張便箋 24 小時(需要位置)\n/help — 這份清單" - } - } - } - }, - "command.msg.not_found" : { - "comment" : "Error when /msg can't resolve the nickname", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' غير موجود" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' পাওয়া যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' nicht gefunden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' not found" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' no encontrado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' پیدا نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi nahanap ang '%@'" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' introuvable" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' לא נמצא" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' नहीं मिला" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' tidak ditemukan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' non trovato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' が見つからない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@'을(를) 찾을 수 없어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' tidak dijumpai" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' फेला परेन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' niet gevonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie znaleziono '%@'" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' não encontrado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' não encontrado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' не найден" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' hittades inte" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' கிடைக்கவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่พบ '%@'" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' bulunamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' не знайдено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' نہیں ملا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không tìm thấy '%@'" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未找到 '%@'" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "找不到 '%@'" - } - } - } - }, - "command.msg.started" : { - "comment" : "Confirmation after /msg opens a private chat", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "بدأت محادثة خاصة مع %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-এর সাথে প্রাইভেট চ্যাট শুরু হলো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "privater chat mit %@ gestartet" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "started private chat with %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "chat privado iniciado con %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "چت خصوصی با %@ شروع شد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "nagsimula ng pribadong chat kay %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "chat privé démarré avec %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "נפתח צ'אט פרטי עם %@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ के साथ निजी चैट शुरू हुई" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "chat pribadi dengan %@ dimulai" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "chat privata avviata con %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ とのプライベートチャットを開始した" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@와(과) 개인 채팅을 시작했어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "sembang peribadi dengan %@ dimulakan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ सँग निजी च्याट सुरु भयो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "privéchat met %@ gestart" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "rozpoczęto prywatny czat z %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "conversa privada iniciada com %@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "chat privado iniciado com %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "начат личный чат с %@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "startade privat chatt med %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ உடன் தனி அரட்டை தொடங்கியது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เริ่มแชทส่วนตัวกับ %@ แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ile özel sohbet başlatıldı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "розпочато приватний чат з %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کے ساتھ نجی چیٹ شروع ہو گئی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đã bắt đầu trò chuyện riêng với %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已开始与 %@ 的私聊" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已與 %@ 開始私人聊天" - } - } - } - }, - "command.msg.usage" : { - "comment" : "Usage hint for /msg", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الاستخدام: /msg @nickname [message]" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্যবহার: /msg @nickname [বার্তা]" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verwendung: /msg @nickname [nachricht]" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /msg @nickname [message]" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /msg @nickname [mensaje]" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کاربرد: /msg @nickname [message]" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "paggamit: /msg @nickname [mensahe]" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage : /msg @nickname [message]" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שימוש: /msg @nickname [message]" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "उपयोग: /msg @nickname [संदेश]" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "penggunaan: /msg @nickname [pesan]" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /msg @nickname [messaggio]" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "使い方: /msg @nickname [メッセージ]" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "사용법: /msg @nickname [메시지]" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "cara guna: /msg @nickname [mesej]" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "प्रयोग: /msg @nickname [सन्देश]" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "gebruik: /msg @nickname [bericht]" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "użycie: /msg @nickname [wiadomość]" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /msg @nickname [mensagem]" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /msg @nickname [mensagem]" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "использование: /msg @nickname [сообщение]" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "användning: /msg @nickname [message]" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பயன்பாடு: /msg @nickname [message]" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "วิธีใช้: /msg @nickname [message]" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kullanım: /msg @nickname [message]" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "використання: /msg @nickname [message]" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "استعمال: /msg @nickname [message]" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cách dùng: /msg @nickname [message]" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/msg @nickname [message]" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/msg @nickname [message]" - } - } - } - }, - "command.pay.invalid" : { - "comment" : "Error when /pay input fails to decode", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمز cashu غير صالح — لا يمكن فك ترميزه إلى رمز معروف بمبلغ، لن يتم إرساله" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "অবৈধ cashu টোকেন — পরিমাণসহ কোনো পরিচিত টোকেনে ডিকোড হচ্ছে না, পাঠানো হচ্ছে না" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "ungültiger cashu-token — er lässt sich nicht zu einem bekannten token mit betrag dekodieren, wird nicht gesendet" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "invalid cashu token — it doesn't decode to a known token with an amount, not sending it" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "token cashu no válido — no se decodifica a un token conocido con un importe, no se envía" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "توکن cashu نامعتبر — به توکن شناخته‌شده‌ای با مبلغ رمزگشایی نمی‌شود، ارسال نمی‌شود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "invalid na cashu token — hindi ito nade-decode sa kilalang token na may halaga, hindi ipapadala" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "token cashu invalide — il ne se décode pas en un token connu avec un montant, envoi annulé" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "טוקן cashu לא חוקי — הוא לא מפוענח לטוקן מוכר עם סכום, לא נשלח" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अमान्य cashu टोकन — यह रकम वाले किसी ज्ञात टोकन में डिकोड नहीं होता, नहीं भेजा जा रहा" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "token cashu tidak valid — tidak bisa didekode jadi token dikenal dengan nominal, tidak jadi dikirim" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "token cashu non valido — non si decodifica in un token noto con un importo, non lo invio" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "無効な cashu トークン — 金額付きの既知のトークンとしてデコードできないので送らない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "잘못된 cashu 토큰이에요 — 금액이 있는 토큰으로 디코딩되지 않아서 보내지 않아요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "token cashu tidak sah — tidak dapat dinyahkod sebagai token yang dikenali dengan jumlah, tidak akan dihantar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अवैध cashu टोकन — रकम भएको ज्ञात टोकनमा डिकोड हुँदैन, पठाइँदैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ongeldig cashu-token — het decodeert niet naar een bekend token met een bedrag, wordt niet verzonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nieprawidłowy token cashu — nie dekoduje się do znanego tokenu z kwotą, nie wysyłam" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "token cashu inválido — não descodifica para um token conhecido com um montante, não vai ser enviado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "token cashu inválido — não decodifica para um token conhecido com um valor, não vou enviar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "неверный cashu-токен — он не декодируется в известный токен с суммой, отправки не будет" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ogiltig cashu-token — den avkodas inte till en känd token med ett belopp, skickar den inte" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "செல்லாத cashu டோக்கன் — தொகையுடன் கூடிய அறியப்பட்ட டோக்கனாக இது டிகோட் ஆகவில்லை, அதை அனுப்பவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "โทเคน cashu ไม่ถูกต้อง — ถอดรหัสออกมาไม่เป็นโทเคนที่รู้จักพร้อมจำนวนเงิน จะไม่ส่งให้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "geçersiz cashu token'ı — tutarı olan bilinen bir token'a çözülemiyor, gönderilmiyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "недійсний cashu-токен — він не декодується у відомий токен із сумою, не надсилаю" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "غلط cashu ٹوکن — یہ رقم والے کسی معلوم ٹوکن میں ڈی کوڈ نہیں ہوتا، اسے نہیں بھیجا جا رہا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "token cashu không hợp lệ — không giải mã được thành token đã biết kèm số tiền, sẽ không gửi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无效的 cashu 代币 — 无法解码成带金额的已知代币,不会发送" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無效的 cashu 代幣 — 無法解碼成帶金額的已知代幣,不會送出" - } - } - } - }, - "command.pay.not_token" : { - "comment" : "Error when /pay input has no cashu prefix", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "هذا لا يبدو رمز cashu — المتوقع cashuA… أو cashuB…" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এটা cashu টোকেনের মতো দেখাচ্ছে না — cashuA… বা cashuB… হওয়ার কথা" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "das sieht nicht nach einem cashu-token aus — erwartet wird cashuA… oder cashuB…" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "that doesn't look like a cashu token — expected cashuA… or cashuB…" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "eso no parece un token cashu — se esperaba cashuA… o cashuB…" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این شبیه توکن cashu نیست — انتظار cashuA… یا cashuB… می‌رفت" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mukhang hindi iyan cashu token — inaasahan ay cashuA… o cashuB…" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ça ne ressemble pas à un token cashu — attendu : cashuA… ou cashuB…" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "זה לא נראה כמו טוקן cashu — אמור להיות cashuA… או cashuB…" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "यह cashu टोकन जैसा नहीं लगता — cashuA… या cashuB… होना चाहिए" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "itu tidak terlihat seperti token cashu — seharusnya cashuA… atau cashuB…" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "non sembra un token cashu — atteso cashuA… o cashuB…" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "cashu トークンには見えない — cashuA… か cashuB… のはず" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "cashu 토큰이 아닌 것 같아요 — cashuA… 또는 cashuB…여야 해요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "itu tidak kelihatan seperti token cashu — sepatutnya cashuA… atau cashuB…" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "त्यो cashu टोकन जस्तो देखिँदैन — cashuA… वा cashuB… हुनुपर्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dat ziet er niet uit als een cashu-token — verwacht cashuA… of cashuB…" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "to nie wygląda na token cashu — oczekiwano cashuA… lub cashuB…" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "isso não parece um token cashu — esperava-se cashuA… ou cashuB…" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "isso não parece um token cashu — esperado cashuA… ou cashuB…" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "это не похоже на cashu-токен — ожидается cashuA… или cashuB…" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "det där ser inte ut som en cashu-token — väntade cashuA… eller cashuB…" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அது cashu டோக்கன் போல தெரியவில்லை — cashuA… அல்லது cashuB… எதிர்பார்க்கப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "อันนั้นดูไม่เหมือนโทเคน cashu — ควรเป็น cashuA… หรือ cashuB…" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu bir cashu token'ına benzemiyor — cashuA… veya cashuB… bekleniyordu" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "це не схоже на cashu-токен — очікується cashuA… або cashuB…" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہ cashu ٹوکن جیسا نہیں لگتا — cashuA… یا cashuB… متوقع تھا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cái đó không giống token cashu — cần dạng cashuA… hoặc cashuB…" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "这看起来不像 cashu 代币 — 应为 cashuA… 或 cashuB…" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "這看起來不像 cashu 代幣 — 應該是 cashuA… 或 cashuB…" - } - } - } - }, - "command.pay.public_confirm" : { - "comment" : "Confirmation gate before sending a cashu token to a public channel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "هذه قناة عامة — أي شخص يقرأها يمكنه استبدال الرمز. للإرسال رغم ذلك: /pay public" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এটা একটা পাবলিক চ্যানেল — যে কেউ পড়ে টোকেনটা ভাঙিয়ে নিতে পারে। তবুও পাঠাতে: /pay <টোকেন> public" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "dies ist ein öffentlicher kanal — alle, die mitlesen, können den token einlösen. trotzdem senden: /pay public" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "this is a public channel — anyone reading it can redeem the token. send anyway: /pay public" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "este es un canal público — cualquiera que lo lea puede canjear el token. enviar de todos modos: /pay public" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این یک کانال عمومی است — هرکسی که آن را بخواند می‌تواند توکن را نقد کند. برای ارسال به‌هرحال: /pay public" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "pampublikong channel ito — kahit sinong nakakabasa ay puwedeng mag-redeem ng token. ipadala pa rin: /pay public" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ceci est un canal public — quiconque le lit peut encaisser le token. envoyer quand même : /pay public" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "זה ערוץ ציבורי — כל מי שקורא אותו יכול לפדות את הטוקן. לשלוח בכל זאת: /pay public" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "यह एक सार्वजनिक चैनल है — इसे पढ़ने वाला कोई भी टोकन भुना सकता है। फिर भी भेजना हो तो: /pay <टोकन> public" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "ini channel publik — siapa pun yang membacanya bisa menukarkan token itu. tetap kirim: /pay public" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "questo è un canale pubblico — chiunque lo legga può riscattare il token. invia comunque: /pay public" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ここは公開チャンネル — 読める人なら誰でもトークンを換金できる。それでも送るなら: /pay <トークン> public" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "여기는 공개 채널이에요 — 읽는 사람 누구나 토큰을 상환할 수 있어요. 그래도 보내려면: /pay <토큰> public" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "ini saluran awam — sesiapa yang membacanya boleh menebus token itu. hantar juga: /pay public" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यो सार्वजनिक च्यानल हो — पढ्ने जो कोहीले टोकन भुनाउन सक्छ। जे होस् पठाउन: /pay <टोकन> public" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dit is een openbaar kanaal — iedereen die meeleest kan het token inwisselen. toch versturen: /pay public" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "to jest kanał publiczny — każdy, kto go czyta, może wykupić token. wyślij mimo to: /pay public" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "este é um canal público — qualquer pessoa que o leia pode resgatar o token. enviar mesmo assim: /pay public" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "este é um canal público — qualquer pessoa lendo pode resgatar o token. enviar mesmo assim: /pay public" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "это публичный канал — любой, кто его читает, может обналичить токен. всё равно отправить: /pay <токен> public" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "det här är en offentlig kanal — vem som helst som läser den kan lösa in tokenen. skicka ändå: /pay public" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இது பொது சேனல் — இதை படிக்கும் யார் வேண்டுமானாலும் டோக்கனை மீட்கலாம். எப்படியும் அனுப்ப: /pay <டோக்கன்> public" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "นี่คือช่องสาธารณะ — ใครที่อ่านอยู่ก็แลกโทเคนได้ ถ้าจะส่งจริง ๆ: /pay <โทเคน> public" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "burası herkese açık bir kanal — okuyan herkes token'ı bozdurabilir. yine de göndermek için: /pay public" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "це публічний канал — будь-хто, хто його читає, може погасити токен. надіслати все одно: /pay <токен> public" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہ ایک عوامی چینل ہے — جو بھی اسے پڑھ رہا ہے وہ ٹوکن ریڈیم کر سکتا ہے۔ پھر بھی بھیجنا ہو تو: /pay <ٹوکن> public" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đây là kênh công khai — bất kỳ ai đọc được đều có thể đổi token. vẫn muốn gửi: /pay public" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "这是公开频道 — 任何看到的人都能兑换这个代币。仍要发送:/pay <代币> public" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "這是公開頻道 — 任何看到的人都能兌換這個代幣。仍要送出:/pay <代幣> public" - } - } - } - }, - "command.pay.sent_private" : { - "comment" : "Confirmation after sending a cashu token in a private chat; placeholder is the amount summary", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم إرسال %@ — cashu رمز لحامله؛ من يستبدله أولًا يحصل على المال" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ পাঠানো হলো — cashu একটি বেয়ারার টোকেন; যে আগে ভাঙাবে, টাকা তারই" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ gesendet — cashu ist ein inhaber-token; wer ihn zuerst einlöst, bekommt das geld" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sent %@ — cashu is a bearer token; whoever redeems it first gets the funds" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ enviado — cashu es un token al portador; quien lo canjee primero se queda con los fondos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ارسال شد — cashu توکن حامل است؛ هرکس اول نقدش کند پول را می‌گیرد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "naipadala ang %@ — bearer token ang cashu; ang unang maka-redeem ang makakakuha ng pondo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ envoyé — cashu est un token au porteur ; la première personne qui l'encaisse récupère les fonds" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ נשלח — cashu הוא טוקן למחזיק בו; מי שפודה אותו ראשון מקבל את הכסף" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ भेजा गया — cashu एक बेयरर टोकन है; जो इसे पहले भुनाएगा, पैसे उसी के" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ terkirim — cashu adalah token pembawa; siapa yang menukarkannya lebih dulu, dia yang mendapat dananya" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ inviato — cashu è un token al portatore; chi lo riscatta per primo ottiene i fondi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ を送った — cashu は持参人払いのトークン。最初に換金した人が資金を受け取る" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 보냈어요 — cashu는 소지자 토큰이라 먼저 상환하는 사람이 돈을 가져가요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ dihantar — cashu ialah token pembawa; sesiapa yang menebusnya dahulu mendapat dananya" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ पठाइयो — cashu वाहक टोकन हो; जसले पहिले भुनाउँछ, उसैले रकम पाउँछ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ verzonden — cashu is een token aan toonder; wie het als eerste inwisselt krijgt het geld" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wysłano %@ — cashu to token na okaziciela; środki dostaje ten, kto pierwszy go wykupi" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ enviado — o cashu é um token ao portador; quem o resgatar primeiro fica com os fundos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ enviado — cashu é um token ao portador; quem resgatar primeiro fica com os fundos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ отправлено — cashu — это токен на предъявителя; средства получит тот, кто первым его обналичит" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "skickade %@ — cashu är en innehavartoken; den som löser in den först får pengarna" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ அனுப்பப்பட்டது — cashu வைத்திருப்பவருக்கே உரிய டோக்கன்; யார் முதலில் மீட்கிறாரோ அவருக்கே பணம் கிடைக்கும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ส่ง %@ แล้ว — cashu เป็นโทเคนแบบผู้ถือ ใครแลกก่อนได้เงินไป" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ gönderildi — cashu hamiline yazılı bir token; kim önce bozdurursa parayı o alır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "надіслано %@ — cashu — це токен на пред'явника; кошти отримає той, хто першим його погасить" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ بھیج دیا گیا — cashu ایک bearer ٹوکن ہے؛ جو پہلے اسے ریڈیم کرے گا رقم اسے ملے گی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đã gửi %@ — cashu là token theo người cầm giữ; ai đổi trước sẽ nhận được tiền" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已发送 %@ — cashu 是不记名代币,谁先兑换资金就归谁" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已送出 %@ — cashu 是不記名代幣,誰先兌換錢就是誰的" - } - } - } - }, - "command.pay.sent_public" : { - "comment" : "Confirmation after sending a cashu token to a public channel; placeholder is the amount summary", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم إرسال %@ إلى القناة العامة — يمكن لأي شخص هنا استبداله" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ পাবলিক চ্যানেলে পাঠানো হলো — এখানে যে কেউ এটা ভাঙিয়ে নিতে পারে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ in den öffentlichen kanal gesendet — alle hier können ihn einlösen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sent %@ to the public channel — anyone here can redeem it" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ enviado al canal público — cualquiera aquí puede canjearlo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ به کانال عمومی ارسال شد — هرکسی اینجا می‌تواند نقدش کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "naipadala ang %@ sa pampublikong channel — kahit sino rito ay puwede itong i-redeem" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ envoyé sur le canal public — tout le monde ici peut l'encaisser" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ נשלח לערוץ הציבורי — כל אחד כאן יכול לפדות אותו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ सार्वजनिक चैनल पर भेजा गया — यहाँ कोई भी इसे भुना सकता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ terkirim ke channel publik — siapa pun di sini bisa menukarkannya" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ inviato al canale pubblico — chiunque qui può riscattarlo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ を公開チャンネルに送った — ここにいる誰でも換金できる" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공개 채널에 %@ 보냈어요 — 여기 있는 누구나 상환할 수 있어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ dihantar ke saluran awam — sesiapa di sini boleh menebusnya" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ सार्वजनिक च्यानलमा पठाइयो — यहाँ भएको जो कोहीले भुनाउन सक्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ naar het openbare kanaal gestuurd — iedereen hier kan het inwisselen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wysłano %@ na kanał publiczny — każdy tutaj może go wykupić" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ enviado para o canal público — qualquer pessoa aqui pode resgatá-lo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ enviado para o canal público — qualquer pessoa aqui pode resgatar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ отправлено в публичный канал — любой здесь может его обналичить" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "skickade %@ till den offentliga kanalen — vem som helst här kan lösa in den" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பொது சேனலுக்கு %@ அனுப்பப்பட்டது — இங்குள்ள யார் வேண்டுமானாலும் அதை மீட்கலாம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ส่ง %@ ไปยังช่องสาธารณะแล้ว — ใครในนี้ก็แลกได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ herkese açık kanala gönderildi — buradaki herkes bozdurabilir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "надіслано %@ у публічний канал — будь-хто тут може його погасити" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ عوامی چینل پر بھیج دیا گیا — یہاں کوئی بھی اسے ریڈیم کر سکتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đã gửi %@ vào kênh công khai — bất kỳ ai ở đây cũng có thể đổi nó" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已把 %@ 发送到公开频道 — 这里任何人都能兑换" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已把 %@ 送到公開頻道 — 這裡任何人都能兌換" - } - } - } - }, - "command.pay.usage" : { - "comment" : "Usage hint for /pay", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الاستخدام: /pay — الصق رمز cashu: /pay cashuA…" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্যবহার: /pay <টোকেন> — একটি cashu টোকেন পেস্ট করো: /pay cashuA…" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verwendung: /pay — füge einen cashu-token ein: /pay cashuA…" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /pay — paste a cashu token: /pay cashuA…" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /pay — pega un token cashu: /pay cashuA…" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کاربرد: /pay — یک توکن cashu را جای‌گذاری کن: /pay cashuA…" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "paggamit: /pay — i-paste ang isang cashu token: /pay cashuA…" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage : /pay — colle un token cashu : /pay cashuA…" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שימוש: /pay — הדביקו טוקן cashu: /pay cashuA…" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "उपयोग: /pay <टोकन> — एक cashu टोकन पेस्ट करो: /pay cashuA…" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "penggunaan: /pay — tempel token cashu: /pay cashuA…" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /pay — incolla un token cashu: /pay cashuA…" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "使い方: /pay <トークン> — cashu トークンを貼り付けて: /pay cashuA…" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "사용법: /pay <토큰> — cashu 토큰을 붙여넣어요: /pay cashuA…" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "cara guna: /pay — tampal token cashu: /pay cashuA…" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "प्रयोग: /pay <टोकन> — cashu टोकन टाँस्नुहोस्: /pay cashuA…" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "gebruik: /pay — plak een cashu-token: /pay cashuA…" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "użycie: /pay — wklej token cashu: /pay cashuA…" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /pay — cola um token cashu: /pay cashuA…" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /pay — cole um token cashu: /pay cashuA…" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "использование: /pay <токен> — вставь cashu-токен: /pay cashuA…" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "användning: /pay — klistra in en cashu-token: /pay cashuA…" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பயன்பாடு: /pay <டோக்கன்> — ஒரு cashu டோக்கனை ஒட்டவும்: /pay cashuA…" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "วิธีใช้: /pay <โทเคน> — วางโทเคน cashu: /pay cashuA…" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kullanım: /pay — bir cashu token'ı yapıştır: /pay cashuA…" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "використання: /pay <токен> — встав cashu-токен: /pay cashuA…" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "استعمال: /pay <ٹوکن> — cashu ٹوکن پیسٹ کریں: /pay cashuA…" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cách dùng: /pay — dán một token cashu: /pay cashuA…" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/pay <代币> — 粘贴一个 cashu 代币:/pay cashuA…" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/pay <代幣> — 貼上 cashu 代幣:/pay cashuA…" - } - } - } - }, - "command.ping.started" : { - "comment" : "Confirmation that /ping sent a probe", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "جارٍ إرسال ping إلى %@…" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-কে ping করা হচ্ছে…" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "pinge %@…" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "pinging %@…" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "haciendo ping a %@…" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال ping به %@…" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "pini-ping si %@…" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping de %@ en cours…" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שולחים ping אל %@…" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ को ping किया जा रहा है…" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "melakukan ping ke %@…" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "ping a %@…" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ に ping 中…" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@에게 ping 보내는 중…" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "sedang ping %@…" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ लाई ping गर्दै…" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ wordt gepingd…" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "pinguję %@…" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "a fazer ping a %@…" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "fazendo ping em %@…" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "пингую %@…" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "pingar %@…" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ க்கு ping அனுப்புகிறது…" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "กำลัง ping %@…" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ için ping atılıyor…" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "пінгуємо %@…" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کو ping کیا جا رہا ہے…" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đang ping %@…" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在 ping %@…" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在 ping %@…" - } - } - } - }, - "command.trace.no_path" : { - "comment" : "Reply when /trace has no mesh path to the target", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا يوجد مسار معروف إلى %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-এর কোনো জানা পথ নেই" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "kein bekannter pfad zu %@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no known path to %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no hay ruta conocida hacia %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسیر شناخته‌شده‌ای به %@ وجود ندارد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "walang kilalang path papunta kay %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "aucun chemin connu vers %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין נתיב מוכר אל %@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ तक कोई ज्ञात रास्ता नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak ada jalur yang diketahui ke %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "nessun percorso noto verso %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ への既知の経路がない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@(으)로 가는 알려진 경로가 없어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tiada laluan diketahui ke %@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ सम्मको कुनै ज्ञात बाटो छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geen bekend pad naar %@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "brak znanej ścieżki do %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "nenhum caminho conhecido até %@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "nenhum caminho conhecido até %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "нет известного пути до %@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ingen känd väg till %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ க்கு தெரிந்த பாதை இல்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่รู้เส้นทางไปยัง %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ için bilinen bir yol yok" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "немає відомого шляху до %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ تک کوئی معلوم راستہ نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không có đường đi nào đã biết tới %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "没有到 %@ 的已知路径" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "沒有通往 %@ 的已知路徑" - } - } - } - }, - "command.trace.path_many" : { - "comment" : "Reply to /trace; placeholders are the node chain and hop count", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "المسار المقدّر: %1$@ (%2$lld قفزات)" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আনুমানিক পথ: %1$@ (%2$lld হপ)" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "geschätzter pfad: %1$@ (%2$lld hops)" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "estimated path: %1$@ (%2$lld hops)" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ruta estimada: %1$@ (%2$lld saltos)" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسیر تخمینی: %1$@ (%2$lld هاپ)" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "tinatayang path: %1$@ (%2$lld na hop)" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "chemin estimé : %1$@ (%2$lld sauts)" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "נתיב משוער: %1$@ (%2$lld הופים)" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अनुमानित रास्ता: %1$@ (%2$lld हॉप)" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "perkiraan jalur: %1$@ (%2$lld hop)" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "percorso stimato: %1$@ (%2$lld hop)" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "推定経路: %1$@ (%2$lldホップ)" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "예상 경로: %1$@ (%2$lld홉)" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "laluan anggaran: %1$@ (%2$lld hop)" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अनुमानित बाटो: %1$@ (%2$lld हप)" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geschat pad: %1$@ (%2$lld hops)" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "szacowana ścieżka: %1$@ (liczba przeskoków: %2$lld)" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "caminho estimado: %1$@ (%2$lld saltos)" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "caminho estimado: %1$@ (%2$lld saltos)" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "примерный путь: %1$@ (хопов: %2$lld)" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "uppskattad väg: %1$@ (%2$lld hopp)" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "மதிப்பிடப்பட்ட பாதை: %1$@ (%2$lld தாவல்கள்)" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เส้นทางโดยประมาณ: %1$@ (%2$lld ฮอป)" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tahmini yol: %1$@ (%2$lld atlama)" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "орієнтовний шлях: %1$@ (%2$lld хопів)" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تخمینی راستہ: %1$@ (%2$lld ہاپس)" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đường đi ước tính: %1$@ (%2$lld chặng)" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "估计路径:%1$@(%2$lld 跳)" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "估計路徑:%1$@(%2$lld 跳)" - } - } - } - }, - "command.trace.path_one" : { - "comment" : "Reply to /trace for a single-hop path; placeholder is the node chain", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "المسار المقدّر: %@ (قفزة واحدة)" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আনুমানিক পথ: %@ (১ হপ)" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "geschätzter pfad: %@ (1 hop)" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "estimated path: %@ (1 hop)" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ruta estimada: %@ (1 salto)" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسیر تخمینی: %@ (۱ هاپ)" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "tinatayang path: %@ (1 hop)" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "chemin estimé : %@ (1 saut)" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "נתיב משוער: %@ (הופ אחד)" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अनुमानित रास्ता: %@ (1 हॉप)" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "perkiraan jalur: %@ (1 hop)" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "percorso stimato: %@ (1 hop)" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "推定経路: %@ (1ホップ)" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "예상 경로: %@ (1홉)" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "laluan anggaran: %@ (1 hop)" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अनुमानित बाटो: %@ (1 हप)" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geschat pad: %@ (1 hop)" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "szacowana ścieżka: %@ (1 przeskok)" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "caminho estimado: %@ (1 salto)" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "caminho estimado: %@ (1 salto)" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "примерный путь: %@ (1 хоп)" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "uppskattad väg: %@ (1 hopp)" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "மதிப்பிடப்பட்ட பாதை: %@ (1 தாவல்)" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เส้นทางโดยประมาณ: %@ (1 ฮอป)" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tahmini yol: %@ (1 atlama)" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "орієнтовний шлях: %@ (1 хоп)" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تخمینی راستہ: %@ (1 ہاپ)" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đường đi ước tính: %@ (1 chặng)" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "估计路径:%@(1 跳)" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "估計路徑:%@(1 跳)" - } - } - } - }, - "command.trace.you" : { - "comment" : "Label for the local device at the start of a /trace path", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "أنت" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "তুমি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "du" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "tú" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تو" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "ikaw" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "toi" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "את/ה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "तुम" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "kamu" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "tu" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "あなた" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "나" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "kamu" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "तिमी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "jij" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ty" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "tu" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "você" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ты" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "du" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நீ" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "คุณ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "sen" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "ти" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "bạn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "你" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "你" - } - } - } - }, - "command.unblock.done" : { - "comment" : "Confirmation after unblocking a mesh peer", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم رفع الحظر عن %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-কে আনব্লক করা হলো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "blockierung von %@ aufgehoben" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "unblocked %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ desbloqueado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسدودیت %@ برداشته شد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "na-unblock si %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ débloqué" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "החסימה של %@ הוסרה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ अनब्लॉक किया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "blokir %@ dibuka" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ sbloccato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ のブロックを解除した" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 차단을 해제했어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ dinyahsekat" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ अनब्लक गरियो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ gedeblokkeerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "odblokowano %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ desbloqueado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ desbloqueado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ разблокирован" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "avblockerade %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ தடைநீக்கப்பட்டார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลิกบล็อก %@ แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ engeli kaldırıldı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ розблоковано" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کو ان بلاک کر دیا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đã bỏ chặn %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已解除屏蔽 %@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已解除封鎖 %@" - } - } - } - }, - "command.unblock.done_geo" : { - "comment" : "Confirmation after unblocking a geohash participant", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم رفع الحظر عن %@ في محادثات geohash" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash চ্যাটে %@-কে আনব্লক করা হলো" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "blockierung von %@ in geohash-chats aufgehoben" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "unblocked %@ in geohash chats" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ desbloqueado en los chats de geohash" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسدودیت %@ در چت‌های geohash برداشته شد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "na-unblock si %@ sa mga geohash chat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ débloqué dans les chats geohash" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "החסימה של %@ הוסרה בצ'אטים של geohash" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash चैट में %@ को अनब्लॉक किया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "blokir %@ dibuka di chat geohash" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ sbloccato nelle chat geohash" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash チャットで %@ のブロックを解除した" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash 채팅에서 %@ 차단을 해제했어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ dinyahsekat dalam sembang geohash" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash च्याटहरूमा %@ अनब्लक गरियो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ gedeblokkeerd in geohash-chats" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "odblokowano %@ w czatach geohash" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ desbloqueado nas conversas geohash" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ desbloqueado nos chats geohash" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ разблокирован в geohash-чатах" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "avblockerade %@ i geohash-chattar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash அரட்டைகளில் %@ தடைநீக்கப்பட்டார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลิกบล็อก %@ ในแชท geohash แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash sohbetlerinde %@ engeli kaldırıldı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ розблоковано в geohash-чатах" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "geohash چیٹس میں %@ کو ان بلاک کر دیا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đã bỏ chặn %@ trong các kênh trò chuyện geohash" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已在 geohash 聊天中解除屏蔽 %@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已在 geohash 聊天中解除封鎖 %@" - } - } - } - }, - "command.unblock.failed" : { - "comment" : "Error when /unblock can't resolve the target", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعذّر رفع الحظر عن %@: غير موجود" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-কে আনব্লক করা গেল না: পাওয়া যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "kann %@ nicht entblocken: nicht gefunden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot unblock %@: not found" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede desbloquear a %@: no encontrado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمی‌توان مسدودیت %@ را برداشت: پیدا نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi ma-unblock si %@: hindi nahanap" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossible de débloquer %@ : introuvable" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אי אפשר לבטל את החסימה של %@: לא נמצא" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ को अनब्लॉक नहीं कर सकते: नहीं मिला" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak bisa membuka blokir %@: tidak ditemukan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile sbloccare %@: non trovato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ のブロックを解除できない: 見つからない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 차단을 해제할 수 없어요: 찾을 수 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak dapat menyahsekat %@: tidak dijumpai" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ लाई अनब्लक गर्न सकिएन: फेला परेन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan %@ niet deblokkeren: niet gevonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie można odblokować %@: nie znaleziono" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não é possível desbloquear %@: não encontrado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não dá para desbloquear %@: não encontrado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "не получается разблокировать %@: не найден" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kan inte avblockera %@: hittades inte" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ஐ தடைநீக்க முடியாது: கிடைக்கவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลิกบล็อก %@ ไม่ได้: ไม่พบ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ engeli kaldırılamıyor: bulunamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "не вдалося розблокувати %@: не знайдено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کو ان بلاک نہیں کیا جا سکا: نہیں ملا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không thể bỏ chặn %@: không tìm thấy" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法解除屏蔽 %@:未找到" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法解除封鎖 %@:找不到" - } - } - } - }, - "command.unblock.not_blocked" : { - "comment" : "Reply when /unblock targets a nickname that isn't blocked", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ليس محظورًا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ব্লক করা নেই" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ist nicht blockiert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is not blocked" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ no está bloqueado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ مسدود نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi naka-block si %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ n'est pas bloqué" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ לא חסום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ब्लॉक नहीं है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ tidak diblokir" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ non è bloccato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ はブロックされていない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@은(는) 차단되어 있지 않아요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ tidak disekat" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ब्लक गरिएको छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is niet geblokkeerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ nie jest na liście zablokowanych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ não está bloqueado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ não está bloqueado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ не заблокирован" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ är inte blockerad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ தடுக்கப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ไม่ได้ถูกบล็อก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ engelli değil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ не заблоковано" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ بلاک نہیں ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ không bị chặn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 未被屏蔽" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 沒有被封鎖" - } - } - } - }, - "command.unblock.usage" : { - "comment" : "Usage hint for /unblock", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الاستخدام: /unblock " - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্যবহার: /unblock <ডাকনাম>" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verwendung: /unblock " - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /unblock " - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /unblock " - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کاربرد: /unblock " - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "paggamit: /unblock " - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage : /unblock " - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שימוש: /unblock " - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "उपयोग: /unblock <उपनाम>" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "penggunaan: /unblock " - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /unblock " - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "使い方: /unblock <ニックネーム>" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "사용법: /unblock <닉네임>" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "cara guna: /unblock " - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "प्रयोग: /unblock <उपनाम>" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "gebruik: /unblock " - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "użycie: /unblock " - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /unblock " - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /unblock " - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "использование: /unblock <ник>" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "användning: /unblock " - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பயன்பாடு: /unblock <புனைப்பெயர்>" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "วิธีใช้: /unblock <ชื่อเล่น>" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kullanım: /unblock " - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "використання: /unblock <нікнейм>" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "استعمال: /unblock <عرفی نام>" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cách dùng: /unblock " - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/unblock <昵称>" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "用法:/unblock <暱稱>" - } - } - } - }, - "command.who.nobody" : { - "comment" : "Reply to /who when no context is available", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا أحد في الجوار" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আশেপাশে কেউ নেই" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "niemand in der nähe" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "nobody around" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no hay nadie cerca" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کسی این اطراف نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "walang tao sa paligid" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "personne dans les parages" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין אף אחד בסביבה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आस-पास कोई नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak ada orang di sekitar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "nessuno nei paraggi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "周りに誰もいない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "주변에 아무도 없어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tiada sesiapa berdekatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "वरपर कोही छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "niemand in de buurt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nikogo w pobliżu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ninguém por perto" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ninguém por perto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "рядом никого" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ingen i närheten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அருகில் யாரும் இல்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่มีใครอยู่แถวนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "etrafta kimse yok" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "нікого поруч" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "آس پاس کوئی نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không có ai quanh đây" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "附近没有人" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "附近沒有人" - } - } - } - }, - "command.who.none_online" : { - "comment" : "Reply to /who when nobody else is online", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا أحد آخر متصل الآن" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই মুহূর্তে আর কেউ অনলাইনে নেই" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "gerade ist sonst niemand online" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no one else is online right now" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "nadie más está en línea ahora mismo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "الان کس دیگری آنلاین نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "walang ibang online ngayon" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "personne d'autre n'est en ligne pour l'instant" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אף אחד אחר לא מחובר כרגע" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अभी कोई और ऑनलाइन नहीं है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "belum ada orang lain yang online sekarang" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "nessun altro è online in questo momento" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今は他に誰もオンラインじゃない" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "지금은 다른 사람이 아무도 온라인이 아니에요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tiada orang lain dalam talian sekarang" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अहिले अरू कोही अनलाइन छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "er is nu verder niemand online" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nikt inny nie jest teraz online" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não está mais ninguém online neste momento" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ninguém mais está online agora" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "сейчас больше никого нет в сети" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ingen annan är online just nu" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இப்போது வேறு யாரும் ஆன்லைனில் இல்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ตอนนี้ไม่มีคนอื่นออนไลน์อยู่" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "şu anda başka kimse çevrimiçi değil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "зараз більше нікого немає онлайн" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ابھی کوئی اور آن لائن نہیں ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "hiện không có ai khác trực tuyến" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "现在没有其他人在线" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "現在沒有其他人在線上" - } - } - } - }, - "command.who.online" : { - "comment" : "Reply to /who; placeholder is the list of names", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "متصلون: %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "অনলাইনে: %@" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "online: %@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "online: %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "en línea: %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "آنلاین: %@" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "online: %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "en ligne : %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מחוברים: %@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ऑनलाइन: %@" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "online: %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "online: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "オンライン: %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "온라인: %@" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "dalam talian: %@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अनलाइन: %@" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "online: %@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "online: %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "online: %@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "online: %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "в сети: %@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "online: %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ஆன்லைன்: %@" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ออนไลน์: %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "çevrimiçi: %@" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "онлайн: %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "آن لائن: %@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "trực tuyến: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在线:%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "在線上:%@" - } - } - } - }, - "image_picker.none_selected" : { - "comment" : "Placeholder shown in the image picker before a selection", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لم يتم اختيار أي صورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "কোনো ছবি নির্বাচন করা হয়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "kein bild ausgewählt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no image selected" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ninguna imagen seleccionada" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هیچ تصویری انتخاب نشده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "walang napiling larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "aucune image sélectionnée" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "לא נבחרה תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "कोई तस्वीर नहीं चुनी गई" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak ada gambar yang dipilih" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "nessuna immagine selezionata" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像が選択されていません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "선택된 이미지가 없어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tiada imej dipilih" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "कुनै तस्बिर चयन गरिएको छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geen afbeelding geselecteerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie wybrano obrazu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "nenhuma imagem selecionada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "nenhuma imagem selecionada" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "изображение не выбрано" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ingen bild vald" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "படம் எதுவும் தேர்ந்தெடுக்கப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่ได้เลือกรูปภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "görsel seçilmedi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "зображення не вибрано" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "کوئی تصویر منتخب نہیں کی گئی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "chưa chọn ảnh nào" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未选择图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "未選擇圖片" - } - } - } - }, - "notification.nearby.body_many" : { - "comment" : "Body of the nearby notification; placeholder is the peer count", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld أشخاص بالجوار" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আশেপাশে %lld জন আছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld personen in der nähe" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld people around" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld personas cerca" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld نفر در اطراف هستند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld tao sa paligid" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld personnes dans les parages" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld אנשים בסביבה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आस-पास %lld लोग हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld orang di sekitar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld persone nei dintorni" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "近くに%lld人います" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "근처에 %lld명이 있어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld orang di sekitar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "वरपर %lld जना छन्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld mensen in de buurt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld osób w pobliżu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld pessoas por perto" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld pessoas por perto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "людей рядом: %lld" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld personer i närheten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அருகில் %lld பேர் இருக்கிறார்கள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "มี %lld คนอยู่แถวนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "etrafta %lld kişi var" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "людей поруч: %lld" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "آس پاس %lld لوگ ہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "có %lld người ở gần" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "附近有 %lld 个人" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "附近有 %lld 個人" - } - } - } - }, - "notification.nearby.body_one" : { - "comment" : "Body of the nearby notification for exactly one peer", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "شخص واحد بالجوار" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আশেপাশে 1 জন আছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 person in der nähe" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 person around" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 persona cerca" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 نفر در اطراف است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 tao sa paligid" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 personne dans les parages" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אדם אחד בסביבה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आस-पास 1 व्यक्ति है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 orang di sekitar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 persona nei dintorni" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "近くに1人います" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "근처에 1명이 있어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 orang di sekitar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "वरपर 1 जना छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 persoon in de buurt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 osoba w pobliżu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 pessoa por perto" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 pessoa por perto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 человек рядом" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 person i närheten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அருகில் 1 பேர் இருக்கிறார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "มี 1 คนอยู่แถวนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "etrafta 1 kişi var" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 людина поруч" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "آس پاس 1 شخص ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "có 1 người ở gần" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "附近有 1 个人" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "附近有 1 個人" - } - } - } - }, - "notification.nearby.title" : { - "comment" : "Title of the local notification when mesh peers come into range", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 أهل bitchat بالقرب منك!" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 কাছেই bitchat-এর লোকজন!" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 bitchatter in der nähe!" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 bitchatters nearby!" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 ¡bitchateros cerca!" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 بیت‌چتی‌ها همین نزدیکی‌اند!" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 may mga bitchatter sa malapit!" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 des bitchatteurs dans le coin !" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 ביטצ'אטרים בסביבה!" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 पास में bitchat वाले लोग!" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 ada anak bitchat di sekitar!" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 bitchatter nei paraggi!" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 近くにbitchat仲間がいるよ!" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 근처에 bitchat 친구들이 있어요!" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 ada geng bitchat berdekatan!" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 नजिकै bitchat का मान्छेहरू!" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 bitchatters in de buurt!" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 bitchatterzy w pobliżu!" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 bitchatters por perto!" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 bitchatters por perto!" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 битчаттеры рядом!" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 bitchattare i närheten!" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 அருகிலேயே bitchat ஆட்கள்!" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 มีชาว bitchat อยู่ใกล้ๆ!" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 yakınlarda bitchat'çiler var!" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 бітчатери поруч!" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 قریب ہی bitchat والے موجود ہیں!" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 có dân bitchat ở gần đây!" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 附近有 bitchat 小伙伴!" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "👥 附近有 bitchat 夥伴!" - } - } - } - }, - "system.screenshot.you" : { - "comment" : "Local system line after your screenshot notice was sent to the DM peer", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "التقطت لقطة شاشة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "তুমি একটি স্ক্রিনশট নিয়েছ" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "du hast einen screenshot gemacht" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you took a screenshot" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "hiciste una captura de pantalla" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اسکرین‌شات گرفتی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "kumuha ka ng screenshot" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tu as fait une capture d'écran" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "צילמת את המסך" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "तुमने स्क्रीनशॉट लिया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "kamu mengambil tangkapan layar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "hai fatto uno screenshot" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "スクリーンショットを撮りました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "스크린샷을 찍었어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "anda mengambil tangkapan skrin" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "तिमीले स्क्रिनसट लियौ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "je hebt een screenshot gemaakt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zrobiłeś(-aś) zrzut ekranu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "tiraste uma captura de ecrã" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "você tirou uma captura de tela" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ты сделал(а) скриншот" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "du tog en skärmbild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நீங்கள் ஸ்கிரீன்ஷாட் எடுத்தீர்கள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "คุณแคปหน้าจอ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ekran görüntüsü aldın" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "ти зробив(-ла) знімок екрана" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم نے اسکرین شاٹ لیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "bạn vừa chụp màn hình" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "你截屏了" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "你截圖了" - } - } - } - }, - "voice.error.mic_permission" : { - "comment" : "Alert message when the microphone permission is denied", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يلزم الوصول إلى الميكروفون لتسجيل الرسائل الصوتية." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ভয়েস নোট রেকর্ড করতে মাইক্রোফোন অ্যাক্সেস প্রয়োজন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "mikrofonzugriff wird benötigt, um sprachnotizen aufzunehmen." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "microphone access is required to record voice notes." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "se necesita acceso al micrófono para grabar notas de voz." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای ضبط پیام‌های صوتی به دسترسی میکروفون نیاز است." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "kailangan ng access sa mikropono para makapag-record ng voice notes." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "l'accès au micro est nécessaire pour enregistrer des notes vocales." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "נדרשת גישה למיקרופון כדי להקליט הודעות קוליות." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "वॉइस नोट्स रिकॉर्ड करने के लिए माइक्रोफ़ोन की अनुमति ज़रूरी है।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "akses mikrofon diperlukan untuk merekam pesan suara." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "l'accesso al microfono è necessario per registrare note vocali." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ボイスメモを録音するにはマイクへのアクセスが必要です。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "음성 메모를 녹음하려면 마이크 접근 권한이 필요해요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "akses mikrofon diperlukan untuk merakam nota suara." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "भ्वाइस नोट रेकर्ड गर्न माइक्रोफोन पहुँच आवश्यक छ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "microfoontoegang is nodig om spraakmemo's op te nemen." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dostęp do mikrofonu jest wymagany, aby nagrywać wiadomości głosowe." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "é preciso acesso ao microfone para gravar notas de voz." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "o acesso ao microfone é necessário para gravar notas de voz." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "для записи голосовых сообщений нужен доступ к микрофону." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "mikrofonåtkomst krävs för att spela in röstmeddelanden." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "குரல் குறிப்புகளை பதிவு செய்ய மைக்ரோஃபோன் அணுகல் தேவை." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ต้องใช้สิทธิ์เข้าถึงไมโครโฟนเพื่ออัดข้อความเสียง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "sesli not kaydetmek için mikrofon erişimi gerekiyor." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "для запису голосових повідомлень потрібен доступ до мікрофона." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "وائس نوٹس ریکارڈ کرنے کے لیے مائیکروفون تک رسائی درکار ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cần quyền truy cập micro để ghi âm tin nhắn thoại." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "录制语音消息需要麦克风权限。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "錄製語音訊息需要麥克風權限。" - } - } - } - }, - "voice.error.save_failed" : { - "comment" : "Alert message when a finished voice note cannot be saved", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعذّر حفظ التسجيل." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "রেকর্ডিং সংরক্ষণ করা যায়নি।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "aufnahme konnte nicht gespeichert werden." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "recording failed to save." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se pudo guardar la grabación." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ضبط ذخیره نشد." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi na-save ang recording." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "l'enregistrement n'a pas pu être sauvegardé." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שמירת ההקלטה נכשלה." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "रिकॉर्डिंग सेव नहीं हो सकी।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "rekaman gagal disimpan." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile salvare la registrazione." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "録音を保存できませんでした。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "녹음을 저장하지 못했어요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "rakaman gagal disimpan." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "रेकर्डिङ सेभ गर्न सकिएन।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "de opname kon niet worden opgeslagen." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie udało się zapisać nagrania." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não foi possível guardar a gravação." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não foi possível salvar a gravação." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "не удалось сохранить запись." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "inspelningen kunde inte sparas." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பதிவை சேமிக்க முடியவில்லை." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "บันทึกเสียงที่อัดไว้ไม่สำเร็จ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kayıt kaydedilemedi." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "не вдалося зберегти запис." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ریکارڈنگ محفوظ نہیں ہو سکی۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không lưu được bản ghi âm." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "录音保存失败。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "錄音儲存失敗。" - } - } - } - }, - "voice.error.start_failed" : { - "comment" : "Alert message when the recorder fails to start", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعذّر بدء التسجيل." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "রেকর্ডিং শুরু করা যায়নি।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "aufnahme konnte nicht gestartet werden." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not start recording." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se pudo iniciar la grabación." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ضبط شروع نشد." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi masimulan ang pag-record." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossible de démarrer l'enregistrement." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "לא ניתן היה להתחיל את ההקלטה." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "रिकॉर्डिंग शुरू नहीं हो सकी।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak dapat memulai perekaman." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile avviare la registrazione." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "録音を開始できませんでした。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "녹음을 시작할 수 없어요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak dapat memulakan rakaman." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "रेकर्डिङ सुरु गर्न सकिएन।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kon de opname niet starten." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie udało się rozpocząć nagrywania." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "não foi possível iniciar a gravação." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "não foi possível iniciar a gravação." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "не удалось начать запись." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kunde inte starta inspelningen." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பதிவை தொடங்க முடியவில்லை." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เริ่มอัดเสียงไม่ได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kayıt başlatılamadı." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "не вдалося почати запис." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ریکارڈنگ شروع نہیں ہو سکی۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "không thể bắt đầu ghi âm." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法开始录音。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法開始錄音。" - } - } - } - }, - "voice.error.title" : { - "comment" : "Title of the voice recording error alert", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "خطأ في التسجيل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "রেকর্ডিং ত্রুটি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "aufnahmefehler" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "recording error" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "error de grabación" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "خطای ضبط" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "error sa pag-record" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "erreur d'enregistrement" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שגיאת הקלטה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "रिकॉर्डिंग त्रुटि" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "kesalahan perekaman" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "errore di registrazione" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "録音エラー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "녹음 오류" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "ralat rakaman" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "रेकर्डिङ त्रुटि" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "opnamefout" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "błąd nagrywania" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "erro de gravação" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "erro de gravação" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ошибка записи" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "inspelningsfel" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ஒலிப்பதிவு பிழை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "การอัดเสียงผิดพลาด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kayıt hatası" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "помилка запису" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ریکارڈنگ میں خرابی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "lỗi ghi âm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "录音出错" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "錄音錯誤" - } - } - } - }, - "voice.error.too_short" : { - "comment" : "Alert message when a voice note is released too quickly to save", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "التسجيل قصير جدًا." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "রেকর্ডিং খুব ছোট।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "aufnahme ist zu kurz." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "recording is too short." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "la grabación es demasiado corta." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ضبط خیلی کوتاه است." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "masyadong maikli ang recording." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "l'enregistrement est trop court." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ההקלטה קצרה מדי." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "रिकॉर्डिंग बहुत छोटी है।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "rekaman terlalu pendek." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "la registrazione è troppo corta." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "録音が短すぎます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "녹음이 너무 짧아요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "rakaman terlalu pendek." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "रेकर्डिङ धेरै छोटो छ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "de opname is te kort." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nagranie jest za krótkie." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "a gravação é demasiado curta." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "a gravação é muito curta." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "запись слишком короткая." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "inspelningen är för kort." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பதிவு மிகவும் குறுகியது." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เสียงที่อัดสั้นเกินไป" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kayıt çok kısa." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "запис надто короткий." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ریکارڈنگ بہت مختصر ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "bản ghi âm quá ngắn." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "录音太短了。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "錄音太短了。" - } - } - } - }, - "content.delivery.reason.legacy_media_consent_required" : { - "comment" : "Failure reason when a legacy private-media send lacks per-send consent", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "التأكيد مطلوب قبل الإرسال بدون التشفير التام بين الطرفين" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এন্ড-টু-এন্ড এনক্রিপশন ছাড়া পাঠানোর আগে নিশ্চিতকরণ দরকার" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bestätigung erforderlich, bevor ohne Ende-zu-Ende-Verschlüsselung gesendet wird" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmation required before sending without end-to-end encryption" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Se requiere confirmación antes de enviar sin cifrado de extremo a extremo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیش از ارسال بدون رمزگذاری سرتاسری، تأیید لازم است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kailangan ng kumpirmasyon bago magpadala nang walang end-to-end encryption" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmation requise avant l'envoi sans chiffrement de bout en bout" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "נדרש אישור לפני שליחה ללא הצפנה מקצה לקצה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "एंड-टू-एंड एन्क्रिप्शन के बिना भेजने से पहले पुष्टि ज़रूरी है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Perlu konfirmasi sebelum mengirim tanpa enkripsi end-to-end" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "È richiesta una conferma prima di inviare senza crittografia end-to-end" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "エンドツーエンド暗号化なしで送信する前に確認が必要です" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "종단간 암호화 없이 보내려면 먼저 확인이 필요해요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pengesahan diperlukan sebelum menghantar tanpa penyulitan hujung ke hujung" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "एन्ड-टु-एन्ड इन्क्रिप्शनविना पठाउनुअघि पुष्टि आवश्यक छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bevestiging vereist voordat er zonder end-to-end-versleuteling wordt verzonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wymagane potwierdzenie przed wysłaniem bez szyfrowania end-to-end" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "É necessária confirmação antes de enviar sem encriptação de ponta a ponta" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "É preciso confirmar antes de enviar sem criptografia de ponta a ponta" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перед отправкой без сквозного шифрования требуется подтверждение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bekräftelse krävs innan något skickas utan totalsträckskryptering" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "எண்ட்-டு-எண்ட் என்க்ரிப்ஷன் இல்லாமல் அனுப்புவதற்கு முன் உறுதிப்படுத்தல் தேவை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ต้องยืนยันก่อนส่งโดยไม่มีการเข้ารหัสแบบต้นทางถึงปลายทาง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uçtan uca şifreleme olmadan göndermeden önce onay gerekli" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перед надсиланням без наскрізного шифрування потрібне підтвердження" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اینڈ ٹو اینڈ انکرپشن کے بغیر بھیجنے سے پہلے تصدیق درکار ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cần xác nhận trước khi gửi mà không có mã hóa đầu cuối" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在没有端到端加密的情况下发送前需要确认" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "在沒有端對端加密的情況下傳送前需要確認" - } - } - } - }, - "content.delivery.reason.legacy_media_declined" : { - "comment" : "Failure reason after declining the warning for a legacy clear private-media send", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لم يُرسَل بدون التشفير التام بين الطرفين" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এন্ড-টু-এন্ড এনক্রিপশন ছাড়া পাঠানো হয়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ohne Ende-zu-Ende-Verschlüsselung nicht gesendet" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Not sent without end-to-end encryption" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "No se envió sin cifrado de extremo a extremo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بدون رمزگذاری سرتاسری ارسال نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hindi ipinadala nang walang end-to-end encryption" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Non envoyé sans chiffrement de bout en bout" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "לא נשלח ללא הצפנה מקצה לקצה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "एंड-टू-एंड एन्क्रिप्शन के बिना नहीं भेजा गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tidak terkirim tanpa enkripsi end-to-end" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Non inviato senza crittografia end-to-end" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "エンドツーエンド暗号化なしでは送信されませんでした" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "종단간 암호화 없이 전송되지 않았어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tidak dihantar tanpa penyulitan hujung ke hujung" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "एन्ड-टु-एन्ड इन्क्रिप्शनविना पठाइएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niet verzonden zonder end-to-end-versleuteling" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie wysłano bez szyfrowania end-to-end" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Não enviado sem encriptação de ponta a ponta" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Não enviado sem criptografia de ponta a ponta" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не отправлено без сквозного шифрования" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skickades inte utan totalsträckskryptering" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "எண்ட்-டு-எண்ட் என்க்ரிப்ஷன் இல்லாமல் அனுப்பப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่ได้ส่งเพราะไม่มีการเข้ารหัสแบบต้นทางถึงปลายทาง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uçtan uca şifreleme olmadan gönderilmedi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не надіслано без наскрізного шифрування" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اینڈ ٹو اینڈ انکرپشن کے بغیر نہیں بھیجا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chưa gửi vì không có mã hóa đầu cuối" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "缺少端到端加密,未发送" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "缺少端對端加密,未傳送" - } - } - } - }, - "content.delivery.reason.private_media_admission_expired" : { - "comment" : "Failure reason when private-media admission expires before fragment scheduling", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "انتهت مهلة نقل الوسائط قبل أن يبدأ" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মিডিয়া ট্রান্সফার শুরু হওয়ার আগেই সময় শেষ হয়ে গেছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zeitüberschreitung, bevor die Medienübertragung starten konnte" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Media transfer timed out before it could start" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "La transferencia multimedia expiró antes de poder empezar" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مهلت انتقال رسانه پیش از شروع به پایان رسید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nag-time out ang media transfer bago pa ito makapagsimula" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le transfert de média a expiré avant de pouvoir démarrer" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "תם הזמן הקצוב להעברת המדיה לפני שהספיקה להתחיל" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "मीडिया ट्रांसफ़र शुरू होने से पहले ही समय समाप्त हो गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Transfer media kehabisan waktu sebelum sempat dimulai" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Il trasferimento del media è scaduto prima di poter iniziare" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "メディア転送が開始前にタイムアウトしました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "미디어 전송이 시작되기 전에 시간이 초과됐어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pemindahan media tamat masa sebelum sempat bermula" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "मिडिया स्थानान्तरण सुरु हुनुअघि नै समय सकियो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "De mediaoverdracht is verlopen voordat die kon beginnen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Przekroczono limit czasu, zanim transfer multimediów mógł się rozpocząć" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "A transferência de multimédia expirou antes de poder começar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "A transferência de mídia expirou antes de poder começar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Время ожидания передачи медиа истекло до её начала" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tidsgränsen för medieöverföringen gick ut innan den kunde starta" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "மீடியா பரிமாற்றம் தொடங்குவதற்கு முன்பே நேரம் முடிந்துவிட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "การถ่ายโอนสื่อหมดเวลาก่อนที่จะได้เริ่ม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medya aktarımı başlayamadan zaman aşımına uğradı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Час очікування передачі медіа вичерпано ще до її початку" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "میڈیا کی منتقلی شروع ہونے سے پہلے ہی وقت ختم ہو گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quá trình truyền media đã hết thời gian chờ trước khi kịp bắt đầu" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "媒体传输在开始前就已超时" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "媒體傳輸在開始前就已逾時" - } - } - } - }, - "content.delivery.reason.private_media_admission_full" : { - "comment" : "Failure reason when too many private-media transfers are awaiting admission", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "هناك عمليات نقل وسائط كثيرة قيد الانتظار؛ حاول مجددًا بعد قليل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "অনেক বেশি মিডিয়া ট্রান্সফার অপেক্ষায় আছে; একটু পরে আবার চেষ্টা করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zu viele Medienübertragungen warten gerade; versuch es gleich noch mal" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Too many media transfers are waiting; try again shortly" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hay demasiadas transferencias multimedia en espera; inténtalo de nuevo en un momento" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "انتقال‌های رسانهٔ زیادی در انتظارند؛ کمی بعد دوباره تلاش کن" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Masyadong maraming media transfer ang naghihintay; subukan ulit mamaya" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trop de transferts de médias en attente ; réessaie dans un instant" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "יותר מדי העברות מדיה ממתינות; נסו שוב בעוד רגע" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "बहुत सारे मीडिया ट्रांसफ़र प्रतीक्षा में हैं; थोड़ी देर में फिर कोशिश करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Terlalu banyak transfer media yang menunggu; coba lagi sebentar lagi" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ci sono troppi trasferimenti di media in attesa; riprova tra poco" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "待機中のメディア転送が多すぎます。しばらくしてからもう一度お試しください" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "대기 중인 미디어 전송이 너무 많아요. 잠시 후 다시 시도해 주세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Terlalu banyak pemindahan media sedang menunggu; cuba lagi sebentar nanti" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "धेरै मिडिया स्थानान्तरणहरू पर्खिरहेका छन्; केही बेरपछि फेरि प्रयास गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Er wachten te veel mediaoverdrachten; probeer het zo weer" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zbyt wiele transferów multimediów czeka w kolejce; spróbuj ponownie za chwilę" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Há demasiadas transferências de multimédia em espera; tenta novamente daqui a pouco" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Há transferências de mídia demais aguardando; tente de novo em instantes" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Слишком много передач медиа в очереди; попробуй ещё раз чуть позже" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "För många medieöverföringar väntar; försök igen om en stund" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நிறைய மீடியா பரிமாற்றங்கள் காத்திருக்கின்றன; சிறிது நேரத்தில் மீண்டும் முயற்சிக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "มีการถ่ายโอนสื่อรอคิวมากเกินไป ลองใหม่อีกครั้งในอีกสักครู่" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bekleyen çok fazla medya aktarımı var; birazdan tekrar dene" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Забагато передач медіа в черзі; спробуй ще раз трохи згодом" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "بہت زیادہ میڈیا منتقلیاں انتظار میں ہیں؛ تھوڑی دیر بعد دوبارہ کوشش کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Có quá nhiều lượt truyền media đang chờ; thử lại sau chút nhé" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "等待中的媒体传输过多,请稍后再试" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "等待中的媒體傳輸過多,請稍後再試" - } - } - } - }, - "content.delivery.reason.private_media_downgrade_blocked" : { - "comment" : "Failure reason when a peer that previously supported encrypted media appears to downgrade", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الوسائط المشفّرة مطلوبة؛ اطلب من جهة الاتصال هذه التحديث" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এনক্রিপ্ট করা মিডিয়া দরকার; এই কন্টাক্টকে আপডেট করতে বলুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verschlüsselte Medien erforderlich; bitte diesen Kontakt um ein Update" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Encrypted media required; ask this contact to upgrade" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Se requiere multimedia cifrada; pide a este contacto que actualice" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رسانهٔ رمزگذاری‌شده لازم است؛ از این مخاطب بخواه به‌روزرسانی کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kailangan ng naka-encrypt na media; pakisabihan ang contact na ito na mag-update" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Médias chiffrés requis ; demande à ce contact de mettre à jour" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "נדרשת מדיה מוצפנת; יש לבקש מאיש הקשר הזה לעדכן" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "एन्क्रिप्टेड मीडिया ज़रूरी है; इस संपर्क से अपडेट करने के लिए कहें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Media terenkripsi diperlukan; minta kontak ini memperbarui aplikasinya" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sono richiesti media crittografati; chiedi a questo contatto di aggiornare" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "暗号化されたメディアが必要です。この連絡先にアップデートを頼んでください" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "암호화된 미디어가 필요해요. 이 연락처에게 업데이트를 요청해 보세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Media tersulit diperlukan; minta kenalan ini mengemas kini aplikasinya" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "इन्क्रिप्ट गरिएको मिडिया आवश्यक छ; यो सम्पर्कलाई अपडेट गर्न भन्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Versleutelde media vereist; vraag dit contact om te updaten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wymagane są zaszyfrowane multimedia; poproś ten kontakt o aktualizację" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "É necessária multimédia encriptada; pede a este contacto para atualizar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mídia criptografada é obrigatória; peça para este contato atualizar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Требуются зашифрованные медиа; попроси этот контакт обновить приложение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Krypterade media krävs; be den här kontakten att uppdatera" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "என்க்ரிப்ட் செய்யப்பட்ட மீடியா தேவை; இந்தத் தொடர்பைப் புதுப்பிக்கச் சொல்லுங்கள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ต้องใช้สื่อที่เข้ารหัส ลองขอให้ผู้ติดต่อรายนี้อัปเดตแอป" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Şifreli medya gerekli; bu kişiden güncellemesini iste" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Потрібні зашифровані медіа; попроси цей контакт оновити застосунок" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "انکرپٹڈ میڈیا درکار ہے؛ اس رابطے سے اپ ڈیٹ کرنے کو کہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cần media được mã hóa; hãy bảo liên hệ này cập nhật ứng dụng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "需要加密媒体;请让这位联系人升级客户端" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "需要加密媒體;請讓這位聯絡人升級用戶端" - } - } - } - }, - "content.delivery.reason.private_media_too_many_fragments" : { - "comment" : "Failure reason when private media exceeds the Android-compatible fragment limit", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الملف كبير جدًا على تطبيق جهة الاتصال هذه (أكثر من 256 جزءًا عبر الشبكة المتداخلة)" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই কন্টাক্টের ক্লায়েন্টের জন্য ফাইলটি খুব বড় (256টির বেশি মেশ ফ্র্যাগমেন্ট)" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Datei ist zu groß für den Client dieses Kontakts (mehr als 256 Mesh-Fragmente)" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "File is too large for this contact's client (more than 256 mesh fragments)" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "El archivo es demasiado grande para el cliente de este contacto (más de 256 fragmentos de malla)" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "فایل برای کلاینت این مخاطب خیلی بزرگ است (بیش از 256 قطعهٔ مش)" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Masyadong malaki ang file para sa client ng contact na ito (mahigit 256 mesh fragment)" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le fichier est trop volumineux pour le client de ce contact (plus de 256 fragments mesh)" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הקובץ גדול מדי עבור הקליינט של איש הקשר הזה (יותר מ-256 מקטעי mesh)" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "यह फ़ाइल इस संपर्क के क्लाइंट के लिए बहुत बड़ी है (256 से ज़्यादा मेश फ़्रैगमेंट)" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "File terlalu besar untuk klien kontak ini (lebih dari 256 fragmen mesh)" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Il file è troppo grande per il client di questo contatto (più di 256 frammenti mesh)" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "このファイルはこの連絡先のクライアントには大きすぎます(メッシュフラグメントが256個を超えています)" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 연락처의 클라이언트에는 파일이 너무 커요(메시 조각 256개 초과)" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fail terlalu besar untuk klien kenalan ini (lebih daripada 256 fragmen mesh)" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यो सम्पर्कको क्लाइन्टका लागि फाइल धेरै ठूलो छ (256 भन्दा बढी मेश खण्डहरू)" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Het bestand is te groot voor de client van dit contact (meer dan 256 mesh-fragmenten)" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plik jest za duży dla klienta tego kontaktu (ponad 256 fragmentów mesh)" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "O ficheiro é demasiado grande para o cliente deste contacto (mais de 256 fragmentos da malha)" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "O arquivo é grande demais para o cliente deste contato (mais de 256 fragmentos da malha)" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Файл слишком большой для клиента этого контакта (больше 256 фрагментов mesh-сети)" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Filen är för stor för den här kontaktens klient (fler än 256 mesh-fragment)" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இந்தத் தொடர்பின் கிளையண்டுக்கு இந்தக் கோப்பு மிகப் பெரியது (256-க்கும் மேற்பட்ட மெஷ் துண்டுகள்)" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไฟล์ใหญ่เกินไปสำหรับไคลเอ็นต์ของผู้ติดต่อรายนี้ (เกิน 256 ส่วนย่อยของ mesh)" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dosya bu kişinin istemcisi için çok büyük (256'dan fazla mesh parçası)" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Файл завеликий для клієнта цього контакту (понад 256 фрагментів mesh-мережі)" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہ فائل اس رابطے کے کلائنٹ کے لیے بہت بڑی ہے (256 سے زیادہ میش ٹکڑے)" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tệp quá lớn so với ứng dụng của liên hệ này (hơn 256 phân mảnh mesh)" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "文件太大,这位联系人的客户端无法接收(超过 256 个网状网络分片)" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "檔案太大,這位聯絡人的用戶端無法接收(超過 256 個網狀網路分段)" - } - } - } - }, - "content.private_media.legacy_warning.message" : { - "comment" : "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تطبيق %@ لا يعلن عن دعم الوسائط الخاصة المشفّرة. سيتم توقيع هذا الملف لكنه لن يُشفَّر تشفيرًا تامًا بين الطرفين، لذا يمكن لمرحّلات الشبكة المتداخلة رؤيته. هل تريد إرسال الملف على أي حال؟" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-এর ক্লায়েন্ট এনক্রিপ্ট করা প্রাইভেট মিডিয়া সমর্থনের কথা জানায় না। এই ফাইলটি সাইন করা হবে কিন্তু এন্ড-টু-এন্ড এনক্রিপ্ট হবে না, তাই মেশ রিলেগুলো এটি দেখতে পারবে। তবুও ফাইলটি পাঠাবেন?" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Der Client von %@ gibt keine Unterstützung für verschlüsselte private Medien an. Diese Datei wird signiert, aber nicht Ende-zu-Ende-verschlüsselt – Mesh-Relays können sie also sehen. Datei trotzdem senden?" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "El cliente de %@ no anuncia compatibilidad con multimedia privada cifrada. Este archivo se firmará, pero no se cifrará de extremo a extremo, así que los relés de la malla podrán verlo. ¿Enviar el archivo de todos modos?" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کلاینت %@ پشتیبانی از رسانهٔ خصوصی رمزگذاری‌شده را اعلام نمی‌کند. این فایل امضا می‌شود اما رمزگذاری سرتاسری نخواهد داشت، بنابراین رله‌های شبکهٔ مش می‌توانند آن را ببینند. با این حال فایل ارسال شود؟" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hindi ina-advertise ng client ni %@ ang naka-encrypt na pribadong media. Mapipirmahan ang file na ito pero hindi ito magiging end-to-end encrypted, kaya makikita ito ng mga mesh relay. Ipadala pa rin ang file na ito?" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le client de %@ n'annonce pas la prise en charge des médias privés chiffrés. Ce fichier sera signé mais pas chiffré de bout en bout : les relais du mesh pourront donc le voir. Envoyer quand même ce fichier ?" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הקליינט של %@ לא מציין תמיכה במדיה פרטית מוצפנת. הקובץ ייחתם אך לא יוצפן מקצה לקצה, כך שממסרי רשת ה-mesh יוכלו לראות אותו. לשלוח את הקובץ בכל זאת?" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ का क्लाइंट एन्क्रिप्टेड निजी मीडिया का समर्थन नहीं बताता। यह फ़ाइल साइन की जाएगी लेकिन एंड-टू-एंड एन्क्रिप्ट नहीं होगी, इसलिए मेश रिले इसे देख सकते हैं। फिर भी यह फ़ाइल भेजें?" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klien %@ tidak menyatakan dukungan untuk media privat terenkripsi. File ini akan ditandatangani tetapi tidak dienkripsi end-to-end, jadi relai mesh bisa melihatnya. Tetap kirim file ini?" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Il client di %@ non annuncia il supporto ai media privati crittografati. Questo file sarà firmato ma non crittografato end-to-end, quindi i relay della mesh potranno vederlo. Inviare comunque il file?" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@のクライアントは暗号化されたプライベートメディアへの対応を通知していません。このファイルは署名されますが、エンドツーエンド暗号化はされないため、メッシュ中継が内容を見ることができます。それでもこのファイルを送信しますか?" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@님의 클라이언트는 암호화된 개인 미디어 지원을 알리지 않아요. 이 파일은 서명은 되지만 종단간 암호화는 되지 않아 메시 릴레이가 내용을 볼 수 있어요. 그래도 이 파일을 보낼까요?" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klien %@ tidak menyatakan sokongan untuk media peribadi tersulit. Fail ini akan ditandatangani tetapi tidak disulitkan hujung ke hujung, jadi geganti mesh boleh melihatnya. Hantar fail ini juga?" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ को क्लाइन्टले इन्क्रिप्ट गरिएको निजी मिडियाको समर्थन जनाउँदैन। यो फाइल साइन गरिनेछ तर एन्ड-टु-एन्ड इन्क्रिप्ट हुनेछैन, त्यसैले मेश रिलेहरूले यसलाई देख्न सक्छन्। जे होस्, यो फाइल पठाउने हो?" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "De client van %@ geeft niet aan versleutelde privémedia te ondersteunen. Dit bestand wordt ondertekend maar niet end-to-end versleuteld, dus mesh-relays kunnen het zien. Bestand toch verzenden?" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klient %@ nie zgłasza obsługi zaszyfrowanych prywatnych multimediów. Ten plik zostanie podpisany, ale nie zaszyfrowany end-to-end, więc przekaźniki mesh będą mogły go zobaczyć. Mimo to wysłać plik?" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "O cliente de %@ não anuncia suporte para multimédia privada encriptada. Este ficheiro será assinado, mas não encriptado de ponta a ponta, pelo que os relays da malha podem vê-lo. Enviar o ficheiro mesmo assim?" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "O cliente de %@ não anuncia suporte a mídia privada criptografada. Este arquivo será assinado, mas não criptografado de ponta a ponta, então os relays da malha podem vê-lo. Enviar o arquivo mesmo assim?" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Клиент %@ не заявляет поддержку зашифрованных личных медиа. Файл будет подписан, но не защищён сквозным шифрованием, поэтому ретрансляторы mesh-сети смогут его увидеть. Всё равно отправить файл?" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@:s klient anger inte stöd för krypterade privata media. Filen signeras men totalsträckskrypteras inte, så mesh-reläer kan se den. Skicka filen ändå?" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-இன் கிளையண்ட் என்க்ரிப்ட் செய்யப்பட்ட தனிப்பட்ட மீடியாவை ஆதரிப்பதாக அறிவிக்கவில்லை. இந்தக் கோப்பு கையொப்பமிடப்படும், ஆனால் எண்ட்-டு-எண்ட் என்க்ரிப்ட் செய்யப்படாது; எனவே மெஷ் ரிலேக்கள் இதைப் பார்க்க முடியும். இருந்தாலும் இந்தக் கோப்பை அனுப்பவா?" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไคลเอ็นต์ของ %@ ไม่ได้แจ้งว่ารองรับสื่อส่วนตัวแบบเข้ารหัส ไฟล์นี้จะถูกเซ็นกำกับแต่ไม่ได้เข้ารหัสแบบต้นทางถึงปลายทาง รีเลย์ในเครือข่าย mesh จึงมองเห็นได้ ยังจะส่งไฟล์นี้อยู่ไหม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ adlı kişinin istemcisi şifreli özel medya desteği bildirmiyor. Bu dosya imzalanacak ama uçtan uca şifrelenmeyecek, yani mesh aktarıcıları dosyayı görebilir. Dosya yine de gönderilsin mi?" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Клієнт %@ не заявляє підтримку зашифрованих приватних медіа. Файл буде підписано, але не зашифровано наскрізно, тож ретранслятори mesh-мережі зможуть його бачити. Все одно надіслати файл?" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ کا کلائنٹ انکرپٹڈ نجی میڈیا کی حمایت ظاہر نہیں کرتا۔ یہ فائل سائن ہوگی لیکن اینڈ ٹو اینڈ انکرپٹ نہیں ہوگی، اس لیے میش ریلے اسے دیکھ سکتے ہیں۔ پھر بھی یہ فائل بھیجیں؟" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ứng dụng của %@ không báo hỗ trợ media riêng tư được mã hóa. Tệp này sẽ được ký nhưng không được mã hóa đầu cuối, nên các thiết bị chuyển tiếp trong mạng mesh có thể xem được. Vẫn gửi tệp này chứ?" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 的客户端未声明支持加密私密媒体。此文件会经过签名,但不会端到端加密,因此网状中继可以看到内容。仍要发送此文件吗?" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 的用戶端未聲明支援加密私人媒體。這個檔案會經過簽署,但不會端對端加密,因此網狀中繼可以看到內容。仍要傳送這個檔案嗎?" - } - } - } - }, - "content.private_media.legacy_warning.send" : { - "comment" : "Destructive confirmation action for one legacy clear private-media send", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إرسال ملف مكشوف" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "দৃশ্যমান ফাইল পাঠান" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "sichtbare datei senden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "send visible file" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "enviar archivo visible" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال فایل قابل‌مشاهده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "ipadala ang nakikitang file" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "envoyer le fichier visible" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שליחת קובץ גלוי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "दिखने वाली फ़ाइल भेजें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "kirim file terlihat" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "invia file visibile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "見えるファイルを送信" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "보이는 파일 보내기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "hantar fail boleh dilihat" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "देखिने फाइल पठाउनुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zichtbaar bestand verzenden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wyślij widoczny plik" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "enviar ficheiro visível" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "enviar arquivo visível" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "отправить видимый файл" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "skicka synlig fil" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பார்க்கக்கூடிய கோப்பை அனுப்பு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ส่งไฟล์แบบมองเห็นได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "görünür dosyayı gönder" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "надіслати видимий файл" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "دکھائی دینے والی فائل بھیجیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "gửi tệp xem được" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "发送可见文件" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "傳送可見檔案" - } - } - } - }, - "content.private_media.legacy_warning.title" : { - "comment" : "Title warning before sending private media to an older client in a clear signed envelope", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الإرسال بدون التشفير التام بين الطرفين؟" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এন্ড-টু-এন্ড এনক্রিপশন ছাড়া পাঠাবেন?" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ohne Ende-zu-Ende-Verschlüsselung senden?" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Send without end-to-end encryption?" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿Enviar sin cifrado de extremo a extremo?" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بدون رمزگذاری سرتاسری ارسال شود؟" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ipadala nang walang end-to-end encryption?" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Envoyer sans chiffrement de bout en bout ?" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "לשלוח ללא הצפנה מקצה לקצה?" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "एंड-टू-एंड एन्क्रिप्शन के बिना भेजें?" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kirim tanpa enkripsi end-to-end?" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inviare senza crittografia end-to-end?" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "エンドツーエンド暗号化なしで送信しますか?" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "종단간 암호화 없이 보낼까요?" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hantar tanpa penyulitan hujung ke hujung?" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "एन्ड-टु-एन्ड इन्क्रिप्शनविना पठाउने हो?" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verzenden zonder end-to-end-versleuteling?" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wysłać bez szyfrowania end-to-end?" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enviar sem encriptação de ponta a ponta?" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enviar sem criptografia de ponta a ponta?" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Отправить без сквозного шифрования?" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skicka utan totalsträckskryptering?" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "எண்ட்-டு-எண்ட் என்க்ரிப்ஷன் இல்லாமல் அனுப்பவா?" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ส่งโดยไม่มีการเข้ารหัสแบบต้นทางถึงปลายทางไหม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uçtan uca şifreleme olmadan gönderilsin mi?" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Надіслати без наскрізного шифрування?" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اینڈ ٹو اینڈ انکرپشن کے بغیر بھیجیں؟" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gửi mà không mã hóa đầu cuối?" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在没有端到端加密的情况下发送?" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "要在沒有端對端加密的情況下傳送嗎?" - } - } - } - }, - "content.accessibility.carrying_mail" : { - "comment" : "Accessibility label for the courier mail indicator", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تحمل %lld من الرسائل المختومة للأصدقاء" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বন্ধুদের জন্য %lldটি সিল করা মেসেজ বহন করা হচ্ছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Du trägst %lld versiegelte Nachrichten für Freunde" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Carrying %lld sealed messages for friends" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Llevas %lld mensajes sellados para amigos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال حمل %lld پیام مهروموم‌شده برای دوستان" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "May dalang %lld selyadong mensahe para sa mga kaibigan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu transportes %lld messages scellés pour des amis" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "נושא %lld הודעות חתומות עבור חברים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "दोस्तों के लिए %lld सीलबंद संदेश साथ ले जा रहे हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Membawa %lld pesan tersegel untuk teman-teman" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stai trasportando %lld messaggi sigillati per amici" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "友達のために%lld件の封印されたメッセージを運んでいます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "친구를 위해 봉인된 메시지 %lld개를 운반 중" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Membawa %lld mesej termeterai untuk rakan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "साथीहरूका लागि %lld सिलबन्दी सन्देश बोकिँदै" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je draagt %lld verzegelde berichten mee voor vrienden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Przenosisz %lld zapieczętowanych wiadomości dla znajomych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "A transportar %lld mensagens seladas para amigos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Levando %lld mensagens lacradas para amigos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вы несёте %lld запечатанных сообщений для друзей" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bär %lld förseglade meddelanden åt vänner" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நண்பர்களுக்காக %lld முத்திரையிடப்பட்ட செய்திகளை எடுத்துச் செல்கிறது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "กำลังถือข้อความปิดผนึก %lld ข้อความไว้ให้เพื่อน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Arkadaşlar için %lld mühürlü mesaj taşınıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Переноситься %lld запечатаних повідомлень для друзів" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "دوستوں کے لیے %lld مہربند پیغامات لے جا رہے ہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đang mang %lld tin nhắn niêm phong cho bạn bè" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在为朋友携带 %lld 条密封消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在為朋友攜帶 %lld 則密封訊息" - } - } - } - }, - "content.delivery.carried" : { - "comment" : "Delivery status description for messages handed to a courier for physical delivery", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يحملها صديق قد يلتقي به" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এমন এক বন্ধু বহন করছে, যার সাথে তার দেখা হতে পারে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wird von einem Freund getragen, der sie vielleicht trifft" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Carried by a friend who may meet them" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lo lleva un amigo que podría encontrarse con esa persona" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دوستی آن را حمل می‌کند که شاید او را ببیند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dala ng isang kaibigan na baka makasalubong sila" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Transporté par un ami qui pourrait croiser cette personne" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בדרך עם חבר שאולי יפגוש אותם" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "एक दोस्त इसे ले जा रहा है जो शायद उनसे मिले" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dibawa oleh teman yang mungkin akan bertemu dengannya" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trasportato da un amico che potrebbe incontrarli" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "相手に会うかもしれない友達が運んでいます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "상대를 만날 수도 있는 친구가 전달 중" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dibawa oleh rakan yang mungkin bertemu dengan mereka" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "उनीहरूलाई भेट्न सक्ने साथीले बोकिरहेका छन्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wordt meegenomen door een vriend die hen misschien tegenkomt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Przenosi ją znajomy, który może spotkać odbiorcę" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Transportada por um amigo que talvez encontre o destinatário" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Levada por um amigo que talvez encontre o destinatário" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Несёт друг, который может встретить получателя" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bärs av en vän som kanske träffar dem" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அவர்களைச் சந்திக்கக்கூடிய நண்பர் எடுத்துச் செல்கிறார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "มีเพื่อนที่อาจได้เจอพวกเขาช่วยถือไปให้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onlarla karşılaşabilecek bir arkadaş taşıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Несе друг, який може їх зустріти" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ایسا دوست لے جا رہا ہے جو ان سے مل سکتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Một người bạn có thể gặp họ đang mang hộ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "由可能遇见他们的朋友捎带" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "由可能遇見他們的朋友代為攜帶" - } - } - } - }, - "content.delivery.not_sent_yet" : { - "comment" : "Delivery status description for a message that has not entered any send pipeline", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لم تُرسل بعد" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এখনও পাঠানো হয়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Noch nicht gesendet" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Not sent yet" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aún no se ha enviado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هنوز ارسال نشده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hindi pa naipapadala" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pas encore envoyé" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "עדיין לא נשלחה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अभी भेजा नहीं गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Belum terkirim" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Non ancora inviato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "未送信" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "아직 전송되지 않음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Belum dihantar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अहिलेसम्म पठाइएको छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nog niet verzonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jeszcze nie wysłano" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ainda não enviada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ainda não enviada" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ещё не отправлено" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inte skickat än" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இன்னும் அனுப்பப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ยังไม่ได้ส่ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Henüz gönderilmedi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ще не надіслано" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ابھی نہیں بھیجا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chưa gửi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚未发送" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚未傳送" - } - } - } - }, - "content.delivery.reason.media_encoding_failed" : { - "comment" : "Failure reason when private media cannot be encoded", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعذّر تجهيز الوسائط" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মিডিয়া প্রস্তুত করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medien konnten nicht vorbereitet werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Failed to prepare media" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "No se pudo preparar el contenido multimedia" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "آماده‌سازی رسانه ناموفق بود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hindi naihanda ang media" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impossible de préparer le média" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הכנת המדיה נכשלה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "मीडिया तैयार नहीं हो सका" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gagal menyiapkan media" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impossibile preparare il contenuto multimediale" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "メディアの準備に失敗しました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "미디어를 준비하지 못했어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gagal menyediakan media" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "मिडिया तयार गर्न सकिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Voorbereiden van media mislukt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie udało się przygotować multimediów" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Falha ao preparar o conteúdo multimédia" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Falha ao preparar a mídia" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не удалось подготовить медиафайл" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kunde inte förbereda media" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "மீடியாவைத் தயார் செய்ய முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เตรียมสื่อไม่สำเร็จ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medya hazırlanamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не вдалося підготувати медіа" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "میڈیا تیار نہیں ہو سکا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Không thể chuẩn bị media" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法准备媒体" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法準備媒體" - } - } - } - }, - "content.delivery.reason.media_signing_failed" : { - "comment" : "Failure reason when a legacy private-media packet cannot be signed", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعذّر توثيق الوسائط" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মিডিয়া যাচাই করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medien konnten nicht authentifiziert werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Failed to authenticate media" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "No se pudo autenticar el contenido multimedia" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تأیید اصالت رسانه ناموفق بود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hindi na-authenticate ang media" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impossible d'authentifier le média" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אימות המדיה נכשל" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "मीडिया प्रमाणित नहीं हो सका" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gagal mengautentikasi media" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impossibile autenticare il contenuto multimediale" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "メディアの認証に失敗しました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "미디어를 인증하지 못했어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gagal mengesahkan media" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "मिडिया प्रमाणित गर्न सकिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verifiëren van media mislukt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie udało się uwierzytelnić multimediów" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Falha ao autenticar o conteúdo multimédia" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Falha ao autenticar a mídia" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не удалось подтвердить подлинность медиафайла" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kunde inte autentisera media" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "மீடியாவை உறுதிப்படுத்த முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ยืนยันความถูกต้องของสื่อไม่สำเร็จ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medya doğrulanamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не вдалося підтвердити справжність медіа" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "میڈیا کی تصدیق نہیں ہو سکی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Không thể xác thực media" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法验证媒体" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法驗證媒體" - } - } - } - }, - "content.header.carrying_mail" : { - "comment" : "Tooltip for the courier mail indicator", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تحمل رسائل مختومة لتوصيلها نيابةً عن الأصدقاء" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বন্ধুদের হয়ে পৌঁছে দেওয়ার জন্য সিল করা মেসেজ বহন করা হচ্ছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Du trägst versiegelte Nachrichten, um sie für Freunde zuzustellen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Carrying sealed messages for friends to deliver" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Llevas mensajes sellados de amigos para entregarlos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال حمل پیام‌های مهروموم‌شده برای تحویل به‌جای دوستان" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "May dalang mga selyadong mensahe na ihahatid para sa mga kaibigan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu transportes des messages scellés à remettre pour des amis" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "נושא הודעות חתומות למסירה עבור חברים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "दोस्तों की ओर से पहुँचाने के लिए सीलबंद संदेश साथ ले जा रहे हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Membawa pesan tersegel milik teman untuk diantarkan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stai trasportando messaggi sigillati da consegnare agli amici" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "友達に届けるために封印されたメッセージを運んでいます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "친구에게 전달할 봉인된 메시지를 운반 중" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Membawa mesej termeterai untuk dihantar kepada rakan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "साथीहरूलाई पुर्‍याउन सिलबन्दी सन्देशहरू बोकिँदै" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je draagt verzegelde berichten mee om voor vrienden te bezorgen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Przenosisz zapieczętowane wiadomości, by doręczyć je znajomym" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "A transportar mensagens seladas para entregar aos amigos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Levando mensagens lacradas para entregar aos amigos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вы несёте запечатанные сообщения для доставки друзьям" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bär förseglade meddelanden åt vänner för leverans" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நண்பர்களுக்காக முத்திரையிடப்பட்ட செய்திகளை வழங்க எடுத்துச் செல்கிறது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "กำลังถือข้อความปิดผนึกไว้เพื่อนำไปส่งให้เพื่อน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Arkadaşlara ulaştırılmak üzere mühürlü mesajlar taşınıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Переносяться запечатані повідомлення для доставки друзям" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "دوستوں تک پہنچانے کے لیے مہربند پیغامات ساتھ ہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đang mang tin nhắn niêm phong để chuyển cho bạn bè" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在携带要送给朋友的密封消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在攜帶要送給朋友的密封訊息" - } - } - } - }, - "content.banner.bluetooth_denied" : { - "comment" : "Persistent banner while Bluetooth permission is denied; tapping opens system settings", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم رفض الوصول إلى البلوتوث — الشبكة غير متصلة. انقر لفتح الإعدادات." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্লুটুথ অ্যাক্সেস অস্বীকৃত — মেশ অফলাইন। সেটিংস খুলতে ট্যাপ করুন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth-zugriff verweigert — mesh ist offline. tippen, um die einstellungen zu öffnen." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth access denied — mesh is offline. tap to open settings." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "acceso a bluetooth denegado — la malla está desconectada. toca para abrir ajustes." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دسترسی بلوتوث رد شد — مش آفلاین است. برای باز کردن تنظیمات بزنید." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "tinanggihan ang access sa bluetooth — offline ang mesh. i-tap para buksan ang settings." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "accès bluetooth refusé — le mesh est hors ligne. touchez pour ouvrir les réglages." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הגישה לבלוטות' נדחתה — הרשת לא מקוונת. הקישו לפתיחת ההגדרות." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ब्लूटूथ की अनुमति नहीं है — मेश ऑफ़लाइन है। सेटिंग्स खोलने के लिए टैप करें।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "akses bluetooth ditolak — mesh offline. ketuk untuk membuka pengaturan." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "accesso bluetooth negato — la mesh è offline. tocca per aprire le impostazioni." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth へのアクセスが拒否されています — メッシュはオフラインです。タップして設定を開きます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "블루투스 접근이 거부되었습니다 — 메시가 오프라인입니다. 탭하여 설정을 여세요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "akses bluetooth ditolak — mesh di luar talian. ketik untuk membuka tetapan." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "ब्लुटुथ पहुँच अस्वीकृत — मेश अफलाइन छ। सेटिङ खोल्न ट्याप गर्नुहोस्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth-toegang geweigerd — mesh is offline. tik om instellingen te openen." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "odmowa dostępu do bluetooth — sieć mesh jest offline. stuknij, aby otworzyć ustawienia." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "acesso ao bluetooth negado — a mesh está offline. toca para abrir as definições." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "acesso ao bluetooth negado — a mesh está offline. toque para abrir os ajustes." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "доступ к bluetooth запрещён — mesh-сеть недоступна. нажмите, чтобы открыть настройки." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth-åtkomst nekad — mesh är offline. tryck för att öppna inställningar." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "புளூடூத் அணுகல் மறுக்கப்பட்டது — மெஷ் ஆஃப்லைனில் உள்ளது. அமைப்புகளைத் திறக்கத் தட்டவும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "การเข้าถึงบลูทูธถูกปฏิเสธ — เมชออฟไลน์ แตะเพื่อเปิดการตั้งค่า" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth erişimi reddedildi — mesh çevrimdışı. ayarları açmak için dokunun." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "доступ до bluetooth заборонено — mesh-мережа офлайн. торкніться, щоб відкрити налаштування." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "بلوٹوتھ رسائی مسترد — میش آف لائن ہے۔ سیٹنگز کھولنے کے لیے ٹیپ کریں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "quyền truy cập bluetooth bị từ chối — mesh đang ngoại tuyến. chạm để mở cài đặt." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "蓝牙权限被拒绝 — 网状网络已离线。点按打开设置。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "藍牙權限遭拒 — 網狀網路已離線。點按開啟設定。" - } - } - } - }, - "content.banner.bluetooth_off" : { - "comment" : "Persistent banner while Bluetooth is switched off; tapping opens system settings", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "البلوتوث مطفأ — لا يمكن لمن حولك الوصول إليك. انقر للإصلاح." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্লুটুথ বন্ধ — কাছের কেউ আপনার সঙ্গে যোগাযোগ করতে পারবে না। ঠিক করতে ট্যাপ করুন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth ist aus — niemand in der nähe kann dich erreichen. zum beheben tippen." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth is off — nobody nearby can reach you. tap to fix." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth está apagado — nadie cerca puede contactarte. toca para arreglarlo." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بلوتوث خاموش است — هیچ‌کس در نزدیکی نمی‌تواند به شما برسد. برای رفع مشکل بزنید." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "naka-off ang bluetooth — walang makakaabot sa iyo na kalapit. i-tap para ayusin." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "le bluetooth est désactivé — personne à proximité ne peut vous joindre. touchez pour corriger." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הבלוטות' כבוי — אף אחד בסביבה לא יכול להגיע אליך. הקישו לתיקון." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ब्लूटूथ बंद है — आस-पास का कोई आप तक नहीं पहुँच सकता। ठीक करने के लिए टैप करें।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth mati — tidak ada orang di sekitar yang dapat menghubungi anda. ketuk untuk memperbaiki." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "il bluetooth è spento — nessuno nelle vicinanze può raggiungerti. tocca per risolvere." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth がオフです — 近くの人と通信できません。タップして設定へ。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "블루투스가 꺼져 있습니다 — 주변 누구와도 연결할 수 없습니다. 탭하여 해결하세요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth dimatikan — tiada sesiapa berdekatan boleh menghubungi anda. ketik untuk membaiki." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "ब्लुटुथ बन्द छ — नजिकको कोही तपाईंसम्म पुग्न सक्दैन। मिलाउन ट्याप गर्नुहोस्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth staat uit — niemand in de buurt kan je bereiken. tik om dit op te lossen." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth jest wyłączony — nikt w pobliżu nie może się z tobą połączyć. stuknij, aby naprawić." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "o bluetooth está desligado — ninguém por perto te consegue alcançar. toca para corrigir." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "o bluetooth está desligado — ninguém por perto consegue te alcançar. toque para corrigir." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth выключен — никто поблизости не может с вами связаться. нажмите, чтобы исправить." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth är av — ingen i närheten kan nå dig. tryck för att åtgärda." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "புளூடூத் அணைக்கப்பட்டுள்ளது — அருகிலுள்ள யாரும் உங்களை அடைய முடியாது. சரிசெய்யத் தட்டவும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "บลูทูธปิดอยู่ — คนใกล้เคียงติดต่อคุณไม่ได้ แตะเพื่อแก้ไข" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth kapalı — yakınınızdaki hiç kimse size ulaşamaz. düzeltmek için dokunun." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth вимкнено — ніхто поблизу не може з вами зв'язатися. торкніться, щоб виправити." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "بلوٹوتھ بند ہے — قریب کا کوئی آپ تک نہیں پہنچ سکتا۔ ٹھیک کرنے کے لیے ٹیپ کریں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "bluetooth đang tắt — không ai gần bạn có thể liên lạc với bạn. chạm để khắc phục." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "蓝牙已关闭 — 附近的人无法联系到你。点按以修复。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "藍牙已關閉 — 附近的人無法聯繫到你。點按以修復。" - } - } - } - }, - "content.banner.bluetooth_unsupported" : { - "comment" : "Persistent banner on devices without Bluetooth support", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا يوجد بلوتوث على هذا الجهاز — الشبكة غير متاحة. قنوات الموقع لا تزال تعمل عبر الإنترنت." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই ডিভাইসে ব্লুটুথ নেই — মেশ অনুপলব্ধ। লোকেশন চ্যানেল এখনও ইন্টারনেটে কাজ করে।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "kein bluetooth auf diesem gerät — mesh ist nicht verfügbar. standortkanäle funktionieren weiter übers internet." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no bluetooth on this device — mesh is unavailable. location channels still work over the internet." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "este dispositivo no tiene bluetooth — la malla no está disponible. los canales de ubicación siguen funcionando por internet." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این دستگاه بلوتوث ندارد — مش در دسترس نیست. کانال‌های مکان همچنان از طریق اینترنت کار می‌کنند." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "walang bluetooth ang device na ito — hindi available ang mesh. gumagana pa rin ang mga location channel sa internet." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "pas de bluetooth sur cet appareil — le mesh est indisponible. les canaux de localisation fonctionnent toujours via internet." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין בלוטות' במכשיר הזה — הרשת לא זמינה. ערוצי מיקום עדיין עובדים דרך האינטרנט." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "इस डिवाइस में ब्लूटूथ नहीं है — मेश अनुपलब्ध है। लोकेशन चैनल अब भी इंटरनेट पर काम करते हैं।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "perangkat ini tidak memiliki bluetooth — mesh tidak tersedia. kanal lokasi tetap berfungsi lewat internet." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "questo dispositivo non ha il bluetooth — la mesh non è disponibile. i canali di posizione funzionano comunque via internet." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "この端末に bluetooth がありません — メッシュは利用できません。ロケーションチャンネルはインターネット経由で利用できます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 기기에는 블루투스가 없습니다 — 메시를 사용할 수 없습니다. 위치 채널은 인터넷으로 계속 사용할 수 있습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tiada bluetooth pada peranti ini — mesh tidak tersedia. saluran lokasi masih berfungsi melalui internet." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यो यन्त्रमा ब्लुटुथ छैन — मेश उपलब्ध छैन। स्थान च्यानलहरू अझै इन्टरनेटमा चल्छन्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geen bluetooth op dit apparaat — mesh is niet beschikbaar. locatiekanalen werken nog via internet." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "brak bluetooth w tym urządzeniu — sieć mesh jest niedostępna. kanały lokalizacji nadal działają przez internet." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "este dispositivo não tem bluetooth — a mesh está indisponível. os canais de localização continuam a funcionar pela internet." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "este dispositivo não tem bluetooth — a mesh está indisponível. os canais de localização continuam funcionando pela internet." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "на этом устройстве нет bluetooth — mesh-сеть недоступна. каналы мест по-прежнему работают через интернет." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ingen bluetooth på den här enheten — mesh är inte tillgängligt. platskanaler fungerar fortfarande via internet." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இந்தச் சாதனத்தில் புளூடூத் இல்லை — மெஷ் கிடைக்காது. இருப்பிடச் சேனல்கள் இணையம் வழியாக இயங்கும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "อุปกรณ์นี้ไม่มีบลูทูธ — ใช้เมชไม่ได้ ช่องตำแหน่งยังใช้งานผ่านอินเทอร์เน็ตได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu cihazda bluetooth yok — mesh kullanılamıyor. konum kanalları internet üzerinden çalışmaya devam ediyor." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "на цьому пристрої немає bluetooth — mesh-мережа недоступна. канали місць далі працюють через інтернет." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اس ڈیوائس میں بلوٹوتھ نہیں — میش دستیاب نہیں۔ لوکیشن چینلز انٹرنیٹ پر بدستور کام کرتے ہیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "thiết bị này không có bluetooth — mesh không khả dụng. các kênh vị trí vẫn hoạt động qua internet." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此设备没有蓝牙 — 网状网络不可用。位置频道仍可通过互联网使用。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "此裝置沒有藍牙 — 網狀網路無法使用。位置頻道仍可透過網際網路使用。" - } - } - } - }, - "content.banner.tor_blocked" : { - "comment" : "Persistent banner while Tor bootstrap has stalled, likely because the network blocks it", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يتعذر على tor الاتصال — ميزات الإنترنت متوقفة مؤقتًا. الشبكة لا تزال تعمل." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor সংযোগ করতে পারছে না — ইন্টারনেট ফিচার স্থগিত। মেশ এখনও কাজ করছে।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor kann sich nicht verbinden — internetfunktionen pausieren. mesh funktioniert weiter." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor can't connect — internet features are paused. mesh still works." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor no puede conectarse — las funciones de internet están en pausa. la malla sigue funcionando." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor نمی‌تواند متصل شود — قابلیت‌های اینترنتی متوقف‌اند. مش همچنان کار می‌کند." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi makakonekta ang tor — naka-pause ang mga internet feature. gumagana pa rin ang mesh." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor ne peut pas se connecter — les fonctions internet sont en pause. le mesh fonctionne toujours." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor לא מצליח להתחבר — תכונות האינטרנט מושהות. הרשת עדיין עובדת." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor कनेक्ट नहीं हो पा रहा — इंटरनेट सुविधाएँ रुकी हैं। मेश अब भी काम करता है।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor tidak dapat terhubung — fitur internet dijeda. mesh tetap berfungsi." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor non riesce a connettersi — le funzioni internet sono in pausa. la mesh continua a funzionare." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor が接続できません — インターネット機能は一時停止中です。メッシュは引き続き使えます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor가 연결되지 않습니다 — 인터넷 기능이 일시 중지되었습니다. 메시는 계속 작동합니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor tidak dapat bersambung — ciri internet dijeda. mesh masih berfungsi." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor जडान हुन सकेन — इन्टरनेट सुविधाहरू रोकिएका छन्। मेश अझै चल्छ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor kan geen verbinding maken — internetfuncties zijn gepauzeerd. mesh werkt nog." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor nie może się połączyć — funkcje internetowe są wstrzymane. sieć mesh nadal działa." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "o tor não consegue ligar-se — as funções de internet estão em pausa. a mesh continua a funcionar." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "o tor não consegue se conectar — os recursos de internet estão pausados. a mesh continua funcionando." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor не может подключиться — интернет-функции приостановлены. mesh-сеть продолжает работать." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor kan inte ansluta — internetfunktioner är pausade. mesh fungerar fortfarande." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor இணைக்க முடியவில்லை — இணைய அம்சங்கள் இடைநிறுத்தப்பட்டுள்ளன. மெஷ் தொடர்ந்து இயங்குகிறது." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor เชื่อมต่อไม่ได้ — ฟีเจอร์อินเทอร์เน็ตหยุดชั่วคราว เมชยังใช้งานได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor bağlanamıyor — internet özellikleri duraklatıldı. mesh çalışmaya devam ediyor." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor не може під'єднатися — інтернет-функції призупинено. mesh-мережа далі працює." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor منسلک نہیں ہو پا رہا — انٹرنیٹ فیچرز رکے ہوئے ہیں۔ میش بدستور کام کرتا ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor không thể kết nối — các tính năng internet tạm dừng. mesh vẫn hoạt động." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor 无法连接 — 互联网功能已暂停。网状网络仍可使用。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor 無法連線 — 網際網路功能已暫停。網狀網路仍可使用。" - } - } - } - }, - "content.banner.panic_blocked" : { - "comment" : "Banner shown when a panic wipe did not fully commit; relaunching the app retries the wipe", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "المسح غير مكتمل — قد تبقى بعض البيانات. أغلق bitchat وأعد فتحه لإعادة المحاولة." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মুছে ফেলা অসম্পূর্ণ — কিছু ডেটা থেকে যেতে পারে। আবার চেষ্টা করতে bitchat বন্ধ করে আবার খুলুন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "löschen unvollständig — einige daten könnten übrig sein. bitchat beenden und erneut öffnen, um es noch einmal zu versuchen." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "wipe incomplete — some data may remain. quit and reopen bitchat to retry." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "borrado incompleto — pueden quedar algunos datos. cierra y vuelve a abrir bitchat para reintentar." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پاک‌سازی ناقص است — ممکن است برخی داده‌ها باقی مانده باشند. برای تلاش دوباره bitchat را ببندید و دوباره باز کنید." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi kumpleto ang pagbura — maaaring may natirang data. isara at buksan muli ang bitchat para subukan ulit." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "effacement incomplet — des données peuvent subsister. quittez et rouvrez bitchat pour réessayer." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "המחיקה לא הושלמה — ייתכן שנשארו נתונים. סגרו ופתחו מחדש את bitchat כדי לנסות שוב." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "मिटाना अधूरा रहा — कुछ डेटा बच सकता है। फिर से कोशिश करने के लिए bitchat बंद करके दोबारा खोलें।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "penghapusan belum selesai — sebagian data mungkin tersisa. tutup dan buka kembali bitchat untuk mencoba lagi." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "cancellazione incompleta — alcuni dati potrebbero rimanere. chiudi e riapri bitchat per riprovare." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "消去が完了していません — データが残っている可能性があります。bitchat を終了して開き直すと再試行します。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "삭제가 완료되지 않았습니다 — 일부 데이터가 남아 있을 수 있습니다. bitchat을 종료했다가 다시 열면 재시도합니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "pemadaman tidak lengkap — sebahagian data mungkin masih ada. tutup dan buka semula bitchat untuk mencuba lagi." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "मेटाउने काम अधूरो — केही डेटा बाँकी रहन सक्छ। पुनः प्रयास गर्न bitchat बन्द गरेर फेरि खोल्नुहोस्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wissen onvolledig — er kunnen gegevens achterblijven. sluit bitchat en open het opnieuw om het nogmaals te proberen." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wymazywanie niekompletne — część danych może pozostać. zamknij i otwórz bitchat ponownie, aby spróbować jeszcze raz." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "limpeza incompleta — alguns dados podem permanecer. feche e reabra o bitchat para tentar novamente." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "limpeza incompleta — alguns dados podem permanecer. feche e reabra o bitchat para tentar novamente." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "стирание не завершено — часть данных могла остаться. закройте и снова откройте bitchat, чтобы повторить." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "raderingen är ofullständig — viss data kan finnas kvar. avsluta och öppna bitchat igen för att försöka på nytt." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அழித்தல் முழுமையடையவில்லை — சில தரவு எஞ்சியிருக்கலாம். மீண்டும் முயற்சிக்க bitchat ஐ மூடி மீண்டும் திறக்கவும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "การล้างข้อมูลไม่สมบูรณ์ — อาจมีข้อมูลหลงเหลืออยู่ ปิดแล้วเปิด bitchat ใหม่เพื่อลองอีกครั้ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "silme tamamlanmadı — bazı veriler kalmış olabilir. yeniden denemek için bitchat'i kapatıp tekrar açın." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "стирання не завершено — частина даних могла залишитися. закрийте та знову відкрийте bitchat, щоб повторити." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "مٹانا مکمل نہیں ہوا — کچھ ڈیٹا باقی رہ سکتا ہے۔ دوبارہ کوشش کے لیے bitchat بند کر کے دوبارہ کھولیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "xoá chưa hoàn tất — một số dữ liệu có thể còn lại. thoát và mở lại bitchat để thử lại." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "清除未完成 — 可能仍有数据残留。请退出并重新打开 bitchat 重试。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "清除未完成 — 可能仍有資料殘留。請結束並重新開啟 bitchat 重試。" - } - } - } - }, - "content.system.media_delete_refused" : { - "comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept", - "extractionState" : "manual", - "localizations" : { - "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذّر حذف بعض الوسائط. جرّب حذف الوسائط الأقدم أولاً." } }, - "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "কিছু মিডিয়া মুছে ফেলা যায়নি। আগে পুরোনো মিডিয়া মুছে ফেলার চেষ্টা করুন।" } }, - "de" : { "stringUnit" : { "state" : "needs_review", "value" : "einige medien konnten nicht gelöscht werden. versuche zuerst, ältere medien zu löschen." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "some media could not be deleted. try deleting older media first." } }, - "es" : { "stringUnit" : { "state" : "needs_review", "value" : "no se pudieron eliminar algunos archivos multimedia. prueba a eliminar primero los más antiguos." } }, - "fa" : { "stringUnit" : { "state" : "needs_review", "value" : "برخی رسانه‌ها حذف نشدند. ابتدا رسانه‌های قدیمی‌تر را حذف کنید." } }, - "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "hindi ma-delete ang ilang media. subukang i-delete muna ang mas lumang media." } }, - "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "impossible de supprimer certains médias. essaie d'abord de supprimer les médias plus anciens." } }, - "he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן למחוק חלק מהמדיה. נסה קודם למחוק מדיה ישנה יותר." } }, - "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "कुछ मीडिया हटाई नहीं जा सकी। पहले पुरानी मीडिया हटाने का प्रयास करें।" } }, - "id" : { "stringUnit" : { "state" : "needs_review", "value" : "sebagian media tidak dapat dihapus. coba hapus media yang lebih lama dulu." } }, - "it" : { "stringUnit" : { "state" : "needs_review", "value" : "impossibile eliminare alcuni contenuti multimediali. prova prima a eliminare quelli più vecchi." } }, - "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "一部のメディアを削除できませんでした。先に古いメディアを削除してみてください。" } }, - "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "일부 미디어를 삭제하지 못했습니다. 먼저 오래된 미디어를 삭제해 보세요." } }, - "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "sesetengah media tidak dapat dipadamkan. cuba padamkan media yang lebih lama dahulu." } }, - "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "केही मिडिया मेटाउन सकिएन। पहिले पुराना मिडिया मेटाउने प्रयास गर।" } }, - "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "sommige media konden niet worden verwijderd. probeer eerst oudere media te verwijderen." } }, - "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "nie udało się usunąć części multimediów. spróbuj najpierw usunąć starsze multimedia." } }, - "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "não foi possível eliminar alguns ficheiros multimédia. tenta eliminar primeiro os mais antigos." } }, - "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "não foi possível excluir algumas mídias. tente excluir primeiro as mídias mais antigas." } }, - "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "не удалось удалить часть медиафайлов. попробуй сначала удалить более старые." } }, - "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "vissa medier kunde inte raderas. prova att radera äldre medier först." } }, - "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "சில ஊடகங்களை நீக்க முடியவில்லை. முதலில் பழைய ஊடகங்களை நீக்க முயற்சிக்கவும்." } }, - "th" : { "stringUnit" : { "state" : "needs_review", "value" : "ไม่สามารถลบสื่อบางรายการได้ ลองลบสื่อที่เก่ากว่าก่อน" } }, - "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bazı medya silinemedi. önce daha eski medyayı silmeyi dene." } }, - "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "не вдалося видалити частину медіафайлів. спробуй спочатку видалити старіші." } }, - "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "کچھ میڈیا حذف نہیں ہو سکا۔ پہلے پرانا میڈیا حذف کرنے کی کوشش کریں۔" } }, - "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "không thể xóa một số phương tiện. hãy thử xóa phương tiện cũ hơn trước." } }, - "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "部分媒体无法删除。请先尝试删除较早的媒体。" } }, - "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "部分媒體無法刪除。請先嘗試刪除較舊的媒體。" } } - } - }, - "notification.action.wave" : { - "comment" : "Title of the notification action button that sends a friendly wave back to a nearby person", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لوّح 👋" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "হাত নাড়ুন 👋" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "winken 👋" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "wave 👋" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "saludar 👋" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دست تکان دادن 👋" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "kumaway 👋" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "saluer 👋" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "לנופף 👋" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "हाथ हिलाएँ 👋" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "melambai 👋" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "saluta 👋" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "手を振る 👋" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "손 흔들기 👋" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "lambai 👋" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "हात हल्लाउनुहोस् 👋" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zwaaien 👋" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "pomachaj 👋" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "acenar 👋" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "acenar 👋" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "помахать 👋" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "vinka 👋" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "கை அசைக்கவும் 👋" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "โบกมือ 👋" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "el salla 👋" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "помахати 👋" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ہاتھ ہلائیں 👋" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "vẫy tay 👋" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "挥手 👋" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "揮手 👋" - } - } - } - }, - "#%@" : { - "comment" : "Non-localizable channel hashtag format used in code", - "extractionState" : "manual", - "shouldTranslate" : false, - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@" - } - } - } - }, "%@" : { "comment" : "Non-localizable symbol used in code", "extractionState" : "manual", @@ -19242,12 +35,6 @@ "value" : "%@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -19397,7 +184,6 @@ }, "%@ active" : { "comment" : "A label at the bottom of the people list sheet showing the number of active users.", - "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -19429,12 +215,6 @@ "value" : "%@ activos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ فعال" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -19614,12 +394,6 @@ "value" : "bitchat" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchat" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -19766,557 +540,181 @@ } } }, - "app_info.appearance.liquid_glass" : { + "app_info.close" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "زجاج سائل" + "value" : "إغلاق" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "লিকুইড গ্লাস" + "value" : "বন্ধ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "schließen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "close" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "شیشه مایع" + "value" : "cerrar" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "isara" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "fermer" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "זכוכית נוזלית" + "value" : "סגור" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "लिक्विड ग्लास" + "value" : "बंद करें" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "tutup" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "chiudi" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "リキッドガラス" + "value" : "閉じる" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "리퀴드 글래스" + "value" : "닫기" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "tutup" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "लिक्विड ग्लास" + "value" : "बन्द" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "sluiten" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "zamknij" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "fechar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "fechar" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "жидкое стекло" + "value" : "закрыть" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "stäng" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "லிக்விட் கிளாஸ்" + "value" : "மூடு" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ลิควิดกลาส" + "value" : "ปิด" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "kapat" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "рідке скло" + "value" : "закрити" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "لیکویڈ گلاس" + "value" : "بند کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "liquid glass" + "value" : "đóng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "液态玻璃" + "value" : "关闭" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "液態玻璃" - } - } - } - }, - "app_info.appearance.matrix" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ماتريكس" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ম্যাট্রিক্স" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ماتریکس" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מטריקס" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "मैट्रिक्स" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "マトリックス" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "매트릭스" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "म्याट्रिक्स" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "матрица" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "மேட்ரிக்ஸ்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เมทริกซ์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "матриця" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "میٹرکس" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "matrix" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "矩阵" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "矩陣" - } - } - } - }, - "app_info.appearance.title" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "المظهر" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "চেহারা" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "ERSCHEINUNGSBILD" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "APPEARANCE" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "APARIENCIA" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ظاهر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "HITSURA" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "APPARENCE" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מראה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "दिखावट" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "TAMPILAN" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "ASPETTO" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "外観" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "화면 모드" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "PENAMPILAN" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "रूप" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "WEERGAVE" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "WYGLĄD" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "APARÊNCIA" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "APARÊNCIA" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ОФОРМЛЕНИЕ" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "UTSEENDE" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "தோற்றம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ลักษณะที่ปรากฏ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "GÖRÜNÜM" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "ОФОРМЛЕННЯ" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ظاہری شکل" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "GIAO DIỆN" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "外观" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "外觀" + "value" : "關閉" } } } @@ -20354,12 +752,6 @@ "value" : "LISTO" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تمام" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -20506,376 +898,6 @@ } } }, - "app_info.features.bridge.description" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يربط جزر mesh القريبة عبر الإنترنت حتى لا ينقسم الحشد الواحد بسبب مدى الراديو" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "কাছাকাছি মেশ দ্বীপগুলোকে ইন্টারনেটের মাধ্যমে যুক্ত করে, যাতে রেডিও পরিসরের কারণে একই ভিড় ভাগ না হয়" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbindet nahe mesh-inseln über das internet, damit eine menge nicht durch die funkreichweite geteilt wird" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "links nearby mesh islands through the internet so one crowd isn't split by radio range" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "une islas mesh cercanas a través de internet para que una multitud no quede dividida por el alcance de radio" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "جزیره‌های مش اطراف را از طریق اینترنت به هم وصل می‌کند تا یک جمع به‌خاطر برد رادیویی از هم جدا نماند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "iniuugnay ang mga kalapit na mesh island sa pamamagitan ng internet para hindi mahati ng saklaw ng radyo ang iisang grupo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "relie les îlots mesh voisins via internet pour qu'une même foule ne soit pas coupée par la portée radio" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מקשר איי mesh קרובים דרך האינטרנט כדי שקהל אחד לא יתפצל בגלל טווח הרדיו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आसपास के मेश द्वीपों को इंटरनेट से जोड़ता है ताकि एक ही भीड़ रेडियो दायरे से बँट न जाए" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "menghubungkan pulau-pulau mesh terdekat lewat internet agar satu kerumunan tidak terbelah oleh jangkauan radio" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "collega le isole mesh vicine tramite internet così una folla non viene divisa dalla portata radio" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "近くのmeshの島々をインターネットでつなぎ、電波範囲で人の輪が分断されないようにします" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "근처의 mesh 섬들을 인터넷으로 연결해 한 무리가 전파 범위 때문에 갈라지지 않게 합니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "menghubungkan pulau-pulau mesh berdekatan melalui internet supaya satu kumpulan tidak terpisah oleh jangkauan radio" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "नजिकका mesh टापुहरूलाई इन्टरनेटमार्फत जोड्छ ताकि एउटै भीड रेडियो पहुँचले नबाँडियोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbindt nabije mesh-eilanden via internet zodat één groep niet door radiobereik wordt gesplitst" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "łączy pobliskie wyspy mesh przez internet, aby jeden tłum nie był dzielony przez zasięg radiowy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "liga ilhas mesh próximas através da internet para que uma multidão não fique dividida pelo alcance de rádio" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "liga ilhas mesh próximas pela internet para que uma multidão não fique dividida pelo alcance de rádio" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "связывает соседние mesh-острова через интернет, чтобы одна толпа не делилась радиусом радиосвязи" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "länkar närliggande mesh-öar via internet så att en folkmassa inte delas av radioräckvidden" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அருகிலுள்ள mesh தீவுகளை இணையம் வழியாக இணைக்கிறது, ஒரே கூட்டம் ரேடியோ வரம்பால் பிரியாமல் இருக்க" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เชื่อมเกาะ mesh ที่อยู่ใกล้กันผ่านอินเทอร์เน็ต เพื่อให้ฝูงชนเดียวกันไม่ถูกแบ่งด้วยระยะวิทยุ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "yakındaki mesh adalarını internet üzerinden birbirine bağlar, böylece bir kalabalık telsiz menziliyle bölünmez" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "з'єднує сусідні mesh-острови через інтернет, щоб один натовп не ділився радіусом радіозв'язку" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "قریبی mesh جزیروں کو انٹرنیٹ کے ذریعے جوڑتا ہے تاکہ ایک ہجوم ریڈیو رینج سے تقسیم نہ ہو" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "liên kết các đảo mesh gần nhau qua internet để một đám đông không bị chia cắt bởi phạm vi sóng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "通过互联网连接附近的 mesh 孤岛,让同一群人不被无线电范围隔开" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "透過網際網路連接附近的 mesh 孤島,讓同一群人不被無線電範圍隔開" - } - } - } - }, - "app_info.features.bridge.title" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "جسور mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মেশ ব্রিজিং" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh-bridging" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh bridging" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "puentes mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پل‌زنی مش" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh bridging" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "pontage mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "גישור mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "मेश ब्रिजिंग" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "jembatan mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "bridging mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "meshブリッジング" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh 브리징" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "jambatan mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh ब्रिजिङ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh-bridging" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "mostkowanie mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "pontes mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "pontes mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh-мосты" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh-bryggning" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh பாலம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "การบริดจ์ mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh köprüleme" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh-мости" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh پل" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cầu nối mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh 桥接" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh 橋接" - } - } - } - }, "app_info.features.encryption.description" : { "extractionState" : "manual", "localizations" : { @@ -20909,12 +931,6 @@ "value" : "mensajes privados cifrados con el protocolo Noise" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام‌های خصوصی با پروتکل noise رمزنگاری می‌شوند" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -21094,12 +1110,6 @@ "value" : "cifrado de extremo a extremo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری سرتاسری" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -21279,12 +1289,6 @@ "value" : "los mensajes se retransmiten entre pares y llegan lejos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام‌ها از طریق همتاها بازپخش می‌شوند و مسافت را طی می‌کنند" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -21464,12 +1468,6 @@ "value" : "alcance ampliado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برد گسترده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -21649,12 +1647,6 @@ "value" : "recibe avisos cuando tus personas favoritas se conecten" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "وقتی افراد موردعلاقه‌ات می‌پیوندند باخبر شو" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -21834,12 +1826,6 @@ "value" : "favoritos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "موردعلاقه‌ها" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -22019,12 +2005,6 @@ "value" : "canales geohash para chatear con personas en regiones cercanas a través de relays descentralizados anónimos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کانال‌های ژئوهش برای گپ با افراد مناطق نزدیک از طریق رله‌های ناشناس غیرمتمرکز" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -22204,12 +2184,6 @@ "value" : "canales locales" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کانال‌های محلی" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -22389,12 +2363,6 @@ "value" : "usa @nickname para avisar a personas concretas" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای باخبر کردن افراد خاص از @نام‌مستعار استفاده کن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -22574,12 +2542,6 @@ "value" : "menciones" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "منشن‌ها" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -22759,12 +2721,6 @@ "value" : "funciona sin internet utilizando Bluetooth de bajo consumo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بدون اینترنت با بلوتوث کم‌مصرف کار می‌کند" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -22944,12 +2900,6 @@ "value" : "comunicación sin conexión" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارتباط آفلاین" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -23129,12 +3079,6 @@ "value" : "FUNCIONES" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "امکانات" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -23314,12 +3258,6 @@ "value" : "• toca #mesh para cambiar de canal" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "• برای تغییر کانال روی #mesh بزن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -23499,12 +3437,6 @@ "value" : "• toca tres veces el chat para limpiarlo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "• برای پاک کردن، سه بار روی چت بزن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -23684,12 +3616,6 @@ "value" : "• escribe / para ver los comandos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "• برای دستورها / را تایپ کن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -23842,181 +3768,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "• اضغط أيقونة الأشخاص لفتح القائمة" + "value" : "• اضغط أيقونة الأشخاص لفتح الشريط الجانبي" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "• তালিকা খুলতে মানুষ আইকনে ট্যাপ করুন" + "value" : "• সাইডবার খুলতে মানুষ আইকনে ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "• tippe auf das personen-icon für die liste" + "value" : "• tippe auf das personen-icon, um die seitenleiste zu öffnen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "• tap people icon for list" + "value" : "• tap people icon for sidebar" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "• toca el ícono de personas para ver la lista" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "• برای فهرست روی آیکون افراد بزن" + "value" : "• toca el ícono de personas para abrir la barra lateral" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "• i-tap ang icon ng tao para sa listahan" + "value" : "• i-tap ang icon ng tao para buksan ang sidebar" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "• tape sur l'icône personnes pour la liste" + "value" : "• tape sur l'icône personnes pour ouvrir la barre latérale" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "• הקש על אייקון האנשים כדי לפתוח את הרשימה" + "value" : "• הקש על אייקון האנשים כדי לפתוח סרגל צד" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "• सूची खोलने के लिए लोगों वाले आइकन पर टैप करें" + "value" : "• साइडबार खोलने के लिए लोगों वाले आइकन पर टैप करें" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "• ketuk ikon orang untuk membuka daftar" + "value" : "• ketuk ikon orang untuk membuka sidebar" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "• tocca l'icona persone per aprire la lista" + "value" : "• tocca l'icona persone per aprire la barra laterale" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "• 人アイコンをタップして一覧を開く" + "value" : "• 人アイコンをタップしてサイドバーを開く" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "• 사람 아이콘을 탭하여 목록을 엽니다" + "value" : "• 사람 아이콘을 탭하여 사이드바를 엽니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "• ketuk ikon orang untuk membuka senarai" + "value" : "• ketuk ikon orang untuk membuka sidebar" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "• सूची खोल्न मान्छे आइकन ट्याप गर" + "value" : "• साइडबार खोल्न मान्छे आइकन ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "• tik op het personen-icoon voor de lijst" + "value" : "• tik op het personen-icoon om de zijbalk te openen" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "• stuknij ikonę osób, aby otworzyć listę" + "value" : "• stuknij ikonę osób, aby otworzyć panel boczny" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "• toca no ícone das pessoas para abrir a lista" + "value" : "• toca no ícone das pessoas para abrir a barra lateral" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "• toque o ícone de pessoas para abrir a lista" + "value" : "• toque o ícone de pessoas para abrir a barra lateral" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "• нажми на иконку людей, чтобы открыть список" + "value" : "• нажми на иконку людей, чтобы открыть боковое меню" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "• tryck på personikonen för att öppna listan" + "value" : "• tryck på personikonen för att öppna sidomenyn" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "• பட்டியலைத் திறக்க மனிதர் சின்னத்தைத் தட்டுங்கள்" + "value" : "• பக்கப்பட்டியைத் திறக்க மனிதர் சின்னத்தைத் தட்டுங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "• แตะไอคอนคนเพื่อเปิดรายชื่อ" + "value" : "• แตะไอคอนคนเพื่อเปิดแถบด้านข้าง" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "• listeyi açmak için insan simgesine dokunun" + "value" : "• kenar çubuğunu açmak için insan simgesine dokunun" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "• торкни піктограму людей, щоб відкрити список" + "value" : "• торкни піктограму людей, щоб відкрити бічну панель" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "• فہرست کھولنے کیلئے لوگوں کا آئیکن ٹیپ کریں" + "value" : "• سائیڈ بار کھولنے کیلئے لوگوں کا آئیکن ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "• chạm biểu tượng người để mở danh sách" + "value" : "• chạm biểu tượng người để mở thanh bên" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "• 轻点人物图标打开列表" + "value" : "• 轻点人物图标打开侧栏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "• 輕點人物圖示打開列表" + "value" : "• 輕點人物圖示打開側欄" } } } @@ -24054,12 +3974,6 @@ "value" : "• define tu apodo tocándolo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "• با زدن روی نام مستعارت آن را تنظیم کن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -24212,181 +4126,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "• اضغط اسم شخص لبدء رسائل خاصة" + "value" : "• اضغط اسم القرين لبدء رسائل خاصة" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "• ডিএম শুরু করতে কোনো ব্যক্তির নাম ট্যাপ করুন" + "value" : "• ডিএম শুরু করতে পিয়ারের নাম ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "• tippe auf den namen einer person, um eine pn zu starten" + "value" : "• tippe auf den namen eines peers, um eine pn zu starten" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "• tap a person's name to start a DM" + "value" : "• tap a peer's name to start a DM" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "• toca el nombre de una persona para iniciar un MD" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "• برای شروع پیام خصوصی روی نام فرد بزن" + "value" : "• toca el nombre de un participante para iniciar un MD" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "• i-tap ang pangalan ng isang tao para magsimula ng DM" + "value" : "• i-tap ang pangalan ng peer para magsimula ng DM" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "• tape sur le nom d'une personne pour démarrer un mp" + "value" : "• tape sur le nom d'un pair pour démarrer un mp" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "• הקש על שם של אדם כדי להתחיל הודעה פרטית" + "value" : "• הקש על שם עמית כדי להתחיל הודעה פרטית" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "• किसी व्यक्ति का नाम टैप करके DM शुरू करें" + "value" : "• किसी पीयर का नाम टैप करके DM शुरू करें" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "• ketuk nama seseorang untuk mulai dm" + "value" : "• ketuk nama peer untuk mulai dm" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "• tocca il nome di una persona per avviare un dm" + "value" : "• tocca il nome di un peer per avviare un dm" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "• 人の名前をタップしてdm開始" + "value" : "• ピアの名前をタップしてdm開始" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "• 상대방의 이름을 탭하여 DM을 시작합니다" + "value" : "• 피어의 이름을 탭하여 DM을 시작합니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "• ketuk nama seseorang untuk mulai dm" + "value" : "• ketuk nama peer untuk mulai dm" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "• dm सुरु गर्न कुनै व्यक्तिको नाम ट्याप गर" + "value" : "• dm सुरु गर्न कुनै सहकर्मीको नाम ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "• tik op de naam van een persoon om een DM te starten" + "value" : "• tik op de naam van een peer om een DM te starten" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "• stuknij czyjeś imię, aby rozpocząć DM" + "value" : "• stuknij nazwę peera, aby rozpocząć DM" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "• toca no nome de uma pessoa para iniciar um DM" + "value" : "• toca no nome de um par para iniciar um DM" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "• toque o nome de uma pessoa para iniciar um dm" + "value" : "• toque o nome de um par para iniciar um dm" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "• нажми имя человека, чтобы начать лс" + "value" : "• нажми имя пользователя, чтобы начать лс" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "• tryck på en persons namn för att starta ett DM" + "value" : "• tryck på en peers namn för att starta ett DM" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "• ஒருவரின் பெயரைத் தட்டி DM தொடங்கவும்" + "value" : "• peer பெயரைத் தட்டி DM தொடங்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "• แตะชื่อบุคคลเพื่อเริ่ม DM" + "value" : "• แตะชื่อเพียร์เพื่อเริ่ม DM" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "• bir kişinin adına dokunarak DM başlatın" + "value" : "• bir eşin adına dokunarak DM başlatın" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "• торкни ім'я людини, щоб почати приватний чат" + "value" : "• торкни ім'я піра, щоб почати приватний чат" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "• DM شروع کرنے کیلئے کسی شخص کے نام پر ٹیپ کریں" + "value" : "• DM شروع کرنے کیلئے کسی ہم منصب کے نام پر ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "• chạm tên một người để mở DM" + "value" : "• chạm tên một nút ngang hàng để mở DM" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "• 轻点某人名字开始 dm" + "value" : "• 轻点同伴名字开始 dm" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "• 輕點某人名字開始 dm" + "value" : "• 輕點同伴名字開始 dm" } } } @@ -24424,12 +4332,6 @@ "value" : "CÓMO USARLO" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "راهنمای استفاده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -24576,3906 +4478,181 @@ } } }, - "app_info.legend.blocked" : { - "comment" : "Legend entry for the nosign glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "محظور" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্লক করা" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "blockiert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "bloqueado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسدود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "na-block" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bloqué" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "חסום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ब्लॉक किया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "diblokir" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bloccato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ブロック中" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "차단됨" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "diblokir" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ब्लक" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "geblokkeerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zablokowany" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bloqueado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bloqueado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "заблокирован" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "blockerad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "தடுக்கப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ถูกบล็อก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "engellendi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "заблоковано" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بلاک شدہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã chặn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已屏蔽" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已屏蔽" - } - } - } - }, - "app_info.legend.bridged" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "وصلت الرسالة عبر جسر mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বার্তাটি মেশ ব্রিজ পেরিয়ে এসেছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "nachricht kam über eine mesh-brücke an" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message arrived across a mesh bridge" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "el mensaje llegó a través de un puente mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام از طریق پل مش رسیده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "dumating ang mensahe sa pamamagitan ng mesh bridge" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "message arrivé via un pont mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ההודעה הגיעה דרך גשר mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "संदेश मेश ब्रिज से होकर आया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "pesan tiba lewat jembatan mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "messaggio arrivato attraverso un ponte mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "meshブリッジ経由で届いたメッセージ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh 브리지를 거쳐 도착한 메시지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "pesan tiba melalui jambatan mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सन्देश mesh पुल हुँदै आइपुग्यो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "bericht kwam via een mesh-brug binnen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wiadomość dotarła przez most mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensagem chegou através de uma ponte mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensagem chegou por uma ponte mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "сообщение пришло через mesh-мост" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "meddelandet kom via en mesh-brygga" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "செய்தி mesh பாலம் வழியாக வந்தது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ข้อความมาถึงผ่านบริดจ์ mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesaj bir mesh köprüsü üzerinden geldi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "повідомлення надійшло через mesh-міст" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیغام mesh پل کے ذریعے پہنچا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tin nhắn đến qua cầu nối mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "消息经 mesh 桥接送达" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "訊息經 mesh 橋接送達" - } - } - } - }, - "app_info.legend.encrypted" : { - "comment" : "Legend entry for the lock glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "جلسة مشفرة من طرف إلى طرف" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এন্ড-টু-এন্ড এনক্রিপ্টেড সেশন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "end-to-end-verschlüsselte sitzung" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "end-to-end encrypted session" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "sesión cifrada de extremo a extremo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نشست رمزنگاری‌شدهٔ سرتاسری" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "end-to-end na naka-encrypt na session" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "session chiffrée de bout en bout" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "חיבור מוצפן מקצה לקצה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "एंड-टू-एंड एन्क्रिप्टेड सत्र" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sesi terenkripsi ujung ke ujung" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sessione cifrata end-to-end" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "エンドツーエンド暗号化されたセッション" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "종단간 암호화된 세션" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sesi terenkripsi ujung ke ujung" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "एन्ड-टु-एन्ड सङ्केत सत्र" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "end-to-end-versleutelde sessie" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sesja szyfrowana end-to-end" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sessão encriptada ponta a ponta" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sessão criptografada ponto a ponto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сессия со сквозным шифрованием" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "end-to-end-krypterad session" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "முனை-முதல்-முனை குறியாக்கம் செய்யப்பட்ட அமர்வு" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เซสชันที่เข้ารหัสแบบครบวงจร" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uçtan uca şifreli oturum" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "наскрізно зашифрована сесія" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اینڈ ٹو اینڈ خفیہ کردہ سیشن" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "phiên mã hóa đầu cuối" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "端到端加密会话" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "端到端加密會話" - } - } - } - }, - "app_info.legend.encryption_failed" : { - "comment" : "Legend entry for the failed-encryption lock-slash glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "فشل التشفير — الرسائل غير مؤمَّنة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এনক্রিপশন ব্যর্থ — বার্তা সুরক্ষিত নয়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verschlüsselung fehlgeschlagen — nachrichten nicht gesichert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "encryption failed — messages not secured" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "cifrado fallido — mensajes no protegidos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری ناموفق — پیام‌ها ایمن نیستند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nabigo ang pag-encrypt — hindi ligtas ang mga mensahe" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "échec du chiffrement — messages non sécurisés" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ההצפנה נכשלה — ההודעות אינן מאובטחות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "एन्क्रिप्शन विफल — संदेश सुरक्षित नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enkripsi gagal — pesan tidak aman" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cifratura fallita — messaggi non protetti" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "暗号化に失敗 — メッセージは保護されていません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "암호화 실패 — 메시지가 보호되지 않음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enkripsi gagal — pesan tidak selamat" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "सङ्केत असफल — सन्देश सुरक्षित छैनन्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "versleuteling mislukt — berichten niet beveiligd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "szyfrowanie nie powiodło się — wiadomości nie są zabezpieczone" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "falha na encriptação — mensagens não protegidas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "falha na criptografia — mensagens sem proteção" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "шифрование не удалось — сообщения не защищены" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kryptering misslyckades — meddelanden inte skyddade" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குறியாக்கம் தோல்வியடைந்தது — செய்திகள் பாதுகாக்கப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เข้ารหัสไม่สำเร็จ — ข้อความไม่ปลอดภัย" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "şifreleme başarısız — mesajlar güvende değil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "шифрування не вдалося — повідомлення не захищені" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "انکرپشن ناکام — پیغامات محفوظ نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mã hóa thất bại — tin nhắn không được bảo mật" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "加密失败 — 消息未受保护" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "加密失敗 — 訊息未受保護" - } - } - } - }, - "app_info.legend.favorite" : { - "comment" : "Legend entry for the star glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "مفضّل — يتيح الرسائل بدون اتصال عبر nostr عند التفضيل المتبادل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "প্রিয় — পারস্পরিক হলে nostr দিয়ে অফলাইন বার্তা সক্রিয় করে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorit — ermöglicht offline-nachrichten über nostr, wenn beidseitig" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorite — enables offline messages via nostr when mutual" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorito — habilita mensajes sin conexión vía Nostr cuando es mutuo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "موردعلاقه — در صورت دوطرفه بودن، پیام آفلاین از طریق nostr فعال می‌شود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "paborito — nagbibigay-daan sa offline na mensahe sa nostr kapag mutual" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favori — active les messages hors ligne via nostr quand c'est mutuel" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מועדף — מאפשר הודעות לא מקוונות דרך nostr כשההעדפה הדדית" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पसंदीदा — आपसी होने पर नोस्ट्र के ज़रिए ऑफ़लाइन संदेश सक्षम करता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorit — mengaktifkan pesan offline via nostr saat saling favorit" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "preferito — abilita i messaggi offline via nostr quando reciproco" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "お気に入り — 相互になるとnostr経由でオフラインメッセージが可能に" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "즐겨찾기 — 상호 등록 시 nostr를 통해 오프라인 메시지 가능" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorit — membolehkan pesan offline melalui nostr apabila saling favorit" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "मनपर्ने — दुवैतर्फ भएमा nostr मार्फत अफलाइन सन्देश सक्षम गर्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favoriet — schakelt offline berichten via nostr in bij wederzijds" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ulubiony — umożliwia wiadomości offline przez Nostr, gdy wzajemny" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorito — ativa mensagens offline via nostr quando for mútuo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorito — habilita mensagens offline via nostr quando mútuo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "избранное — включает офлайн-сообщения через nostr при взаимности" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorit — möjliggör offline-meddelanden via Nostr när ömsesidig" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "சிறப்பு — பரஸ்பரமாக இருக்கும்போது nostr வழியாக ஆஃப்லைன் செய்திகளை இயக்கும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "คนโปรด — เปิดใช้ข้อความออฟไลน์ผ่าน nostr เมื่อเป็นคนโปรดของกันและกัน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favori — karşılıklı olduğunda Nostr üzerinden çevrimdışı mesajları etkinleştirir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "улюблений — вмикає офлайн-повідомлення через nostr, коли взаємний" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پسندیدہ — باہمی ہونے پر nostr کے ذریعے آف لائن پیغامات فعال کرتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "yêu thích — bật tin nhắn ngoại tuyến qua nostr khi cả hai cùng thích" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "收藏 — 互相收藏后可通过 nostr 发送离线消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "收藏 — 互相收藏後可透過 nostr 收發離線訊息" - } - } - } - }, - "app_info.legend.location_nearby" : { - "comment" : "Legend entry for the map pin glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "موجود فعليًا في منطقة قناة الموقع هذه" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "সশরীরে এই লোকেশন চ্যানেলের এলাকায়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "physisch im gebiet dieses standortkanals" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "physically in this location channel's area" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "físicamente en el área de este canal de ubicación" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "به‌صورت فیزیکی در محدودهٔ این کانال موقعیت است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pisikal na nasa lugar ng channel ng lokasyon na ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "physiquement dans la zone de ce canal de localisation" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "נמצא פיזית באזור של ערוץ המיקום הזה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इस लोकेशन चैनल के क्षेत्र में मौजूद" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "secara fisik berada di area kanal lokasi ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fisicamente nell'area di questo canale di posizione" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "この位置チャンネルのエリア内にいます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이 위치 채널 영역 안에 있음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "secara fizikal berada di kawasan kanal lokasi ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "यो स्थान च्यानलको क्षेत्रमा भौतिक रूपमा" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fysiek in het gebied van dit locatiekanaal" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fizycznie w obszarze tego kanału lokalizacji" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fisicamente na área deste canal de localização" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fisicamente na área deste canal de localização" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "физически в зоне этого локального канала" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fysiskt i den här platskanalens område" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இந்த இருப்பிட சேனலின் பகுதியில் நேரடியாக உள்ளார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "อยู่ในพื้นที่ของช่องตามตำแหน่งนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fiziksel olarak bu konum kanalının bölgesinde" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "фізично в зоні цього каналу локації" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اس لوکیشن چینل کے علاقے میں جسمانی طور پر موجود" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hiện đang ở trong khu vực của kênh vị trí này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "本人就在此位置频道的区域内" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "實際位於此位置頻道的區域內" - } - } - } - }, - "app_info.legend.mesh_connected" : { - "comment" : "Legend entry for the antenna glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "متصل مباشرة عبر bluetooth" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "সরাসরি ব্লুটুথে সংযুক্ত" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "direkt über bluetooth verbunden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "connected directly over bluetooth" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "conectado directamente por Bluetooth" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مستقیم از طریق بلوتوث متصل است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "direktang nakakonekta sa bluetooth" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "connecté directement en bluetooth" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מחובר ישירות דרך bluetooth" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ब्लूटूथ से सीधे जुड़ा" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "terhubung langsung lewat bluetooth" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "connesso direttamente via bluetooth" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bluetoothで直接接続中" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bluetooth로 직접 연결됨" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bersambung terus melalui bluetooth" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bluetooth मार्फत सिधै जडान" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rechtstreeks verbonden via bluetooth" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "połączono bezpośrednio przez Bluetooth" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ligado diretamente por bluetooth" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conectado diretamente por bluetooth" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "подключён напрямую через bluetooth" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ansluten direkt via Bluetooth" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bluetooth மூலம் நேரடியாக இணைக்கப்பட்டுள்ளது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เชื่อมต่อโดยตรงผ่าน bluetooth" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "doğrudan Bluetooth üzerinden bağlı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "з'єднано напряму через bluetooth" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "براہ راست Bluetooth کے ذریعے جڑا ہوا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kết nối trực tiếp qua Bluetooth" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "通过 bluetooth 直接连接" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "透過 bluetooth 直接連線" - } - } - } - }, - "app_info.legend.mesh_relayed" : { - "comment" : "Legend entry for the relayed-mesh glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "يمكن الوصول إليه عبر mesh، بإعادة تمرير من الآخرين" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "মেশের মাধ্যমে পৌঁছানো যায়, অন্যরা রিলে করে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "über das mesh erreichbar, von anderen weitergeleitet" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reachable through the mesh, relayed by others" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "alcanzable a través del mesh, retransmitido por otros" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "از طریق مش در دسترس است، با بازپخش دیگران" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "maabot sa pamamagitan ng mesh, ipinapasa ng iba" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "joignable via le mesh, relayé par d'autres" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "נגיש דרך ה-mesh, בשידור חוזר על ידי אחרים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "मेश के ज़रिए पहुँच योग्य, दूसरों द्वारा रिले किया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bisa dijangkau lewat mesh, diteruskan oleh yang lain" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "raggiungibile tramite la mesh, inoltrato da altri" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh経由で到達可能、他のピアがリレー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh를 통해 도달 가능, 다른 피어가 중계" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "boleh dicapai melalui mesh, diteruskan oleh orang lain" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh मार्फत पुग्न सकिने, अरूले रिले गरेको" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bereikbaar via de mesh, doorgestuurd door anderen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "osiągalny przez mesh, przekazywany przez innych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "acessível através da mesh, retransmitido por outros" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "alcançável pelo mesh, retransmitido por outros" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "доступен через mesh, ретранслируется другими" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nåbar via mesh, vidarebefordrad av andra" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh வழியாக அணுகக்கூடியது, மற்றவர்களால் ரிலே செய்யப்படுகிறது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เข้าถึงได้ผ่าน mesh โดยมีผู้อื่นส่งต่อ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh üzerinden ulaşılabilir, başkaları tarafından aktarılır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "досяжно через mesh, ретрансльовано іншими" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh کے ذریعے قابل رسائی، دوسروں کے ذریعے ریلے شدہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "có thể tiếp cận qua mesh, được người khác chuyển tiếp" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "可通过 mesh 到达,由他人中继" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "可透過 mesh 到達,由其他人中繼" - } - } - } - }, - "app_info.legend.nostr" : { - "comment" : "Legend entry for the globe glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "يمكن الوصول إليه عبر الإنترنت (nostr) — المفضّلون المتبادلون فقط" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ইন্টারনেটে পৌঁছানো যায় (nostr) — শুধু পারস্পরিক প্রিয়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "über das internet erreichbar (nostr) — nur bei beidseitigen favoriten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reachable over the internet (nostr) — mutual favorites only" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "alcanzable por internet (Nostr) — solo favoritos mutuos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "از طریق اینترنت (nostr) در دسترس — فقط موردعلاقه‌های دوطرفه" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "maabot sa internet (nostr) — mutual na paborito lamang" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "joignable via internet (nostr) — favoris mutuels uniquement" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "נגיש דרך האינטרנט (nostr) — מועדפים הדדיים בלבד" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इंटरनेट (नोस्ट्र) पर पहुँच योग्य — केवल आपसी पसंदीदा" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bisa dijangkau lewat internet (nostr) — hanya untuk saling favorit" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "raggiungibile via internet (nostr) — solo preferiti reciproci" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "インターネット(nostr)経由で到達可能 — 相互お気に入りのみ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "인터넷(nostr)을 통해 도달 가능 — 상호 즐겨찾기만" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "boleh dicapai melalui internet (nostr) — hanya untuk saling favorit" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इन्टरनेट (nostr) मार्फत पुग्न सकिने — दुवैतर्फका मनपर्ने मात्र" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bereikbaar via internet (nostr) — alleen wederzijdse favorieten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "osiągalny przez internet (Nostr) — tylko wzajemni ulubieni" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "acessível pela internet (nostr) — apenas favoritos mútuos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "alcançável pela internet (nostr) — apenas favoritos mútuos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "доступен через интернет (nostr) — только для взаимного избранного" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nåbar via internet (Nostr) — endast ömsesidiga favoriter" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இணையம் வழியாக அணுகக்கூடியது (nostr) — பரஸ்பர சிறப்பினர் மட்டுமே" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เข้าถึงได้ผ่านอินเทอร์เน็ต (nostr) — เฉพาะคนโปรดของกันและกัน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internet üzerinden ulaşılabilir (Nostr) — yalnızca karşılıklı favoriler" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "досяжно через інтернет (nostr) — лише взаємні улюблені" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "انٹرنیٹ (nostr) کے ذریعے قابل رسائی — صرف باہمی پسندیدہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "có thể tiếp cận qua internet (nostr) — chỉ khi cả hai cùng thích" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "可通过互联网 (nostr) 到达 — 仅限互相收藏" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "可透過網際網路 (nostr) 到達 — 僅限互相收藏" - } - } - } - }, - "app_info.legend.offline" : { - "comment" : "Legend entry for the offline person glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "غير متصل — لا يمكن الوصول إليه حاليًا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "অফলাইন — এখন পৌঁছানো যাচ্ছে না" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline — derzeit nicht erreichbar" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "offline — not currently reachable" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "sin conexión — no alcanzable ahora" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "آفلاین — فعلاً در دسترس نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline — kasalukuyang hindi maabot" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hors ligne — actuellement injoignable" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא מקוון — לא נגיש כרגע" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ऑफ़लाइन — फ़िलहाल पहुँच योग्य नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline — saat ini tidak bisa dijangkau" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline — attualmente non raggiungibile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "オフライン — 現在到達できません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "오프라인 — 현재 도달할 수 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline — kini tidak boleh dicapai" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "अफलाइन — अहिले पुग्न सकिँदैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline — momenteel niet bereikbaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline — obecnie nieosiągalny" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline — atualmente inacessível" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline — inalcançável no momento" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "офлайн — сейчас недоступен" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline — inte nåbar just nu" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ஆஃப்லைன் — தற்போது அணுக முடியாது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ออฟไลน์ — ไม่สามารถเข้าถึงได้ในขณะนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "çevrimdışı — şu anda ulaşılamıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "офлайн — зараз недосяжно" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آف لائن — فی الحال قابل رسائی نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ngoại tuyến — hiện không thể tiếp cận" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "离线 — 当前不可达" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "離線 — 目前無法到達" - } - } - } - }, - "app_info.legend.teleported" : { - "comment" : "Legend entry for the teleported glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "منتقل فوريًا — انضم إلى القناة من مكان آخر" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "টেলিপোর্টেড — অন্য কোথাও থেকে চ্যানেলে যোগ দিয়েছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teleportiert — dem kanal von woanders beigetreten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "teleported — joined the channel from somewhere else" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "teletransportado — se unió al canal desde otro lugar" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تله‌پورت‌شده — از جای دیگری به کانال پیوسته" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nag-teleport — sumali sa channel mula sa ibang lugar" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "téléporté — a rejoint le canal depuis un autre endroit" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "טלפורט — הצטרף לערוץ ממקום אחר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "टेलीपोर्टेड — कहीं और से चैनल में शामिल हुआ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teleport — bergabung ke kanal dari tempat lain" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teletrasportato — è entrato nel canale da un altro luogo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "テレポート — 別の場所からチャンネルに参加" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "텔레포트 — 다른 곳에서 채널에 참여함" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teleport — menyertai kanal dari tempat lain" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "टेलिपोर्ट भएको — अन्त कतैबाट च्यानलमा जोडिएको" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "geteleporteerd — het kanaal van elders binnengekomen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teleportowany — dołączył do kanału skądinąd" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teletransportado — entrou no canal a partir de outro sítio" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teletransportado — entrou no canal de outro lugar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "телепортирован — присоединился к каналу из другого места" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teleporterad — gick med i kanalen från en annan plats" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "டெலிபோர்ட் செய்யப்பட்டது — வேறு இடத்திலிருந்து சேனலில் சேர்ந்தார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เทเลพอร์ต — เข้าร่วมช่องจากที่อื่น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ışınlandı — kanala başka bir yerden katıldı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "телепортований — приєднався до каналу з іншого місця" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ٹیلی پورٹ شدہ — کسی اور جگہ سے چینل میں شامل ہوا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã dịch chuyển — tham gia kênh từ nơi khác" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已瞬移 — 从别处加入此频道" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "瞬移 — 從其他地方加入此頻道" - } - } - } - }, - "app_info.legend.title" : { - "comment" : "Section header for the symbols legend in app info", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الرموز" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "প্রতীকসমূহ" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SYMBOLE" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "SYMBOLS" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "SÍMBOLOS" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمادها" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "MGA SIMBOLO" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SYMBOLES" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "סמלים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "प्रतीक" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SIMBOL" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SIMBOLI" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "記号" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "기호" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SIMBOL" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चिन्हहरू" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SYMBOLEN" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SYMBOLE" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SÍMBOLOS" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SÍMBOLOS" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "СИМВОЛЫ" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SYMBOLER" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "சின்னங்கள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "สัญลักษณ์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SEMBOLLER" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "СИМВОЛИ" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "علامات" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "KÝ HIỆU" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "符号" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "符號" - } - } - } - }, - "app_info.legend.unread" : { - "comment" : "Legend entry for the envelope glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسائل خاصة غير مقروءة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "অপঠিত ব্যক্তিগত বার্তা" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ungelesene private nachrichten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "unread private messages" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensajes privados sin leer" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام‌های خصوصی خوانده‌نشده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi pa nababasang mga pribadong mensahe" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messages privés non lus" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעות פרטיות שלא נקראו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "अपठित निजी संदेश" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan pribadi belum dibaca" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messaggi privati non letti" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "未読のプライベートメッセージ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "읽지 않은 개인 메시지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan peribadi belum dibaca" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "नपढेका व्यक्तिगत सन्देश" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ongelezen privéberichten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nieprzeczytane wiadomości prywatne" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagens privadas não lidas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagens privadas não lidas" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "непрочитанные личные сообщения" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "olästa privata meddelanden" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படிக்காத தனிப்பட்ட செய்திகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ข้อความส่วนตัวที่ยังไม่ได้อ่าน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "okunmamış özel mesajlar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "непрочитані приватні повідомлення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نہ پڑھے گئے نجی پیغامات" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tin nhắn riêng tư chưa đọc" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "未读私信" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "未讀私信" - } - } - } - }, - "app_info.legend.verified" : { - "comment" : "Legend entry for the verified seal glyph", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تم التحقق من الهوية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "পরিচয় যাচাই করা হয়েছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "identität verifiziert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "identity verified" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "identidad verificada" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هویت تأییدشده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "beripikado ang pagkakakilanlan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "identité vérifiée" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הזהות אומתה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पहचान सत्यापित" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "identitas terverifikasi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "identità verificata" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "本人確認済み" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "신원 확인됨" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "identiti disahkan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पहिचान प्रमाणित" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "identiteit geverifieerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tożsamość zweryfikowana" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "identidade verificada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "identidade verificada" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "личность подтверждена" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "identitet verifierad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "அடையாளம் சரிபார்க்கப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ยืนยันตัวตนแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kimlik doğrulandı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "особу підтверджено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "شناخت کی تصدیق ہو گئی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "danh tính đã xác minh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "身份已验证" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "身份已驗證" - } - } - } - }, - "app_info.location.notes.description" : { - "comment" : "Description of the location notes toggle in app info", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ثبّت ملاحظات في الأماكن باستخدام /drop، يقرؤها أي مارّ خلال 24 ساعة. يتطلب الموقع." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "/drop দিয়ে জায়গায় নোট পিন করুন, 24 ঘণ্টা ধরে পাশ দিয়ে যাওয়া যে কেউ পড়তে পারবে। অবস্থান প্রয়োজন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "hefte notizen mit /drop an orte, 24h lesbar für alle, die vorbeikommen. benötigt standort." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "pin notes to places with /drop, readable by anyone passing by for 24h. needs location." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "fija notas en lugares con /drop, legibles por cualquiera que pase durante 24h. necesita ubicación." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "با /drop یادداشت به مکان‌ها سنجاق کن؛ هر رهگذری تا ۲۴ ساعت می‌تواند بخواند. به موقعیت نیاز دارد." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mag-pin ng mga note sa mga lugar gamit ang /drop, mababasa ng sinumang dadaan sa loob ng 24h. kailangan ng lokasyon." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "épinglez des notes à des lieux avec /drop, lisibles par quiconque passe pendant 24h. nécessite la localisation." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הצמד פתקים למקומות עם /drop, קריאים לכל מי שעובר במשך 24 שעות. דורש מיקום." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "/drop से जगहों पर नोट पिन करें, 24 घंटे तक वहां से गुजरने वाला कोई भी पढ़ सकता है। स्थान आवश्यक।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "sematkan catatan di tempat dengan /drop, dapat dibaca siapa pun yang lewat selama 24 jam. perlu lokasi." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "fissa note nei luoghi con /drop, leggibili da chiunque passi per 24h. richiede la posizione." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "/drop で場所にメモをピン留め。24時間、通りかかった誰でも読めます。位置情報が必要です。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "/drop로 장소에 메모를 고정하세요. 24시간 동안 지나가는 누구나 읽을 수 있습니다. 위치 필요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "pinkan nota di tempat dengan /drop, boleh dibaca sesiapa yang lalu selama 24 jam. perlukan lokasi." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "/drop ले ठाउँहरूमा नोट पिन गर्नुहोस्, 24 घण्टासम्म त्यहाँबाट जाने जोकोहीले पढ्न सक्छ। स्थान आवश्यक।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "pin notities aan plekken met /drop, 24 uur leesbaar voor iedereen die langskomt. vereist locatie." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "przypinaj notatki do miejsc za pomocą /drop, czytelne dla każdego przechodzącego przez 24h. wymaga lokalizacji." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "fixe notas em locais com /drop, legíveis por quem passar durante 24h. requer localização." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "fixe notas em lugares com /drop, legíveis por quem passar durante 24h. precisa de localização." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "закрепляйте заметки в местах с помощью /drop — 24 часа их сможет прочитать любой, кто проходит мимо. нужна геолокация." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "fäst anteckningar på platser med /drop, läsbara av alla som passerar i 24h. kräver plats." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "/drop மூலம் இடங்களில் குறிப்புகளை பின் செய்யுங்கள், 24 மணி நேரம் கடந்து செல்லும் யாரும் படிக்கலாம். இருப்பிடம் தேவை." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ปักโน้ตไว้ตามสถานที่ด้วย /drop ใครผ่านมาก็อ่านได้ตลอด 24 ชั่วโมง ต้องใช้ตำแหน่งที่ตั้ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "/drop ile yerlere not sabitle, 24 saat boyunca yoldan geçen herkes okuyabilir. konum gerekir." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "закріплюйте нотатки в місцях за допомогою /drop — 24 години їх зможе прочитати будь-хто, хто проходить повз. потрібне місцезнаходження." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "/drop سے جگہوں پر نوٹ پن کریں، 24 گھنٹے تک وہاں سے گزرنے والا کوئی بھی پڑھ سکتا ہے۔ مقام درکار ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ghim ghi chú vào địa điểm bằng /drop, ai đi ngang cũng đọc được trong 24h. cần vị trí." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用 /drop 把便签钉在地点,24小时内路过的任何人都能读到。需要位置权限。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "用 /drop 將便箋釘在地點,24小時內路過的任何人都能讀到。需要位置權限。" - } - } - } - }, - "app_info.location.notes.title" : { - "comment" : "Title of the location notes toggle in app info", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ملاحظات الموقع" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "অবস্থান নোট" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "standortnotizen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "location notes" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "notas de ubicación" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "یادداشت‌های موقعیت" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mga note sa lokasyon" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "notes de lieu" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פתקי מיקום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान नोट" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "catatan lokasi" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "note di posizione" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "位置メモ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "위치 쪽지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "nota lokasi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान नोटहरू" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "locatienotities" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "notatki lokalizacji" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "notas de localização" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "notas de localização" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "геозаметки" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "platsanteckningar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இருப்பிடக் குறிப்புகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "โน้ตตำแหน่งที่ตั้ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "konum notları" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "геонотатки" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "مقام کے نوٹس" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ghi chú vị trí" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "位置留言" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "位置留言" - } - } - } - }, - "app_info.network.title" : { - "comment" : "Section header for network diagnostics in the app info sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الشبكة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "নেটওয়ার্ক" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "NETZWERK" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "NETWORK" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "RED" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "شبکه" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "NETWORK" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "RÉSEAU" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "רשת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "नेटवर्क" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "JARINGAN" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "RETE" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ネットワーク" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "네트워크" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "RANGKAIAN" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "नेटवर्क" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "NETWERK" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "SIEĆ" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "REDE" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "REDE" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "СЕТЬ" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "NÄTVERK" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "நெட்வொர்க்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เครือข่าย" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "AĞ" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "МЕРЕЖА" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نیٹ ورک" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "MẠNG" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "网络" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "網路" - } - } - } - }, - "app_info.network.topology.description" : { - "comment" : "Row description for the mesh topology map", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "خريطة الأقران والروابط المُستنتَجة من إعلانات mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "মেশ অ্যানাউন্স থেকে জানা পিয়ার ও লিঙ্কের মানচিত্র" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "karte der peers und verbindungen, aus mesh-announces gelernt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "map of peers and links learned from mesh announces" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mapa de peers y enlaces obtenidos de los anuncios del mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نقشهٔ همتاها و پیوندهایی که از اعلان‌های مش به دست آمده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mapa ng mga peer at link na natutunan mula sa mga mesh announce" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "carte des pairs et des liens issue des annonces mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מפה של עמיתים וקישורים שנלמדו מהכרזות mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "मेश अनाउंस से सीखे गए पीयरों और लिंकों का नक्शा" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "peta peer dan tautan yang dipelajari dari announce mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mappa dei peer e dei collegamenti appresa dagli announce mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh announceから学習したピアとリンクのマップ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh announce에서 학습한 피어와 링크의 지도" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "peta peer dan pautan yang dipelajari daripada announce mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh घोषणाबाट थाहा भएका सहकर्मी र लिंकहरूको नक्सा" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kaart van peers en verbindingen, geleerd uit mesh-announces" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mapa peerów i połączeń poznanych z ogłoszeń mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mapa de pares e ligações obtido dos announces mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mapa de pares e conexões aprendido dos anúncios do mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "карта пиров и связей, полученная из mesh-анонсов" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "karta över peers och länkar från mesh-announces" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh அறிவிப்புகளிலிருந்து அறியப்பட்ட peer-கள் மற்றும் இணைப்புகளின் வரைபடம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แผนที่ของเพียร์และการเชื่อมต่อที่เรียนรู้จาก mesh announce" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh duyurularından öğrenilen eşlerin ve bağlantıların haritası" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "карта пірів і зв'язків, отриманих з оголошень mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh اعلانات سے سیکھے گئے ہم منصبوں اور روابط کا نقشہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bản đồ các nút ngang hàng và liên kết học được từ các announce mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "根据 mesh 广播获知的同伴与链路地图" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "根據 mesh 宣告得知的同伴與連線地圖" - } - } - } - }, - "app_info.network.topology.hint" : { - "comment" : "Accessibility hint for the mesh topology row", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "يفتح خريطة طوبولوجيا mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "মেশ টপোলজি মানচিত্র খোলে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öffnet die mesh-topologie-karte" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens the mesh topology map" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre el mapa de topología del mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نقشهٔ توپولوژی مش را باز می‌کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "binubuksan ang mapa ng mesh topology" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ouvre la carte de topologie mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "פותח את מפת טופולוגיית ה-mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "मेश टोपोलॉजी नक्शा खोलता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka peta topologi mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "apre la mappa della topologia mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh トポロジーマップを開きます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh 토폴로지 지도를 엽니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka peta topologi mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh टोपोलोजी नक्सा खोल्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "opent de mesh-topologiekaart" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "otwiera mapę topologii mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abre o mapa de topologia mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abre o mapa de topologia do mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "открывает карту топологии mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öppnar mesh-topologikartan" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh டோபாலஜி வரைபடத்தைத் திறக்கும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เปิดแผนที่โทโพโลยี mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh topolojisi haritasını açar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "відкриває карту топології mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh ٹوپولوجی نقشہ کھولتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mở bản đồ cấu trúc mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "打开 mesh 拓扑图" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "打開 mesh 拓撲地圖" - } - } - } - }, - "app_info.network.topology.title" : { - "comment" : "Row title opening the mesh topology map", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "طوبولوجيا mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "মেশ টপোলজি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh-topologie" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh topology" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "topología del mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "توپولوژی مش" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh topology" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologie mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "טופולוגיית mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "मेश टोपोलॉजी" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologi mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologia mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh トポロジー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh 토폴로지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologi mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh टोपोलोजी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh-topologie" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologia mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologia mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologia do mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "топология mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh-topologi" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh டோபாலஜி" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "โทโพโลยี mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh topolojisi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "топологія mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh ٹوپولوجی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cấu trúc mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh 拓扑" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh 拓撲" - } - } - } - }, "app_info.privacy.ephemeral.description" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "هويتك زوج مفاتيح يُنشأ على هذا الجهاز — المسح الطارئ يستبدلها فورًا" + "value" : "يُولد معرف قرين جديد بانتظام" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "আপনার পরিচয় এই ডিভাইসে তৈরি একটি কী-জোড়া — প্যানিক ওয়াইপ সঙ্গে সঙ্গে এটি বদলে দেয়" + "value" : "নিয়মিত নতুন পিয়ার আইডি তৈরি হয়" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "deine identität ist ein schlüsselpaar, das auf diesem gerät erzeugt wird — der panik-löscher ersetzt es sofort" + "value" : "neue peer-id wird regelmäßig erzeugt" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "your identity is a keypair created on this device — panic wipe replaces it instantly" + "value" : "new peer ID generated regularly" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "tu identidad es un par de claves creado en este dispositivo — el borrado de pánico la reemplaza al instante" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هویت شما یک جفت کلید است که روی این دستگاه ساخته می‌شود — پاک‌سازی اضطراری آن را فوراً جایگزین می‌کند" + "value" : "nuevo ID de peer generado periódicamente" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "ang iyong pagkakakilanlan ay isang key pair na ginawa sa device na ito — agad itong pinapalitan ng panic wipe" + "value" : "regular na lumilikha ng bagong peer ID" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "votre identité est une paire de clés créée sur cet appareil — l'effacement d'urgence la remplace instantanément" + "value" : "nouvel id de pair généré régulièrement" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "הזהות שלך היא צמד מפתחות שנוצר במכשיר הזה — מחיקת חירום מחליפה אותה מיד" + "value" : "מזהה עמית חדש נוצר באופן קבוע" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "आपकी पहचान इस डिवाइस पर बनी एक कुंजी-जोड़ी है — पैनिक वाइप इसे तुरंत बदल देता है" + "value" : "नया पीयर आईडी नियमित रूप से बनाया जाता है" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "identitas anda adalah pasangan kunci yang dibuat di perangkat ini — panic wipe langsung menggantinya" + "value" : "id peer baru dibuat secara berkala" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "la tua identità è una coppia di chiavi creata su questo dispositivo — la cancellazione di emergenza la sostituisce all'istante" + "value" : "nuovo id peer generato regolarmente" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "アイデンティティはこの端末上で生成される鍵ペアです — パニックワイプで即座に置き換えられます" + "value" : "新しいpeer idが定期的に生成されます" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "신원은 이 기기에서 생성되는 키 쌍입니다 — 패닉 와이프로 즉시 교체됩니다" + "value" : "새로운 피어 ID가 주기적으로 생성됩니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "identiti anda ialah pasangan kunci yang dicipta pada peranti ini — panic wipe menggantikannya serta-merta" + "value" : "id peer baru dibuat secara berkala" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "तपाईंको परिचय यही यन्त्रमा बनेको कुञ्जी-जोडी हो — प्यानिक वाइपले तुरुन्तै बदल्छ" + "value" : "नयाँ peer id नियमित रूपमा सिर्जना हुन्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "je identiteit is een sleutelpaar dat op dit apparaat wordt gemaakt — de paniekwis vervangt het direct" + "value" : "nieuw peer-ID wordt regelmatig aangemaakt" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "twoja tożsamość to para kluczy utworzona na tym urządzeniu — wymazanie awaryjne natychmiast ją zastępuje" + "value" : "nowe ID peera generowane regularnie" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "a tua identidade é um par de chaves criado neste dispositivo — a limpeza de pânico substitui-a de imediato" + "value" : "novo ID de par gerado regularmente" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "sua identidade é um par de chaves criado neste dispositivo — a limpeza de pânico a substitui na hora" + "value" : "novo id de peer gerado regularmente" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "ваша идентичность — пара ключей, созданная на этом устройстве. экстренное стирание мгновенно её заменяет" + "value" : "новый id пира создаётся регулярно" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "din identitet är ett nyckelpar som skapas på den här enheten — panikradering ersätter det direkt" + "value" : "nytt peer-ID skapas regelbundet" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "உங்கள் அடையாளம் இந்தச் சாதனத்தில் உருவாக்கப்படும் விசை-இணை — பேனிக் அழிப்பு உடனே அதை மாற்றும்" + "value" : "புதிய peer ID வழக்கமாக உருவாக்கப்படுகிறது" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ตัวตนของคุณคือคู่กุญแจที่สร้างบนเครื่องนี้ — panic wipe จะแทนที่ทันที" + "value" : "สร้างรหัสเพียร์ใหม่เป็นระยะ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "kimliğiniz bu cihazda oluşturulan bir anahtar çiftidir — panik silme onu anında değiştirir" + "value" : "yeni eş kimliği düzenli olarak oluşturulur" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "ваша ідентичність — пара ключів, створена на цьому пристрої. екстрене стирання миттєво її замінює" + "value" : "новий id піра генерується регулярно" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "آپ کی شناخت اس ڈیوائس پر بنایا گیا کلیدی جوڑا ہے — پینک وائپ اسے فوراً بدل دیتا ہے" + "value" : "نیا peer ID باقاعدگی سے بنایا جاتا ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "danh tính của bạn là một cặp khoá được tạo trên thiết bị này — panic wipe thay thế nó ngay lập tức" + "value" : "tạo ID nút mới định kỳ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "你的身份是在本机生成的密钥对 — 紧急清除会立即更换它" + "value" : "定期生成新的 peer id" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "你的身分是在本機產生的金鑰對 — 緊急清除會立即更換它" + "value" : "定期生成新的 peer id" } } } @@ -28486,181 +4663,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "هوية محفوظة على الجهاز" + "value" : "هوية مؤقتة" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "ডিভাইসে থাকা পরিচয়" + "value" : "অস্থায়ী পরিচয়" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "gerätelokale identität" + "value" : "flüchtige identität" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "device-local identity" + "value" : "ephemeral identity" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "identidad local del dispositivo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هویت محلی دستگاه" + "value" : "identidad efímera" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "pagkakakilanlang nasa device" + "value" : "panandaliang pagkakakilanlan" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "identité locale à l'appareil" + "value" : "identité éphémère" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "זהות מקומית במכשיר" + "value" : "זהות זמנית" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "डिवाइस-स्थानीय पहचान" + "value" : "अस्थायी पहचान" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "identitas lokal perangkat" + "value" : "identitas sementara" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "identità locale al dispositivo" + "value" : "identità effimera" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "端末ローカルのアイデンティティ" + "value" : "一時的なアイデンティティ" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기기 로컬 신원" + "value" : "임시 신원" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "identiti setempat peranti" + "value" : "identitas sementara" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "यन्त्र-स्थानीय परिचय" + "value" : "क्षणिक पहिचान" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "apparaat-lokale identiteit" + "value" : "tijdelijke identiteit" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "tożsamość lokalna urządzenia" + "value" : "tymczasowa tożsamość" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "identidade local do dispositivo" + "value" : "identidade efémera" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "identidade local do dispositivo" + "value" : "identidade efêmera" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "идентичность на устройстве" + "value" : "эфемерная личность" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "enhetslokal identitet" + "value" : "tillfällig identitet" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "சாதன-உள்ளூர் அடையாளம்" + "value" : "தற்காலிக அடையாளம்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ตัวตนในเครื่อง" + "value" : "ตัวตนชั่วคราว" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "cihaza yerel kimlik" + "value" : "geçici kimlik" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "ідентичність на пристрої" + "value" : "ефемерна ідентичність" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "ڈیوائس مقامی شناخت" + "value" : "عارضی شناخت" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "danh tính cục bộ trên thiết bị" + "value" : "danh tính tạm thời" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "设备本地身份" + "value" : "临时身份" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "裝置本機身分" + "value" : "臨時身份" } } } @@ -28671,181 +4842,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "لا حسابات، لا أرقام هواتف، لا جمع بيانات" + "value" : "لا خوادم أو حسابات أو جمع بيانات" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "কোনো অ্যাকাউন্ট নেই, ফোন নম্বর নেই, ডেটা সংগ্রহ নেই" + "value" : "কোনো সার্ভার, অ্যাকাউন্ট বা ডেটা সংগ্রহ নেই" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "keine konten, keine telefonnummern, keine datensammlung" + "value" : "keine server, konten oder datensammlung" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "no accounts, no phone numbers, no data collection" + "value" : "no servers, accounts, or data collection" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "sin cuentas, sin números de teléfono, sin recopilación de datos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بدون حساب، بدون شماره تلفن، بدون جمع‌آوری داده" + "value" : "sin servidores, cuentas ni recopilación de datos" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "walang account, walang numero ng telepono, walang pangongolekta ng data" + "value" : "walang mga server, account, o pagkuha ng data" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "pas de comptes, pas de numéros de téléphone, pas de collecte de données" + "value" : "sans serveurs, comptes ni collecte de données" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "בלי חשבונות, בלי מספרי טלפון, בלי איסוף נתונים" + "value" : "אין שרתים, חשבונות או איסוף נתונים" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "कोई खाता नहीं, कोई फ़ोन नंबर नहीं, कोई डेटा संग्रह नहीं" + "value" : "कोई सर्वर, खाते या डेटा संग्रह नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "tanpa akun, tanpa nomor telepon, tanpa pengumpulan data" + "value" : "tanpa server, akun, atau pengumpulan data" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "niente account, niente numeri di telefono, nessuna raccolta dati" + "value" : "niente server, account o raccolta dati" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "アカウントなし・電話番号なし・データ収集なし" + "value" : "サーバーもアカウントもデータ収集もなし" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "계정 없음, 전화번호 없음, 데이터 수집 없음" + "value" : "서버와 계정이 없으며 데이터를 수집하지 않습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "tiada akaun, tiada nombor telefon, tiada pengumpulan data" + "value" : "tanpa server, akun, atau pengumpulan data" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "कुनै खाता छैन, फोन नम्बर छैन, डेटा सङ्कलन छैन" + "value" : "सर्भर, खाताहरू वा तथ्याङ्क सङ्कलन छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "geen accounts, geen telefoonnummers, geen dataverzameling" + "value" : "geen servers, accounts of dataverzameling" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "bez kont, bez numerów telefonów, bez zbierania danych" + "value" : "bez serwerów, kont ani zbierania danych" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "sem contas, sem números de telefone, sem recolha de dados" + "value" : "sem servidores, contas ou recolha de dados" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "sem contas, sem números de telefone, sem coleta de dados" + "value" : "sem servidores, contas ou coleta de dados" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "без аккаунтов, без номеров телефона, без сбора данных" + "value" : "без серверов, аккаунтов и сбора данных" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "inga konton, inga telefonnummer, ingen datainsamling" + "value" : "inga servrar, konton eller datainsamling" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "கணக்குகள் இல்லை, தொலைபேசி எண்கள் இல்லை, தரவு சேகரிப்பு இல்லை" + "value" : "சர்வர்கள் இல்லை, கணக்குகள் இல்லை, தரவுச் சேகரிப்பு இல்லை" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ไม่มีบัญชี ไม่มีเบอร์โทรศัพท์ ไม่มีการเก็บข้อมูล" + "value" : "ไม่มีเซิร์ฟเวอร์ บัญชี หรือการเก็บข้อมูล" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "hesap yok, telefon numarası yok, veri toplama yok" + "value" : "sunucu, hesap veya veri toplama yok" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "без облікових записів, без номерів телефону, без збору даних" + "value" : "жодних серверів, обліковок чи збору даних" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "نہ اکاؤنٹ، نہ فون نمبر، نہ ڈیٹا اکٹھا کرنا" + "value" : "کوئی سرور، اکاؤنٹس یا ڈیٹا جمع نہیں کیا جاتا" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "không tài khoản, không số điện thoại, không thu thập dữ liệu" + "value" : "không máy chủ, không tài khoản, không thu thập dữ liệu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无账号、无电话号码、无数据收集" + "value" : "无服务器、无账号、无数据收集" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無帳號、無電話號碼、無資料收集" + "value" : "無伺服器、無帳號、無數據收集" } } } @@ -28883,12 +5048,6 @@ "value" : "sin seguimiento" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بدون ردیابی" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -29041,181 +5200,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "انقر ثلاث مرات على الشعار لمسح جميع البيانات (مع تأكيد)" + "value" : "اضغط الشعار ثلاث مرات لمسح كل البيانات فوراً" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "সব ডেটা মুছতে লোগোতে তিনবার ট্যাপ করুন (নিশ্চিতকরণসহ)" + "value" : "লোগোতে তিনবার ট্যাপ করলে সঙ্গে সঙ্গে সব ডেটা মুছে যাবে" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "dreimal aufs logo tippen, um alle daten zu löschen (mit bestätigung)" + "value" : "tippe dreimal auf das logo, um alle daten sofort zu löschen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "triple-tap logo to wipe all data (asks to confirm)" + "value" : "triple-tap logo to instantly clear all data" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "toca tres veces el logo para borrar todos los datos (con confirmación)" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای پاک کردن همه داده‌ها سه بار روی لوگو بزنید (با تأیید)" + "value" : "toca el logotipo tres veces para borrar todos los datos al instante" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "i-tap nang tatlong beses ang logo para burahin ang lahat ng data (may kumpirmasyon)" + "value" : "i-tap ang logo nang tatlong beses para agad burahin ang lahat ng data" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "touchez trois fois le logo pour effacer toutes les données (avec confirmation)" + "value" : "tape trois fois sur le logo pour tout effacer instantanément" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "הקישו שלוש פעמים על הלוגו כדי למחוק את כל הנתונים (עם אישור)" + "value" : "הקש על הלוגו שלוש פעמים למחיקת כל הנתונים מייד" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "सारा डेटा मिटाने के लिए लोगो पर तीन बार टैप करें (पुष्टि के साथ)" + "value" : "लोगो पर तीन बार टैप करें और तुरंत सारा डेटा साफ़ करें" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "ketuk logo tiga kali untuk menghapus semua data (dengan konfirmasi)" + "value" : "ketuk logo tiga kali untuk langsung menghapus semua data" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "tocca tre volte il logo per cancellare tutti i dati (con conferma)" + "value" : "tocca il logo tre volte per cancellare subito tutti i dati" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ロゴを3回タップですべてのデータを消去(確認あり)" + "value" : "ロゴを3回タップすると全データを即削除" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로고를 세 번 탭하면 모든 데이터 삭제 (확인 후 진행)" + "value" : "로고를 세 번 탭하여 모든 데이터 즉시 삭제할 수 있습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "ketik logo tiga kali untuk memadamkan semua data (dengan pengesahan)" + "value" : "ketuk logo tiga kali untuk langsung menghapus semua data" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "सबै डेटा मेटाउन लोगोमा तीन पटक ट्याप गर्नुहोस् (पुष्टिसहित)" + "value" : "लगो तीन पटक ट्याप गर्दा सबै डाटा तुरुन्त मेटिन्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "tik drie keer op het logo om alle gegevens te wissen (met bevestiging)" + "value" : "tik drie keer op het logo om alle data direct te wissen" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "stuknij logo trzy razy, aby wymazać wszystkie dane (z potwierdzeniem)" + "value" : "stuknij logo trzy razy, aby natychmiast usunąć wszystkie dane" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "toque três vezes no logótipo para apagar todos os dados (com confirmação)" + "value" : "toca três vezes no logótipo para limpar todos os dados de imediato" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "toque três vezes no logo para apagar todos os dados (com confirmação)" + "value" : "toque o logo três vezes para limpar todos os dados instantaneamente" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "трижды коснитесь логотипа, чтобы стереть все данные (с подтверждением)" + "value" : "тройной тап по логотипу мгновенно очищает все данные" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "trippeltryck på loggan för att radera all data (med bekräftelse)" + "value" : "tryck tre gånger på logotypen för att radera all data direkt" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "எல்லா தரவையும் அழிக்க லோகோவை மூன்று முறை தட்டவும் (உறுதிப்படுத்தலுடன்)" + "value" : "எல்லா தரவையும் உடனடியாக நீக்க லோகோவை மூன்று முறைத் தட்டுங்கள்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "แตะโลโก้สามครั้งเพื่อล้างข้อมูลทั้งหมด (มีการยืนยัน)" + "value" : "แตะโลโก้สามครั้งเพื่อลบข้อมูลทั้งหมดทันที" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "tüm verileri silmek için logoya üç kez dokunun (onaylı)" + "value" : "logoya üç kez dokunun, tüm veriler anında silinsin" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "тричі торкніться логотипа, щоб стерти всі дані (з підтвердженням)" + "value" : "тричі торкни логотип, щоб миттєво стерти всі дані" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "تمام ڈیٹا مٹانے کے لیے لوگو پر تین بار ٹیپ کریں (تصدیق کے ساتھ)" + "value" : "تمام ڈیٹا فوری صاف کرنے کیلئے لوگو پر تین بار ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "chạm ba lần vào logo để xoá toàn bộ dữ liệu (có xác nhận)" + "value" : "chạm logo ba lần để xóa toàn bộ dữ liệu ngay lập tức" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "连点三次标志以清除所有数据(需确认)" + "value" : "三击标志立即清除全部数据" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "連點三次標誌以清除所有資料(需確認)" + "value" : "三擊標誌立即清除全部數據" } } } @@ -29253,12 +5406,6 @@ "value" : "modo pánico" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حالت اضطراری" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -29438,12 +5585,6 @@ "value" : "PRIVACIDAD" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حریم خصوصی" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -29590,2969 +5731,6 @@ } } }, - "app_info.settings.bridge.cell" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "خلية اللقاء: %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মিলনস্থল সেল: %@" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "rendezvous-zelle: %@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "rendezvous cell: %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "celda de encuentro: %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "سلول ملاقات: %@" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "rendezvous cell: %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "cellule de rendez-vous : %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "תא מפגש: %@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "मिलन सेल: %@" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "sel titik temu: %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "cella di incontro: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ランデブーセル: %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "랑데부 셀: %@" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "sel titik temu: %@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "भेट हुने सेल: %@" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ontmoetingscel: %@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "komórka spotkania: %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "célula de encontro: %@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "célula de encontro: %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ячейка встречи: %@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "mötescell: %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "சந்திப்பு செல்: %@" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เซลล์นัดพบ: %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "buluşma hücresi: %@" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "комірка зустрічі: %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ملاقات سیل: %@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ô hẹn gặp: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "会合单元:%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "會合單元:%@" - } - } - } - }, - "app_info.settings.bridge.no_cell" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا توجد خلية لقاء بعد — يلزم صلاحية الموقع أو قرين جسر قريب" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এখনও কোনো মিলনস্থল সেল নেই — লোকেশন অ্যাক্সেস বা কাছাকাছি একটি ব্রিজ পিয়ার দরকার" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "noch keine rendezvous-zelle — braucht standortzugriff oder einen brücken-peer in der nähe" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no rendezvous cell yet — needs location access or a nearby bridge peer" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "aún no hay celda de encuentro — necesita acceso a la ubicación o un peer puente cercano" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هنوز سلول ملاقاتی نیست — به دسترسی موقعیت یا یک همتای پل در نزدیکی نیاز دارد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "wala pang rendezvous cell — kailangan ng access sa lokasyon o kalapit na bridge peer" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "pas encore de cellule de rendez-vous — nécessite l'accès localisation ou un pair pont à proximité" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "עדיין אין תא מפגש — נדרשת גישת מיקום או עמית גשר קרוב" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अभी कोई मिलन सेल नहीं — लोकेशन एक्सेस या आसपास का कोई ब्रिज पीयर चाहिए" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "belum ada sel titik temu — perlu akses lokasi atau peer jembatan terdekat" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "ancora nessuna cella di incontro — serve l'accesso alla posizione o un peer ponte vicino" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ランデブーセルはまだありません — 位置アクセスか近くのブリッジピアが必要です" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "아직 랑데부 셀이 없습니다 — 위치 접근 권한이나 근처의 브리지 피어가 필요합니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "belum ada sel titik temu — perlu capaian lokasi atau peer jambatan berdekatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "अहिलेसम्म भेट हुने सेल छैन — स्थान पहुँच वा नजिकैको bridge peer चाहिन्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nog geen ontmoetingscel — vereist toegang tot locatie of een brug-peer in de buurt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nie ma jeszcze komórki spotkania — potrzebny dostęp do lokalizacji lub pobliski peer mostu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ainda sem célula de encontro — precisa de acesso à localização ou de um par ponte próximo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ainda sem célula de encontro — precisa de acesso à localização ou de um par ponte por perto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ячейки встречи пока нет — нужен доступ к локации или ближайший пир-мост" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ingen mötescell ännu — kräver platsåtkomst eller en brygg-peer i närheten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இன்னும் சந்திப்பு செல் இல்லை — இட அணுகல் அல்லது அருகிலுள்ள பாலம் peer தேவை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ยังไม่มีเซลล์นัดพบ — ต้องมีสิทธิ์เข้าถึงตำแหน่งหรือเพียร์บริดจ์ที่อยู่ใกล้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "henüz buluşma hücresi yok — konum erişimi veya yakında bir köprü eşi gerekiyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "комірки зустрічі поки немає — потрібен доступ до локації або ближній пір-міст" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ابھی کوئی ملاقات سیل نہیں — لوکیشن رسائی یا قریبی پل ہم منصب درکار ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "chưa có ô hẹn gặp — cần quyền truy cập vị trí hoặc một nút cầu nối gần đó" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚无会合单元 — 需要位置访问或附近有桥接同伴" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚無會合單元 — 需要位置存取權或附近有橋接同伴" - } - } - } - }, - "app_info.settings.bridge.subtitle" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يصل جزر mesh القريبة عبر الإنترنت: ما تقوله في قناة mesh يصل أيضًا إلى أشخاص في منطقتك خارج مدى الراديو، وتظهر رسائلهم هنا معلَّمة برمز الشبكة. ما دام لديك إنترنت، يحمل جهازك أيضًا حركة الجسر وقنوات الموقع للهواتف من حولك التي لا تملك اتصالًا." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "কাছাকাছি মেশ দ্বীপগুলোকে ইন্টারনেটের মাধ্যমে যুক্ত করে: মেশ চ্যানেলে আপনি যা বলেন তা আপনার এলাকার রেডিও পরিসরের বাইরের মানুষের কাছেও পৌঁছায়, আর তাদের বার্তা নেটওয়ার্ক চিহ্নসহ এখানে দেখা যায়। আপনার ইন্টারনেট থাকাকালীন, আপনার ডিভাইস আশপাশের ইন্টারনেটবিহীন ফোনের জন্য ব্রিজ ও লোকেশন-চ্যানেলের ট্র্যাফিকও বহন করে।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbindet nahe mesh-inseln über das internet: was du im mesh-kanal sagst, erreicht auch personen in deiner gegend außerhalb der funkreichweite, und ihre nachrichten erscheinen hier mit dem netzwerk-symbol markiert. solange du internet hast, trägt dein gerät auch brücken- und standortkanal-verkehr für handys um dich herum, die keins haben." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "une islas mesh cercanas a través de internet: lo que dices en el canal mesh también llega a personas de tu zona fuera del alcance de radio, y sus mensajes aparecen aquí marcados con el símbolo de red. mientras tengas internet, tu dispositivo también lleva el tráfico del puente y de los canales de ubicación para teléfonos a tu alrededor que no tienen conexión." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "جزیره‌های مش اطراف را از طریق اینترنت به هم می‌پیوندد: حرف‌هایت در کانال مش به افراد منطقه‌ات فراتر از برد رادیویی هم می‌رسد و پیام‌های آن‌ها اینجا با نماد شبکه نشان داده می‌شود. تا وقتی اینترنت داری، دستگاهت ترافیک پل و کانال‌های موقعیت را هم برای گوشی‌های اطرافت که اینترنت ندارند جابه‌جا می‌کند." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "pinagdurugtong ang mga kalapit na mesh island sa pamamagitan ng internet: ang sinasabi mo sa mesh channel ay umaabot din sa mga tao sa iyong lugar na lampas sa saklaw ng radyo, at lumalabas dito ang kanilang mga mensahe na may markang simbolo ng network. habang may internet ka, dinadala rin ng device mo ang trapiko ng bridge at ng mga channel ng lokasyon para sa mga teleponong nasa paligid mo na walang koneksyon." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "relie les îlots mesh voisins via internet : ce que tu dis dans le canal mesh atteint aussi les personnes de ta zone hors de portée radio, et leurs messages apparaissent ici marqués du symbole réseau. tant que tu as internet, ton appareil achemine aussi le trafic du pont et des canaux localisation pour les téléphones autour de toi qui n'en ont pas." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מחבר איי mesh קרובים דרך האינטרנט: מה שאתה אומר בערוץ ה-mesh מגיע גם לאנשים באזורך מעבר לטווח הרדיו, וההודעות שלהם מופיעות כאן מסומנות בסמל הרשת. כל עוד יש לך אינטרנט, המכשיר שלך גם מעביר תעבורת גשר וערוצי מיקום עבור טלפונים סביבך שאין להם חיבור." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आसपास के मेश द्वीपों को इंटरनेट से जोड़ता है: मेश चैनल में आप जो कहते हैं वह आपके क्षेत्र में रेडियो दायरे से बाहर के लोगों तक भी पहुँचता है, और उनके संदेश यहाँ नेटवर्क चिह्न के साथ दिखते हैं। जब तक आपके पास इंटरनेट है, आपका डिवाइस आसपास के बिना इंटरनेट वाले फोनों के लिए ब्रिज और लोकेशन-चैनल का ट्रैफ़िक भी पहुँचाता है।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "menyatukan pulau-pulau mesh terdekat lewat internet: yang kamu katakan di kanal mesh juga sampai ke orang-orang di areamu di luar jangkauan radio, dan pesan mereka muncul di sini ditandai dengan simbol jaringan. selama kamu punya internet, perangkatmu juga membawa lalu lintas jembatan dan kanal lokasi untuk ponsel di sekitarmu yang tidak punya koneksi." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "unisce le isole mesh vicine tramite internet: ciò che dici nel canale mesh raggiunge anche persone della tua zona oltre la portata radio, e i loro messaggi appaiono qui contrassegnati con il simbolo di rete. finché hai internet, il tuo dispositivo trasporta anche il traffico del ponte e dei canali posizione per i telefoni intorno a te che non ne hanno." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "近くのmeshの島々をインターネットでつなぎます: meshチャンネルでの発言はエリア内の電波範囲外の人にも届き、相手のメッセージはネットワーク記号付きでここに表示されます。インターネットがある間は、あなたの端末が周囲の接続を持たない端末のためにブリッジとロケーションチャンネルの通信も運びます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "근처의 mesh 섬들을 인터넷으로 연결합니다: mesh 채널에서 말한 내용이 이 지역의 전파 범위 밖 사람들에게도 전달되고, 그들의 메시지는 네트워크 기호가 붙어 여기에 표시됩니다. 인터넷이 있는 동안에는 내 기기가 주변의 연결이 없는 휴대폰을 위해 브리지와 위치 채널 트래픽도 전달합니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "menyatukan pulau-pulau mesh berdekatan melalui internet: apa yang kamu katakan di kanal mesh juga sampai kepada orang di kawasanmu di luar jangkauan radio, dan pesan mereka muncul di sini ditanda dengan simbol rangkaian. selagi kamu ada internet, perantimu juga membawa trafik jambatan dan kanal lokasi untuk telefon di sekelilingmu yang tiada sambungan." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "नजिकका mesh टापुहरूलाई इन्टरनेटमार्फत जोड्छ: mesh च्यानलमा तिमीले भनेको कुरा तिम्रो क्षेत्रमा रेडियो पहुँचभन्दा बाहिरका मानिससम्म पनि पुग्छ, र उनीहरूका सन्देश नेटवर्क चिन्हसहित यहाँ देखिन्छन्। तिमीसँग इन्टरनेट भएसम्म, तिम्रो यन्त्रले वरपरका इन्टरनेट नभएका फोनहरूका लागि पुल र स्थान-च्यानलको ट्राफिक पनि बोक्छ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbindt nabije mesh-eilanden via internet: wat je in het mesh-kanaal zegt bereikt ook mensen in jouw omgeving buiten radiobereik, en hun berichten verschijnen hier gemarkeerd met het netwerksymbool. zolang je internet hebt, draagt je apparaat ook brug- en locatiekanaalverkeer voor telefoons om je heen die geen verbinding hebben." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "łączy pobliskie wyspy mesh przez internet: to, co mówisz na kanale mesh, dociera też do osób w twojej okolicy poza zasięgiem radiowym, a ich wiadomości pojawiają się tutaj oznaczone symbolem sieci. dopóki masz internet, twoje urządzenie przenosi też ruch mostu i kanałów lokalizacji dla telefonów wokół ciebie, które go nie mają." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "junta ilhas mesh próximas através da internet: o que dizes no canal mesh também chega a pessoas na tua zona fora do alcance de rádio, e as mensagens delas aparecem aqui marcadas com o símbolo de rede. enquanto tens internet, o teu dispositivo também transporta o tráfego da ponte e dos canais de localização para telemóveis à tua volta que não têm ligação." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "junta ilhas mesh próximas pela internet: o que você diz no canal mesh também chega a pessoas na sua área fora do alcance de rádio, e as mensagens delas aparecem aqui marcadas com o símbolo de rede. enquanto você tem internet, seu dispositivo também leva o tráfego da ponte e dos canais de localização para celulares ao seu redor que não têm conexão." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "объединяет соседние mesh-острова через интернет: то, что ты говоришь в mesh-канале, доходит и до людей в твоём районе за пределами радиуса радиосвязи, а их сообщения появляются здесь с пометкой символом сети. пока у тебя есть интернет, твоё устройство также переносит трафик моста и каналов локации для телефонов вокруг, у которых его нет." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kopplar ihop närliggande mesh-öar via internet: det du säger i mesh-kanalen når även personer i ditt område utom radioräckvidd, och deras meddelanden visas här markerade med nätverkssymbolen. så länge du har internet bär din enhet också brygg- och platskanaltrafik åt telefoner omkring dig som saknar anslutning." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அருகிலுள்ள mesh தீவுகளை இணையம் வழியாக இணைக்கிறது: mesh சேனலில் நீங்கள் சொல்வது உங்கள் பகுதியில் ரேடியோ வரம்புக்கு அப்பாலுள்ளவர்களையும் சென்றடையும், அவர்களின் செய்திகள் நெட்வொர்க் சின்னத்துடன் இங்கே தோன்றும். உங்களிடம் இணையம் இருக்கும் வரை, சுற்றியுள்ள இணைப்பு இல்லாத போன்களுக்காக உங்கள் சாதனம் பாலம் மற்றும் இட சேனல்களின் போக்குவரத்தையும் சுமக்கிறது." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เชื่อมเกาะ mesh ที่อยู่ใกล้กันผ่านอินเทอร์เน็ต: สิ่งที่คุณพูดในช่อง mesh ยังไปถึงผู้คนในพื้นที่ของคุณที่อยู่นอกระยะวิทยุ และข้อความของพวกเขาจะปรากฏที่นี่พร้อมสัญลักษณ์เครือข่ายกำกับ ตราบใดที่คุณมีอินเทอร์เน็ต อุปกรณ์ของคุณยังส่งต่อทราฟฟิกของบริดจ์และช่องตามตำแหน่งให้โทรศัพท์รอบตัวที่ไม่มีอินเทอร์เน็ตด้วย" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "yakındaki mesh adalarını internet üzerinden birleştirir: mesh kanalında söylediklerin bölgendeki telsiz menzili dışındaki kişilere de ulaşır ve onların mesajları burada ağ simgesiyle işaretli olarak görünür. internetin olduğu sürece cihazın, çevrendeki bağlantısı olmayan telefonlar için köprü ve konum kanalı trafiğini de taşır." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "об'єднує сусідні mesh-острови через інтернет: те, що ти кажеш у mesh-каналі, доходить і до людей у твоєму районі поза радіусом радіозв'язку, а їхні повідомлення з'являються тут із позначкою символом мережі. поки в тебе є інтернет, твій пристрій також переносить трафік мосту й каналів локації для телефонів навколо, які його не мають." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "قریبی mesh جزیروں کو انٹرنیٹ کے ذریعے جوڑتا ہے: mesh چینل میں آپ جو کہتے ہیں وہ آپ کے علاقے میں ریڈیو رینج سے باہر لوگوں تک بھی پہنچتا ہے، اور ان کے پیغامات یہاں نیٹ ورک علامت کے ساتھ نظر آتے ہیں۔ جب تک آپ کے پاس انٹرنیٹ ہے، آپ کا آلہ ارد گرد کے بغیر انٹرنیٹ والے فونز کے لیے پل اور لوکیشن چینلز کی ٹریفک بھی پہنچاتا ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "nối các đảo mesh gần nhau qua internet: những gì bạn nói trong kênh mesh cũng đến được mọi người trong khu vực ngoài phạm vi sóng, và tin nhắn của họ hiện ở đây kèm ký hiệu mạng. khi bạn có internet, thiết bị của bạn cũng chuyển lưu lượng cầu nối và kênh vị trí cho những điện thoại xung quanh không có kết nối." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "通过互联网连接附近的 mesh 孤岛:你在 mesh 频道说的话也会送达本地区无线电范围之外的人,他们的消息会带网络符号显示在这里。当你有互联网时,你的设备还会为周围没有网络的手机传送桥接和位置频道流量。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "透過網際網路連接附近的 mesh 孤島:你在 mesh 頻道說的話也會送達本地區無線電範圍之外的人,他們的訊息會帶網路符號顯示在這裡。當你有網際網路時,你的裝置還會為周圍沒有網路的手機傳送橋接和位置頻道流量。" - } - } - } - }, - "app_info.settings.bridge.title" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "جسر mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মেশ ব্রিজ" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh-brücke" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh bridge" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "puente mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پل مش" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh bridge" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "pont mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "גשר mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "मेश ब्रिज" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "jembatan mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "ponte mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "meshブリッジ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh 브리지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "jambatan mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh पुल" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh-brug" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "most mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ponte mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ponte mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh-мост" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh-brygga" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh பாலம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "บริดจ์ mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh köprüsü" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh-міст" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh پل" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cầu nối mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh 桥接" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh 橋接" - } - } - } - }, - "app_info.settings.connectivity.title" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الاتصال" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "সংযোগ" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "KONNEKTIVITÄT" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "CONNECTIVITY" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "CONECTIVIDAD" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اتصال" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "KONEKTIBIDAD" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "CONNECTIVITÉ" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "קישוריות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "कनेक्टिविटी" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "KONEKTIVITAS" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "CONNETTIVITÀ" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "接続" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "연결" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "KESAMBUNGAN" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "जडान" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "CONNECTIVITEIT" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ŁĄCZNOŚĆ" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "CONECTIVIDADE" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "CONECTIVIDADE" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "СВЯЗЬ" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ANSLUTNING" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இணைப்பு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "การเชื่อมต่อ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "BAĞLANTI" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "ЗВ'ЯЗОК" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "کنیکٹیویٹی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "KẾT NỐI" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "连接" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "連線" - } - } - } - }, - "app_info.settings.danger.panic_button" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسح الذعر" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "প্যানিক ওয়াইপ" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "panik-löschung" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "panic wipe" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "borrado de pánico" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پاک‌سازی اضطراری" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "panic wipe" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "effacement panique" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מחיקת בהלה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "पैनिक वाइप" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "hapus panik" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "cancellazione panico" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "パニック消去" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "패닉 삭제" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "hapus panik" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "घबराहट मेटाइ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "paniekwissen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "awaryjne wymazanie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "limpeza de pânico" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "limpeza de pânico" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "экстренное стирание" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "panikradering" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பதற்ற அழிப்பு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ล้างข้อมูลฉุกเฉิน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "panik silme" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "екстрене стирання" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پینک وائپ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "xóa khẩn cấp" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "紧急抹除" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "緊急抹除" - } - } - } - }, - "app_info.settings.danger.panic_confirm_action" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسح كل شيء" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "সব মুছে ফেলুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "alles löschen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "wipe everything" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "borrar todo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پاک کردن همه‌چیز" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "burahin lahat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tout effacer" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מחק הכול" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सब कुछ मिटाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "hapus semuanya" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "cancella tutto" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "すべて消去" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "모두 지우기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "hapus semuanya" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सबै मेट" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "alles wissen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wymaż wszystko" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "limpar tudo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "apagar tudo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "стереть всё" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "radera allt" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அனைத்தையும் அழி" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ล้างทั้งหมด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "her şeyi sil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "стерти все" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "سب کچھ مٹا دیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "xóa tất cả" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "抹除全部" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "抹除全部" - } - } - } - }, - "app_info.settings.danger.panic_confirm_title" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسح كل البيانات؟" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "সব ডেটা মুছবেন?" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "alle daten löschen?" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "wipe all data?" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿borrar todos los datos?" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "همهٔ داده‌ها پاک شود؟" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "burahin ang lahat ng data?" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "effacer toutes les données ?" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "למחוק את כל הנתונים?" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सारा डेटा मिटाएँ?" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "hapus semua data?" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "cancellare tutti i dati?" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "全データを消去しますか?" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "모든 데이터를 지울까요?" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "hapus semua data?" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सबै डाटा मेट्ने हो?" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "alle data wissen?" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wymazać wszystkie dane?" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "limpar todos os dados?" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "apagar todos os dados?" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "стереть все данные?" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "radera all data?" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "எல்லா தரவையும் அழிக்கவா?" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ล้างข้อมูลทั้งหมด?" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tüm veriler silinsin mi?" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "стерти всі дані?" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تمام ڈیٹا مٹا دیں؟" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "xóa toàn bộ dữ liệu?" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "抹除全部数据?" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "抹除全部資料?" - } - } - } - }, - "app_info.settings.danger.panic_note" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يمسح جميع الرسائل والمفاتيح والهوية. النقر ثلاث مرات على شعار bitchat/ يفعل الشيء نفسه." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "সব বার্তা, কী এবং পরিচয় মুছে ফেলে। bitchat/ লোগোতে তিনবার ট্যাপ করলেও একই কাজ হয়।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "löscht alle nachrichten, schlüssel und die identität. dreimaliges tippen aufs bitchat/-logo macht dasselbe." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "borra todos los mensajes, claves e identidad. tocar tres veces el logo de bitchat/ hace lo mismo." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "همه پیام‌ها، کلیدها و هویت را پاک می‌کند. سه بار زدن روی لوگوی bitchat/ همین کار را می‌کند." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "binubura ang lahat ng mensahe, key, at pagkakakilanlan. ganoon din ang mangyayari kapag na-tap nang tatlong beses ang bitchat/ logo." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "efface tous les messages, les clés et l'identité. toucher trois fois le logo bitchat/ fait la même chose." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מוחק את כל ההודעות, המפתחות והזהות. הקשה משולשת על הלוגו של bitchat/ עושה את אותו הדבר." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सभी संदेश, कुंजियाँ और पहचान मिटा देता है। bitchat/ लोगो पर तीन बार टैप करने से भी यही होता है।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "menghapus semua pesan, kunci, dan identitas. mengetuk logo bitchat/ tiga kali melakukan hal yang sama." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "cancella tutti i messaggi, le chiavi e l'identità. toccare tre volte il logo bitchat/ fa lo stesso." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "すべてのメッセージ・鍵・アイデンティティを消去します。bitchat/ ロゴの3回タップでも同じことができます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "모든 메시지, 키, 신원을 지웁니다. bitchat/ 로고를 세 번 탭해도 동일합니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "memadamkan semua mesej, kunci dan identiti. mengetik logo bitchat/ tiga kali melakukan perkara yang sama." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सबै सन्देश, कुञ्जी र परिचय मेटाउँछ। bitchat/ लोगोमा तीन पटक ट्याप गर्दा पनि त्यही हुन्छ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wist alle berichten, sleutels en identiteit. drie keer tikken op het bitchat/-logo doet hetzelfde." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wymazuje wszystkie wiadomości, klucze i tożsamość. trzykrotne stuknięcie logo bitchat/ robi to samo." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logótipo bitchat/ faz o mesmo." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logo bitchat/ faz o mesmo." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "стирает все сообщения, ключи и идентичность. тройное касание логотипа bitchat/ делает то же самое." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "raderar alla meddelanden, nycklar och identitet. att trippeltrycka på bitchat/-loggan gör samma sak." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "எல்லா செய்திகள், விசைகள் மற்றும் அடையாளத்தை அழிக்கிறது. bitchat/ லோகோவை மூன்று முறை தட்டினாலும் இதுவே நடக்கும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ลบข้อความ กุญแจ และตัวตนทั้งหมด การแตะโลโก้ bitchat/ สามครั้งให้ผลเหมือนกัน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tüm mesajları, anahtarları ve kimliği siler. bitchat/ logosuna üç kez dokunmak da aynısını yapar." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "стирає всі повідомлення, ключі та ідентичність. потрійне торкання логотипа bitchat/ робить те саме." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تمام پیغامات، کلیدیں اور شناخت مٹا دیتا ہے۔ bitchat/ لوگو پر تین بار ٹیپ کرنے سے بھی یہی ہوتا ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "xoá toàn bộ tin nhắn, khoá và danh tính. chạm ba lần vào logo bitchat/ cũng làm điều tương tự." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "清除所有消息、密钥和身份。连点三次 bitchat/ 标志效果相同。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "清除所有訊息、金鑰和身分。連點三次 bitchat/ 標誌效果相同。" - } - } - } - }, - "app_info.settings.danger.title" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "منطقة الخطر" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বিপদ অঞ্চল" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "GEFAHRENZONE" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "DANGER ZONE" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ZONA DE PELIGRO" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "منطقهٔ خطر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "DANGER ZONE" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ZONE DE DANGER" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אזור סכנה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "खतरे का क्षेत्र" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "ZONA BERBAHAYA" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "ZONA PERICOLOSA" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "危険ゾーン" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "위험 구역" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "ZON BAHAYA" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "खतरा क्षेत्र" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "GEVARENZONE" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "STREFA ZAGROŻENIA" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ZONA DE PERIGO" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ZONA DE PERIGO" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ОПАСНАЯ ЗОНА" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "FARLIG ZON" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ஆபத்து மண்டலம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "โซนอันตราย" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "TEHLİKELİ BÖLGE" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "НЕБЕЗПЕЧНА ЗОНА" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "خطرے کا علاقہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "VÙNG NGUY HIỂM" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "危险区域" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "危險區域" - } - } - } - }, - "app_info.settings.hide_previews.subtitle" : { - "comment" : "Subtitle explaining what hiding notification message previews does", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تقول الإشعارات إن شيئًا ما وصل دون إظهار الرسالة ولا من أرسلها ولا قناة الموقع التي جاءت منها. من يحمل هاتفك المقفل لا يعرف شيئًا من شاشة القفل. مفعّل افتراضيًا." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নোটিফিকেশন শুধু জানায় যে কিছু এসেছে — বার্তা, কে পাঠিয়েছে বা কোন লোকেশন চ্যানেল থেকে এসেছে তা দেখায় না। আপনার লক করা ফোন যার হাতেই থাকুক, লক স্ক্রিন থেকে কিছুই জানতে পারবে না। ডিফল্টভাবে চালু।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "benachrichtigungen sagen nur, dass etwas angekommen ist — ohne die nachricht, ohne wer sie geschickt hat und ohne den standortkanal, aus dem sie kam. wer dein gesperrtes handy in der hand hält, erfährt vom sperrbildschirm nichts. standardmäßig an." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "las notificaciones dicen que ha llegado algo sin mostrar el mensaje, quién lo envió ni de qué canal de ubicación viene. quien tenga tu teléfono bloqueado en la mano no aprende nada de la pantalla de bloqueo. activado por defecto." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اعلان‌ها فقط می‌گویند چیزی رسیده است، بدون نشان دادن متن پیام، فرستنده یا کانال موقعیتی که از آن آمده. هر کسی گوشی قفل‌شده‌ات را در دست بگیرد، از صفحهٔ قفل چیزی نمی‌فهمد. به‌طور پیش‌فرض روشن." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "sinasabi lang ng mga notification na may dumating, hindi ang mensahe, hindi kung sino ang nagpadala, at hindi kung saang channel ng lokasyon ito nanggaling. kahit sino ang may hawak ng naka-lock na telepono mo, wala siyang malalaman sa lock screen. naka-on bilang default." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "les notifications disent que quelque chose est arrivé sans montrer le message, qui l'a envoyé, ni de quel canal de localisation il vient. quiconque tient ton téléphone verrouillé n'apprend rien depuis l'écran de verrouillage. activé par défaut." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ההתראות מודיעות שמשהו הגיע בלי להציג את ההודעה, מי שלח אותה או מאיזה ערוץ מיקום היא באה. מי שמחזיק את הטלפון הנעול שלך לא לומד דבר ממסך הנעילה. פעיל כברירת מחדל." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "नोटिफिकेशन सिर्फ बताते हैं कि कुछ आया है — न संदेश, न भेजने वाला, न वह लोकेशन चैनल जहाँ से वह आया। आपका लॉक किया हुआ फोन किसी के हाथ में हो, लॉक स्क्रीन से उसे कुछ पता नहीं चलेगा। डिफ़ॉल्ट रूप से चालू।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "notifikasi hanya bilang ada sesuatu yang masuk, tanpa menampilkan pesannya, siapa pengirimnya, atau dari kanal lokasi mana. siapa pun yang memegang ponselmu yang terkunci tidak mendapat apa pun dari layar kunci. aktif secara bawaan." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "le notifiche dicono che è arrivato qualcosa senza mostrare il messaggio, chi l'ha inviato o da quale canale posizione arriva. chi tiene in mano il tuo telefono bloccato non scopre nulla dalla schermata di blocco. attivo per impostazione predefinita." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "通知は何か届いたことだけを伝え、メッセージの内容も、送り主も、どのロケーションチャンネルから来たかも表示しません。ロックしたスマホを誰が手にしても、ロック画面からは何もわかりません。初期設定はオンです。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "알림은 무언가 도착했다는 사실만 알리고 메시지 내용, 보낸 사람, 어느 위치 채널에서 왔는지는 보여주지 않습니다. 잠긴 휴대폰을 누가 들고 있어도 잠금 화면에서 알 수 있는 것은 없습니다. 기본값은 켜짐입니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "notifikasi hanya memberitahu ada sesuatu masuk, tanpa menunjukkan mesejnya, siapa yang menghantar, atau dari kanal lokasi mana. sesiapa yang memegang telefonmu yang berkunci tidak tahu apa-apa daripada skrin kunci. hidup secara lalai." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचनाहरूले केवल कुनै कुरा आयो भन्छन्, सन्देश, पठाउने व्यक्ति वा कुन स्थान च्यानलबाट आयो भन्ने देखाउँदैनन्। तिम्रो लक भएको फोन जोसुकैको हातमा परे पनि लक स्क्रिनबाट केही थाहा पाउँदैन। पूर्वनिर्धारित रूपमा अन।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "meldingen zeggen alleen dat er iets is binnengekomen, zonder het bericht, de afzender of het locatiekanaal waaruit het komt. wie je vergrendelde telefoon vasthoudt, komt niets te weten via het vergrendelscherm. standaard aan." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "powiadomienia mówią tylko, że coś przyszło — bez treści, bez nadawcy i bez kanału lokalizacji, z którego przyszło. ktokolwiek trzyma twój zablokowany telefon, nie dowie się niczego z ekranu blokady. domyślnie włączone." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "as notificações dizem que chegou algo sem mostrar a mensagem, quem a enviou nem de que canal de localização vem. quem tem o teu telemóvel bloqueado na mão não fica a saber nada pelo ecrã de bloqueio. ligado por predefinição." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "as notificações dizem que algo chegou sem mostrar a mensagem, quem enviou nem de qual canal de localização veio. quem estiver com seu celular bloqueado na mão não descobre nada pela tela de bloqueio. ligado por padrão." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "уведомления сообщают только, что что-то пришло — без текста, без отправителя и без канала локации, откуда оно. кто бы ни держал твой заблокированный телефон, с экрана блокировки он не узнает ничего. по умолчанию включено." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "aviseringar säger bara att något kommit in, utan meddelandet, vem som skickade det eller vilken platskanal det kom från. den som håller i din låsta telefon får inget veta från låsskärmen. på som standard." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அறிவிப்புகள் ஏதோ வந்ததை மட்டும் சொல்லும் — செய்தியையும், அனுப்பியவரையும், எந்த இட சேனலிலிருந்து வந்ததையும் காட்டாது. உங்கள் பூட்டிய தொலைபேசியை யார் கையில் வைத்திருந்தாலும், பூட்டுத் திரையிலிருந்து எதுவும் தெரியாது. இயல்பாக இயக்கத்தில் உள்ளது." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "การแจ้งเตือนจะบอกแค่ว่ามีอะไรเข้ามา โดยไม่แสดงข้อความ ไม่บอกว่าใครส่ง และไม่บอกว่ามาจากช่องตามตำแหน่งใด ใครถือโทรศัพท์ที่ล็อกอยู่ของคุณก็ไม่รู้อะไรจากหน้าจอล็อก เปิดไว้เป็นค่าเริ่มต้น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bildirimler yalnızca bir şeyin geldiğini söyler; mesajı, kimin gönderdiğini ya da hangi konum kanalından geldiğini göstermez. kilitli telefonunu elinde tutan kişi kilit ekranından hiçbir şey öğrenemez. varsayılan olarak açık." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "повідомлення лише кажуть, що щось надійшло — без тексту, без відправника й без каналу локації, звідки воно. хто б не тримав твій заблокований телефон, з екрана блокування він не дізнається нічого. типово ввімкнено." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اطلاعات صرف یہ بتاتی ہیں کہ کچھ آیا ہے، نہ پیغام دکھاتی ہیں، نہ بھیجنے والا، نہ یہ کہ کس لوکیشن چینل سے آیا۔ آپ کا مقفل فون جس کے ہاتھ میں بھی ہو، لاک اسکرین سے اسے کچھ معلوم نہیں ہوتا۔ بطور طے شدہ آن ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "thông báo chỉ cho biết có gì vừa đến, không hiện nội dung tin nhắn, ai gửi, hay đến từ kênh vị trí nào. ai đang giữ chiếc điện thoại đã khoá của bạn cũng không biết được gì từ màn hình khoá. bật theo mặc định." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "通知只告诉你有新消息抵达,不显示内容、发送者,也不显示来自哪个位置频道。谁拿着你锁定的手机,都无法从锁屏上得知任何信息。默认开启。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "通知只告訴你有新訊息抵達,不顯示內容、發送者,也不顯示來自哪個位置頻道。誰拿著你鎖定的手機,都無法從鎖定畫面得知任何資訊。預設開啟。" - } - } - } - }, - "app_info.settings.hide_previews.title" : { - "comment" : "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إخفاء معاينات الرسائل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বার্তার প্রিভিউ লুকান" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "nachrichtenvorschau ausblenden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "hide message previews" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ocultar vistas previas de mensajes" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پنهان کردن پیش‌نمایش پیام‌ها" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "itago ang preview ng mensahe" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "masquer les aperçus de messages" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הסתרת תצוגה מקדימה של הודעות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "संदेश प्रीव्यू छुपाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "sembunyikan pratinjau pesan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "nascondi le anteprime dei messaggi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "メッセージのプレビューを隠す" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "메시지 미리보기 숨기기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "sembunyikan pratonton mesej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सन्देशको प्रिभ्यु लुकाउ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "berichtvoorbeelden verbergen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ukryj podglądy wiadomości" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ocultar pré-visualizações de mensagens" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ocultar prévias de mensagens" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "скрывать превью сообщений" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "dölj förhandsvisning av meddelanden" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "செய்தி முன்தோற்றத்தை மறை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ซ่อนตัวอย่างข้อความ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesaj önizlemelerini gizle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "приховувати попередній перегляд повідомлень" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیغام کی جھلک چھپائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ẩn nội dung xem trước tin nhắn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "隐藏消息预览" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "隱藏訊息預覽" - } - } - } - }, - "app_info.settings.privacy.title" : { - "comment" : "Section header (uppercase) for privacy settings such as hiding notification previews", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "خصوصية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "গোপনীয়তা" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIVATSPHÄRE" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIVACY" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIVACIDAD" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حریم خصوصی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIBASYA" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "CONFIDENTIALITÉ" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פרטיות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "गोपनीयता" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIVASI" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIVACY" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "プライバシー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "개인정보 보호" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIVASI" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "गोपनीयता" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIVACY" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRYWATNOŚĆ" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIVACIDADE" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIVACIDADE" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "КОНФИДЕНЦИАЛЬНОСТЬ" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "PRIVAT" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "தனியுரிமை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ความเป็นส่วนตัว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "GİZLİLİK" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "КОНФІДЕНЦІЙНІСТЬ" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "رازداری" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "QUYỀN RIÊNG TƯ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "隐私" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "隱私" - } - } - } - }, - "app_info.tab.info" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "معلومات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "তথ্য" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "info" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "info" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "info" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اطلاعات" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "impormasyon" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "infos" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מידע" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "जानकारी" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "info" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "info" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "情報" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "정보" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "info" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "जानकारी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "info" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "informacje" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "info" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "informações" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "инфо" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "info" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "தகவல்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ข้อมูล" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bilgi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "інфо" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "معلومات" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "thông tin" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "信息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "資訊" - } - } - } - }, - "app_info.tab.picker_label" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "عرض" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ভিউ" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "ansicht" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "view" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "vista" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نما" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "view" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "vue" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "תצוגה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "दृश्य" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tampilan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "vista" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "보기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "paparan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "दृश्य" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "weergave" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "widok" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "vista" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "exibição" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "вид" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "vy" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "காட்சி" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "มุมมอง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "görünüm" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "вигляд" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "منظر" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "chế độ xem" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "视图" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "檢視" - } - } - } - }, - "app_info.tab.settings" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الإعدادات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "সেটিংস" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "einstellungen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "settings" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ajustes" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تنظیمات" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "settings" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "réglages" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הגדרות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सेटिंग्स" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "pengaturan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impostazioni" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "설정" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tetapan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सेटिङ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "instellingen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ustawienia" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "definições" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ajustes" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "настройки" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "inställningar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அமைப்புகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ตั้งค่า" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ayarlar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "налаштування" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "سیٹنگز" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cài đặt" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "设置" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定" - } - } - } - }, "app_info.tagline" : { "extractionState" : "manual", "localizations" : { @@ -32586,12 +5764,6 @@ "value" : "sidegroupchat" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گروه‌چت کناری" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -32738,2045 +5910,360 @@ } } }, - "app_info.voice.live.description" : { - "comment" : "Description of the live voice messages setting", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يُبث أثناء التحدث؛ الصوت المباشر الوارد يُشغَّل تلقائيًا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "কথা বলার সাথে সাথে স্ট্রিম হয়; আগত লাইভ ভয়েস স্বয়ংক্রিয়ভাবে চলে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "überträgt live beim sprechen; eingehende live-stimme wird automatisch abgespielt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "streams as you speak; incoming live voice plays automatically" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "transmite mientras hablas; la voz en vivo entrante se reproduce automáticamente" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "همان‌طور که حرف می‌زنی پخش می‌شود؛ صدای زندهٔ دریافتی خودکار پخش می‌شود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "nag-i-stream habang nagsasalita ka; awtomatikong tumutugtog ang papasok na live na boses" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "diffuse pendant que vous parlez ; la voix en direct entrante est lue automatiquement" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "משדר בזמן שאתה מדבר; קול חי נכנס מושמע אוטומטית" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "बोलते समय स्ट्रीम होता है; आने वाली लाइव आवाज़ अपने आप चलती है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "streaming saat Anda berbicara; suara langsung yang masuk diputar otomatis" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "trasmette mentre parli; la voce in diretta in arrivo viene riprodotta automaticamente" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "話しながらライブ配信。受信したライブ音声は自動再生されます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "말하는 동안 실시간 전송됩니다. 수신된 라이브 음성은 자동 재생됩니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "strim semasa anda bercakap; suara langsung masuk dimainkan secara automatik" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "बोल्दै गर्दा स्ट्रिम हुन्छ; आगमन लाइभ आवाज स्वतः बज्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "streamt terwijl je praat; inkomende live spraak wordt automatisch afgespeeld" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "przesyła na żywo podczas mówienia; przychodzący głos na żywo odtwarza się automatycznie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "transmite enquanto fala; a voz ao vivo recebida é reproduzida automaticamente" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "transmite enquanto você fala; a voz ao vivo recebida toca automaticamente" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "передаёт, пока вы говорите; входящий живой голос воспроизводится автоматически" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "strömmar medan du pratar; inkommande live-röst spelas upp automatiskt" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நீங்கள் பேசும்போதே நேரலையாக அனுப்பப்படும்; உள்வரும் நேரலை குரல் தானாக ஒலிக்கும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "สตรีมขณะพูด เสียงสดขาเข้าจะเล่นอัตโนมัติ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "siz konuşurken canlı iletilir; gelen canlı ses otomatik çalınır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "передає, поки ви говорите; вхідний живий голос відтворюється автоматично" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "بولتے وقت لائیو نشر ہوتا ہے؛ موصول ہونے والی لائیو آواز خود بخود چلتی ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "phát trực tiếp khi bạn nói; giọng nói trực tiếp đến sẽ tự phát" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "边说边实时传输;收到的实时语音会自动播放" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "邊說邊即時傳輸;收到的即時語音會自動播放" - } - } - } - }, - "app_info.voice.live.title" : { - "comment" : "Title of the live voice messages setting", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "رسائل صوتية مباشرة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "লাইভ ভয়েস বার্তা" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "live-sprachnachrichten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "live voice messages" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensajes de voz en vivo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام‌های صوتی زنده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "live na mga voice message" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "messages vocaux en direct" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הודעות קוליות חיות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "लाइव वॉयस संदेश" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "pesan suara langsung" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "messaggi vocali in diretta" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ライブ音声メッセージ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "라이브 음성 메시지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesej suara langsung" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "लाइभ भ्वाइस सन्देशहरू" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "live spraakberichten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wiadomości głosowe na żywo" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensagens de voz ao vivo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensagens de voz ao vivo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "живые голосовые сообщения" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "live-röstmeddelanden" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நேரலை குரல் செய்திகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ข้อความเสียงสด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "canlı sesli mesajlar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "живі голосові повідомлення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "لائیو صوتی پیغامات" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tin nhắn thoại trực tiếp" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "实时语音消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "即時語音訊息" - } - } - } - }, - "app_info.voice.title" : { - "comment" : "Section header for voice settings in the app info sheet", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الصوت" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ভয়েস" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "AUDIO" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "VOICE" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "VOZ" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "صدا" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "BOSES" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "VOIX" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "קול" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "वॉयस" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "SUARA" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "VOCE" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ボイス" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "음성" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "SUARA" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "आवाज" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "SPRAAK" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "GŁOS" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "VOZ" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "VOZ" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ГОЛОС" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "RÖST" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "குரல்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เสียง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "SES" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "ГОЛОС" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "آواز" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "GIỌNG NÓI" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "语音" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "語音" - } - } - } - }, - "bridge_people.accessibility.row_hint" : { - "comment" : "Accessibility hint for a person listed in the bridge section of the people sheet", + "app_info.warning.message" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "في منطقتك، متصل عبر الجسر" + "value" : "أمان الرسائل الخاصة لم يتم تدقيقه بالكامل بعد. لا تستخدمها في الحالات الحرجة حتى يختفي هذا التحذير." } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "আপনার এলাকায়, সেতুর মাধ্যমে সংযুক্ত" + "value" : "ব্যক্তিগত বার্তার নিরাপত্তা এখনো সম্পূর্ণ অডিট হয়নি। এই সতর্কতা না থাকা পর্যন্ত জরুরি পরিস্থিতিতে ব্যবহার করবেন না।" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "In deiner Gegend, verbunden über die Brücke" + "value" : "die sicherheit privater nachrichten wurde noch nicht vollständig geprüft. nutze sie nicht für kritische situationen, solange dieser hinweis erscheint." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "In your area, connected through the bridge" + "value" : "private message security has not yet been fully audited. do not use for critical situations until this warning disappears." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "En tu zona, conectado a través del puente" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در منطقهٔ تو، متصل از طریق پل" + "value" : "la seguridad de los mensajes privados aún no ha sido auditada por completo. no lo uses en situaciones críticas hasta que este aviso desaparezca." } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "Nasa inyong lugar, konektado sa pamamagitan ng tulay" + "value" : "hindi pa ganap na na-audit ang seguridad ng pribadong mensahe. huwag gamitin para sa kritikal na sitwasyon hangga't hindi nawawala ang babalang ito." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Dans votre zone, connecté via le pont" + "value" : "la sécurité des messages privés n'a pas encore été entièrement auditée. n'utilise pas pour des situations critiques tant que cet avertissement reste." } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "באזור שלך, מחובר דרך הגשר" + "value" : "אבטחת ההודעות הפרטיות עדיין לא נבדקה במלואה. אל תשתמש למצבים קריטיים עד שהאזהרה תיעלם." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "आपके क्षेत्र में, पुल के माध्यम से जुड़ा हुआ" + "value" : "निजी संदेश सुरक्षा का अभी पूरा ऑडिट नहीं हुआ है। यह चेतावनी हटने तक गंभीर स्थितियों में उपयोग न करें।" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Di area Anda, terhubung melalui jembatan" + "value" : "keamanan pesan pribadi belum diaudit sepenuhnya. jangan dipakai untuk situasi kritis sampai peringatan ini hilang." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Nella tua zona, connesso tramite il ponte" + "value" : "la sicurezza dei messaggi privati non è stata ancora auditata completamente. non usarli in situazioni critiche finché questo avviso resta." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "あなたのエリア内、ブリッジ経由で接続" + "value" : "プライベートメッセージの安全性はまだ完全に監査されていません。この警告が消えるまで重要な場面では使わないでください。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "당신의 지역에서 브리지를 통해 연결됨" + "value" : "비공개 메시지 보안은 아직 완전히 감사받지 않았습니다. 이 경고가 사라질 때까지 중요한 상황에서는 사용하지 마세요." } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "Di kawasan anda, disambungkan melalui jambatan" + "value" : "keamanan pesan pribadi belum diaudit sepenuhnya. jangan diguna untuk situasi kritis sampai peringatan ini hilang." } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "तपाईंको क्षेत्रमा, पुल मार्फत जोडिएको" + "value" : "व्यक्तिगत सन्देशको सुरक्षा पूर्ण रूपमा अडिट भएको छैन। यो चेतावनी हट्दासम्म गम्भीर अवस्थामा प्रयोग नगर्नु।" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "In jouw omgeving, verbonden via de brug" + "value" : "de beveiliging van privéberichten is nog niet volledig geaudit. gebruik dit niet in kritieke situaties totdat deze melding verdwijnt." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "W Twojej okolicy, połączony przez most" + "value" : "bezpieczeństwo wiadomości prywatnych nie zostało jeszcze w pełni sprawdzone. nie używaj w sytuacjach krytycznych, dopóki to ostrzeżenie nie zniknie." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Na sua área, ligado através da ponte" + "value" : "a segurança das mensagens privadas ainda não foi totalmente auditada. não uses em situações críticas até este aviso desaparecer." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Na sua área, conectado pela ponte" + "value" : "a segurança das mensagens privadas ainda não foi totalmente auditada. não use em situações críticas até que este aviso desapareça." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "В вашем районе, на связи через мост" + "value" : "безопасность приватных сообщений ещё не прошла полный аудит. не используй для критичных случаев, пока предупреждение не исчезнет." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "I ditt område, ansluten via bron" + "value" : "säkerheten för privata meddelanden är ännu inte fullständigt granskad. använd inte i kritiska situationer förrän detta meddelande försvinner." } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "உங்கள் பகுதியில், பாலம் வழியாக இணைக்கப்பட்டுள்ளது" + "value" : "தனிப்பட்ட செய்தி பாதுகாப்பு இன்னும் முழுமையாக ஆய்வு செய்யப்படவில்லை. இந்த எச்சரிக்கை மறையும் வரை முக்கிய அவசரங்களுக்கு பயன்படுத்தாதீர்கள்." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ในพื้นที่ของคุณ เชื่อมต่อผ่านสะพาน" + "value" : "ความปลอดภัยของข้อความส่วนตัวยังไม่ได้รับการตรวจสอบทั้งหมด อย่าใช้ในสถานการณ์วิกฤติจนกว่าคำเตือนนี้จะหายไป" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Bölgenizde, köprü üzerinden bağlı" + "value" : "özel mesaj güvenliği henüz tamamen denetlenmedi. bu uyarı kaybolana kadar kritik durumlarda kullanmayın." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "У вашому районі, на зв'язку через міст" + "value" : "безпека приватних повідомлень ще не пройшла повний аудит. не використовуй для критичних ситуацій, поки це попередження не зникне." } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "آپ کے علاقے میں، پل کے ذریعے منسلک" + "value" : "نجی پیغامات کی سیکیورٹی کا ابھی مکمل آڈٹ نہیں ہوا۔ اس انتباہ کے ختم ہونے تک اسے اہم حالات میں استعمال نہ کریں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Trong khu vực của bạn, kết nối qua cầu" + "value" : "bảo mật tin nhắn riêng tư vẫn chưa được kiểm toán đầy đủ. đừng dùng cho tình huống quan trọng cho tới khi cảnh báo này biến mất." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在您的区域内,通过桥接连接" + "value" : "私信安全尚未完全审计。在此警告消失前不要用于关键情境。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "在您的區域內,透過橋接連線" + "value" : "私信安全尚未完全審計。在此警告消失前不要用於關鍵情境。" } } } }, - "bridge_people.section_title" : { - "comment" : "Section header in the people sheet for participants reachable via the mesh bridge", + "app_info.warning.title" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "عبر الجسر" + "value" : "تحذير" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "সেতুর ওপারে" + "value" : "সতর্কতা" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "über die brücke" + "value" : "WARNUNG" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "across the bridge" + "value" : "WARNING" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "al otro lado del puente" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "آن‌سوی پل" + "value" : "ADVERTENCIA" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "sa kabila ng tulay" + "value" : "BABALA" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "de l'autre côté du pont" + "value" : "AVERTISSEMENT" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "מעבר לגשר" + "value" : "אזהרה" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "पुल के पार" + "value" : "चेतावनी" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "di seberang jembatan" + "value" : "PERINGATAN" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "oltre il ponte" + "value" : "AVVISO" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ブリッジの向こう側" + "value" : "警告" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "브리지 건너편" + "value" : "경고" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "di seberang jambatan" + "value" : "PERINGATAN" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "पुल पारि" + "value" : "चेतावनी" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "aan de overkant van de brug" + "value" : "WAARSCHUWING" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "po drugiej stronie mostu" + "value" : "OSTRZEŻENIE" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "do outro lado da ponte" + "value" : "AVISO" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "do outro lado da ponte" + "value" : "AVISO" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "по ту сторону моста" + "value" : "ПРЕДУПРЕЖДЕНИЕ" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "på andra sidan bron" + "value" : "VARNING" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "பாலத்தின் மறுபுறம்" + "value" : "எச்சரிக்கை" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "อีกฝั่งของสะพาน" + "value" : "คำเตือน" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "köprünün öte yanında" + "value" : "UYARI" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "по той бік мосту" + "value" : "ПОПЕРЕДЖЕННЯ" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "پل کے پار" + "value" : "انتباہ" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "bên kia cầu" + "value" : "CẢNH BÁO" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "桥的另一端" + "value" : "警告" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "橋的另一端" - } - } - } - }, - "Choose an image" : { - "comment" : "A label displayed above a button that allows the user to choose an image to send.", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اختر صورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "একটি ছবি নির্বাচন করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bild auswählen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose an image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elige una imagen" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "انتخاب تصویر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pumili ng larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choisir une image" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בחר תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "एक चित्र चुनें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scegli un’immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像を選択" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이미지를 선택하세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "एउटा तस्वीर चयन गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kies een afbeelding" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wybierz obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolher uma imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolha uma imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выберите изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Välj en bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลือกภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bir görüntü seç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Виберіть зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ایک تصویر منتخب کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chọn một hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "选择图像" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "選擇圖像" - } - } - } - }, - "channel.share.action" : { - "comment" : "Context-menu / accessibility / button label that shares a location-channel invite", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مشاركة القناة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "চ্যানেল শেয়ার করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "kanal teilen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "share channel" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "compartir canal" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اشتراک‌گذاری کانال" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "i-share ang channel" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "partager le canal" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שתף ערוץ" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "चैनल साझा करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "bagikan kanal" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "condividi canale" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "チャンネルを共有" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "채널 공유" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "kongsi saluran" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "च्यानल सेयर गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kanaal delen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "udostępnij kanał" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "partilhar canal" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "compartilhar canal" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "поделиться каналом" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "dela kanal" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "சேனலைப் பகிர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "แชร์ช่อง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kanalı paylaş" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "поділитися каналом" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "چینل شیئر کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "chia sẻ kênh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "分享频道" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "分享頻道" - } - } - } - }, - "channel.share.payload" : { - "comment" : "Plain-text share payload for a location channel; %1$@ is the geohash, %2$@ is the App Store URL", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "انضم إلى قناة #%1$@ على bitchat: bitchat://geohash/%1$@ — جديد على bitchat؟ حمّله من %2$@ ثم اكتب #%1$@ ضمن قنوات الموقع." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchat-এ #%1$@ চ্যানেলে যোগ দিন: bitchat://geohash/%1$@ — bitchat-এ নতুন? %2$@ থেকে নিন, তারপর লোকেশন চ্যানেলে #%1$@ টাইপ করুন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "tritt dem #%1$@-kanal auf bitchat bei: bitchat://geohash/%1$@ — neu bei bitchat? hole es unter %2$@ und tippe dann #%1$@ unter standort-kanäle." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "join the #%1$@ channel on bitchat: bitchat://geohash/%1$@ — new to bitchat? get it at %2$@ then type #%1$@ under location channels." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "únete al canal #%1$@ en bitchat: bitchat://geohash/%1$@ — ¿nuevo en bitchat? consíguelo en %2$@ y luego escribe #%1$@ en canales de ubicación." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "به کانال #%1$@ در bitchat بپیوندید: bitchat://geohash/%1$@ — تازه‌واردید؟ از %2$@ بگیرید و سپس #%1$@ را در کانال‌های موقعیت وارد کنید." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "sumali sa #%1$@ channel sa bitchat: bitchat://geohash/%1$@ — bago sa bitchat? kunin ito sa %2$@ tapos i-type ang #%1$@ sa mga channel ng lokasyon." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "rejoins le canal #%1$@ sur bitchat : bitchat://geohash/%1$@ — nouveau sur bitchat ? télécharge-le sur %2$@ puis saisis #%1$@ dans les canaux localisation." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הצטרף לערוץ #%1$@ ב-bitchat: bitchat://geohash/%1$@ — חדש ב-bitchat? הורד מ-%2$@ ואז הקלד #%1$@ תחת ערוצי מיקום." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchat पर #%1$@ चैनल से जुड़ें: bitchat://geohash/%1$@ — bitchat नए हैं? %2$@ से पाएँ, फिर लोकेशन चैनल में #%1$@ टाइप करें।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "gabung ke kanal #%1$@ di bitchat: bitchat://geohash/%1$@ — baru di bitchat? unduh di %2$@ lalu ketik #%1$@ di kanal lokasi." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "entra nel canale #%1$@ su bitchat: bitchat://geohash/%1$@ — nuovo su bitchat? scaricalo da %2$@ e poi digita #%1$@ nei canali posizione." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchatの#%1$@チャンネルに参加: bitchat://geohash/%1$@ — はじめてですか? %2$@ から入手し、ロケーションチャンネルで #%1$@ と入力してください。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchat의 #%1$@ 채널에 참여하세요: bitchat://geohash/%1$@ — bitchat이 처음이신가요? %2$@에서 받은 뒤 위치 채널에서 #%1$@을(를) 입력하세요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "sertai saluran #%1$@ di bitchat: bitchat://geohash/%1$@ — baharu di bitchat? dapatkannya di %2$@ kemudian taip #%1$@ di bawah kanal lokasi." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchat मा #%1$@ च्यानलमा सामेल हुनुहोस्: bitchat://geohash/%1$@ — bitchat मा नयाँ? %2$@ बाट लिनुहोस्, त्यसपछि स्थान च्यानलमा #%1$@ टाइप गर्नुहोस्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "doe mee met het #%1$@-kanaal op bitchat: bitchat://geohash/%1$@ — nieuw bij bitchat? haal het op %2$@ en typ daarna #%1$@ onder locatiekanalen." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dołącz do kanału #%1$@ w bitchat: bitchat://geohash/%1$@ — nowość w bitchat? pobierz z %2$@, a potem wpisz #%1$@ w kanałach lokalizacji." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "junta-te ao canal #%1$@ no bitchat: bitchat://geohash/%1$@ — novo no bitchat? obtém-no em %2$@ e depois escreve #%1$@ em canais de localização." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "entre no canal #%1$@ no bitchat: bitchat://geohash/%1$@ — novo no bitchat? baixe em %2$@ e depois digite #%1$@ em canais de localização." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "присоединяйся к каналу #%1$@ в bitchat: bitchat://geohash/%1$@ — впервые в bitchat? скачай на %2$@, затем введи #%1$@ в каналах локации." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "gå med i #%1$@-kanalen på bitchat: bitchat://geohash/%1$@ — ny på bitchat? hämta den på %2$@ och skriv sedan #%1$@ under platskanaler." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchat-இல் #%1$@ சேனலில் சேரவும்: bitchat://geohash/%1$@ — bitchat புதியவரா? %2$@ இல் பெற்று, பின்னர் இட சேனல்களில் #%1$@ என தட்டச்சு செய்யவும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เข้าร่วมช่อง #%1$@ บน bitchat: bitchat://geohash/%1$@ — ใหม่กับ bitchat? ดาวน์โหลดที่ %2$@ แล้วพิมพ์ #%1$@ ในช่องตามตำแหน่ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchat'te #%1$@ kanalına katıl: bitchat://geohash/%1$@ — bitchat'te yeni misin? %2$@ adresinden indir, sonra konum kanallarında #%1$@ yaz." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "приєднуйся до каналу #%1$@ у bitchat: bitchat://geohash/%1$@ — вперше в bitchat? завантаж на %2$@, потім введи #%1$@ у каналах локації." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchat پر #%1$@ چینل میں شامل ہوں: bitchat://geohash/%1$@ — bitchat نئے ہیں؟ %2$@ سے حاصل کریں، پھر لوکیشن چینلز میں #%1$@ ٹائپ کریں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tham gia kênh #%1$@ trên bitchat: bitchat://geohash/%1$@ — mới dùng bitchat? tải tại %2$@ rồi nhập #%1$@ trong kênh vị trí." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "加入 bitchat 上的 #%1$@ 频道:bitchat://geohash/%1$@ — 初次使用 bitchat?前往 %2$@ 获取,然后在位置频道中输入 #%1$@。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "加入 bitchat 上的 #%1$@ 頻道:bitchat://geohash/%1$@ — 初次使用 bitchat?前往 %2$@ 取得,然後在位置頻道中輸入 #%1$@。" - } - } - } - }, - "channel.share.precision_warning.confirm" : { - "comment" : "Confirms sharing a fine-precision location channel after the OpSec warning", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "المشاركة على أي حال" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "তবুও শেয়ার করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "trotzdem teilen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "share anyway" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "compartir de todos modos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "باز هم اشتراک‌گذاری" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "i-share pa rin" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "partager quand même" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שתף בכל זאת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "फिर भी साझा करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "bagikan saja" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "condividi comunque" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "それでも共有" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "그래도 공유" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "kongsi juga" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "तैपनि सेयर गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "toch delen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "udostępnij mimo to" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "partilhar na mesma" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "compartilhar mesmo assim" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "всё равно поделиться" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "dela ändå" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இருந்தாலும் பகிர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "แชร์อยู่ดี" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "yine de paylaş" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "все одно поділитися" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پھر بھی شیئر کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "vẫn chia sẻ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "仍然分享" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "仍然分享" - } - } - } - }, - "channel.share.precision_warning.message" : { - "comment" : "Body of the confirmation before sharing a fine-precision geohash invite", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تغطي هذه القناة منطقة صغيرة. الدعوة عبر الرسائل أو imessage تظهر لمشغّل الشبكة والجهازين — وتكشف الاهتمام بذلك المكان، وليس فقط أن شخصًا يستخدم bitchat." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই চ্যানেলটি ছোট এলাকা জুড়ে। sms বা imessage-এ পাঠানো আমন্ত্রণ ক্যারিয়ার ও দুই হ্যান্ডসেটে দেখা যায় — এতে bitchat ব্যবহারের পাশাপাশি সেই জায়গায় আগ্রহও প্রকাশ পায়।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "dieser kanal deckt ein kleines gebiet ab. eine einladung per sms oder imessage ist für den netzanbieter und beide geräte sichtbar — sie verrät interesse an diesem ort, nicht nur dass jemand bitchat nutzt." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "este canal cubre un área pequeña. una invitación enviada por sms o imessage es visible para el operador y ambos teléfonos — revela interés en ese lugar, no solo que alguien usa bitchat." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این کانال ناحیه کوچکی را پوشش می‌دهد. دعوت‌نامهٔ sms یا imessage برای اپراتور و هر دو دستگاه دیده می‌شود — علاقه به آن مکان را فاش می‌کند، نه فقط اینکه کسی از bitchat استفاده می‌کند." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "maliit na lugar ang saklaw ng channel na ito. ang invite sa sms o imessage ay makikita ng carrier at ng parehong device — inilalantad nito ang interes sa lugar na iyon, hindi lang na may gumagamit ng bitchat." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ce canal couvre une petite zone. une invitation envoyée par sms ou imessage est visible par l’opérateur et les deux appareils — elle révèle un intérêt pour ce lieu, pas seulement que quelqu’un utilise bitchat." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הערוץ הזה מכסה אזור קטן. הזמנה ב-sms או imessage גלויה לספק ולשני המכשירים — היא חושפת עניין במקום, לא רק שמישהו משתמש ב-bitchat." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "यह चैनल छोटे क्षेत्र को कवर करता है। sms या imessage से भेजा निमंत्रण कैरियर और दोनों हैंडसेट को दिखता है — इससे bitchat इस्तेमाल करने के अलावा उस जगह में रुचि भी ज़ाहिर होती है।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "kanal ini mencakup area kecil. undangan lewat sms atau imessage terlihat oleh operator dan kedua perangkat — itu mengungkapkan minat pada tempat itu, bukan hanya bahwa seseorang memakai bitchat." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "questo canale copre un’area piccola. un invito inviato via sms o imessage è visibile all’operatore e a entrambi i telefoni — rivela interesse per quel luogo, non solo che qualcuno usa bitchat." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "このチャンネルは狭い範囲をカバーします。smsやimessageで送った招待は通信事業者と双方の端末に見えます — bitchatを使っていることだけでなく、その場所への関心も伝わります。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 채널은 좁은 영역을 다룹니다. sms나 imessage로 보낸 초대는 통신사와 양쪽 기기에 보입니다 — bitchat을 쓴다는 사실뿐 아니라 그 장소에 대한 관심도 드러납니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "saluran ini meliputi kawasan kecil. jemputan melalui sms atau imessage kelihatan kepada operator dan kedua-dua peranti — ia mendedahkan minat terhadap tempat itu, bukan hanya bahawa seseorang menggunakan bitchat." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यो च्यानल सानो क्षेत्र समेट्छ। sms वा imessage मार्फत पठाइएको निमन्त्रणा क्यारियर र दुवै ह्यान्डसेटमा देखिन्छ — यसले bitchat प्रयोग मात्र होइन, त्यो ठाउँप्रति चासो पनि खुलाउँछ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "dit kanaal dekt een klein gebied. een uitnodiging via sms of imessage is zichtbaar voor de provider en beide toestellen — het onthult interesse in die plek, niet alleen dat iemand bitchat gebruikt." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ten kanał obejmuje mały obszar. zaproszenie wysłane sms-em lub imessage jest widoczne dla operatora i obu telefonów — ujawnia zainteresowanie tym miejscem, nie tylko to, że ktoś używa bitchat." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "este canal cobre uma área pequena. um convite enviado por sms ou imessage fica visível para a operadora e ambos os telemóveis — revela interesse nesse local, não só que alguém usa bitchat." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "este canal cobre uma área pequena. um convite enviado por sms ou imessage fica visível para a operadora e ambos os celulares — revela interesse naquele lugar, não só que alguém usa bitchat." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "этот канал покрывает небольшую область. приглашение по sms или imessage видно оператору и обоим устройствам — оно раскрывает интерес к этому месту, а не только то, что кто-то пользуется bitchat." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "denna kanal täcker ett litet område. en inbjudan via sms eller imessage syns för operatören och båda enheterna — den avslöjar intresse för platsen, inte bara att någon använder bitchat." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இந்த சேனல் சிறிய பகுதியை உள்ளடக்குகிறது. sms அல்லது imessage அழைப்பு கேரியருக்கும் இரு சாதனங்களுக்கும் தெரியும் — bitchat பயன்படுத்துவது மட்டுமல்ல, அந்த இடத்தில் ஆர்வமும் வெளிப்படும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ช่องนี้ครอบคลุมพื้นที่เล็ก คำเชิญทาง sms หรือ imessage เปิดเผยต่อผู้ให้บริการและทั้งสองเครื่อง — มันเผยความสนใจในสถานที่นั้น ไม่ใช่แค่การใช้ bitchat" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu kanal küçük bir alanı kapsar. sms veya imessage ile gönderilen davet operatör ve her iki cihazda görünür — yalnızca birinin bitchat kullandığını değil, o yere ilgiyi de açığa çıkarır." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "цей канал покриває невелику ділянку. запрошення через sms або imessage бачать оператор і обидва пристрої — воно розкриває інтерес до цього місця, а не лише те, що хтось користується bitchat." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہ چینل چھوٹے علاقے کا احاطہ کرتا ہے۔ sms یا imessage پر بھیجی گئی دعوت کیریئر اور دونوں ہینڈسیٹس کو نظر آتی ہے — اس سے صرف bitchat کا استعمال نہیں بلکہ اس جگہ میں دلچسپی بھی ظاہر ہوتی ہے۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "kênh này phủ một khu vực nhỏ. lời mời gửi qua sms hoặc imessage hiện với nhà mạng và cả hai máy — nó tiết lộ sự quan tâm đến nơi đó, không chỉ việc ai đó dùng bitchat." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此频道覆盖范围很小。通过短信或 iMessage 发送的邀请对运营商和双方设备可见——它会暴露对该地点的兴趣,而不仅是有人在使用 bitchat。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "此頻道覆蓋範圍很小。透過簡訊或 iMessage 傳送的邀請對電信商與雙方裝置可見——它會揭露對該地點的興趣,而不只是有人在使用 bitchat。" - } - } - } - }, - "channel.share.precision_warning.title" : { - "comment" : "Title of the confirmation before sharing a neighborhood-or-finer geohash invite", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مشاركة قناة موقع دقيقة؟" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "সঠিক লোকেশন চ্যানেল শেয়ার করবেন?" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "präzisen standort-kanal teilen?" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "share a precise location channel?" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿compartir un canal de ubicación precisa?" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کانال موقعیت دقیق به اشتراک گذاشته شود؟" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "i-share ang isang precise na location channel?" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "partager un canal de localisation précis ?" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "לשתף ערוץ מיקום מדויק?" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सटीक लोकेशन चैनल साझा करें?" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "bagikan kanal lokasi yang tepat?" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "condividere un canale di posizione precisa?" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "精密なロケーションチャンネルを共有しますか?" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "정밀한 위치 채널을 공유할까요?" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "kongsi saluran lokasi yang tepat?" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सटीक स्थान च्यानल सेयर गर्ने?" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "een precies locatiekanaal delen?" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "udostępnić precyzyjny kanał lokalizacji?" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "partilhar um canal de localização precisa?" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "compartilhar um canal de localização precisa?" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "поделиться точным каналом локации?" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "dela en precis platskanal?" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "துல்லியமான இட சேனலைப் பகிரவா?" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "แชร์ช่องตำแหน่งที่แม่นยำ?" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "hassas bir konum kanalı paylaşılsın mı?" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "поділитися точним каналом локації?" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "درست لوکیشن چینل شیئر کریں؟" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "chia sẻ kênh vị trí chính xác?" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "分享精确的位置频道?" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "分享精確的位置頻道?" + "value" : "警告" } } } @@ -34814,12 +6301,6 @@ "value" : "cerrar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بستن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -34999,12 +6480,6 @@ "value" : "cancelar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "لغو" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -35184,12 +6659,6 @@ "value" : "cerrar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بستن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -35369,12 +6838,6 @@ "value" : "copiar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کپی" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -35521,192 +6984,6 @@ } } }, - "common.done" : { - "comment" : "Dismisses a sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "সম্পন্ন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "fertig" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "done" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "listo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تمام" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "tapos" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "terminé" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בוצע" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "हो गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "selesai" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "fatto" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "完了" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "완료" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "selesai" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सम्पन्न" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "klaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "gotowe" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "concluído" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "concluído" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "готово" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "klart" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "முடிந்தது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เสร็จสิ้น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitti" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "готово" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "مکمل" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "xong" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "完成" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "完成" - } - } - } - }, "common.ok" : { "extractionState" : "manual", "localizations" : { @@ -35740,12 +7017,6 @@ "value" : "aceptar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "باشه" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -35925,12 +7196,6 @@ "value" : "desactivado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "خاموش" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -36110,12 +7375,6 @@ "value" : "activado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "روشن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -36295,12 +7554,6 @@ "value" : "desconocido" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نامشخص" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -36480,12 +7733,6 @@ "value" : "agregar a favoritos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "افزودن به موردعلاقه‌ها" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -36632,564 +7879,6 @@ } } }, - "content.accessibility.app_info_hint" : { - "comment" : "Accessibility hint on the bitchat/ logo explaining a tap opens app info", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "يعرض معلومات التطبيق" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "অ্যাপের তথ্য দেখায়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zeigt app-infos" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "shows app info" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "muestra la información de la app" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اطلاعات برنامه را نشان می‌دهد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ipinapakita ang impormasyon ng app" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "affiche les infos de l'app" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מציג את פרטי האפליקציה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ऐप जानकारी दिखाता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "menampilkan info aplikasi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mostra le info dell'app" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "アプリ情報を表示します" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "앱 정보를 표시합니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "menunjukkan maklumat aplikasi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "एप जानकारी देखाउँछ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toont app-info" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pokazuje informacje o aplikacji" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mostra as informações da app" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mostra informações do app" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "показывает информацию о приложении" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "visar appinfo" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "செயலி தகவலைக் காட்டும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แสดงข้อมูลแอป" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uygulama bilgisini gösterir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "показує інформацію про застосунок" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ایپ کی معلومات دکھاتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hiển thị thông tin ứng dụng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "显示应用信息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "顯示應用程式資訊" - } - } - } - }, - "content.accessibility.attach_photo" : { - "comment" : "Accessibility label for the photo attachment button", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "إرفاق صورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি যুক্ত করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "foto anhängen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "attach photo" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "adjuntar foto" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیوست عکس" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "maglakip ng larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "joindre une photo" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "צירוף תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "फ़ोटो संलग्न करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lampirkan foto" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "allega foto" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "写真を添付" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "사진 첨부" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lampirkan foto" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर संलग्न गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "foto bijvoegen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dołącz zdjęcie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anexar foto" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anexar foto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "прикрепить фото" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bifoga foto" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "புகைப்படத்தை இணைக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แนบรูปภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fotoğraf ekle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "додати фото" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر منسلک کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đính kèm ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "附加照片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "附加照片" - } - } - } - }, - "content.accessibility.attach_photo_hint" : { - "comment" : "Accessibility hint explaining the attachment button opens the photo library", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "يفتح مكتبة الصور؛ استخدم إجراء التقاط صورة للكاميرا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবির লাইব্রেরি খোলে; ক্যামেরার জন্য ছবি তোলার অ্যাকশন ব্যবহার করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öffnet die fotobibliothek; nutze die aktion foto aufnehmen für die kamera" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens the photo library; use the take photo action for the camera" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre la fototeca; usa la acción tomar foto para la cámara" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کتابخانهٔ عکس را باز می‌کند؛ برای دوربین از گزینهٔ گرفتن عکس استفاده کن" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "binubuksan ang photo library; gamitin ang aksyong kumuha ng larawan para sa camera" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ouvre la photothèque ; utilise l'action prendre une photo pour l'appareil photo" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "פותח את ספריית התמונות; לשימוש במצלמה השתמש בפעולת צילום תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "फ़ोटो लाइब्रेरी खोलता है; कैमरे के लिए फ़ोटो लें क्रिया का उपयोग करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka galeri foto; gunakan aksi ambil foto untuk kamera" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "apre la libreria foto; usa l'azione scatta foto per la fotocamera" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "写真ライブラリを開きます。カメラには写真を撮るを使用してください" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "사진 보관함을 엽니다. 카메라는 사진 촬영 동작을 사용하세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka galeri foto; guna tindakan ambil foto untuk kamera" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर लाइब्रेरी खोल्छ; क्यामेराका लागि तस्बिर खिच्ने कार्य प्रयोग गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "opent de fotobibliotheek; gebruik de actie foto maken voor de camera" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "otwiera bibliotekę zdjęć; użyj akcji zrób zdjęcie, aby użyć aparatu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abre a biblioteca de fotos; usa a ação tirar foto para a câmara" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abre a biblioteca de fotos; use a ação tirar foto para a câmera" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "открывает библиотеку фото; для камеры используй действие «сделать фото»" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öppnar fotobiblioteket; använd ta foto för kameran" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "புகைப்படத் தொகுப்பைத் திறக்கும்; கேமராவுக்கு புகைப்படம் எடுக்கும் செயலைப் பயன்படுத்தவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เปิดคลังรูปภาพ ใช้การถ่ายรูปสำหรับกล้อง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fotoğraf kitaplığını açar; kamera için fotoğraf çek eylemini kullanın" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "відкриває бібліотеку фото; для камери скористайся дією зробити фото" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویری لائبریری کھولتا ہے؛ کیمرے کیلئے تصویر لینے کا عمل استعمال کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mở thư viện ảnh; dùng thao tác chụp ảnh cho máy ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "打开照片图库;使用拍照操作可调用相机" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "打開照片圖庫;要用相機請使用拍照操作" - } - } - } - }, "content.accessibility.available_nostr" : { "extractionState" : "manual", "localizations" : { @@ -37223,12 +7912,6 @@ "value" : "disponible vía Nostr" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در دسترس از طریق Nostr" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -37408,12 +8091,6 @@ "value" : "volver al chat principal" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بازگشت به چت اصلی" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -37560,562 +8237,6 @@ } } }, - "content.accessibility.bridged_count" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld أشخاص آخرون عبر الجسر" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্রিজের ওপারে আরও %lld জন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld weitere personen über die brücke" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld more people across the bridge" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld personas más al otro lado del puente" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld نفر دیگر آن‌سوی پل" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld pang tao sa kabila ng bridge" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld personnes de plus via le pont" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "עוד %lld אנשים מעבר לגשר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ब्रिज के पार %lld और लोग" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld orang lagi di seberang jembatan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "altre %lld persone oltre il ponte" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ブリッジの先にさらに%lld人" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "브리지 너머 %lld명 더" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld lagi orang di seberang jambatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "पुलपारि थप %lld जना" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld extra mensen via de brug" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld więcej osób po drugiej stronie mostu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "mais %lld pessoas do outro lado da ponte" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "mais %lld pessoas do outro lado da ponte" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ещё %lld человек по ту сторону моста" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld personer till via bryggan" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பாலத்தின் அப்பால் மேலும் %lld பேர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "อีก %lld คนที่อีกฝั่งของบริดจ์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "köprünün ötesinde %lld kişi daha" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "ще %lld людей по той бік мосту" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پل کے پار %lld مزید لوگ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "thêm %lld người bên kia cầu" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "桥另一侧还有 %lld 人" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "橋另一側還有 %lld 人" - } - } - } - }, - "content.accessibility.bridged_message" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "وصلت عبر جسر mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "মেশ ব্রিজ পেরিয়ে এসেছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Über eine mesh-brücke angekommen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Arrived across a mesh bridge" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Llegó a través de un puente mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "از طریق پل مش رسیده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dumating sa pamamagitan ng mesh bridge" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Arrivé via un pont mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הגיעה דרך גשר mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "मेश ब्रिज से होकर आया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiba lewat jembatan mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Arrivato attraverso un ponte mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "meshブリッジ経由で届きました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh 브리지를 거쳐 도착함" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiba melalui jambatan mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh पुल हुँदै आइपुग्यो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Via een mesh-brug binnengekomen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dotarła przez most mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chegou através de uma ponte mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chegou por uma ponte mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Пришло через mesh-мост" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kom via en mesh-brygga" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh பாலம் வழியாக வந்தது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "มาถึงผ่านบริดจ์ mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bir mesh köprüsü üzerinden geldi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Надійшло через mesh-міст" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh پل کے ذریعے پہنچا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đến qua cầu nối mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "经 mesh 桥接送达" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "經 mesh 橋接送達" - } - } - } - }, - "content.accessibility.choose_photo" : { - "comment" : "Accessibility label for the macOS photo picker button", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اختيار صورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি বেছে নিন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "foto auswählen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "choose photo" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "elegir foto" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "انتخاب عکس" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pumili ng larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "choisir une photo" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "בחירת תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "फ़ोटो चुनें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pilih foto" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "scegli foto" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "写真を選択" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "사진 선택" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pilih foto" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर छान" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "foto kiezen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wybierz zdjęcie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "escolher foto" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "escolher foto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "выбрать фото" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "välj foto" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "புகைப்படத்தைத் தேர்ந்தெடுக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เลือกรูปภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fotoğraf seç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "вибрати фото" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر منتخب کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chọn ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "选择照片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "選擇照片" - } - } - } - }, "content.accessibility.connected_mesh" : { "extractionState" : "manual", "localizations" : { @@ -38149,12 +8270,6 @@ "value" : "conectado por mesh" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "متصل از طریق مش" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -38301,192 +8416,6 @@ } } }, - "content.accessibility.delivery_detail_hint" : { - "comment" : "Accessibility hint for the delivery status glyph explaining a tap reveals details", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اضغط لعرض تفاصيل التسليم" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ডেলিভারির বিবরণ দেখতে ট্যাপ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tippe, um zustelldetails anzuzeigen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tap to show delivery details" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "toca para ver los detalles de entrega" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای دیدن جزئیات تحویل ضربه بزنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i-tap para makita ang mga detalye ng paghahatid" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "touche pour afficher les détails de livraison" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הקש להצגת פרטי מסירה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "डिलीवरी विवरण दिखाने के लिए टैप करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ketuk untuk menampilkan detail pengiriman" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tocca per mostrare i dettagli di consegna" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "タップして配信の詳細を表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "탭하여 전송 세부정보 표시" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ketuk untuk menunjukkan butiran penghantaran" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "डेलिभरी विवरण देखाउन ट्याप गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tik om bezorgdetails te tonen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "stuknij, aby pokazać szczegóły dostarczenia" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toca para mostrar os detalhes de entrega" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toque para ver detalhes da entrega" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "нажми, чтобы показать детали доставки" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tryck för att visa leveransdetaljer" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "வழங்கல் விவரங்களைக் காண தட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แตะเพื่อแสดงรายละเอียดการส่ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teslimat ayrıntılarını göstermek için dokunun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "торкнися, щоб показати деталі доставки" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ترسیل کی تفصیلات دیکھنے کیلئے ٹیپ کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chạm để xem chi tiết gửi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "轻点显示送达详情" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "輕點顯示送達詳情" - } - } - } - }, "content.accessibility.encryption_status" : { "extractionState" : "manual", "localizations" : { @@ -38520,12 +8449,6 @@ "value" : "estado de cifrado: %@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "وضعیت رمزنگاری: %@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -38672,748 +8595,6 @@ } } }, - "content.accessibility.gateway_active" : { - "comment" : "Accessibility label for the internet gateway indicator", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بوابة الإنترنت نشطة، تشارك اتصالك مع mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ইন্টারনেট গেটওয়ে সক্রিয়, মেশের সঙ্গে আপনার সংযোগ ভাগ করছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Internet-gateway aktiv, teilt deine verbindung mit dem mesh" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Internet gateway active, sharing your connection with the mesh" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Puerta de enlace a internet activa, compartiendo tu conexión con el mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دروازه اینترنت فعال است؛ اتصال شما با مش به اشتراک گذاشته می‌شود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Aktibo ang internet gateway, ibinabahagi ang koneksyon mo sa mesh" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "passerelle internet active, partage ta connexion avec le mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שער אינטרנט פעיל, משתף את החיבור שלך עם ה-mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इंटरनेट गेटवे सक्रिय, आपका कनेक्शन मेश के साथ साझा किया जा रहा है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway internet aktif, membagikan koneksimu dengan mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway internet attivo, condivide la tua connessione con la mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "インターネットゲートウェイが有効、接続をmeshと共有中" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "인터넷 게이트웨이 활성화됨, 연결을 mesh와 공유 중" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway internet aktif, berkongsi sambunganmu dengan mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इन्टरनेट गेटवे सक्रिय, तिम्रो जडान mesh सँग साझेदारी गरिँदै" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internetgateway actief, deelt je verbinding met de mesh" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "brama internetowa aktywna, udostępniasz swoje połączenie sieci mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway de internet ativo, a partilhar a tua ligação com a mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Gateway de internet ativo, compartilhando sua conexão com o mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "интернет-шлюз активен, соединение раздаётся в mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internetgateway aktiv, delar din anslutning med mesh" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இணைய நுழைவாயில் செயலில் உள்ளது, உங்கள் இணைப்பை mesh உடன் பகிர்கிறது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เกตเวย์อินเทอร์เน็ตทำงานอยู่ กำลังแชร์การเชื่อมต่อของคุณกับ mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internet ağ geçidi etkin, bağlantınız mesh ile paylaşılıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "інтернет-шлюз активний, ділишся своїм з'єднанням з mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "انٹرنیٹ گیٹ وے فعال، آپ کا کنکشن mesh کے ساتھ شیئر کر رہا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cổng internet đang hoạt động, chia sẻ kết nối của bạn với mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "互联网网关已激活,正在与 mesh 共享你的连接" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "網際網路閘道已啟用,正在與 mesh 分享你的連線" - } - } - } - }, - "content.accessibility.gateway_settings_hint" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يفتح الإعدادات لتشغيل البوابة أو إيقافها" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "গেটওয়ে চালু বা বন্ধ করতে সেটিংস খোলে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Öffnet die einstellungen, um das gateway ein- oder auszuschalten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Opens settings to turn the gateway on or off" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abre los ajustes para activar o desactivar la puerta de enlace" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تنظیمات را برای روشن یا خاموش کردن دروازه باز می‌کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Binubuksan ang mga setting para i-on o i-off ang gateway" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ouvre les réglages pour activer ou désactiver la passerelle" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פותח את ההגדרות כדי להפעיל או לכבות את השער" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "गेटवे चालू या बंद करने के लिए सेटिंग्स खोलता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Membuka pengaturan untuk menyalakan atau mematikan gateway" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apre le impostazioni per attivare o disattivare il gateway" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定を開いてゲートウェイのオン/オフを切り替えます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "설정을 열어 게이트웨이를 켜거나 끕니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Membuka tetapan untuk menghidupkan atau mematikan gateway" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "गेटवे खोल्न वा बन्द गर्न सेटिङहरू खोल्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Opent instellingen om de gateway aan of uit te zetten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Otwiera ustawienia, aby włączyć lub wyłączyć bramę" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abre as definições para ligar ou desligar o gateway" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abre as configurações para ligar ou desligar o gateway" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Открывает настройки, чтобы включить или выключить шлюз" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Öppnar inställningar för att slå på eller av gatewayen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "கேட்வேயை இயக்க அல்லது அணைக்க அமைப்புகளைத் திறக்கிறது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เปิดการตั้งค่าเพื่อเปิดหรือปิดเกตเวย์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ağ geçidini açmak veya kapatmak için ayarları açar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Відкриває налаштування, щоб увімкнути або вимкнути шлюз" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "گیٹ وے کو آن یا آف کرنے کے لیے ترتیبات کھولتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mở cài đặt để bật hoặc tắt gateway" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "打开设置以开启或关闭网关" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "開啟設定以開啟或關閉閘道" - } - } - } - }, - "content.accessibility.group_chat" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "دردشة جماعية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "গ্রুপ চ্যাট" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Gruppenchat" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group chat" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chat de grupo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گپ گروهی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Group chat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Discussion de groupe" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "צ'אט קבוצתי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह चैट" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "obrolan grup" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Chat di gruppo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループチャット" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹 채팅" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sembang kumpulan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह च्याट" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Groepschat" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "czat grupowy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Conversa de grupo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Chat de grupo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "групповой чат" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppchatt" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குழு உரையாடல்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แชทกลุ่ม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grup sohbeti" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "груповий чат" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گروپ چیٹ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "trò chuyện nhóm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "群聊" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "群組聊天" - } - } - } - }, - "content.accessibility.jump_to_latest" : { - "comment" : "Accessibility label for the jump to latest messages button", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الانتقال إلى أحدث الرسائل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "সর্বশেষ বার্তায় যান" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zu den neuesten nachrichten springen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "jump to latest messages" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ir a los mensajes más recientes" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پرش به آخرین پیام‌ها" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tumalon sa pinakabagong mga mensahe" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "aller aux derniers messages" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "קפיצה להודעות האחרונות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "नवीनतम संदेशों पर जाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lompat ke pesan terbaru" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "vai ai messaggi più recenti" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "最新のメッセージへ移動" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "최신 메시지로 이동" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lompat ke pesan terbaru" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पछिल्ला सन्देशमा जाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "naar de nieuwste berichten springen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "przejdź do najnowszych wiadomości" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ir para as mensagens mais recentes" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pular para as mensagens mais recentes" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "перейти к последним сообщениям" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hoppa till senaste meddelanden" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "சமீபத்திய செய்திகளுக்குச் செல்லவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไปยังข้อความล่าสุด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "en son mesajlara atla" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "перейти до останніх повідомлень" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تازہ ترین پیغامات پر جائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhảy đến tin nhắn mới nhất" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "跳到最新消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "跳至最新訊息" - } - } - } - }, "content.accessibility.location_channels" : { "extractionState" : "manual", "localizations" : { @@ -39447,12 +8628,6 @@ "value" : "canales de ubicación" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کانال‌های موقعیت" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -39599,744 +8774,181 @@ } } }, - "content.accessibility.nearby_only_off" : { + "content.accessibility.location_notes" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "موصول بالجسر: تصل الرسائل أيضًا إلى من هم عبر الجسر" + "value" : "ملاحظات الموقع لهذا المكان" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "ব্রিজযুক্ত: বার্তা ব্রিজের ওপারের মানুষের কাছেও পৌঁছায়" + "value" : "এই স্থানের লোকেশন নোট" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Überbrückt: nachrichten erreichen auch personen über die brücke" + "value" : "standortnotizen für diesen ort" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bridged: messages also reach people across the bridge" + "value" : "location notes for this place" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Con puente: los mensajes también llegan a personas al otro lado del puente" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پل‌شده: پیام‌ها به افراد آن‌سوی پل هم می‌رسند" + "value" : "notas de ubicación de este lugar" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "Naka-bridge: umaabot din ang mga mensahe sa mga tao sa kabila ng bridge" + "value" : "mga tala para sa lugar na ito" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Pont actif : les messages atteignent aussi les gens de l'autre côté du pont" + "value" : "notes de localisation pour cet endroit" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "מגושר: ההודעות מגיעות גם לאנשים מעבר לגשר" + "value" : "הערות מיקום למקום הזה" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "ब्रिज सक्रिय: संदेश ब्रिज के पार के लोगों तक भी पहुँचते हैं" + "value" : "इस स्थान के लोकेशन नोट" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Terjembatani: pesan juga sampai ke orang di seberang jembatan" + "value" : "catatan lokasi untuk tempat ini" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Con ponte: i messaggi raggiungono anche le persone oltre il ponte" + "value" : "note di posizione per questo posto" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ブリッジ中: メッセージはブリッジの先の人にも届きます" + "value" : "この場所のロケーションノート" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "브리지 사용: 메시지가 브리지 너머 사람들에게도 전달됩니다" + "value" : "이 장소의 위치 노트" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "Berjambatan: pesan juga sampai kepada orang di seberang jambatan" + "value" : "catatan lokasi untuk tempat ini" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "पुल जोडिएको: सन्देशहरू पुलपारिका मानिससम्म पनि पुग्छन्" + "value" : "यस ठाउँका स्थान नोटहरू" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Overbrugd: berichten bereiken ook mensen via de brug" + "value" : "locatienotities voor deze plek" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Zmostkowane: wiadomości docierają też do osób po drugiej stronie mostu" + "value" : "notatki lokalizacyjne dla tego miejsca" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Com ponte: as mensagens também chegam a pessoas do outro lado da ponte" + "value" : "notas de localização deste lugar" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Com ponte: as mensagens também chegam a pessoas do outro lado da ponte" + "value" : "notas de localização deste lugar" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Через мост: сообщения доходят и до людей по ту сторону моста" + "value" : "заметки для этого места" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Bryggad: meddelanden når även personer via bryggan" + "value" : "platsanteckningar för den här platsen" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "பாலம் இணைந்தது: செய்திகள் பாலத்தின் அப்பாலுள்ளவர்களையும் சென்றடையும்" + "value" : "இந்த இடத்திற்கான குறிப்புகள்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "บริดจ์อยู่: ข้อความยังไปถึงคนที่อีกฝั่งของบริดจ์ด้วย" + "value" : "บันทึกตำแหน่งสำหรับสถานที่นี้" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Köprülü: mesajlar köprünün ötesindeki kişilere de ulaşır" + "value" : "bu yer için konum notları" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Через міст: повідомлення доходять і до людей по той бік мосту" + "value" : "замітки про це місце" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "پل فعال: پیغامات پل کے پار لوگوں تک بھی پہنچتے ہیں" + "value" : "اس جگہ کیلئے لوکیشن نوٹس" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đã bắc cầu: tin nhắn cũng đến người bên kia cầu" + "value" : "ghi chú vị trí cho nơi này" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已桥接:消息也会送达桥另一侧的人" + "value" : "此位置的笔记" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已橋接:訊息也會送達橋另一側的人" - } - } - } - }, - "content.accessibility.nearby_only_on" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "قريب فقط: تبقى الرسائل ضمن مدى الراديو" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "শুধু কাছাকাছি: বার্তা রেডিও পরিসরের মধ্যেই থাকে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur in der nähe: nachrichten bleiben innerhalb der funkreichweite" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nearby only: messages stay within radio range" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Solo cerca: los mensajes se quedan dentro del alcance de radio" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "فقط اطراف: پیام‌ها در برد رادیویی می‌مانند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Malapit lamang: nananatili ang mga mensahe sa loob ng saklaw ng radyo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Proximité uniquement : les messages restent à portée radio" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "קרוב בלבד: ההודעות נשארות בטווח הרדיו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "केवल आसपास: संदेश रेडियो दायरे के भीतर रहते हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hanya terdekat: pesan tetap dalam jangkauan radio" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Solo vicinanze: i messaggi restano entro la portata radio" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "近くのみ: メッセージは電波範囲内にとどまります" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "근처 전용: 메시지가 전파 범위 안에만 머뭅니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Berdekatan sahaja: pesan kekal dalam jangkauan radio" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "नजिक मात्र: सन्देशहरू रेडियो पहुँचभित्रै रहन्छन्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alleen dichtbij: berichten blijven binnen radiobereik" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tylko w pobliżu: wiadomości pozostają w zasięgu radiowym" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Só por perto: as mensagens ficam dentro do alcance de rádio" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Só por perto: as mensagens ficam dentro do alcance de rádio" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Только рядом: сообщения остаются в радиусе радиосвязи" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Endast i närheten: meddelanden stannar inom radioräckvidd" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அருகில் மட்டும்: செய்திகள் ரேடியோ வரம்புக்குள் இருக்கும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เฉพาะใกล้เคียง: ข้อความอยู่ภายในระยะวิทยุ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yalnızca yakın: mesajlar telsiz menzili içinde kalır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Лише поблизу: повідомлення залишаються в радіусі радіозв'язку" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "صرف قریبی: پیغامات ریڈیو رینج کے اندر رہتے ہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chỉ gần đây: tin nhắn ở trong phạm vi sóng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "仅限附近:消息只在无线电范围内传播" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "僅限附近:訊息只在無線電範圍內傳播" - } - } - } - }, - "content.accessibility.notices" : { - "comment" : "Accessibility label for the notices button", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إعلانات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নোটিশ" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hinweise" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notices" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avisos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اطلاعیه‌ها" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mga paunawa" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annonces" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מודעות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचनाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pengumuman" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avvisi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お知らせ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pengumuman" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचनाहरू" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mededelingen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ogłoszenia" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avisos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avisos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Объявления" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anslag" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அறிவிப்புகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ประกาศ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Duyurular" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Оголошення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اعلانات" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thông báo" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "公告" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "公告" - } - } - } - }, - "content.accessibility.notices_new" : { - "comment" : "Accessibility value for the notices button when unseen pins arrived", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld جديد" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lldটি নতুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld neu" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld new" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld nuevos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld جدید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld bago" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld nouvelles" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld חדשות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld नई" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld baru" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld nuovi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld件の新着" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld개 새 항목" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld baharu" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld नयाँ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld nieuw" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld nowych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld novos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld novos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld новых" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld nya" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld புதியவை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ใหม่ %lld รายการ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld yeni" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld нових" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld نئے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld mới" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld 条新公告" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld 則新公告" + "value" : "此位置的筆記" } } } @@ -40374,12 +8986,6 @@ "value" : "abrir chat privado sin leer" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "باز کردن گپ خصوصی خوانده‌نشده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -40526,378 +9132,6 @@ } } }, - "content.accessibility.peers_connected" : { - "comment" : "Accessibility value when peers are reachable", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "متصل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "সংযুক্ত" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verbunden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "connected" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "conectado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "متصل" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nakakonekta" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "connecté" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מחובר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "जुड़ा हुआ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "terhubung" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "connesso" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "接続中" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "연결됨" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bersambung" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "जडान भयो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verbonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "połączono" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ligado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conectado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "подключено" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ansluten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இணைக்கப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เชื่อมต่อแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bağlı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "з'єднано" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "جڑا ہوا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã kết nối" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已连接" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已連線" - } - } - } - }, - "content.accessibility.peers_none" : { - "comment" : "Accessibility value when no peers are reachable", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لا أحد يمكن الوصول إليه" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "কেউ পৌঁছানোর মতো নেই" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "niemand erreichbar" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no one reachable" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "nadie alcanzable" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کسی در دسترس نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "walang taong maabot" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "personne n'est joignable" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אף אחד לא נגיש" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "कोई पहुँच योग्य नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak ada yang bisa dijangkau" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nessuno raggiungibile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "到達可能な相手がいません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "도달 가능한 사람 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tiada siapa boleh dicapai" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "कोही पुग्न सकिँदैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "niemand bereikbaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nikt nieosiągalny" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ninguém acessível" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ninguém alcançável" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "никого не достать" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ingen nåbar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "யாரையும் அணுக முடியாது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่มีใครที่เข้าถึงได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kimseye ulaşılamıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "нікого не досяжно" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "کوئی قابل رسائی نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không ai có thể tiếp cận" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "没有可达的人" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "沒有可到達的人" - } - } - } - }, "content.accessibility.people_count" : { "extractionState" : "manual", "localizations" : { @@ -41043,34 +9277,6 @@ } } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%#@people@" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d نفر" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d نفر" - } - } - } - } - } - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -41522,12 +9728,6 @@ "value" : "chat privado con %@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گپ خصوصی با %@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -41707,12 +9907,6 @@ "value" : "disponible por mesh" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در دسترس از طریق مش" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -41859,564 +10053,6 @@ } } }, - "content.accessibility.record_voice_hint" : { - "comment" : "Accessibility hint explaining double-tap toggles voice recording", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اضغط ضغطة مزدوجة لبدء التسجيل، واضغط مرة أخرى للإرسال" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "রেকর্ডিং শুরু করতে দুইবার ট্যাপ করুন, পাঠাতে আবার দুইবার ট্যাপ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "doppeltippen zum aufnehmen, erneut doppeltippen zum senden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "double-tap to start recording, double-tap again to send" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "toca dos veces para empezar a grabar, toca dos veces de nuevo para enviar" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای شروع ضبط دوبار ضربه بزنید، برای ارسال دوباره دوبار ضربه بزنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i-double tap para simulan ang pagre-record, i-double tap ulit para ipadala" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "double-touche pour démarrer l'enregistrement, double-touche à nouveau pour envoyer" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הקש הקשה כפולה כדי להתחיל הקלטה, הקש שוב כדי לשלוח" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "रिकॉर्डिंग शुरू करने के लिए दो बार टैप करें, भेजने के लिए फिर दो बार टैप करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ketuk dua kali untuk mulai merekam, ketuk dua kali lagi untuk mengirim" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tocca due volte per iniziare a registrare, tocca di nuovo due volte per inviare" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ダブルタップで録音開始、もう一度ダブルタップで送信" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "두 번 탭하여 녹음 시작, 다시 두 번 탭하여 전송" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ketuk dua kali untuk mula merakam, ketuk dua kali lagi untuk menghantar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "रेकर्ड सुरु गर्न दुई पटक ट्याप गर, पठाउन फेरि दुई पटक ट्याप गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dubbeltik om op te nemen, dubbeltik opnieuw om te verzenden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "stuknij dwukrotnie, aby rozpocząć nagrywanie, stuknij ponownie, aby wysłać" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toca duas vezes para começar a gravar, toca duas vezes de novo para enviar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toque duas vezes para começar a gravar, toque duas vezes de novo para enviar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "двойной тап — начать запись, ещё один двойной тап — отправить" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dubbeltryck för att börja spela in, dubbeltryck igen för att skicka" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "பதிவைத் தொடங்க இரட்டைத் தட்டவும், அனுப்ப மீண்டும் இரட்டைத் தட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แตะสองครั้งเพื่อเริ่มบันทึก แตะสองครั้งอีกครั้งเพื่อส่ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kaydı başlatmak için çift dokunun, göndermek için tekrar çift dokunun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "двічі торкнися, щоб почати запис, торкнися ще раз, щоб надіслати" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ریکارڈنگ شروع کرنے کیلئے ڈبل ٹیپ کریں، بھیجنے کیلئے دوبارہ ڈبل ٹیپ کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chạm hai lần để bắt đầu ghi, chạm hai lần nữa để gửi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "双击开始录音,再次双击发送" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "雙擊開始錄音,再次雙擊發送" - } - } - } - }, - "content.accessibility.record_voice_note" : { - "comment" : "Accessibility label for the voice note button", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تسجيل ملاحظة صوتية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ভয়েস নোট রেকর্ড করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sprachnachricht aufnehmen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "record voice note" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "grabar nota de voz" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ضبط پیام صوتی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mag-record ng voice note" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enregistrer une note vocale" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הקלטת הערה קולית" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वॉइस नोट रिकॉर्ड करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rekam catatan suara" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "registra nota vocale" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ボイスメモを録音" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "음성 메모 녹음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rakam nota suara" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "भ्वाइस नोट रेकर्ड गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "spraakbericht opnemen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nagraj notatkę głosową" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gravar nota de voz" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gravar mensagem de voz" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "записать голосовое сообщение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "spela in röstmeddelande" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குரல் குறிப்பைப் பதிவு செய்யவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "บันทึกข้อความเสียง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sesli not kaydet" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "записати голосову нотатку" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "وائس نوٹ ریکارڈ کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ghi chú giọng nói" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "录制语音消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "錄製語音訊息" - } - } - } - }, - "content.accessibility.recording" : { - "comment" : "Accessibility value announced while a voice note is recording", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "جارٍ التسجيل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "রেকর্ড হচ্ছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "aufnahme" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "recording" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "grabando" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال ضبط" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nagre-record" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enregistrement" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מקליט" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "रिकॉर्डिंग हो रही है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "merekam" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "registrazione" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "録音中" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "녹음 중" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "merakam" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "रेकर्ड हुँदै" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "opnemen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nagrywanie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "a gravar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gravando" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "запись" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "spelar in" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "பதிவு செய்கிறது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "กำลังบันทึก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kaydediliyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "запис" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ریکارڈنگ جاری" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đang ghi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "录音中" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "錄音中" - } - } - } - }, "content.accessibility.remove_favorite" : { "extractionState" : "manual", "localizations" : { @@ -42450,12 +10086,6 @@ "value" : "quitar de favoritos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حذف از علاقه‌مندی‌ها" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -42635,12 +10265,6 @@ "value" : "introduce un mensaje para enviarlo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای ارسال، پیامی بنویسید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -42820,12 +10444,6 @@ "value" : "toca dos veces para enviar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای ارسال دوبار ضربه بزنید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -43005,12 +10623,6 @@ "value" : "enviar mensaje" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال پیام" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -43157,377 +10769,6 @@ } } }, - "content.accessibility.someone_speaking" : { - "comment" : "Accessibility value on the mic button naming who is talking live in the public channel", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ يتحدث الآن" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ কথা বলছেন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ spricht gerade" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is speaking" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ está hablando" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ در حال صحبت است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "nagsasalita si %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ est en train de parler" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ מדבר כעת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ बोल रहे हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ sedang berbicara" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ sta parlando" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@が話しています" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 님이 말하는 중" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ sedang bercakap" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ बोल्दै हुनुहुन्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is aan het spreken" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ mówi" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ está a falar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ está falando" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ говорит" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ pratar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ பேசுகிறார்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ กำลังพูด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ konuşuyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ говорить" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ بول رہے ہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ đang nói" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 正在讲话" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 正在說話" - } - } - } - }, - "content.accessibility.take_photo" : { - "comment" : "Accessibility action name for taking a photo with the camera", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "التقاط صورة بالكاميرا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ক্যামেরা দিয়ে ছবি তুলুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "foto mit kamera aufnehmen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "take photo with camera" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "tomar foto con la cámara" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گرفتن عکس با دوربین" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kumuha ng larawan gamit ang camera" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "prendre une photo avec l'appareil" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "צילום תמונה במצלמה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "कैमरे से फ़ोटो लें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ambil foto dengan kamera" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "scatta foto con la fotocamera" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "カメラで写真を撮る" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "카메라로 사진 촬영" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ambil foto dengan kamera" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "क्यामेराले तस्बिर खिच" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "foto maken met camera" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zrób zdjęcie aparatem" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tirar foto com a câmara" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tirar foto com a câmera" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сделать фото камерой" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ta foto med kameran" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "கேமராவால் புகைப்படம் எடுக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ถ่ายรูปด้วยกล้อง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kamerayla fotoğraf çek" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "зробити фото камерою" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "کیمرے سے تصویر لیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chụp ảnh bằng máy ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "用相机拍照" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "用相機拍照" - } - } - } - }, "content.accessibility.toggle_bookmark" : { "extractionState" : "manual", "localizations" : { @@ -43561,12 +10802,6 @@ "value" : "alternar marcador para #%@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تغییر وضعیت نشانک #%@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -43713,373 +10948,181 @@ } } }, - "content.accessibility.verification" : { - "comment" : "Accessibility label for the verification QR button", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "التحقق من التشفير" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এনক্রিপশন যাচাই করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verschlüsselung verifizieren" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "verify encryption" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "verificar cifrado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تأیید رمزنگاری" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "beripikahin ang pag-encrypt" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "vérifier le chiffrement" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אימות הצפנה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "एन्क्रिप्शन सत्यापित करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verifikasi enkripsi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verifica la cifratura" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "暗号化を確認" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "암호화 확인" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sahkan enkripsi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "सङ्केत प्रमाणित गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "versleuteling verifiëren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zweryfikuj szyfrowanie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verificar a encriptação" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verificar criptografia" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "проверить шифрование" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verifiera kryptering" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குறியாக்கத்தைச் சரிபார்க்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ยืนยันการเข้ารหัส" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "şifrelemeyi doğrula" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "перевірити шифрування" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "انکرپشن کی توثیق کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "xác minh mã hóa" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "验证加密" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "驗證加密" - } - } - } - }, - "content.accessibility.verified_sender" : { + "content.accessibility.toggle_favorite_hint" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "مرسل موثق" + "value" : "اضغط مرتين لتبديل حالة المفضلة" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "যাচাইকৃত প্রেরক" + "value" : "প্রিয় অবস্থা বদলাতে দুইবার ট্যাপ করুন" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Verifizierter absender" + "value" : "doppelt tippen, um favoritenstatus zu wechseln" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Verified sender" + "value" : "double tap to toggle favorite status" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Remitente verificado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "فرستنده تأییدشده" + "value" : "toca dos veces para alternar el estado de favorito" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "Beripikadong nagpadala" + "value" : "i-double tap para i-toggle ang paborito" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Expéditeur vérifié" + "value" : "tape deux fois pour basculer le statut favori" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "שולח מאומת" + "value" : "הקש פעמיים כדי להחליף מצב מועדפים" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "सत्यापित प्रेषक" + "value" : "पसंदीदा स्थिति बदलने के लिए डबल टैप करें" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Pengirim terverifikasi" + "value" : "ketuk dua kali untuk mengubah status favorit" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Mittente verificato" + "value" : "tocca due volte per cambiare stato preferito" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "確認済みの送信者" + "value" : "ダブルタップでお気に入り状態を切り替え" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "확인된 보낸 사람" + "value" : "두 번 탭하여 즐겨찾기 상태 토글" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "Pengirim yang disahkan" + "value" : "ketuk dua kali untuk mengubah status favorit" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "प्रमाणित प्रेषक" + "value" : "मनपर्ने स्थिति बदल्न दोहोरो ट्याप गर" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Geverifieerde afzender" + "value" : "dubbelklikken om favoriet te schakelen" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Zweryfikowany nadawca" + "value" : "stuknij dwukrotnie, aby zmienić stan ulubionych" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Remetente verificado" + "value" : "toca duas vezes para alternar o estado de favorito" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Remetente verificado" + "value" : "toque duas vezes para alternar status de favorito" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Подтверждённый отправитель" + "value" : "дважды тапни, чтобы переключить статус избранного" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Verifierad avsändare" + "value" : "dubbeltryck för att växla favoritstatus" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "சரிபார்க்கப்பட்ட அனுப்புநர்" + "value" : "பிரியப்பட்ட நிலையை மாற்ற இருமுறை தட்டவும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ผู้ส่งที่ยืนยันแล้ว" + "value" : "แตะสองครั้งเพื่อสลับสถานะรายการโปรด" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Doğrulanmış gönderen" + "value" : "favori durumunu değiştirmek için çift dokunun" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Підтверджений відправник" + "value" : "торкни двічі, щоб змінити статус вибраного" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "تصدیق شدہ مرسل" + "value" : "پسندیدہ حالت بدلنے کیلئے دو بار ٹیپ کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Người gửi đã xác minh" + "value" : "chạm hai lần để chuyển trạng thái yêu thích" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已验证的发送者" + "value" : "双击切换收藏状态" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已驗證的傳送者" + "value" : "雙擊切換收藏狀態" } } } @@ -44117,12 +11160,6 @@ "value" : "toca para ver la huella de cifrado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای دیدن اثرانگشت رمزنگاری ضربه بزنید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -44302,12 +11339,6 @@ "value" : "bloquear" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسدود کردن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -44487,12 +11518,6 @@ "value" : "mensaje directo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام مستقیم" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -44672,12 +11697,6 @@ "value" : "abrazo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بغل" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -44857,12 +11876,6 @@ "value" : "mencionar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "منشن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -45009,192 +12022,6 @@ } } }, - "content.actions.resend" : { - "comment" : "Context menu action that resends a failed private message", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "إعادة الإرسال" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আবার পাঠান" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "erneut senden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "resend" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "reenviar" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال دوباره" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ipadala muli" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "renvoyer" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שלח שוב" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "फिर भेजें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kirim ulang" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "reinvia" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "再送信" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "다시 전송" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hantar semula" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पुनः पठाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "opnieuw verzenden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wyślij ponownie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "reenviar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "reenviar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "отправить снова" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "skicka igen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "மீண்டும் அனுப்பு" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งอีกครั้ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "yeniden gönder" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "надіслати знову" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "دوبارہ بھیجیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gửi lại" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "重新发送" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "重新發送" - } - } - } - }, "content.actions.slap" : { "extractionState" : "manual", "localizations" : { @@ -45228,12 +12055,6 @@ "value" : "bofetada" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "سیلی" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -45413,12 +12234,6 @@ "value" : "acciones" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اقدامات" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -45598,12 +12413,6 @@ "value" : "bluetooth está desactivado. Actívalo en Ajustes para usar BitChat." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بلوتوث خاموش است. برای استفاده از bitchat لطفاً بلوتوث را در تنظیمات روشن کنید." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -45783,12 +12592,6 @@ "value" : "bitChat necesita permiso de Bluetooth para conectarse con dispositivos cercanos. Habilita el acceso en Ajustes." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchat برای اتصال به دستگاه‌های اطراف به مجوز بلوتوث نیاز دارد. لطفاً دسترسی بلوتوث را در تنظیمات فعال کنید." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -45968,12 +12771,6 @@ "value" : "ajustes" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تنظیمات" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -46153,12 +12950,6 @@ "value" : "se requiere Bluetooth" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بلوتوث لازم است" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -46338,12 +13129,6 @@ "value" : "este dispositivo no admite Bluetooth. BitChat necesita Bluetooth para funcionar." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این دستگاه از بلوتوث پشتیبانی نمی‌کند. bitchat برای کار کردن به بلوتوث نیاز دارد." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -46523,12 +13308,6 @@ "value" : "las capturas de pantalla de los canales de ubicación revelarán tu ubicación. Piensa antes de compartirlas públicamente." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اسکرین‌شات از کانال‌های موقعیت، موقعیت شما را لو می‌دهد. پیش از اشتراک‌گذاری عمومی فکر کنید." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -46708,12 +13487,6 @@ "value" : "atención" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "توجه" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -46860,378 +13633,6 @@ } } }, - "content.clear.confirm_action" : { - "comment" : "Destructive confirmation button that clears the current chat", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "مسح الدردشة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "চ্যাট মুছুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chat leeren" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "clear chat" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "limpiar chat" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پاک کردن گپ" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "burahin ang chat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "effacer la discussion" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "נקה צ'אט" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चैट साफ़ करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hapus obrolan" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "svuota chat" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "チャットをクリア" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "채팅 지우기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kosongkan sembang" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "च्याट खाली गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chat wissen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wyczyść czat" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "limpar conversa" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "limpar chat" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "очистить чат" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rensa chatt" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "உரையாடலை அழி" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ล้างแชท" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sohbeti temizle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "очистити чат" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "چیٹ صاف کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "xóa cuộc trò chuyện" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "清除聊天" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "清除聊天" - } - } - } - }, - "content.clear.confirm_title" : { - "comment" : "Title of the confirmation dialog shown before clearing the current chat", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "مسح هذه الدردشة؟" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এই চ্যাট মুছবেন?" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "diesen chat leeren?" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "clear this chat?" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿limpiar este chat?" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این گپ پاک شود؟" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "burahin ang chat na ito?" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "effacer cette discussion ?" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לנקות את הצ'אט הזה?" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "यह चैट साफ़ करें?" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hapus obrolan ini?" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "svuotare questa chat?" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "このチャットをクリアしますか?" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이 채팅을 지울까요?" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kosongkan sembang ini?" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "यो च्याट खाली गर्ने?" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "deze chat wissen?" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wyczyścić ten czat?" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "limpar esta conversa?" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "limpar este chat?" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "очистить этот чат?" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rensa den här chatten?" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இந்த உரையாடலை அழிக்கவா?" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ล้างแชทนี้หรือไม่?" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bu sohbet temizlensin mi?" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "очистити цей чат?" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "یہ چیٹ صاف کریں؟" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "xóa cuộc trò chuyện này?" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "清除此聊天?" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "清除此聊天?" - } - } - } - }, "content.commands.block" : { "extractionState" : "manual", "localizations" : { @@ -47265,12 +13666,6 @@ "value" : "bloquear o listar usuarios bloqueados" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسدود کردن یا فهرست همتاهای مسدود" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -47450,12 +13845,6 @@ "value" : "borrar los mensajes del chat" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پاک کردن پیام‌های گپ" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -47602,192 +13991,6 @@ } } }, - "content.commands.drop" : { - "comment" : "Description of the /drop command in the command suggestions panel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ثبّت ملاحظة في هذا المكان لمدة 24 ساعة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই জায়গায় 24 ঘণ্টার জন্য একটি নোট পিন করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "eine notiz für 24 std. an diesem ort anheften" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "pin a note to this place for 24h" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "fija una nota en este lugar por 24 h" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "سنجاق کردن یادداشتی به این مکان برای ۲۴ ساعت" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mag-pin ng note sa lugar na ito nang 24 na oras" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "épingle une note à cet endroit pendant 24 h" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הצמד פתק למקום הזה ל-24 שעות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "इस जगह पर 24 घंटे के लिए एक नोट पिन करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "sematkan catatan di tempat ini selama 24 jam" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "fissa una nota in questo luogo per 24 ore" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "この場所に24時間メモをピン留め" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 장소에 24시간 동안 쪽지 고정" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "pinkan nota di tempat ini selama 24 jam" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यस ठाउँमा 24 घण्टाका लागि नोट पिन गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "pin een notitie op deze plek voor 24 uur" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "przypnij notatkę do tego miejsca na 24 godz." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "fixar uma nota neste local por 24 h" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "fixar uma nota neste lugar por 24 h" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "закрепить заметку в этом месте на 24 ч" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "fäst en anteckning på den här platsen i 24 tim" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இந்த இடத்தில் 24 மணி நேரத்திற்கு ஒரு குறிப்பை பொருத்தவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ปักโน้ตไว้ที่นี่เป็นเวลา 24 ชม." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu yere 24 saatliğine bir not sabitle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "закріпити нотатку в цьому місці на 24 год" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اس جگہ پر 24 گھنٹے کے لیے ایک نوٹ پن کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ghim một ghi chú tại nơi này trong 24 giờ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在此地固定一条留言 24 小时" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "在此地固定一則留言 24 小時" - } - } - } - }, "content.commands.favorite" : { "extractionState" : "manual", "localizations" : { @@ -47821,12 +14024,6 @@ "value" : "agregar a favoritos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "افزودن به علاقه‌مندی‌ها" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -47973,377 +14170,6 @@ } } }, - "content.commands.group" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "إنشاء أو إدارة المجموعات الخاصة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্যক্তিগত গ্রুপ তৈরি বা পরিচালনা করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "private gruppen erstellen oder verwalten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "create or manage private groups" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "crear o gestionar grupos privados" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ساخت یا مدیریت گروه‌های خصوصی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lumikha o mamahala ng mga pribadong grupo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "créer ou gérer des groupes privés" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "יצירה או ניהול של קבוצות פרטיות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी समूह बनाएँ या प्रबंधित करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "buat atau kelola grup pribadi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "crea o gestisci gruppi privati" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "プライベートグループを作成または管理" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "비공개 그룹 생성 또는 관리" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cipta atau urus kumpulan peribadi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी समूह सिर्जना वा व्यवस्थापन गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privégroepen maken of beheren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "twórz lub zarządzaj prywatnymi grupami" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "criar ou gerir grupos privados" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "criar ou gerenciar grupos privados" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "создать частные группы или управлять ими" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "skapa eller hantera privata grupper" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "தனிப்பட்ட குழுக்களை உருவாக்கவும் அல்லது நிர்வகிக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "สร้างหรือจัดการกลุ่มส่วนตัว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "özel gruplar oluştur veya yönet" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "створюй або керуй приватними групами" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نجی گروپ بنائیں یا ان کا نظم کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tạo hoặc quản lý nhóm riêng tư" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "创建或管理私密群组" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "建立或管理私密群組" - } - } - } - }, - "content.commands.help" : { - "comment" : "Description of the /help command in the suggestions panel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "عرض الأوامر المتاحة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "উপলব্ধ কমান্ড দেখান" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verfügbare befehle anzeigen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "show available commands" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mostrar los comandos disponibles" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمایش دستورات موجود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ipakita ang mga available na utos" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afficher les commandes disponibles" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הצג פקודות זמינות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "उपलब्ध कमांड दिखाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tampilkan perintah yang tersedia" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mostra i comandi disponibili" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "利用可能なコマンドを表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "사용 가능한 명령어 표시" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tunjukkan perintah yang tersedia" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "उपलब्ध आदेशहरू देखाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "beschikbare commando's tonen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pokaż dostępne komendy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mostrar os comandos disponíveis" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mostrar comandos disponíveis" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "показать доступные команды" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "visa tillgängliga kommandon" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "கிடைக்கும் கட்டளைகளைக் காட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แสดงคำสั่งที่ใช้ได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kullanılabilir komutları göster" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "показати доступні команди" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "دستیاب کمانڈز دکھائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hiển thị các lệnh khả dụng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "显示可用指令" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "顯示可用指令" - } - } - } - }, "content.commands.hug" : { "extractionState" : "manual", "localizations" : { @@ -48377,12 +14203,6 @@ "value" : "enviar un abrazo caluroso" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "فرستادن یک بغل گرم برای کسی" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -48562,12 +14382,6 @@ "value" : "enviar mensaje privado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال پیام خصوصی" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -48714,378 +14528,6 @@ } } }, - "content.commands.pay" : { - "comment" : "Autocomplete description for the /pay command that sends a Cashu ecash token", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "إرسال رمز cashu ecash في هذه الدردشة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এই চ্যাটে একটি ক্যাশু ইক্যাশ টোকেন পাঠান" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "einen cashu-ecash-token in diesem chat senden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "send a cashu ecash token in this chat" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "enviar un token de ecash Cashu en este chat" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال توکن ecash از cashu در این گپ" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "magpadala ng cashu ecash token sa chat na ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "envoyer un token ecash cashu dans cette discussion" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שליחת אסימון cashu ecash בצ'אט הזה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इस चैट में कैशु ईकैश टोकन भेजें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kirim token cashu ecash di obrolan ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "invia un token ecash cashu in questa chat" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "このチャットでcashuのecashトークンを送る" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이 채팅에서 cashu ecash 토큰 보내기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hantar token cashu ecash dalam sembang ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "यो च्याटमा cashu इ-क्यास टोकन पठाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "een cashu-ecash-token in deze chat sturen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wyślij token ecash Cashu na tym czacie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enviar um token ecash cashu nesta conversa" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enviar um token ecash cashu neste chat" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "отправить токен cashu ecash в этот чат" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "skicka en Cashu ecash-token i den här chatten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இந்த உரையாடலில் Cashu ecash டோக்கனை அனுப்பவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งโทเคน ecash ของ cashu ในแชทนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bu sohbette cashu ecash tokenı gönder" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "надіслати токен cashu ecash у цьому чаті" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اس چیٹ میں Cashu ecash ٹوکن بھیجیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gửi token cashu ecash trong cuộc trò chuyện này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "在此聊天中发送 cashu ecash 代币" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "在此聊天中發送 cashu ecash 代幣" - } - } - } - }, - "content.commands.ping" : { - "comment" : "Description of the /ping command in the suggestions panel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "قياس زمن الذهاب والإياب إلى قرين mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "কোনো মেশ পিয়ারে রাউন্ড-ট্রিপ সময় মাপুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "die roundtrip-zeit zu einem mesh-peer messen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "measure round-trip time to a mesh peer" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "medir el tiempo de ida y vuelta a un peer del mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اندازه‌گیری زمان رفت‌وبرگشت تا یک همتای مش" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sukatin ang round-trip time papunta sa isang mesh peer" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesurer le temps d'aller-retour vers un pair mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מדידת זמן הלוך ושוב לעמית mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "किसी मेश पीयर तक राउंड-ट्रिप समय मापें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ukur waktu bolak-balik ke peer mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "misura il tempo di andata e ritorno verso un peer mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "meshピアへの往復時間を測定" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh 피어까지의 왕복 시간 측정" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ukur masa pergi-balik ke peer mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh सहकर्मीसम्मको राउन्ड-ट्रिप समय नाप" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "de retourtijd naar een mesh-peer meten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zmierz czas do peera mesh i z powrotem" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "medir o tempo de ida e volta até um par mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "medir o tempo de ida e volta até um par do mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "измерить время отклика до mesh-пира" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mät tur-och-retur-tid till en mesh-peer" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh peer க்கு செல்லவந்த நேரத்தை அளவிடவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "วัดเวลาไป-กลับไปยังเพียร์ mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bir mesh eşine gidiş-dönüş süresini ölç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "виміряти час туди-назад до піра mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh ہم منصب تک راؤنڈ ٹرپ وقت ناپیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đo thời gian khứ hồi đến một nút mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "测量到 mesh 同伴的往返时间" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "測量到 mesh 同伴的往返時間" - } - } - } - }, "content.commands.slap" : { "extractionState" : "manual", "localizations" : { @@ -49119,12 +14561,6 @@ "value" : "abofetear a alguien con una trucha" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "سیلی زدن به کسی با ماهی قزل‌آلا" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -49271,192 +14707,6 @@ } } }, - "content.commands.trace" : { - "comment" : "Description of the /trace command in the suggestions panel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تقدير مسار mesh إلى قرين" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "কোনো পিয়ার পর্যন্ত মেশ পথ অনুমান করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "den mesh-pfad zu einem peer schätzen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "estimate the mesh path to a peer" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "estimar la ruta del mesh hasta un peer" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تخمین مسیر مش تا یک همتا" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tantiyahin ang mesh path papunta sa isang peer" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "estimer le chemin mesh vers un pair" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הערכת נתיב ה-mesh לעמית" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "किसी पीयर तक मेश पथ का अनुमान लगाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "perkirakan jalur mesh ke peer" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "stima il percorso mesh verso un peer" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ピアへのmeshの経路を推定" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "피어까지의 mesh 경로 추정" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anggarkan laluan mesh ke peer" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "सहकर्मीसम्मको mesh मार्ग अनुमान गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "het mesh-pad naar een peer schatten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "oszacuj ścieżkę mesh do peera" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "estimar o caminho mesh até um par" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "estimar o caminho no mesh até um par" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "оценить mesh-путь до пира" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uppskatta mesh-vägen till en peer" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "peer க்கான mesh பாதையை மதிப்பிடவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ประมาณเส้นทาง mesh ไปยังเพียร์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bir eşe giden mesh yolunu tahmin et" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "оцінити шлях mesh до піра" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "کسی ہم منصب تک mesh راستے کا تخمینہ لگائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ước tính đường đi mesh đến một nút" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "估计到某个同伴的 mesh 路径" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "估算到同伴的 mesh 路徑" - } - } - } - }, "content.commands.unblock" : { "extractionState" : "manual", "localizations" : { @@ -49487,13 +14737,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "desbloquear a una persona" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رفع مسدودی یک همتا" + "value" : "desbloquear a un usuario" } }, "fil" : { @@ -49675,12 +14919,6 @@ "value" : "quitar de favoritos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حذف از علاقه‌مندی‌ها" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -49860,12 +15098,6 @@ "value" : "ver quién está en línea" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دیدن افراد آنلاین" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -50012,376 +15244,6 @@ } } }, - "content.composer.nearby_only_off" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "موصول بالجسر — تصل إلى أشخاص خارج مدى الراديو في هذه المنطقة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ব্রিজযুক্ত — এই এলাকায় রেডিও পরিসরের বাইরের মানুষের কাছেও পৌঁছায়" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Überbrückt — erreicht personen außerhalb der funkreichweite in dieser gegend" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bridged — reaches people beyond radio range in this area" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Con puente — llega a personas fuera del alcance de radio en esta zona" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پل‌شده — به افراد فراتر از برد رادیویی در این منطقه می‌رسد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Naka-bridge — umaabot sa mga taong lampas sa saklaw ng radyo sa lugar na ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pont actif — atteint les gens hors de portée radio dans cette zone" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מגושר — מגיעה לאנשים מעבר לטווח הרדיו באזור הזה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ब्रिज सक्रिय — इस क्षेत्र में रेडियो दायरे से बाहर के लोगों तक पहुँचता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Terjembatani — sampai ke orang di luar jangkauan radio di area ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Con ponte — raggiunge persone oltre la portata radio in questa zona" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ブリッジ中 — このエリアの電波範囲外の人にも届きます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "브리지 사용 — 이 지역의 전파 범위 밖 사람들에게도 전달됩니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Berjambatan — sampai kepada orang di luar jangkauan radio di kawasan ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "पुल जोडिएको — यस क्षेत्रमा रेडियो पहुँचभन्दा बाहिरका मानिससम्म पुग्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Overbrugd — bereikt mensen buiten radiobereik in deze omgeving" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zmostkowane — dociera do osób poza zasięgiem radiowym w tej okolicy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Com ponte — chega a pessoas fora do alcance de rádio nesta zona" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Com ponte — chega a pessoas fora do alcance de rádio nesta área" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Через мост — доходит до людей за пределами радиуса радиосвязи в этом районе" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bryggad — når personer utom radioräckvidd i det här området" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பாலம் இணைந்தது — இந்தப் பகுதியில் ரேடியோ வரம்புக்கு அப்பாலுள்ளவர்களைச் சென்றடையும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "บริดจ์อยู่ — ไปถึงคนที่อยู่นอกระยะวิทยุในพื้นที่นี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Köprülü — bu bölgedeki telsiz menzili dışındaki kişilere ulaşır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Через міст — доходить до людей поза радіусом радіозв'язку в цьому районі" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پل فعال — اس علاقے میں ریڈیو رینج سے باہر لوگوں تک پہنچتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đã bắc cầu — đến được người ngoài phạm vi sóng trong khu vực này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已桥接 — 可送达本地区无线电范围之外的人" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已橋接 — 可送達本地區無線電範圍之外的人" - } - } - } - }, - "content.composer.nearby_only_on" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "قريب فقط — لن تعبر هذه الرسالة الجسر" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "শুধু কাছাকাছি — এই বার্তা ব্রিজ পার হবে না" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur in der nähe — diese nachricht überquert die brücke nicht" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nearby only — this message won't cross the bridge" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Solo cerca — este mensaje no cruzará el puente" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "فقط اطراف — این پیام از پل عبور نمی‌کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Malapit lamang — hindi tatawid sa bridge ang mensaheng ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Proximité uniquement — ce message ne franchira pas le pont" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "קרוב בלבד — ההודעה הזו לא תחצה את הגשר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "केवल आसपास — यह संदेश ब्रिज पार नहीं करेगा" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hanya terdekat — pesan ini tidak akan menyeberangi jembatan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Solo vicinanze — questo messaggio non attraverserà il ponte" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "近くのみ — このメッセージはブリッジを越えません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "근처 전용 — 이 메시지는 브리지를 건너지 않습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Berdekatan sahaja — pesan ini tidak akan menyeberangi jambatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "नजिक मात्र — यो सन्देश पुल तर्दैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alleen dichtbij — dit bericht gaat de brug niet over" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tylko w pobliżu — ta wiadomość nie przejdzie przez most" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Só por perto — esta mensagem não vai atravessar a ponte" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Só por perto — esta mensagem não vai atravessar a ponte" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Только рядом — это сообщение не пересечёт мост" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Endast i närheten — det här meddelandet korsar inte bryggan" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அருகில் மட்டும் — இந்தச் செய்தி பாலத்தைத் தாண்டாது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เฉพาะใกล้เคียง — ข้อความนี้จะไม่ข้ามบริดจ์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yalnızca yakın — bu mesaj köprüyü geçmeyecek" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Лише поблизу — це повідомлення не перетне міст" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "صرف قریبی — یہ پیغام پل پار نہیں کرے گا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chỉ gần đây — tin nhắn này sẽ không qua cầu" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "仅限附近 — 这条消息不会跨越桥接" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "僅限附近 — 這則訊息不會跨越橋接" - } - } - } - }, "content.delivery.delivered_members" : { "extractionState" : "manual", "localizations" : { @@ -50415,12 +15277,6 @@ "value" : "entregado a %1$d de %2$d miembros" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تحویل‌شده به %1$d از %2$d عضو" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -50600,12 +15456,6 @@ "value" : "entregado a %@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تحویل‌شده به %@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -50785,12 +15635,6 @@ "value" : "falló: %@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ناموفق: %@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -50970,12 +15814,6 @@ "value" : "leído por %@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "خوانده‌شده توسط %@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -51128,925 +15966,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "هذا الشخص محظور" + "value" : "المستخدم محظور" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "এই ব্যক্তি ব্লক করা" + "value" : "ব্যবহারকারী ব্লক করা" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "diese person ist blockiert" + "value" : "nutzer blockiert" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "this person is blocked" + "value" : "user is blocked" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "esta persona está bloqueada" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این فرد مسدود است" + "value" : "el usuario está bloqueado" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "naka-block ang taong ito" + "value" : "na-block ang user" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "cette personne est bloquée" + "value" : "utilisateur bloqué" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "האדם הזה חסום" + "value" : "המשתמש חסום" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "यह व्यक्ति ब्लॉक है" + "value" : "उपयोगकर्ता ब्लॉक है" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "orang ini diblokir" + "value" : "pengguna diblokir" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "questa persona è bloccata" + "value" : "utente bloccato" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "この人をブロック中" + "value" : "ユーザーをブロック中" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "차단된 사람입니다" + "value" : "차단된 사용자입니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "orang ini diblokir" + "value" : "pengguna diblokir" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "यो व्यक्ति ब्लक गरिएको छ" + "value" : "प्रयोगकर्ता ब्लक गरिएको" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "deze persoon is geblokkeerd" + "value" : "gebruiker is geblokkeerd" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "ta osoba jest zablokowana" + "value" : "użytkownik zablokowany" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "esta pessoa está bloqueada" + "value" : "utilizador bloqueado" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "esta pessoa está bloqueada" + "value" : "usuário bloqueado" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "этот человек заблокирован" + "value" : "пользователь заблокирован" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "den här personen är blockerad" + "value" : "användaren är blockerad" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "இந்த நபர் தடுக்கப்பட்டுள்ளார்" + "value" : "பயனர் தடுக்கப்பட்டுள்ளார்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "คนนี้ถูกบล็อก" + "value" : "ผู้ใช้ถูกบล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "bu kişi engellendi" + "value" : "kullanıcı engellendi" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "цю людину заблоковано" + "value" : "користувач заблокований" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "یہ شخص بلاک ہے" + "value" : "صارف بلاک ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "người này bị chặn" + "value" : "người dùng bị chặn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此人已被屏蔽" + "value" : "用户已被屏蔽" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此人已被屏蔽" - } - } - } - }, - "content.delivery.reason.encryption_failed" : { - "comment" : "Failure reason shown when a message could not be encrypted for the peer", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "فشل التشفير" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এনক্রিপশন ব্যর্থ" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verschlüsselung fehlgeschlagen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "encryption failed" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "cifrado fallido" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری ناموفق بود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nabigo ang pag-encrypt" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "échec du chiffrement" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ההצפנה נכשלה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "एन्क्रिप्शन विफल" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enkripsi gagal" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cifratura fallita" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "暗号化に失敗" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "암호화 실패" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enkripsi gagal" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "सङ्केत असफल" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "versleuteling mislukt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "szyfrowanie nie powiodło się" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "falha na encriptação" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "falha na criptografia" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "шифрование не удалось" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kryptering misslyckades" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குறியாக்கம் தோல்வியடைந்தது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เข้ารหัสไม่สำเร็จ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "şifreleme başarısız" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "шифрування не вдалося" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "انکرپشن ناکام" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mã hóa thất bại" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "加密失败" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "加密失敗" - } - } - } - }, - "content.delivery.reason.private_media_capability_unresolved" : { - "comment" : "Failure reason shown when the peer's support for encrypted media could not be confirmed before sending", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تعذّر تأكيد دعم الوسائط المشفّرة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এনক্রিপ্ট করা মিডিয়া সমর্থন নিশ্চিত করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Unterstützung für verschlüsselte Medien konnte nicht bestätigt werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Could not confirm encrypted media support" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "No se pudo confirmar la compatibilidad con multimedia cifrada" - } - }, - "fa" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پشتیبانی از رسانه رمزنگاری‌شده تأیید نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Hindi makumpirma ang suporta sa naka-encrypt na media" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Impossible de confirmer la prise en charge des médias chiffrés" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא ניתן לאמת תמיכה במדיה מוצפנת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "एन्क्रिप्टेड मीडिया समर्थन की पुष्टि नहीं हो सकी" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Tidak dapat memastikan dukungan media terenkripsi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Impossibile confermare il supporto dei contenuti multimediali cifrati" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "暗号化メディアの対応を確認できませんでした" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "암호화된 미디어 지원을 확인할 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Tidak dapat mengesahkan sokongan media tersulit" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "एन्क्रिप्टेड मिडिया समर्थन पुष्टि गर्न सकिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Ondersteuning voor versleutelde media kon niet worden bevestigd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Nie udało się potwierdzić obsługi zaszyfrowanych multimediów" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Não foi possível confirmar o suporte a multimédia cifrada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Não foi possível confirmar o suporte a mídia criptografada" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Не удалось подтвердить поддержку зашифрованных медиа" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Det gick inte att bekräfta stöd för krypterade medier" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "மறைகுறியாக்கப்பட்ட மீடியா ஆதரவை உறுதிப்படுத்த முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถยืนยันการรองรับสื่อที่เข้ารหัสได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Şifreli medya desteği doğrulanamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Не вдалося підтвердити підтримку зашифрованих медіа" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "خفیہ کردہ میڈیا کی معاونت کی تصدیق نہیں ہو سکی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Không thể xác nhận hỗ trợ phương tiện được mã hóa" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "无法确认加密媒体支持" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "無法確認加密媒體支援" - } - } - } - }, - "content.delivery.reason.private_media_delivery_unconfirmed" : { - "comment" : "Failure reason shown when an encrypted media message was sent but its delivery was never confirmed", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تعذّر تأكيد التسليم" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ডেলিভারি নিশ্চিত করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Zustellung konnte nicht bestätigt werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delivery could not be confirmed" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "No se pudo confirmar la entrega" - } - }, - "fa" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تحویل تأیید نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Hindi makumpirma ang paghahatid" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Impossible de confirmer la remise" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא ניתן לאמת את המסירה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "डिलीवरी की पुष्टि नहीं हो सकी" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Pengiriman tidak dapat dipastikan" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Impossibile confermare la consegna" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "配信を確認できませんでした" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "전달을 확인할 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Penghantaran tidak dapat disahkan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "डेलिभरी पुष्टि गर्न सकिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Bezorging kon niet worden bevestigd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Nie udało się potwierdzić dostarczenia" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Não foi possível confirmar a entrega" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Não foi possível confirmar a entrega" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Не удалось подтвердить доставку" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Leveransen kunde inte bekräftas" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "விநியோகத்தை உறுதிப்படுத்த முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถยืนยันการส่งได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Teslimat doğrulanamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Не вдалося підтвердити доставлення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ترسیل کی تصدیق نہیں ہو سکی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Không thể xác nhận việc gửi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "无法确认送达" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "無法確認送達" - } - } - } - }, - "content.delivery.reason.not_delivered" : { - "comment" : "Failure reason shown when the router gave up delivering a message", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لم يُسلَّم" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "পৌঁছায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nicht zugestellt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "not delivered" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no entregado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تحویل نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi naihatid" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "non livré" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא נמסר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "डिलीवर नहीं हुआ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak terkirim" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "non consegnato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "未配信" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "전송되지 않음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak dihantar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "डेलिभर भएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "niet bezorgd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie dostarczono" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não entregue" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não entregue" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не доставлено" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inte levererat" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "வழங்கப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ยังไม่ได้ส่ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teslim edilmedi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не доставлено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نہیں پہنچا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chưa gửi được" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "未送达" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "未送達" + "value" : "使用者已被屏蔽" } } } @@ -52084,12 +16172,6 @@ "value" : "no puedes enviarte mensajes a ti mismo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمی‌توانید به خودتان پیام بدهید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -52269,12 +16351,6 @@ "value" : "error al enviar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "خطای ارسال" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -52454,12 +16530,6 @@ "value" : "destinatario desconocido" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گیرنده ناشناس" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -52606,3350 +16676,181 @@ } } }, - "content.delivery.reason.voice_send_failed" : { - "comment" : "Failure reason shown when a voice note could not be sent", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "فشل إرسال الملاحظة الصوتية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ভয়েস নোট পাঠানো যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sprachnachricht konnte nicht gesendet werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "voice note failed to send" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se pudo enviar la nota de voz" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال پیام صوتی ناموفق بود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nabigong maipadala ang voice note" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "échec de l'envoi de la note vocale" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שליחת ההערה הקולית נכשלה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वॉइस नोट भेजने में विफल" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "catatan suara gagal dikirim" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "invio della nota vocale non riuscito" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ボイスメモの送信に失敗" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "음성 메모 전송 실패" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nota suara gagal dihantar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "भ्वाइस नोट पठाउन असफल" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "spraakbericht kon niet worden verzonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie udało się wysłać notatki głosowej" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "falha ao enviar a nota de voz" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não foi possível enviar a mensagem de voz" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не удалось отправить голосовое сообщение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "röstmeddelandet kunde inte skickas" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குரல் குறிப்பு அனுப்ப முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งข้อความเสียงไม่สำเร็จ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sesli not gönderilemedi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не вдалося надіслати голосову нотатку" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "وائس نوٹ بھیجنے میں ناکام" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gửi ghi chú giọng nói thất bại" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "语音消息发送失败" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "語音訊息發送失敗" - } - } - } - }, - "content.delivery.reason.voice_too_large" : { - "comment" : "Failure reason shown when a voice note exceeds the size limit", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الملاحظة الصوتية كبيرة جدًا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ভয়েস নোট খুব বড়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sprachnachricht zu groß" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "voice note too large" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "nota de voz demasiado grande" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام صوتی بیش از حد بزرگ است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "masyadong malaki ang voice note" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "note vocale trop volumineuse" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ההערה הקולית גדולה מדי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वॉइस नोट बहुत बड़ा" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "catatan suara terlalu besar" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nota vocale troppo grande" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ボイスメモが大きすぎます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "음성 메모가 너무 큼" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nota suara terlalu besar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "भ्वाइस नोट धेरै ठूलो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "spraakbericht te groot" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "notatka głosowa zbyt duża" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nota de voz demasiado grande" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem de voz grande demais" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "голосовое сообщение слишком большое" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "röstmeddelandet är för stort" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குரல் குறிப்பு மிகப் பெரியது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ข้อความเสียงใหญ่เกินไป" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sesli not çok büyük" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "голосова нотатка завелика" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "وائس نوٹ بہت بڑا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ghi chú giọng nói quá lớn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "语音消息过大" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "語音訊息過大" - } - } - } - }, - "content.delivery.sending" : { - "comment" : "Delivery status description while a private message is being sent", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "جارٍ الإرسال..." - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "পাঠানো হচ্ছে..." - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wird gesendet ..." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sending..." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "enviando..." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال ارسال..." - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ipinapadala..." - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "envoi..." - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שולח..." - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "भेजा जा रहा है..." - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mengirim..." - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "invio in corso..." - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "送信中..." - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "전송 중..." - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "menghantar..." - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पठाइँदै..." - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verzenden..." - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wysyłanie..." - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "a enviar..." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enviando..." - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "отправка..." - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "skickar..." - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "அனுப்புகிறது..." - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "กำลังส่ง..." - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gönderiliyor..." - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "надсилання..." - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بھیجا جا رہا ہے..." - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đang gửi..." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "发送中..." - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "發送中..." - } - } - } - }, - "content.delivery.sent" : { - "comment" : "Delivery status description for a sent but not yet confirmed private message", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "أُرسِل — لا يوجد تأكيد تسليم بعد" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "পাঠানো হয়েছে — এখনো ডেলিভারি নিশ্চিতকরণ নেই" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gesendet — noch keine zustellbestätigung" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sent — no delivery confirmation yet" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "enviado — aún sin confirmación de entrega" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال شد — هنوز تأیید تحویل نرسیده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "naipadala — wala pang kumpirmasyon ng paghahatid" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "envoyé — aucune confirmation de livraison pour l'instant" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "נשלח — אין עדיין אישור מסירה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "भेजा गया — अभी तक डिलीवरी की पुष्टि नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "terkirim — belum ada konfirmasi pengiriman" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inviato — ancora nessuna conferma di consegna" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "送信済み — まだ配信確認がありません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "전송됨 — 아직 전송 확인 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dihantar — belum ada pengesahan penghantaran" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पठाइयो — अझै डेलिभरी पुष्टि छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verzonden — nog geen bezorgbevestiging" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wysłano — brak potwierdzenia dostarczenia" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enviado — ainda sem confirmação de entrega" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enviada — ainda sem confirmação de entrega" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "отправлено — подтверждения доставки пока нет" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "skickat — ingen leveransbekräftelse än" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "அனுப்பப்பட்டது — இன்னும் வழங்கல் உறுதிப்படுத்தல் இல்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งแล้ว — ยังไม่มีการยืนยันการส่ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gönderildi — henüz teslim onayı yok" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "надіслано — ще немає підтвердження доставки" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بھیج دیا گیا — ابھی ترسیل کی تصدیق نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã gửi — chưa có xác nhận nhận được" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已发送 — 尚无送达确认" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已發送 — 尚無送達確認" - } - } - } - }, - "content.echoes.divider" : { - "comment" : "System line shown above dimmed archived messages replayed on the mesh timeline at launch", + "content.delivery.reason.unreachable" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "سُمع هنا سابقًا · آخر 6 ساعات" + "value" : "القرين غير متاح" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "আগে এখানে শোনা গেছে · গত ৬ ঘণ্টা" + "value" : "পিয়ার পৌঁছানো যাচ্ছে না" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "vorhin hier gehört · letzte 6 std." + "value" : "peer nicht erreichbar" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "heard here earlier · last 6h" + "value" : "peer not reachable" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "escuchado aquí antes · últimas 6 h" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "قبلاً اینجا شنیده شده · ۶ ساعت اخیر" + "value" : "el destinatario no es alcanzable" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "narinig dito kanina · huling 6 oras" + "value" : "hindi maabot ang peer" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "entendu ici plus tôt · 6 dernières h" + "value" : "pair injoignable" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "נשמע כאן קודם · 6 שעות אחרונות" + "value" : "עמית לא זמין" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "पहले यहाँ सुना गया · पिछले 6 घंटे" + "value" : "पीयर पहुंच योग्य नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "terdengar di sini sebelumnya · 6 jam terakhir" + "value" : "peer tidak dapat dijangkau" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "sentito qui prima · ultime 6 ore" + "value" : "peer irraggiungibile" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ここで聞こえた · 過去6時間" + "value" : "ピアに到達できません" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "여기서 들린 대화 · 지난 6시간" + "value" : "피어에 연결할 수 없습니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "didengar di sini tadi · 6 jam lepas" + "value" : "peer tidak dapat dijangkau" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "पहिले यहाँ सुनिएको · पछिल्लो ६ घण्टा" + "value" : "पीयर पहुँचयोग्य छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "eerder hier gehoord · afgelopen 6 u" + "value" : "peer onbereikbaar" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "słyszane tu wcześniej · ostatnie 6 godz." + "value" : "peer nieosiągalny" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "ouvido aqui antes · últimas 6 h" + "value" : "par inacessível" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "ouvido aqui antes · últimas 6 h" + "value" : "par inalcançável" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "слышано здесь ранее · последние 6 ч" + "value" : "пир недостижим" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "hört här tidigare · senaste 6 tim" + "value" : "peer ej nåbar" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "முன்பு இங்கே கேட்டது · கடந்த 6 மணி" + "value" : "peer அணுக முடியவில்லை" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ได้ยินที่นี่ก่อนหน้านี้ · 6 ชม.ที่ผ่านมา" + "value" : "ติดต่อเพียร์ไม่ได้" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "daha önce burada duyuldu · son 6 sa" + "value" : "eşe ulaşılamıyor" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "почуто тут раніше · останні 6 год" + "value" : "пір недосяжний" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "پہلے یہاں سنا گیا · گزشتہ 6 گھنٹے" + "value" : "peer تک رسائی نہیں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "đã nghe ở đây trước đó · 6 giờ qua" + "value" : "không liên lạc được với nút" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "之前在这里听到 · 最近6小时" + "value" : "同伴不可达" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "之前在這裡聽到 · 最近6小時" - } - } - } - }, - "content.empty.activity_many" : { - "comment" : "Empty mesh timeline hint when several people are chatting in a nearby geohash channel; placeholder is the geohash", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "أشخاص يتحدثون في #%@ — انقر للانضمام" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "লোকজন #%@-এ কথা বলছে — যোগ দিতে ট্যাপ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "leute reden in #%@ — tippen zum beitreten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "people are talking in #%@ — tap to join" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "hay gente hablando en #%@ — toca para unirte" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "افرادی در #%@ گفتگو می‌کنند — برای پیوستن ضربه بزنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "may mga nag-uusap sa #%@ — i-tap para sumali" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "des gens parlent dans #%@ — appuyez pour rejoindre" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אנשים מדברים ב-#%@ — הקש כדי להצטרף" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ में लोग बात कर रहे हैं — शामिल होने के लिए टैप करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "orang-orang sedang mengobrol di #%@ — ketuk untuk bergabung" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "c'è gente che parla in #%@ — tocca per unirti" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ で人々が話しています — タップして参加" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@에서 사람들이 이야기 중 — 탭하여 참여" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "ramai sedang berbual di #%@ — ketik untuk sertai" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ मा मानिसहरू कुरा गर्दैछन् — सामेल हुन ट्याप गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensen praten in #%@ — tik om mee te doen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ludzie rozmawiają w #%@ — stuknij, aby dołączyć" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "há pessoas a falar em #%@ — toque para entrar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "tem gente conversando em #%@ — toque para entrar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "люди общаются в #%@ — нажмите, чтобы присоединиться" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "folk pratar i #%@ — tryck för att gå med" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ இல் மக்கள் பேசுகிறார்கள் — சேர தட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "หลายคนกำลังคุยใน #%@ — แตะเพื่อเข้าร่วม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ içinde insanlar konuşuyor — katılmak için dokun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "люди спілкуються в #%@ — торкніться, щоб приєднатися" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ میں لوگ بات کر رہے ہیں — شامل ہونے کے لیے تھپتھپائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mọi người đang trò chuyện trong #%@ — chạm để tham gia" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "大家正在 #%@ 聊天 — 点按加入" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "大家正在 #%@ 聊天 — 點按加入" - } - } - } - }, - "content.empty.activity_one" : { - "comment" : "Empty mesh timeline hint when one person is chatting in a nearby geohash channel; placeholder is the geohash", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "أحدهم يتحدث في #%@ — انقر للانضمام" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "কেউ #%@-এ কথা বলছে — যোগ দিতে ট্যাপ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "jemand redet in #%@ — tippen zum beitreten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "someone is talking in #%@ — tap to join" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "alguien está hablando en #%@ — toca para unirte" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کسی در #%@ صحبت می‌کند — برای پیوستن ضربه بزنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "may nagsasalita sa #%@ — i-tap para sumali" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "quelqu'un parle dans #%@ — appuyez pour rejoindre" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מישהו מדבר ב-#%@ — הקש כדי להצטרף" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ में कोई बात कर रहा है — शामिल होने के लिए टैप करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "seseorang sedang mengobrol di #%@ — ketuk untuk bergabung" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "qualcuno sta parlando in #%@ — tocca per unirti" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ で誰かが話しています — タップして参加" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@에서 누군가 이야기 중 — 탭하여 참여" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "seseorang sedang berbual di #%@ — ketik untuk sertai" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ मा कोही कुरा गर्दैछ — सामेल हुन ट्याप गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "iemand praat in #%@ — tik om mee te doen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ktoś rozmawia w #%@ — stuknij, aby dołączyć" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "alguém está a falar em #%@ — toque para entrar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "alguém está conversando em #%@ — toque para entrar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "кто-то общается в #%@ — нажмите, чтобы присоединиться" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "någon pratar i #%@ — tryck för att gå med" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ இல் யாரோ பேசுகிறார்கள் — சேர தட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "มีคนกำลังคุยใน #%@ — แตะเพื่อเข้าร่วม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ içinde biri konuşuyor — katılmak için dokun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "хтось спілкується в #%@ — торкніться, щоб приєднатися" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%@ میں کوئی بات کر رہا ہے — شامل ہونے کے لیے تھپتھپائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "có người đang trò chuyện trong #%@ — chạm để tham gia" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "有人正在 #%@ 聊天 — 点按加入" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "有人正在 #%@ 聊天 — 點按加入" - } - } - } - }, - "content.empty.check_notes" : { - "comment" : "Empty mesh timeline action that starts looking for notes left at this place; before tapping, no lookup runs", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تحقّق من الملاحظات المتروكة هنا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এখানে রাখা নোট আছে কি না দেখুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "nachsehen, ob hier notizen hinterlassen wurden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "check for notes left here" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "buscar notas dejadas aquí" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بررسی یادداشت‌های جامانده در اینجا" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "tingnan kung may mga note na naiwan dito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "vérifier s'il y a des notes laissées ici" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בדיקה אם הושארו כאן פתקים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "देखें कि यहाँ नोट छोड़े गए हैं या नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "periksa catatan yang ditinggalkan di sini" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "controlla se ci sono note lasciate qui" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ここに残されたメモを確認" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "여기 남겨진 쪽지 확인" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "semak nota yang ditinggalkan di sini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kijk of hier notities zijn achtergelaten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "sprawdź, czy zostawiono tutaj notatki" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ver se há notas deixadas aqui" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ver se há notas deixadas aqui" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "проверить, есть ли здесь заметки" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "kolla om anteckningar lämnats här" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "buraya bırakılan notlara bak" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "перевірити, чи залишено тут нотатки" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "kiểm tra ghi chú để lại ở đây" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "查看这里留下的留言" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "查看這裡留下的留言" - } - } - } - }, - "content.empty.location_intro" : { - "comment" : "First line of an empty geohash timeline naming the channel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "أنت في #%@ — قناة موقع عامة عبر الإنترنت" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি #%@-এ আছেন — ইন্টারনেটের ওপর একটি পাবলিক লোকেশন চ্যানেল" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du bist in #%@ — ein öffentlicher standortkanal über das internet" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you're in #%@ — a public location channel over the internet" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "estás en #%@ — un canal de ubicación público por internet" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "شما در #%@ هستید — یک کانال موقعیت عمومی روی اینترنت" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nasa #%@ ka — isang pampublikong channel ng lokasyon sa internet" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tu es dans #%@ — un canal de localisation public sur internet" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אתה ב-#%@ — ערוץ מיקום ציבורי דרך האינטרנט" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आप #%@ में हैं — इंटरनेट पर एक सार्वजनिक लोकेशन चैनल" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kamu di #%@ — kanal lokasi publik lewat internet" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sei in #%@ — un canale di posizione pubblico su internet" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ にいます — インターネット経由の公開位置チャンネルです" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ 에 있습니다 — 인터넷을 통한 공개 위치 채널입니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anda di #%@ — kanal lokasi awam melalui internet" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिमी #%@ मा छौ — इन्टरनेटमाथिको सार्वजनिक स्थान च्यानल" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "je bent in #%@ — een openbaar locatiekanaal via internet" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "jesteś w #%@ — publiczny kanał lokalizacji przez internet" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "estás em #%@ — um canal de localização público pela internet" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "você está em #%@ — um canal público de localização pela internet" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ты в #%@ — публичном локальном канале через интернет" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du är i #%@ — en publik platskanal över internet" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "நீங்கள் #%@ இல் இருக்கிறீர்கள் — இணையம் வழியாக ஒரு பொது இருப்பிட சேனல்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "คุณอยู่ใน #%@ — ช่องตามตำแหน่งสาธารณะผ่านอินเทอร์เน็ต" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ kanalındasın — internet üzerinden herkese açık bir konum kanalı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ти в #%@ — публічний канал локації через інтернет" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آپ #%@ میں ہیں — انٹرنیٹ پر ایک عوامی لوکیشن چینل" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bạn đang ở #%@ — một kênh vị trí công khai qua internet" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你在 #%@ — 一个通过互联网的公共位置频道" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你在 #%@ — 一個透過網際網路的公開位置頻道" - } - } - } - }, - "content.empty.mesh_intro" : { - "comment" : "First line of the empty mesh timeline explaining what the mesh channel is", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "أنت في #mesh — تصل إلى الأشخاص ضمن نطاق bluetooth" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি #mesh-এ আছেন — ব্লুটুথ পরিসরের মধ্যে মানুষের কাছে পৌঁছায়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du bist auf #mesh — erreicht menschen in bluetooth-reichweite" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you're on #mesh — reaches people within bluetooth range" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "estás en #mesh — llega a personas dentro del alcance de Bluetooth" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "شما در #mesh هستید — به افراد در برد بلوتوث می‌رسد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nasa #mesh ka — umaabot sa mga taong nasa saklaw ng bluetooth" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tu es sur #mesh — atteint les personnes à portée bluetooth" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אתה ב-#mesh — מגיע לאנשים בטווח bluetooth" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आप #mesh पर हैं — ब्लूटूथ रेंज के भीतर लोगों तक पहुँचता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kamu di #mesh — menjangkau orang dalam jangkauan bluetooth" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sei su #mesh — raggiunge le persone a portata bluetooth" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh にいます — bluetooth圏内の人に届きます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh 에 있습니다 — bluetooth 범위 내의 사람에게 도달합니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anda di #mesh — mencapai orang dalam jangkauan bluetooth" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिमी #mesh मा छौ — bluetooth दायराभित्रका मानिससम्म पुग्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "je bent op #mesh — bereikt mensen binnen bluetoothbereik" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "jesteś na #mesh — dociera do osób w zasięgu Bluetooth" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "estás em #mesh — alcança pessoas dentro do alcance bluetooth" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "você está no #mesh — alcança pessoas dentro do alcance do bluetooth" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ты в #mesh — достаёт людей в радиусе bluetooth" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du är på #mesh — når personer inom Bluetooth-räckvidd" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "நீங்கள் #mesh இல் இருக்கிறீர்கள் — bluetooth வரம்பிற்குள் உள்ளவர்களை அடையும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "คุณอยู่ใน #mesh — เข้าถึงคนในระยะ bluetooth" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh kanalındasın — Bluetooth menzilindeki kişilere ulaşır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ти на #mesh — досягає людей у радіусі bluetooth" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آپ #mesh پر ہیں — Bluetooth رینج میں لوگوں تک پہنچتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bạn đang ở #mesh — tiếp cận mọi người trong phạm vi Bluetooth" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你在 #mesh — 可触达 bluetooth 范围内的人" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你在 #mesh — 可到達 bluetooth 範圍內的人" - } - } - } - }, - "content.empty.mesh_waiting" : { - "comment" : "Second line of the empty mesh timeline saying no peers are in range yet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لا أحد في النطاق بعد... ستظهر الرسائل هنا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এখনো কেউ পরিসরে নেই... বার্তা এখানে দেখা যাবে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "noch niemand in reichweite ... nachrichten erscheinen hier" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "nobody in range yet... messages appear here" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "nadie a tu alcance todavía... los mensajes aparecerán aquí" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هنوز کسی در برد نیست... پیام‌ها اینجا نمایش داده می‌شوند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wala pang tao sa saklaw... dito lalabas ang mga mensahe" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "personne à portée pour l'instant... les messages apparaîtront ici" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אף אחד לא בטווח עדיין... הודעות יופיעו כאן" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "अभी तक कोई रेंज में नहीं... संदेश यहाँ दिखाई देंगे" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "belum ada siapa pun dalam jangkauan... pesan akan muncul di sini" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ancora nessuno a portata... i messaggi appariranno qui" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "まだ圏内に誰もいません... メッセージはここに表示されます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "아직 범위 내에 아무도 없습니다... 메시지가 여기에 표시됩니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "belum ada sesiapa dalam jangkauan... pesan akan muncul di sini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "अझै दायरामा कोही छैन... सन्देश यहाँ देखिन्छन्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nog niemand in bereik... berichten verschijnen hier" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nikogo w zasięgu... wiadomości pojawią się tutaj" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ainda ninguém ao alcance... as mensagens aparecem aqui" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ninguém no alcance ainda... as mensagens aparecem aqui" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "пока никого рядом... сообщения появятся здесь" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ingen inom räckvidd än... meddelanden visas här" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இன்னும் வரம்பில் யாரும் இல்லை... செய்திகள் இங்கே தோன்றும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ยังไม่มีใครอยู่ในระยะ... ข้อความจะปรากฏที่นี่" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "menzilde henüz kimse yok... mesajlar burada görünür" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "поки нікого в радіусі... повідомлення з'являться тут" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ابھی رینج میں کوئی نہیں... پیغامات یہاں ظاہر ہوں گے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chưa có ai trong phạm vi... tin nhắn sẽ xuất hiện ở đây" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "范围内还没有人... 消息会显示在这里" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "範圍內還沒有人... 訊息會顯示在這裡" - } - } - } - }, - "content.empty.notes_many" : { - "comment" : "Empty mesh timeline hint counting notes left at this place", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تُركت %lld ملاحظات هنا — انقر للقراءة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এখানে %lldটি নোট রাখা আছে — পড়তে ট্যাপ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld notizen hier hinterlassen — tippen zum lesen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld notes left here — tap to read" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld notas dejadas aquí — toca para leer" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld یادداشت اینجا گذاشته شده — برای خواندن ضربه بزنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld note ang naiwan dito — i-tap para basahin" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld notes laissées ici — appuyez pour lire" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld פתקים הושארו כאן — הקש לקריאה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "यहाँ %lld नोट छोड़े गए हैं — पढ़ने के लिए टैप करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld catatan ditinggalkan di sini — ketuk untuk membaca" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld note lasciate qui — tocca per leggere" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ここに%lld件のメモがあります — タップして読む" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "여기 남겨진 쪽지 %lld개 — 탭하여 읽기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld nota ditinggalkan di sini — ketik untuk baca" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यहाँ %lld नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld notities hier achtergelaten — tik om te lezen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld notatek zostawionych tutaj — stuknij, aby przeczytać" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld notas deixadas aqui — toque para ler" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld notas deixadas aqui — toque para ler" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "здесь оставлено заметок: %lld — нажмите, чтобы прочитать" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld anteckningar lämnade här — tryck för att läsa" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இங்கே %lld குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "มี %lld โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "buraya %lld not bırakıldı — okumak için dokun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "тут залишено %lld нотаток — торкніться, щоб прочитати" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہاں %lld نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "có %lld ghi chú để lại ở đây — chạm để đọc" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "这里留有 %lld 条留言 — 点按阅读" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "這裡留有 %lld 則留言 — 點按閱讀" - } - } - } - }, - "content.empty.notes_one" : { - "comment" : "Empty mesh timeline hint when exactly one note was left at this place", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تُركت ملاحظة واحدة هنا — انقر للقراءة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 notiz hier hinterlassen — tippen zum lesen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 note left here — tap to read" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 nota dejada aquí — toca para leer" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "۱ یادداشت اینجا گذاشته شده — برای خواندن ضربه بزنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 note ang naiwan dito — i-tap para basahin" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 note laissée ici — appuyez pour lire" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פתק אחד הושאר כאן — הקש לקריאה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 catatan ditinggalkan di sini — ketuk untuk membaca" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 nota lasciata qui — tocca per leggere" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ここに1件のメモがあります — タップして読む" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "여기 남겨진 쪽지 1개 — 탭하여 읽기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 nota ditinggalkan di sini — ketik untuk baca" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 notitie hier achtergelaten — tik om te lezen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 notatka zostawiona tutaj — stuknij, aby przeczytać" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 nota deixada aqui — toque para ler" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 nota deixada aqui — toque para ler" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "здесь оставлена 1 заметка — нажмите, чтобы прочитать" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 anteckning lämnad här — tryck för att läsa" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "buraya 1 not bırakıldı — okumak için dokun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "тут залишено 1 нотатку — торкніться, щоб прочитати" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "có 1 ghi chú để lại ở đây — chạm để đọc" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "这里留有 1 条留言 — 点按阅读" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "這裡留有 1 則留言 — 點按閱讀" - } - } - } - }, - "content.empty.sightings_many" : { - "comment" : "Empty mesh timeline stat counting devices that came within range today", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مرّ %lld جهاز ضمن النطاق اليوم" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আজ %lldটি ডিভাইস রেঞ্জের মধ্যে দিয়ে গেছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld geräte waren heute in reichweite" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld devices passed within range today" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld dispositivos pasaron dentro del alcance hoy" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld دستگاه امروز از محدوده برد عبور کردند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld device ang dumaan sa saklaw ngayong araw" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld appareils sont passés à portée aujourd'hui" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld מכשירים עברו בטווח היום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आज %lld डिवाइस रेंज में से गुज़रे" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld perangkat lewat dalam jangkauan hari ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld dispositivi sono passati nel raggio oggi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今日%lld台のデバイスが範囲内を通過" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "오늘 기기 %lld대가 범위 안을 지나갔어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld peranti lalu dalam jangkauan hari ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "आज %lld यन्त्रहरू दायराभित्र आए" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld apparaten kwamen vandaag binnen bereik" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld urządzeń było dziś w zasięgu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld dispositivos passaram dentro do alcance hoje" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld dispositivos passaram dentro do alcance hoje" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "сегодня %lld устройств прошло в зоне досягаемости" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld enheter passerade inom räckvidd idag" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இன்று %lld சாதனங்கள் வரம்பிற்குள் கடந்தன" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "วันนี้มี %lld อุปกรณ์ผ่านเข้ามาในระยะ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bugün %lld cihaz menzilden geçti" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "сьогодні %lld пристроїв пройшло в зоні досяжності" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "آج %lld ڈیوائسز رینج میں سے گزریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "hôm nay có %lld thiết bị đi qua trong tầm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "今天有 %lld 台设备经过范围内" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "今天有 %lld 台裝置經過範圍內" - } - } - } - }, - "content.empty.sightings_one" : { - "comment" : "Empty mesh timeline stat when exactly one device came within range today", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مرّ جهاز واحد ضمن النطاق اليوم" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আজ 1টি ডিভাইস রেঞ্জের মধ্যে দিয়ে গেছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 gerät war heute in reichweite" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 device passed within range today" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 dispositivo pasó dentro del alcance hoy" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "۱ دستگاه امروز از محدوده برد عبور کرد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 device ang dumaan sa saklaw ngayong araw" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 appareil est passé à portée aujourd'hui" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מכשיר אחד עבר בטווח היום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आज 1 डिवाइस रेंज में से गुज़रा" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 perangkat lewat dalam jangkauan hari ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 dispositivo è passato nel raggio oggi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今日1台のデバイスが範囲内を通過" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "오늘 기기 1대가 범위 안을 지나갔어요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 peranti lalu dalam jangkauan hari ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "आज 1 यन्त्र दायराभित्र आयो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 apparaat kwam vandaag binnen bereik" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 urządzenie było dziś w zasięgu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 dispositivo passou dentro do alcance hoje" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 dispositivo passou dentro do alcance hoje" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "сегодня 1 устройство прошло в зоне досягаемости" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "1 enhet passerade inom räckvidd idag" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இன்று 1 சாதனம் வரம்பிற்குள் கடந்தது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "วันนี้มี 1 อุปกรณ์ผ่านเข้ามาในระยะ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bugün 1 cihaz menzilden geçti" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "сьогодні 1 пристрій пройшов у зоні досяжності" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "آج 1 ڈیوائس رینج میں سے گزری" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "hôm nay có 1 thiết bị đi qua trong tầm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "今天有 1 台设备经过范围内" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "今天有 1 台裝置經過範圍內" - } - } - } - }, - "content.empty.switch_hint" : { - "comment" : "Empty timeline hint pointing at the channel switcher and the help screen", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اضغط اسم القناة بالأعلى للتبديل · اضغط bitchat/ للمساعدة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "বদলাতে উপরের চ্যানেলের নাম ট্যাপ করুন · সাহায্যের জন্য bitchat/ ট্যাপ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tippe oben auf den kanalnamen zum wechseln · tippe bitchat/ für hilfe" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tap the channel name above to switch · tap bitchat/ for help" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "toca el nombre del canal de arriba para cambiar · toca bitchat/ para ayuda" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای تغییر، نام کانال بالا را ضربه بزنید · برای راهنما bitchat/ را ضربه بزنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i-tap ang pangalan ng channel sa itaas para magpalit · i-tap ang bitchat/ para sa tulong" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "touche le nom du canal ci-dessus pour changer · touche bitchat/ pour l'aide" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הקש על שם הערוץ למעלה כדי להחליף · הקש על bitchat/ לעזרה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "स्विच करने के लिए ऊपर चैनल का नाम टैप करें · मदद के लिए bitchat/ टैप करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ketuk nama kanal di atas untuk beralih · ketuk bitchat/ untuk bantuan" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tocca il nome del canale in alto per cambiare · tocca bitchat/ per l'aiuto" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "上のチャンネル名をタップして切り替え · bitchat/ をタップしてヘルプ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "위의 채널 이름을 탭하여 전환 · bitchat/ 를 탭하여 도움말" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ketuk nama kanal di atas untuk beralih · ketuk bitchat/ untuk bantuan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "बदल्न माथिको च्यानल नाम ट्याप गर · मद्दतका लागि bitchat/ ट्याप गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tik op de kanaalnaam hierboven om te wisselen · tik op bitchat/ voor hulp" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "stuknij nazwę kanału powyżej, aby przełączyć · stuknij bitchat/ po pomoc" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toca no nome do canal acima para trocar · toca em bitchat/ para ajuda" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toque o nome do canal acima para trocar · toque bitchat/ para ajuda" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "нажми на имя канала выше, чтобы переключиться · нажми bitchat/ для справки" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tryck på kanalnamnet ovan för att byta · tryck på bitchat/ för hjälp" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "மாற்ற மேலே உள்ள சேனல் பெயரைத் தட்டவும் · உதவிக்கு bitchat/ ஐத் தட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แตะชื่อช่องด้านบนเพื่อสลับ · แตะ bitchat/ เพื่อดูวิธีใช้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "değiştirmek için yukarıdaki kanal adına dokunun · yardım için bitchat/ dokunun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "торкнися назви каналу вгорі, щоб перемкнути · торкнися bitchat/ для довідки" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تبدیل کرنے کیلئے اوپر چینل کے نام پر ٹیپ کریں · مدد کیلئے bitchat/ پر ٹیپ کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chạm tên kênh phía trên để chuyển · chạm bitchat/ để được trợ giúp" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "轻点上方频道名切换 · 轻点 bitchat/ 获取帮助" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "輕點上方頻道名稱切換 · 輕點 bitchat/ 取得協助" - } - } - } - }, - "content.header.gateway_active" : { - "comment" : "Tooltip for the internet gateway indicator", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "مشاركة اتصال الإنترنت مع أقران mesh القريبين" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "কাছাকাছি মেশ পিয়ারদের সঙ্গে আপনার ইন্টারনেট সংযোগ ভাগ করছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teilt deine internetverbindung mit nahen mesh-peers" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sharing your internet connection with nearby mesh peers" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Compartiendo tu conexión a internet con peers cercanos del mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اشتراک‌گذاری اتصال اینترنت شما با همتاهای مش اطراف" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Ibinabahagi ang iyong koneksyon sa internet sa mga kalapit na mesh peer" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "partage ta connexion internet avec les pairs mesh à proximité" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "משתף את חיבור האינטרנט שלך עם עמיתי mesh קרובים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आपका इंटरनेट कनेक्शन आसपास के मेश पीयरों के साथ साझा किया जा रहा है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membagikan koneksi internetmu dengan peer mesh terdekat" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "condivide la tua connessione internet con i peer mesh vicini" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "近くのmeshピアとインターネット接続を共有中" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "근처 mesh 피어와 인터넷 연결을 공유 중" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "berkongsi sambungan internetmu dengan peer mesh berdekatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिम्रो इन्टरनेट जडान नजिकका mesh सहकर्मीसँग साझेदारी गरिँदै" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "deelt je internetverbinding met mesh-peers in de buurt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Udostępniasz swoje połączenie internetowe pobliskim peerom mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "a partilhar a tua ligação à internet com pares mesh próximos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Compartilhando sua conexão de internet com pares do mesh por perto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "раздаёт твоё интернет-соединение ближайшим mesh-пирам" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Delar din internetanslutning med mesh-peers i närheten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "உங்கள் இணைய இணைப்பை அருகிலுள்ள mesh peer-களுடன் பகிர்கிறது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "กำลังแชร์การเชื่อมต่ออินเทอร์เน็ตของคุณกับเพียร์ mesh ที่อยู่ใกล้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "İnternet bağlantınızı yakındaki mesh eşleriyle paylaşıyorsunuz" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Ділишся своїм інтернет-з'єднанням з ближніми пірами mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "قریبی mesh ہم منصبوں کے ساتھ اپنا انٹرنیٹ کنکشن شیئر کر رہا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chia sẻ kết nối internet của bạn với các nút mesh gần đó" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "正在与附近 mesh 同伴共享你的互联网连接" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "正在與附近的 mesh 同伴分享你的網際網路連線" - } - } - } - }, - "content.header.notices" : { - "comment" : "Tooltip for the notices button", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إعلانات: منشورات مثبتة لهذه المنطقة وشبكة mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নোটিশ: এই এলাকা ও মেশের জন্য পিন করা পোস্ট" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hinweise: angeheftete Beiträge für diese Gegend und das Mesh" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notices: pinned posts for this area and the mesh" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avisos: publicaciones fijadas para esta zona y el mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اطلاعیه‌ها: پست‌های سنجاق‌شده برای این منطقه و مش" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mga paunawa: naka-pin na post para sa lugar na ito at sa mesh" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annonces : publications épinglées pour cette zone et le mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מודעות: פוסטים נעוצים לאזור הזה ול-mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचनाएँ: इस क्षेत्र और मेश के लिए पिन की गई पोस्ट" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pengumuman: kiriman yang disematkan untuk area ini dan mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avvisi: post appuntati per questa zona e il mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お知らせ: このエリアとmeshのピン留め投稿" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공지: 이 지역과 mesh에 고정된 게시물" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pengumuman: kiriman disemat untuk kawasan ini dan mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचनाहरू: यो क्षेत्र र मेशका लागि पिन गरिएका पोस्ट" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mededelingen: vastgeprikte berichten voor deze omgeving en het mesh" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ogłoszenia: przypięte wpisy dla tej okolicy i mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avisos: publicações afixadas para esta zona e o mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avisos: publicações fixadas para esta área e o mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Объявления: закреплённые записи для этого места и mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anslag: uppnålade inlägg för det här området och mesh" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அறிவிப்புகள்: இந்தப் பகுதி மற்றும் மெஷுக்கான பின் செய்யப்பட்ட பதிவுகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ประกาศ: โพสต์ที่ปักหมุดสำหรับบริเวณนี้และ mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Duyurular: bu bölge ve mesh için sabitlenmiş gönderiler" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Оголошення: закріплені дописи для цієї місцевості та mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اعلانات: اس علاقے اور mesh کیلئے پن شدہ پوسٹس" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thông báo: bài ghim cho khu vực này và mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "公告:此区域和 mesh 的置顶帖子" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "公告:此區域和 mesh 的置頂貼文" + "value" : "同伴不可達" } } } @@ -55987,12 +16888,6 @@ "value" : "PERSONAS" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "افراد" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -56172,12 +17067,6 @@ "value" : "verificación: mostrar mi QR o escanear a un amigo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تأیید: نمایش QR من یا اسکن یک دوست" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -56324,187 +17213,181 @@ } } }, - "content.input.group_placeholder" : { + "content.input.message_placeholder" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "اكتب رسالة..." } }, "bn" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "একটি বার্তা লিখুন..." } }, "de" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "nachricht eingeben..." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "create|invite|leave|list" + "value" : "type a message..." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "create|invite|leave|list" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "create|invite|leave|list" + "value" : "escribe un mensaje..." } }, "fil" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "mag-type ng mensahe..." } }, "fr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "écris un message..." } }, "he" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "כתוב הודעה..." } }, "hi" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "संदेश लिखें..." } }, "id" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "ketik pesan..." } }, "it" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "scrivi un messaggio..." } }, "ja" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "メッセージを入力..." } }, "ko" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "메시지를 입력하세요..." } }, "ms" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "ketik pesan..." } }, "ne" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "सन्देश टाइप गर..." } }, "nl" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "typ een bericht..." } }, "pl" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "wpisz wiadomość..." } }, "pt" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "escreve uma mensagem..." } }, "pt-BR" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "digite uma mensagem..." } }, "ru" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "напиши сообщение..." } }, "sv" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "skriv ett meddelande..." } }, "ta" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "ஒரு செய்தியைத் தட்டச்சு செய்க..." } }, "th" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "พิมพ์ข้อความ..." } }, "tr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "bir mesaj yazın..." } }, "uk" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "напиши повідомлення..." } }, "ur" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "پیغام ٹائپ کریں..." } }, "vi" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "nhập tin nhắn..." } }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "输入消息..." } }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", - "value" : "create|invite|leave|list" + "state" : "translated", + "value" : "輸入訊息..." } } } @@ -56542,12 +17425,6 @@ "value" : "apodo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نام مستعار" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -56694,1122 +17571,6 @@ } } }, - "content.input.note_placeholder" : { - "comment" : "Placeholder argument name for the /drop command suggestion", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "رسالة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বার্তা" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "nachricht" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensahe" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "message" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הודעה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "संदेश" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "pesan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "messaggio" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "メッセージ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "메시지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सन्देश" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "bericht" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wiadomość" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "сообщение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "meddelande" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "செய்தி" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ข้อความ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesaj" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "повідомлення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیغام" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tin nhắn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "訊息" - } - } - } - }, - "content.input.placeholder.location" : { - "comment" : "Composer placeholder for a public geohash channel, naming it", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسالة #%@ — عام" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@-এ বার্তা — পাবলিক" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nachricht an #%@ — öffentlich" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message #%@ — public" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje #%@ — público" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام به #%@ — عمومی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensahe sa #%@ — pampubliko" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "message vers #%@ — public" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעה ל-#%@ — ציבורי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "संदेश #%@ — सार्वजनिक" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #%@ — publik" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messaggio a #%@ — pubblico" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ にメッセージ — 公開" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ 에 메시지 — 공개" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #%@ — awam" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ मा सन्देश — सार्वजनिक" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bericht naar #%@ — openbaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wiadomość #%@ — publiczna" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para #%@ — público" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para #%@ — público" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сообщение в #%@ — публичное" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "meddelande #%@ — publikt" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ க்கு செய்தி — பொது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งข้อความถึง #%@ — สาธารณะ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesaj #%@ — herkese açık" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "повідомлення #%@ — публічне" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پیغام #%@ — عوامی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhắn #%@ — công khai" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "发消息到 #%@ — 公开" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "發訊息到 #%@ — 公開" - } - } - } - }, - "content.input.placeholder.mesh" : { - "comment" : "Composer placeholder for the public mesh channel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسالة #mesh — عام، قريب" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh-এ বার্তা — পাবলিক, কাছাকাছি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nachricht an #mesh — öffentlich, in der nähe" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message #mesh — public, nearby" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje #mesh — público, cerca" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام به #mesh — عمومی، اطراف شما" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensahe sa #mesh — pampubliko, malapit" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "message vers #mesh — public, à proximité" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעה ל-#mesh — ציבורי, קרוב" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "संदेश #mesh — सार्वजनिक, आसपास" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #mesh — publik, terdekat" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messaggio a #mesh — pubblico, nelle vicinanze" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh にメッセージ — 公開、近くの人へ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh 에 메시지 — 공개, 근처" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #mesh — awam, berdekatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh मा सन्देश — सार्वजनिक, नजिकको" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bericht naar #mesh — openbaar, in de buurt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wiadomość #mesh — publiczna, w pobliżu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para #mesh — público, próximo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para #mesh — público, por perto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сообщение в #mesh — публичное, рядом" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "meddelande #mesh — publikt, i närheten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh க்கு செய்தி — பொது, அருகில்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งข้อความถึง #mesh — สาธารณะ, ใกล้เคียง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesaj #mesh — herkese açık, yakında" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "повідомлення #mesh — публічне, поблизу" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پیغام #mesh — عوامی، قریبی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhắn #mesh — công khai, gần đây" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "发消息到 #mesh — 公开、附近" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "發訊息到 #mesh — 公開、附近" - } - } - } - }, - "content.input.placeholder.private" : { - "comment" : "Composer placeholder inside a private chat, naming the conversation partner", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسالة %@ — خاص" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@-কে বার্তা — ব্যক্তিগত" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nachricht an %@ — privat" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message %@ — private" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje %@ — privado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام به %@ — خصوصی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensahe kay %@ — pribado" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "message à %@ — privé" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעה ל-%@ — פרטי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "संदेश %@ — निजी" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan %@ — pribadi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messaggio a %@ — privato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ にメッセージ — プライベート" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 에게 메시지 — 비공개" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan %@ — peribadi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ लाई सन्देश — निजी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bericht naar %@ — privé" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wiadomość do %@ — prywatna" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para %@ — privado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para %@ — privado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сообщение для %@ — личное" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "meddelande %@ — privat" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ க்கு செய்தி — தனிப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งข้อความถึง %@ — ส่วนตัว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesaj %@ — özel" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "повідомлення %@ — приватне" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پیغام %@ — نجی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhắn %@ — riêng tư" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "发消息给 %@ — 私密" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "發訊息給 %@ — 私密" - } - } - } - }, - "content.input.token_placeholder" : { - "comment" : "Placeholder shown after /pay in the command suggestion panel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رمز" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "টোকেন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "token" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "token" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "توکن" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "טוקן" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "टोकन" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "トークン" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "토큰" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "टोकन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "токен" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "டோக்கன்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "โทเคน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "токен" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ٹوکن" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "代币" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token" - } - } - } - }, - "content.jump.new_count" : { - "comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld جديدة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld নতুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld neu" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld new" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld nuevos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld جدید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld bago" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld nouveaux" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld חדשות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld नए" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld baru" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld nuovi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld 件の新着" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "새 메시지 %lld개" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld baru" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld नयाँ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld nieuw" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld nowych" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld novas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld novas" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld новых" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld nya" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld புதியது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ใหม่ %lld รายการ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld yeni" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld нових" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld نئی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld mới" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld 条新消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%lld 則新訊息" - } - } - } - }, "content.location.enable" : { "extractionState" : "manual", "localizations" : { @@ -57843,12 +17604,6 @@ "value" : "activar ubicación" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "فعال‌سازی موقعیت" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -58028,12 +17783,6 @@ "value" : "copiar mensaje" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کپی پیام" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -58213,12 +17962,6 @@ "value" : "mostrar menos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمایش کمتر" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -58398,12 +18141,6 @@ "value" : "mostrar más" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمایش بیشتر" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -58583,12 +18320,6 @@ "value" : "ubicación no disponible" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "موقعیت در دسترس نیست" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -58735,6 +18466,185 @@ } } }, + "content.notes.title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ملاحظات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "notizen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "notas" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga tala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "notes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הערות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नोट्स" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "catatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "note" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ノート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노트" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "catatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नोट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "notities" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "notatki" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "notas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "notas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "заметки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "anteckningar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "குறிப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บันทึก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "notlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "замітки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "نوٹس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghi chú" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "笔记" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "筆記" + } + } + } + }, "content.payment.cashu" : { "extractionState" : "manual", "localizations" : { @@ -58768,12 +18678,6 @@ "value" : "pagar con Cashu" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پرداخت با cashu" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -58920,192 +18824,6 @@ } } }, - "content.payment.copy_token" : { - "comment" : "Context menu action copying a Cashu token to the pasteboard", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نسخ الرمز" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "টোকেন কপি করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token kopieren" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "copy token" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "copiar token" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کپی توکن" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kopyahin ang token" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "copier le token" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "העתק טוקן" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "टोकन कॉपी करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "salin token" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "copia token" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "トークンをコピー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "토큰 복사" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "salin token" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "टोकन कपी गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "token kopiëren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kopiuj token" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "copiar token" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "copiar token" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "скопировать токен" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kopiera token" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "டோக்கனை நகலெடு" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "คัดลอกโทเคน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tokenı kopyala" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "скопіювати токен" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ٹوکن کاپی کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sao chép token" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "复制代币" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "複製代幣" - } - } - } - }, "content.payment.lightning" : { "extractionState" : "manual", "localizations" : { @@ -59139,12 +18857,6 @@ "value" : "pagar con Lightning" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پرداخت با لایتنینگ" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -59291,935 +19003,6 @@ } } }, - "content.payment.redeem_wallet" : { - "comment" : "Context menu action opening a Cashu token in an ecash wallet app", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الاسترداد في المحفظة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ওয়ালেটে রিডিম করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "in wallet einlösen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "redeem in wallet" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "canjear en la cartera" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دریافت در کیف پول" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i-redeem sa wallet" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "utiliser dans le wallet" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מימוש בארנק" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वॉलेट में रिडीम करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tukarkan di dompet" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "riscatta nel wallet" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ウォレットで受け取る" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "지갑에서 받기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tebus dalam dompet" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वालेटमा भजाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inwisselen in wallet" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zrealizuj w portfelu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "resgatar na wallet" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "resgatar na carteira" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "обменять в кошельке" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lös in i plånbok" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "வாலெட்டில் மீட்டெடு" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แลกในกระเป๋าเงิน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cüzdanda kullan" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "погасити в гаманці" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "والیٹ میں ریڈیم کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đổi trong ví" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "在钱包中兑换" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "在錢包中兌換" - } - } - } - }, - "content.payment.redeem_web" : { - "comment" : "Context menu action opening a Cashu token in the web redemption page", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الاسترداد على الويب" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ওয়েবে রিডিম করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "im web einlösen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "redeem on web" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "canjear en la web" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دریافت در وب" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i-redeem sa web" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "utiliser sur le web" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מימוש באינטרנט" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वेब पर रिडीम करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tukarkan di web" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "riscatta sul web" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ウェブで受け取る" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "웹에서 받기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tebus di web" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वेबमा भजाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inwisselen op web" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zrealizuj w sieci" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "resgatar na web" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "resgatar na web" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "обменять в вебе" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lös in på webben" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "வெப்பில் மீட்டெடு" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แลกบนเว็บ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "webde kullan" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "погасити у вебі" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ویب پر ریڈیم کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đổi trên web" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "在网页上兑换" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "在網頁上兌換" - } - } - } - }, - "content.private.caption" : { - "comment" : "Caption above the private chat composer before encryption is established", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "محادثة خاصة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্যক্তিগত কথোপকথন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privates gespräch" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "private conversation" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "conversación privada" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گفتگوی خصوصی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pribadong pag-uusap" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conversation privée" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שיחה פרטית" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी बातचीत" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "percakapan pribadi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conversazione privata" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "プライベートな会話" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "비공개 대화" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "perbualan peribadi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी कुराकानी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privégesprek" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rozmowa prywatna" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conversa privada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conversa privada" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "личный разговор" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privat konversation" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "தனிப்பட்ட உரையாடல்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "การสนทนาส่วนตัว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "özel konuşma" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "приватна розмова" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نجی گفتگو" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cuộc trò chuyện riêng tư" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "私密对话" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "私密對話" - } - } - } - }, - "content.private.caption_encrypted" : { - "comment" : "Caption above the private chat composer once the session is end-to-end encrypted", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "خاص · مشفّر من طرف إلى طرف" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্যক্তিগত · এন্ড-টু-এন্ড এনক্রিপ্টেড" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privat · end-to-end-verschlüsselt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "private · end-to-end encrypted" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "privado · cifrado de extremo a extremo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "خصوصی · رمزنگاری سرتاسری" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pribado · end-to-end na naka-encrypt" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privé · chiffré de bout en bout" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "פרטי · מוצפן מקצה לקצה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी · एंड-टू-एंड एन्क्रिप्टेड" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pribadi · terenkripsi ujung ke ujung" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privato · cifrato end-to-end" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "プライベート · エンドツーエンド暗号化" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "비공개 · 종단간 암호화" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "peribadi · terenkripsi ujung ke ujung" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी · एन्ड-टु-एन्ड सङ्केत" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privé · end-to-end-versleuteld" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "prywatna · szyfrowana end-to-end" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privado · encriptado ponta a ponta" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privado · criptografado ponto a ponto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "лично · сквозное шифрование" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privat · end-to-end-krypterad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "தனிப்பட்டது · முனை-முதல்-முனை குறியாக்கம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่วนตัว · เข้ารหัสแบบครบวงจร" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "özel · uçtan uca şifreli" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "приватна · наскрізно зашифрована" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نجی · اینڈ ٹو اینڈ خفیہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "riêng tư · mã hóa đầu cuối" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "私密 · 端到端加密" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "私密 · 端到端加密" - } - } - } - }, - "content.private.caption_group" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "مجموعة مشفّرة · للأعضاء فقط" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এনক্রিপ্টেড গ্রুপ · শুধু সদস্য" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verschlüsselte gruppe · nur mitglieder" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "encrypted group · members only" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "grupo cifrado · solo miembros" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گروه رمزنگاری‌شده · فقط اعضا" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "naka-encrypt na grupo · mga miyembro lamang" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "groupe chiffré · membres uniquement" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "קבוצה מוצפנת · לחברים בלבד" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "एन्क्रिप्टेड समूह · केवल सदस्य" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grup terenkripsi · hanya anggota" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppo cifrato · solo membri" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "暗号化グループ · メンバー限定" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "암호화된 그룹 · 멤버 전용" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kumpulan terenkripsi · ahli sahaja" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "सङ्केतित समूह · सदस्य मात्र" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "versleutelde groep · alleen leden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "szyfrowana grupa · tylko członkowie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupo encriptado · apenas membros" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupo criptografado · apenas membros" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "зашифрованная группа · только участники" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "krypterad grupp · endast medlemmar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குறியாக்கம் செய்யப்பட்ட குழு · உறுப்பினர்கள் மட்டுமே" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "กลุ่มที่เข้ารหัส · เฉพาะสมาชิก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "şifreli grup · yalnızca üyeler" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "зашифрована група · лише учасники" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "خفیہ گروپ · صرف اراکین" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhóm mã hóa · chỉ thành viên" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "加密群组 · 仅限成员" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "加密群組 · 僅限成員" - } - } - } - }, "encryption.accessibility.establishing" : { "extractionState" : "manual", "localizations" : { @@ -60253,12 +19036,6 @@ "value" : "estableciendo cifrado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال برقراری رمزنگاری" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -60438,12 +19215,6 @@ "value" : "cifrado fallido" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری ناموفق" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -60623,12 +19394,6 @@ "value" : "sin cifrar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری‌نشده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -60808,12 +19573,6 @@ "value" : "cifrado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری‌شده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -60993,12 +19752,6 @@ "value" : "cifrado y verificado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری‌شده و تأییدشده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -61178,12 +19931,6 @@ "value" : "estableciendo cifrado..." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال برقراری رمزنگاری..." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -61363,12 +20110,6 @@ "value" : "cifrado fallido" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری ناموفق" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -61548,12 +20289,6 @@ "value" : "sin cifrar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری‌نشده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -61733,12 +20468,6 @@ "value" : "cifrado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری‌شده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -61918,12 +20647,6 @@ "value" : "cifrado y verificado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری‌شده و تأییدشده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -62103,12 +20826,6 @@ "value" : "marcar como verificado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "علامت‌گذاری به‌عنوان تأییدشده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -62288,12 +21005,6 @@ "value" : "eliminar verificación" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حذف تأیید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -62473,12 +21184,6 @@ "value" : "⚠️ NO VERIFICADO" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "⚠️ تأییدنشده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -62658,12 +21363,6 @@ "value" : "✓ VERIFICADO" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ تأییدشده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -62810,192 +21509,6 @@ } } }, - "fingerprint.badge.vouched" : { - "comment" : "Badge shown when a peer is vouched for by people the user verified but not directly verified", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ موثوق" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ সমর্থিত" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ VERBÜRGT" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ VOUCHED" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ AVALADO" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ ضمانت‌شده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ GINARANTIYAHAN" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ ATTESTÉ" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ בערבות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ अनुशंसित" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ DIJAMIN" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ GARANTITO" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ 保証済み" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ 보증됨" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ DIJAMIN" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ जमानत" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ INGESTAAN" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ PORĘCZONY" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ ATESTADO" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ ENDOSSADO" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ ПОРУЧЕНО" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ INTYGAD" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ உத்தரவாதம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ รับรองแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ KEFİL OLUNDU" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ ЗАСВІДЧЕНО" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ ضمانت شدہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ ĐÃ BẢO CHỨNG" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ 已担保" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ 已擔保" - } - } - } - }, "fingerprint.handshake_pending" : { "extractionState" : "manual", "localizations" : { @@ -63029,12 +21542,6 @@ "value" : "no disponible: el handshake está en curso" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در دسترس نیست - دست‌دهی در حال انجام" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -63181,564 +21688,6 @@ } } }, - "fingerprint.local_alias.hint" : { - "comment" : "Explanation under the local alias field", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "على هذا الجهاز فقط. اتركه فارغًا لاستخدام اللقب الذي يدّعونه." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "শুধু এই ডিভাইসে। তাদের দাবিকৃত ডাকনাম ব্যবহার করতে খালি রাখুন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "nur auf diesem gerät. leer lassen, um den beanspruchten nickname zu verwenden." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "only on this device. leave blank to use their claimed nickname." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "solo en este dispositivo. déjalo vacío para usar su apodo reclamado." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "فقط روی این دستگاه. برای استفاده از نام مستعار اعلام‌شده خالی بگذارید." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "sa device na ito lang. iwanang blangko para gamitin ang kanilang claimed nickname." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "uniquement sur cet appareil. laisse vide pour utiliser leur surnom annoncé." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "רק במכשיר הזה. השאר ריק כדי להשתמש בכינוי שהם מצהירים עליו." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "केवल इस डिवाइस पर। उनका दावा किया उपनाम इस्तेमाल करने के लिए खाली छोड़ें।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "hanya di perangkat ini. biarkan kosong untuk memakai nama panggilan yang mereka klaim." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "solo su questo dispositivo. lascia vuoto per usare il nickname dichiarato." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "この端末のみ。空欄なら相手の名乗りニックネームを使います。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 기기에만 저장됩니다. 비워 두면 상대가 주장한 닉네임을 씁니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "hanya pada peranti ini. biarkan kosong untuk guna nama samaran yang mereka tuntut." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यस यन्त्रमा मात्र। उनीहरूको दाबी गरिएको उपनाम प्रयोग गर्न खाली छोड्नुहोस्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "alleen op dit apparaat. laat leeg om hun geclaimde bijnaam te gebruiken." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "tylko na tym urządzeniu. zostaw puste, by użyć ich deklarowanego nicku." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "apenas neste dispositivo. deixa em branco para usar a alcunha reclamada." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "somente neste dispositivo. deixe em branco para usar o apelido declarado." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "только на этом устройстве. оставь пустым, чтобы использовать заявленный ник." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "bara på den här enheten. lämna tomt för att använda deras angivna smeknamn." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இந்த சாதனத்தில் மட்டும். அவர்கள் கூறும் புனைபெயரைப் பயன்படுத்த காலியாக விடவும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เฉพาะบนอุปกรณ์นี้ เว้นว่างเพื่อใช้ชื่อเล่นที่พวกเขาอ้าง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "yalnızca bu cihazda. iddia edilen takma adı kullanmak için boş bırak." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "лише на цьому пристрої. залиш порожнім, щоб використати їх заявлене ім’я." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "صرف اس ڈیوائس پر۔ ان کا دعویٰ کردہ عرفی نام استعمال کرنے کے لیے خالی چھوڑیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "chỉ trên thiết bị này. để trống để dùng biệt danh họ tuyên bố." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "仅保存在此设备。留空则使用对方声称的昵称。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "僅保存在此裝置。留空則使用對方聲稱的暱稱。" - } - } - } - }, - "fingerprint.local_alias.label" : { - "comment" : "Label for the local-only alias field on the fingerprint sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اسم مستعار محلي" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "স্থানীয় উপনাম" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "lokaler alias" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "local alias" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "alias local" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نام مستعار محلی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "local na alias" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "alias local" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "כינוי מקומי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थानीय उपनाम" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "alias lokal" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "alias locale" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ローカル別名" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "로컬 별칭" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "alias tempatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थानीय उपनाम" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "lokale alias" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "lokalny alias" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "alcunha local" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "apelido local" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "локальный псевдоним" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "lokalt alias" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "உள்ளூர் மாற்றுப்பெயர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ชื่อเล่นในเครื่อง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "yerel takma ad" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "локальний псевдонім" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "مقامی عرفی نام" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "bí danh cục bộ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "本地别名" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "本地別名" - } - } - } - }, - "fingerprint.local_alias.placeholder" : { - "comment" : "Placeholder for the local alias field on the fingerprint sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اسم لهذا الشخص" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এই ব্যক্তির জন্য নাম" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "name für diese person" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "name for this person" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "nombre para esta persona" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نام برای این فرد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "pangalan para sa taong ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "nom pour cette personne" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שם לאדם הזה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "इस व्यक्ति के लिए नाम" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "nama untuk orang ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "nome per questa persona" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "この人の名前" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 사람의 이름" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "nama untuk orang ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "यस व्यक्तिको लागि नाम" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "naam voor deze persoon" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "nazwa dla tej osoby" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "nome para esta pessoa" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "nome para esta pessoa" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "имя для этого человека" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "namn för den här personen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இந்த நபருக்கான பெயர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ชื่อสำหรับคนนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bu kişi için ad" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "ім’я для цієї людини" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اس شخص کا نام" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tên cho người này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "给此人的名称" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "給此人的名稱" - } - } - } - }, "fingerprint.message.verified" : { "extractionState" : "manual", "localizations" : { @@ -63772,12 +21721,6 @@ "value" : "has verificado la identidad de esta persona." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هویت این شخص را تأیید کرده‌اید." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -63957,12 +21900,6 @@ "value" : "compara estas huellas con %@ mediante un canal seguro." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این اثرانگشت‌ها را از راهی امن با %@ مقایسه کنید." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -64109,848 +22046,6 @@ } } }, - "fingerprint.message.vouched_by" : { - "comment" : "How many people the user verified have vouched for this peer", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "زكّاه %#@people@ ممن تحققت منهم" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d أشخاص" - } - }, - "many" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d شخصًا" - } - }, - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d شخص" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d شخص" - } - }, - "two" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d شخصان" - } - }, - "zero" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d أشخاص" - } - } - } - } - } - } - }, - "bn" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" - } - } - } - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verbürgt von %#@people@, die du verifiziert hast" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d Person" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d Personen" - } - } - } - } - } - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "vouched for by %#@people@ you verified" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d person" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d people" - } - } - } - } - } - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "avalado por %#@people@ que verificaste" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d persona" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d personas" - } - } - } - } - } - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "توسط %#@people@ که تأیید کرده‌اید ضمانت شده" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d نفر" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d نفر" - } - } - } - } - } - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ginarantiyahan ng %#@people@ na na-verify mo" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d tao" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d tao" - } - } - } - } - } - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "recommandé par %#@people@ que tu as vérifiées" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d personne" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d personnes" - } - } - } - } - } - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ערבו לו %#@people@ שאימתת" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "many" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d אנשים" - } - }, - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d אדם" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d אנשים" - } - }, - "two" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d אנשים" - } - } - } - } - } - } - }, - "hi" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आपके सत्यापित %d व्यक्ति द्वारा अनुशंसित" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आपके सत्यापित %d लोगों द्वारा अनुशंसित" - } - } - } - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dijamin oleh %#@people@ yang kamu verifikasi" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d orang" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d orang" - } - } - } - } - } - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "garantito da %#@people@ che hai verificato" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d persona" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d persone" - } - } - } - } - } - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%#@people@ が保証しています(あなたが確認済み)" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d人" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d人" - } - } - } - } - } - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "당신이 확인한 %#@people@이(가) 보증함" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d명" - } - } - } - } - } - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dijamin oleh %#@people@ yang anda sahkan" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d orang" - } - } - } - } - } - } - }, - "ne" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिमीले प्रमाणित गरेका %d व्यक्तिले जमानत गरेका" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिमीले प्रमाणित गरेका %d व्यक्तिले जमानत गरेका" - } - } - } - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "aanbevolen door %#@people@ die je hebt geverifieerd" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d persoon" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d personen" - } - } - } - } - } - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "poręczone przez %#@people@, które zweryfikowano" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d osoby" - } - }, - "many" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d osób" - } - }, - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d osobę" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d osoby" - } - } - } - } - } - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "avalizado por %#@people@ que verificaste" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d pessoa" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d pessoas" - } - } - } - } - } - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "avalizado por %#@people@ que você verificou" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d pessoa" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d pessoas" - } - } - } - } - } - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "за него поручились %#@people@, кого ты проверил" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d человека" - } - }, - "many" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d человек" - } - }, - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d человек" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d человека" - } - } - } - } - } - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "intygad av %#@people@ som du har verifierat" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d person" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d personer" - } - } - } - } - } - } - }, - "ta" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "நீங்கள் சரிபார்த்த %d நபரால் உத்தரவாதம் அளிக்கப்பட்டது" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "நீங்கள் சரிபார்த்த %d நபர்களால் உத்தரவாதம் அளிக்கப்பட்டது" - } - } - } - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "รับรองโดย %#@people@ ที่คุณยืนยันแล้ว" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d คน" - } - } - } - } - } - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "doğruladığın %#@people@ kefil oldu" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d kişi" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d kişi" - } - } - } - } - } - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "за нього поручилися %#@people@, яких ти перевірив" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d особи" - } - }, - "many" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d осіб" - } - }, - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d особа" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d особи" - } - } - } - } - } - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آپ کے تصدیق شدہ %#@people@ نے ضمانت دی ہے" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d شخص" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d افراد" - } - } - } - } - } - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "được bảo chứng bởi %#@people@ mà bạn đã xác minh" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d người" - } - } - } - } - } - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "由你验证过的 %#@people@ 担保" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d 人" - } - } - } - } - } - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "由你驗證過的 %#@people@ 擔保" - }, - "substitutions" : { - "people" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d 人" - } - } - } - } - } - } - } - } - }, "fingerprint.their_label" : { "extractionState" : "manual", "localizations" : { @@ -64984,12 +22079,6 @@ "value" : "huella de la otra persona:" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اثرانگشت او:" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -65169,12 +22258,6 @@ "value" : "verificación de seguridad" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تأیید امنیتی" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -65354,12 +22437,6 @@ "value" : "tu huella:" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اثرانگشت شما:" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -65539,12 +22616,6 @@ "value" : "bloquear" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسدود کردن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -65724,12 +22795,6 @@ "value" : "desbloquear" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رفع مسدودیت" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -65909,12 +22974,6 @@ "value" : "nadie cerca..." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کسی این اطراف نیست..." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -66061,564 +23120,6 @@ } } }, - "geohash_people.state.nearby" : { - "comment" : "State label for someone physically in the location channel's area", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "في هذه المنطقة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এই এলাকায়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "in diesem gebiet" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "in this area" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "en esta zona" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در این منطقه" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nasa lugar na ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dans cette zone" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "באזור הזה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इस क्षेत्र में" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "di area ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "in questa zona" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "このエリア内" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이 지역 내" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "di kawasan ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "यही क्षेत्रमा" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "in dit gebied" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "w tym obszarze" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nesta área" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nesta área" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "в этой зоне" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i det här området" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இந்தப் பகுதியில்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ในพื้นที่นี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bu bölgede" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "у цій зоні" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اس علاقے میں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "trong khu vực này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "在此区域内" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "在此區域內" - } - } - } - }, - "geohash_people.state.teleported" : { - "comment" : "State label for someone who joined the location channel from elsewhere", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "منتقل فوريًا من مكان آخر" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "অন্য কোথাও থেকে টেলিপোর্টেড" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "von woanders teleportiert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "teleported from elsewhere" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "teletransportado desde otro lugar" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تلپورت‌شده از جای دیگر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nag-teleport mula sa ibang lugar" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "téléporté d'ailleurs" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "עבר בטלפורט ממקום אחר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "कहीं और से टेलीपोर्टेड" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teleport dari tempat lain" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teletrasportato da altrove" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "別の場所からテレポート" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "다른 곳에서 텔레포트" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teleport dari tempat lain" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "अन्त कतैबाट टेलिपोर्ट" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "van elders geteleporteerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teleportowany skądinąd" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teletransportado de outro lugar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teletransportado de outro lugar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "телепортирован из другого места" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teleporterad från annan plats" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "வேறு இடத்திலிருந்து டெலிபோர்ட் செய்யப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เทเลพอร์ตจากที่อื่น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "başka bir yerden ışınlandı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "телепортований звідкись" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "کہیں اور سے ٹیلی پورٹ ہوا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dịch chuyển từ nơi khác" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "从别处瞬移而来" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "從其他地方瞬移而來" - } - } - } - }, - "geohash_people.state.you" : { - "comment" : "State label marking your own row in the people list", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "أنت" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "tú" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "شما" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ikaw" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toi" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אתה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आप" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kamu" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tu" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "あなた" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "나" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anda" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिमी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "jij" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ty" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tu" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "você" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ты" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "நீங்கள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "คุณ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sen" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ти" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آپ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bạn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你" - } - } - } - }, "geohash_people.tooltip.blocked" : { "extractionState" : "manual", "localizations" : { @@ -66652,12 +23153,6 @@ "value" : "bloqueado en geohash" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در ژئوهش مسدود شده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -66837,12 +23332,6 @@ "value" : " (tú)" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : " (شما)" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -66989,1860 +23478,6 @@ } } }, - "groups.accessibility.open_group_hint" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "يفتح الدردشة الجماعية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "গ্রুপ চ্যাট খোলে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Öffnet den gruppenchat" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Opens the group chat" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abre el chat de grupo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گفتگوی گروهی را باز می‌کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Binubuksan ang group chat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Ouvre la discussion de groupe" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "פותח את הצ'אט הקבוצתי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह चैट खोलता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka obrolan grup" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Apre la chat di gruppo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループチャットを開きます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹 채팅을 엽니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka sembang kumpulan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह च्याट खोल्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Opent de groepschat" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Otwiera czat grupowy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Abre a conversa de grupo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Abre o chat do grupo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "открывает групповой чат" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Öppnar gruppchatten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குழு உரையாடலைத் திறக்கும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เปิดแชทกลุ่ม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Grup sohbetini açar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Відкриває груповий чат" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گروپ چیٹ کھولتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mở cuộc trò chuyện nhóm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "打开该群聊" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "打開群組聊天" - } - } - } - }, - "groups.member_count %@" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "(%@)" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "(%@)" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "(%@)" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "(%@)" - } - } - } - }, - "groups.section.header" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "المجموعات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "গ্রুপ" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "groups" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "grupos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گروه‌ها" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mga grupo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "groupes" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "קבוצות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grup" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kumpulan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूहहरू" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "groepen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "группы" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupper" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குழுக்கள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "กลุ่ม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruplar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "групи" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گروپس" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhóm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "群组" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "群組" - } - } - } - }, - "groups.state.creator" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "المُنشئ" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "নির্মাতা" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Ersteller" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Creator" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Creador" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "سازنده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Tagalikha" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Créateur" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "יוצר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निर्माता" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pembuat" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Creatore" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "作成者" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "생성자" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pencipta" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "सिर्जनाकर्ता" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Maker" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Twórca" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Criador" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Criador" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "создатель" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Skapare" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "உருவாக்கியவர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ผู้สร้าง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Oluşturan" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Створювач" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بنانے والا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "người tạo" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "创建者" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "建立者" - } - } - } - }, - "Images are only available in mesh chats." : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الصور متاحة فقط في محادثات الميش." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bilder sind nur im Mesh-Chat verfügbar." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Images are only available in mesh chats." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Las imágenes solo están disponibles en los chats de mesh." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصاویر فقط در گفتگوهای مش در دسترس هستند." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ang mga larawan ay available lamang sa mga mesh chat." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Les images sont uniquement disponibles dans les discussions mesh." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "תמונות זמינות רק בצ׳אט של mesh." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gambar hanya tersedia di obrolan mesh." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le immagini sono disponibili solo nelle chat mesh." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像はメッシュチャットでのみ利用できます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Imej hanya tersedia dalam sembang mesh." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obrazy są dostępne tylko na czatach mesh." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "As imagens só estão disponíveis nos chats mesh." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "As imagens só estão disponíveis nos chats mesh." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Изображения доступны только в mesh-чатах." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bilder är bara tillgängliga i mesh-chattar." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Зображення доступні лише в mesh-чатах." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "图片仅可在 mesh 聊天中使用。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "圖片僅能在 mesh 聊天中使用。" - } - } - } - }, - "live %@" : { - "comment" : "Recording HUD label while a voice message streams live to the recipient", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مباشر %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "লাইভ %@" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "live %@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "live %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "en vivo %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "زنده %@" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "live %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "en direct %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שידור חי %@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "लाइव %@" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "langsung %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "in diretta %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ライブ %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "라이브 %@" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "langsung %@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "लाइभ %@" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "live %@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "na żywo %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ao vivo %@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ao vivo %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "в эфире %@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "live %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நேரலை %@" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "สด %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "canlı %@" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "наживо %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "لائیو %@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "trực tiếp %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "直播 %@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "直播 %@" - } - } - } - }, - "location_channels.row_title_unknown" : { - "comment" : "List row title for a channel whose participant count is unknown for privacy; placeholder is the place label", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [؟ أشخاص]" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? জন]" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? leute]" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? people]" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? personas]" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [؟ نفر]" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? tao]" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? personnes]" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? אנשים]" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? लोग]" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? orang]" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? persone]" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? 人]" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? 명]" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? orang]" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? जना]" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? mensen]" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? osób]" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? pessoas]" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? pessoas]" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? человек]" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? personer]" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? பேர்]" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? คน]" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? kişi]" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? людей]" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? لوگ]" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? người]" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? 人]" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ [? 人]" - } - } - } - }, - "location_channels.accessibility.add_bookmark" : { - "comment" : "Accessibility action name for bookmarking a channel", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "إضافة إشارة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "চ্যানেল বুকমার্ক করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kanal mit lesezeichen versehen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "bookmark channel" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "marcar canal" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نشانک‌گذاری کانال" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i-bookmark ang channel" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mettre le canal en signet" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הוסף סימנייה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चैनल बुकमार्क करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tandai kanal" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "aggiungi il canale ai segnalibri" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "チャンネルをブックマーク" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "채널 북마크" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tanda buku kanal" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "च्यानल बुकमार्क गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kanaal toevoegen aan bladwijzers" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dodaj kanał do zakładek" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "marcar canal" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "marcar canal" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "добавить канал в закладки" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bokmärk kanal" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "சேனலைப் புக்மார்க் செய்யவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "บุ๊กมาร์กช่อง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kanalı yer imlerine ekle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "додати канал у закладки" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "چینل کو بُک مارک کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đánh dấu kênh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "为频道添加书签" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "將頻道加入書簽" - } - } - } - }, - "location_channels.accessibility.remove_bookmark" : { - "comment" : "Accessibility action name for removing a channel bookmark", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "إزالة الإشارة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "বুকমার্ক সরান" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lesezeichen entfernen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "remove bookmark" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "quitar marcador" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حذف نشانک" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "alisin ang bookmark" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "supprimer le signet" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הסר סימנייה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "बुकमार्क हटाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hapus tanda" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rimuovi segnalibro" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ブックマークを削除" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "북마크 제거" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "buang tanda buku" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "बुकमार्क हटाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bladwijzer verwijderen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "usuń zakładkę" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "remover marcador" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "remover marcador" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "удалить закладку" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ta bort bokmärke" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "புக்மார்க்கை நீக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ลบบุ๊กมาร์ก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "yer imini kaldır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "видалити закладку" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بُک مارک ہٹائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bỏ đánh dấu" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "移除书签" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "移除書簽" - } - } - } - }, - "location_channels.accessibility.switch_hint" : { - "comment" : "Accessibility hint on a channel row explaining activation switches to it", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "التبديل إلى هذه القناة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এই চ্যানেলে বদলে যায়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wechselt zu diesem kanal" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "switches to this channel" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "cambia a este canal" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "به این کانال جابه‌جا می‌شود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lumilipat sa channel na ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bascule vers ce canal" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מחליף לערוץ הזה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इस चैनल पर स्विच करता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "beralih ke kanal ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "passa a questo canale" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "このチャンネルに切り替えます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이 채널로 전환합니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "beralih ke kanal ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "यो च्यानलमा बदल्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "schakelt naar dit kanaal" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "przełącza na ten kanał" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "muda para este canal" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "troca para este canal" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "переключает на этот канал" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "byter till den här kanalen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இந்த சேனலுக்கு மாறும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "สลับไปยังช่องนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bu kanala geçer" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "перемикає на цей канал" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اس چینل پر منتقل ہوتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chuyển sang kênh này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "切换到此频道" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "切換到此頻道" - } - } - } - }, "location_channels.action.open_settings" : { "extractionState" : "manual", "localizations" : { @@ -68876,12 +23511,6 @@ "value" : "abrir ajustes" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "باز کردن تنظیمات" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -69061,12 +23690,6 @@ "value" : "eliminar acceso a la ubicación" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حذف دسترسی موقعیت" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -69246,12 +23869,6 @@ "value" : "obtener mi ubicación y mis geohashes" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دریافت موقعیت و ژئوهش‌های من" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -69431,12 +24048,6 @@ "value" : "teletransportar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تلپورت" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -69616,12 +24227,6 @@ "value" : "marcados" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نشانک‌شده" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -69774,181 +24379,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "دردش مع من حولك عبر قنوات geohash. الـ geohash المحدد علني وقد يكشف منطقة تقريبية؛ لا تتم مشاركة إحداثيات GPS الدقيقة أبدًا. مع تشغيل tor (الوضع الافتراضي)، ترى المرحّلات عنوان tor بدلاً من عنوان IP الخاص بك." + "value" : "تحدث مع أشخاص قريبين عبر قنوات geohash. نشارك geohash تقريبي فقط، وليس gps الدقيق. يتم إخفاء عنوان ip لأن كل المرور يمر عبر tor." } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "geohash চ্যানেল ব্যবহার করে কাছের মানুষদের সঙ্গে চ্যাট করুন। নির্বাচিত geohash সর্বজনীন এবং আনুমানিক এলাকা প্রকাশ করতে পারে; সঠিক GPS কখনও শেয়ার হয় না। tor চালু থাকলে (ডিফল্ট), রিলেগুলো আপনার IP-এর বদলে tor-এর ঠিকানা দেখে।" + "value" : "জিওহ্যাশ চ্যানেল দিয়ে কাছাকাছি অঞ্চলের মানুষের সঙ্গে চ্যাট করুন। শুধুই স্থূল জিওহ্যাশ শেয়ার হয়, কখনো সঠিক GPS নয়। আপনার IP টর দিয়ে সব ট্রাফিক রাউট করে লুকানো থাকে।" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "chatte mit leuten in deiner nähe über geohash-kanäle. der gewählte geohash ist öffentlich und kann ein ungefähres gebiet verraten; exaktes GPS wird nie geteilt. mit eingeschaltetem tor (standard) sehen relays tors adresse statt deiner IP-adresse." + "value" : "chatte mit menschen in deiner nähe über geohash-kanäle. geteilt wird nur ein grober geohash, niemals exakte gps-daten. deine ip bleibt verborgen, weil der gesamte verkehr über tor läuft." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "chat with people near you using geohash channels. the selected geohash is public and may reveal an approximate area; exact GPS is never shared. with tor on (the default), relays see tor's address instead of your IP address." + "value" : "chat with people near you using geohash channels. only a coarse geohash is shared, never exact GPS. your IP address is hidden by routing all traffic over tor." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "chatea con gente cercana usando canales geohash. el geohash seleccionado es público y puede revelar una zona aproximada; el GPS exacto nunca se comparte. con tor activado (el valor predeterminado), los relés ven la dirección de tor en lugar de tu IP." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "با افراد نزدیک خود از طریق کانال‌های geohash گفتگو کنید. geohash انتخابی عمومی است و ممکن است منطقه‌ای تقریبی را نشان دهد؛ GPS دقیق هرگز به اشتراک گذاشته نمی‌شود. با روشن بودن tor (پیش‌فرض)، رله‌ها به جای IP شما آدرس tor را می‌بینند." + "value" : "chatea con personas cercanas usando canales geohash. Solo se comparte un geohash aproximado, nunca GPS exacto. Tu IP se oculta al enrutar todo el tráfico por Tor." } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "makipag-chat sa mga taong malapit sa iyo gamit ang mga geohash channel. pampubliko ang napiling geohash at maaaring magbunyag ng tinatayang lugar; hindi kailanman ibinabahagi ang eksaktong GPS. kapag naka-on ang tor (ang default), ang address ng tor ang nakikita ng mga relay sa halip na ang iyong IP." + "value" : "makipag-chat sa mga taong malapit sa iyo gamit ang mga geohash channel. coarse na geohash lang ang ibinabahagi, hindi ang eksaktong GPS. tinatago ang iyong IP address sa pagreruta ng lahat ng traffic sa tor." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "discutez avec les gens autour de vous via les canaux geohash. le geohash choisi est public et peut révéler une zone approximative ; le GPS exact n'est jamais partagé. avec tor activé (par défaut), les relais voient l'adresse de tor au lieu de votre IP." + "value" : "discute avec les personnes proches grâce aux canaux geohash. seul un geohash grossier est partagé, jamais de gps exact. ton ip reste cachée car tout le trafic passe par tor." } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "שוחחו עם אנשים בסביבה דרך ערוצי geohash. ה-geohash שנבחר הוא ציבורי ועשוי לחשוף אזור משוער; מיקום GPS מדויק לעולם לא משותף. כש-tor פועל (ברירת המחדל), הממסרים רואים את הכתובת של tor במקום ה-IP שלכם." + "value" : "שוחח עם אנשים קרובים בערוצי geohash. משתף רק geohash גס, אף פעם לא gps מדויק. כתובת ה-ip מוסתרת כי כל התעבורה עוברת דרך tor." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "geohash चैनलों के ज़रिए आस-पास के लोगों से चैट करें। चुना गया geohash सार्वजनिक है और अनुमानित क्षेत्र दिखा सकता है; सटीक GPS कभी साझा नहीं होता। tor चालू होने पर (डिफ़ॉल्ट), रिले आपके IP की जगह tor का पता देखते हैं।" + "value" : "जियोहैश चैनलों से आसपास के लोगों से चैट करें। केवल मोटा जियोहैश साझा होता है, कभी सटीक GPS नहीं। आपका IP सारा ट्रैफ़िक Tor से रूट कर छिपा रहता है।" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "mengobrol dengan orang di sekitar melalui kanal geohash. geohash yang dipilih bersifat publik dan dapat mengungkap area perkiraan; GPS persis tidak pernah dibagikan. dengan tor aktif (bawaan), relay melihat alamat tor alih-alih IP anda." + "value" : "ngobrol dengan orang terdekat lewat kanal geohash. hanya geohash kasar yang dibagikan, tidak pernah gps tepat. alamat ip-mu tersembunyi karena seluruh trafik lewat tor." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "chatta con chi ti sta vicino usando i canali geohash. il geohash selezionato è pubblico e può rivelare un'area approssimativa; il GPS esatto non viene mai condiviso. con tor attivo (impostazione predefinita), i relay vedono l'indirizzo di tor invece del tuo IP." + "value" : "chatta con le persone vicine tramite canali geohash. condividiamo solo geohash approssimativi, mai il gps esatto. il tuo ip resta nascosto perché tutto il traffico passa da tor." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "geohash チャンネルで近くの人とチャットできます。選択した geohash は公開され、おおよそのエリアが分かる場合があります。正確な GPS は決して共有されません。tor が有効(デフォルト)なら、リレーにはあなたの IP ではなく tor のアドレスが見えます。" + "value" : "geohashチャンネルで近くの人と会話。共有されるのはざっくりしたgeohashだけで正確なgpsは含みません。全トラフィックをtor経由にすることであなたのipを隠します。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "geohash 채널로 주변 사람들과 대화하세요. 선택한 geohash는 공개되며 대략적인 지역이 드러날 수 있습니다. 정확한 GPS는 절대 공유되지 않습니다. tor가 켜져 있으면(기본값) 릴레이에는 내 IP 대신 tor의 주소가 보입니다." + "value" : "geohash 채널을 사용해 주변 사람들과 대화하세요. 정확한 GPS가 아닌 대략적인 geohash만 공유됩니다. 모든 트래픽을 tor로 라우팅하여 IP 주소를 숨깁니다." } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "berbual dengan orang berdekatan melalui saluran geohash. geohash yang dipilih adalah umum dan mungkin mendedahkan kawasan anggaran; GPS tepat tidak pernah dikongsi. dengan tor dihidupkan (lalai), relay melihat alamat tor dan bukan IP anda." + "value" : "ngobrol dengan orang terdekat lewat kanal geohash. hanya geohash kasar yang dibagikan, tidak pernah gps tepat. alamat ip-mu tersembunyi karena seluruh trafik lewat tor." } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "geohash च्यानलहरू प्रयोग गरेर नजिकका मानिसहरूसँग कुराकानी गर्नुहोस्। चयन गरिएको geohash सार्वजनिक हुन्छ र अनुमानित क्षेत्र देखाउन सक्छ; सटीक GPS कहिल्यै साझा हुँदैन। tor खुला हुँदा (पूर्वनिर्धारित), रिलेहरूले तपाईंको IP को सट्टा tor को ठेगाना देख्छन्।" + "value" : "geohash च्यानलबाट नजिकका मानिससँग कुरा गर। केवल मोटामो geohash साझा हुन्छ, कहिल्यै सहि gps होइन। सबै ट्राफिक tor मार्फत गएका कारण तिम्रो ip लुकेको हुन्छ।" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "chat met mensen in de buurt via geohash-kanalen. de gekozen geohash is openbaar en kan een globaal gebied onthullen; exacte GPS wordt nooit gedeeld. met tor aan (standaard) zien relays het adres van tor in plaats van je IP-adres." + "value" : "chat met mensen bij jou in de buurt via geohash-kanalen. enkel een grove geohash wordt gedeeld, nooit exacte GPS. je IP-adres wordt verborgen doordat al het verkeer via tor wordt gerouteerd." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "rozmawiaj z ludźmi w pobliżu przez kanały geohash. wybrany geohash jest publiczny i może zdradzić przybliżony obszar; dokładny GPS nigdy nie jest udostępniany. przy włączonym tor (domyślnie) przekaźniki widzą adres tora zamiast twojego IP." + "value" : "rozmawiaj z osobami w pobliżu za pomocą kanałów geohash. udostępniany jest tylko zgrubny geohash, nigdy dokładny GPS. twój adres IP jest ukryty przez kierowanie całego ruchu przez tor." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "conversa com pessoas por perto através de canais geohash. o geohash selecionado é público e pode revelar uma área aproximada; o GPS exato nunca é partilhado. com o tor ligado (predefinição), os relays veem o endereço do tor em vez do teu IP." + "value" : "conversa com pessoas perto de ti usando canais geohash. apenas um geohash de baixa precisão é partilhado, nunca GPS exato. o teu IP fica oculto ao encaminhar todo o tráfego através do Tor." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "converse com pessoas por perto usando canais geohash. o geohash selecionado é público e pode revelar uma área aproximada; o GPS exato nunca é compartilhado. com o tor ligado (padrão), os relays veem o endereço do tor em vez do seu IP." + "value" : "converse com pessoas próximas usando canais geohash. apenas um geohash grosseiro é compartilhado, nunca gps exato. seu ip fica oculto ao rotear todo o tráfego por tor." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "общайтесь с людьми поблизости через geohash-каналы. выбранный geohash публичен и может раскрыть примерный район; точные GPS-координаты никогда не передаются. при включённом tor (по умолчанию) релеи видят адрес tor вместо вашего IP." + "value" : "общайся с людьми рядом через каналы geohash. делится только грубый geohash, без точного gps. твой ip скрывается за счёт маршрутизации трафика через tor." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "chatta med folk i närheten via geohash-kanaler. vald geohash är offentlig och kan avslöja ett ungefärligt område; exakt GPS delas aldrig. med tor på (standard) ser reläer tors adress istället för din IP." + "value" : "chatta med folk nära dig via geohash-kanaler. endast en grov geohash delas, aldrig exakt GPS. din IP-adress döljs genom att all trafik går via tor." } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "geohash சேனல்கள் மூலம் அருகிலுள்ளவர்களுடன் அரட்டையடிக்கவும். தேர்ந்தெடுத்த geohash பொதுவானது, தோராயமான பகுதியை வெளிப்படுத்தலாம்; துல்லியமான GPS ஒருபோதும் பகிரப்படாது. tor இயக்கத்தில் இருந்தால் (இயல்புநிலை), ரிலேக்கள் உங்கள் IP க்குப் பதிலாக tor இன் முகவரியைக் காண்கின்றன." + "value" : "geohash சேனல்கள் மூலம் அருகிலுள்ளவர்களுடன் உரையாடுங்கள். மிகவும் பொது geohash மட்டுமே பகிரப்படும், துல்லியமான GPS அல்ல. உங்கள் IP, அனைத்து போக்குவரத்தையும் tor வழியாக மாற்றுவதன் மூலம் மறைக்கப்படுகிறது." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "แชทกับผู้คนใกล้ตัวผ่านช่อง geohash โดย geohash ที่เลือกเป็นแบบสาธารณะและอาจเผยพื้นที่โดยประมาณ พิกัด GPS ที่แน่นอนจะไม่ถูกแชร์ เมื่อเปิด tor (ค่าเริ่มต้น) รีเลย์จะเห็นที่อยู่ของ tor แทน IP ของคุณ" + "value" : "แชทกับคนใกล้คุณผ่านช่อง geohash แบ่งปันเพียง geohash แบบหยาบ ไม่ใช่ GPS ที่แม่นยำ ที่อยู่ IP ของคุณถูกซ่อนด้วยการส่งข้อมูลผ่าน tor" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "geohash kanallarıyla yakınınızdaki insanlarla sohbet edin. seçilen geohash herkese açıktır ve yaklaşık bir bölgeyi açığa çıkarabilir; tam GPS asla paylaşılmaz. tor açıkken (varsayılan) röleler IP'niz yerine tor'un adresini görür." + "value" : "geohash kanallarıyla yakınınızdaki insanlarla sohbet edin. yalnızca kaba bir geohash paylaşılır, tam GPS asla değil. IP adresiniz tüm trafik Tor üzerinden yönlendirilerek gizlenir." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "спілкуйтеся з людьми поруч через geohash-канали. вибраний geohash є публічним і може розкрити приблизний район; точні GPS-координати ніколи не передаються. з увімкненим tor (типово) релеї бачать адресу tor замість вашого IP." + "value" : "спілкуйся з людьми поруч у каналах geohash. передається лише грубий geohash, без точного gps. твій ip приховується, бо весь трафік йде через tor." } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "geohash چینلز کے ذریعے قریبی لوگوں سے چیٹ کریں۔ منتخب geohash عوامی ہے اور تخمینی علاقہ ظاہر کر سکتا ہے؛ درست GPS کبھی شیئر نہیں ہوتا۔ tor آن ہونے پر (ڈیفالٹ)، ریلے آپ کے IP کی بجائے tor کا پتہ دیکھتے ہیں۔" + "value" : "قریب کے لوگوں سے گفتگو کیلئے geohash چینلز استعمال کریں۔ صرف عمومی geohash شیئر کیا جاتا ہے، کبھی درست GPS نہیں۔ آپ کا IP tor کے ذریعے ساری ٹریفک روٹ کر کے چھپایا جاتا ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "trò chuyện với những người gần bạn qua các kênh geohash. geohash đã chọn là công khai và có thể tiết lộ khu vực gần đúng; GPS chính xác không bao giờ được chia sẻ. khi bật tor (mặc định), các relay thấy địa chỉ của tor thay vì IP của bạn." + "value" : "trò chuyện với người gần bạn bằng các kênh geohash. chỉ chia sẻ geohash thô, không bao giờ là GPS chính xác. địa chỉ IP của bạn được ẩn bằng cách định tuyến toàn bộ lưu lượng qua tor." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "通过 geohash 频道与附近的人聊天。所选 geohash 是公开的,可能暴露大致区域;精确 GPS 绝不会被共享。开启 tor 时(默认),中继看到的是 tor 的地址而不是你的 IP。" + "value" : "使用 geohash 频道与附近的人聊天。只会共享粗略 geohash,从不泄露精确 GPS。所有流量通过 tor 路由来隐藏你的 IP。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "透過 geohash 頻道與附近的人聊天。所選 geohash 是公開的,可能透露大致區域;精確 GPS 絕不會被分享。開啟 tor 時(預設),中繼看到的是 tor 的位址而不是你的 IP。" + "value" : "使用 geohash 頻道與附近的人聊天。只會共享粗略 geohash,從不洩露精確 GPS。所有流量透過 tor 路由來隱藏你的 IP。" } } } @@ -69986,12 +24585,6 @@ "value" : "geohash no válido" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ژئوهش نامعتبر" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -70138,192 +24731,6 @@ } } }, - "location_channels.grant_to_find" : { - "comment" : "Hint shown in the channel list instead of a loading spinner when location permission is missing", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "امنح إذن الموقع للعثور على القنوات القريبة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "কাছাকাছি চ্যানেল খুঁজতে অবস্থানের অনুমতি দিন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "standortzugriff erlauben, um kanäle in der nähe zu finden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "grant location access to find nearby channels" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "concede acceso a la ubicación para encontrar canales cercanos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای یافتن کانال‌های اطراف، دسترسی موقعیت بدهید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "payagan ang access sa lokasyon para makahanap ng mga kalapit na channel" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "autorisez l'accès à la position pour trouver les canaux à proximité" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אפשרו גישה למיקום כדי למצוא ערוצים בקרבת מקום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आस-पास के चैनल खोजने के लिए स्थान की अनुमति दें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "izinkan akses lokasi untuk menemukan channel terdekat" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "consenti l'accesso alla posizione per trovare i canali vicini" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "近くのチャンネルを見つけるには位置情報へのアクセスを許可してください" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "주변 채널을 찾으려면 위치 접근을 허용하세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "benarkan akses lokasi untuk mencari saluran berdekatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "नजिकैका च्यानलहरू फेला पार्न स्थान पहुँच दिनुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geef locatietoegang om kanalen in de buurt te vinden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "przyznaj dostęp do lokalizacji, aby znaleźć pobliskie kanały" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "conceda acesso à localização para encontrar canais próximos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "conceda acesso à localização para encontrar canais próximos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "разрешите доступ к геопозиции, чтобы найти каналы поблизости" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ge platsåtkomst för att hitta kanaler i närheten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அருகிலுள்ள சேனல்களைக் கண்டறிய இருப்பிட அணுகலை வழங்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "อนุญาตการเข้าถึงตำแหน่งเพื่อค้นหาช่องใกล้เคียง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "yakındaki kanalları bulmak için konum erişimine izin verin" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "надайте доступ до геолокації, щоб знайти канали поблизу" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "قریبی چینلز تلاش کرنے کے لیے مقام تک رسائی دیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "cấp quyền truy cập vị trí để tìm các kênh gần đây" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "授予位置权限以查找附近的频道" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "授予位置權限以尋找附近的頻道" - } - } - } - }, "location_channels.loading_nearby" : { "extractionState" : "manual", "localizations" : { @@ -70357,12 +24764,6 @@ "value" : "buscando canales cercanos…" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال یافتن کانال‌های اطراف…" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -70542,12 +24943,6 @@ "value" : "mesh" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مش" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -70727,12 +25122,6 @@ "value" : "permiso de ubicación denegado. Actívalo en Ajustes para usar los canales de ubicación." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دسترسی موقعیت رد شد. برای استفاده از کانال‌های موقعیت، آن را در تنظیمات فعال کنید." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -71024,34 +25413,6 @@ } } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ [%2$#@people_count@]" - }, - "substitutions" : { - "people_count" : { - "argNum" : 2, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d نفر" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d نفر" - } - } - } - } - } - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -71503,12 +25864,6 @@ "value" : "#%@ • %@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%1$@ • %2$@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -71688,12 +26043,6 @@ "value" : "%1$@ • %2$@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ • %2$@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -71873,12 +26222,6 @@ "value" : "#canales de ubicación" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "#کانال‌های موقعیت" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -72025,6 +26368,185 @@ } } }, + "location_channels.tor.subtitle" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يخفي ip لقنوات الموقع. الموصى به: تشغيل." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "লোকেশন চ্যানেলের জন্য আপনার IP লুকায়। সুপারিশ: চালু রাখুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbirgt deine ip für standortkanäle. empfohlen: an." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hides your IP for location channels. recommended: on." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "oculta tu IP para los canales de ubicación. Recomendado: activado." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "itinatago ang iyong IP para sa mga channel ng lokasyon. inirerekomenda: naka-on." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "cache ton ip pour les canaux localisation. recommandé : activé." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מסתיר את ה-ip שלך לערוצי מיקום. מומלץ: פעיל." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "लोकेशन चैनलों के लिए आपका IP छुपाता है। अनुशंसित: चालू रखें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nasconde il tuo ip per i canali posizione. consigliato: attivo." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ロケーションチャンネル用にipを隠します。推奨: オン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위치 채널에서 IP를 숨깁니다. 권장: 켜기." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थान च्यानलका लागि तिम्रो ip लुकाउँछ। सिफारिस: अन।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbergt je IP voor locatiekanalen. aanbevolen: aan." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ukrywa twój IP dla kanałów lokalizacji. zalecane: włączone." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "oculta o teu IP para canais de localização. recomendado: ligado." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "oculta seu ip para canais de localização. recomendado: ligado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "скрывает твой ip для каналов локации. рекомендуем включить." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "döljer din IP för platskanaler. rekommenderas: på." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இட சேனல்களுக்கு உங்கள் IP ஐ மறைக்கும். பரிந்துரை: இயக்கப்பட்டது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ซ่อน IP ของคุณสำหรับช่องตำแหน่ง แนะนำให้เปิด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "konum kanalları için IP'nizi gizler. önerilen: açık." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "приховує твій ip для каналів локації. рекомендовано ввімкнути." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "لوکیشن چینلز کیلئے آپ کا IP چھپاتا ہے۔ تجویز: آن رکھیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ẩn IP của bạn cho kênh vị trí. khuyến nghị: bật." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为位置频道隐藏你的 IP。推荐:开启。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "為位置頻道隱藏你的 IP。推薦:開啟。" + } + } + } + }, "location_channels.tor.title" : { "extractionState" : "manual", "localizations" : { @@ -72058,12 +26580,6 @@ "value" : "enrutamiento Tor" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسیریابی Tor" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -72243,12 +26759,6 @@ "value" : "bloque" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بلوک" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -72428,12 +26938,6 @@ "value" : "edificio" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ساختمان" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -72613,12 +27117,6 @@ "value" : "ciudad" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "شهر" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -72798,12 +27296,6 @@ "value" : "barrio" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "محله" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -72983,12 +27475,6 @@ "value" : "provincia" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "استان" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -73168,12 +27654,6 @@ "value" : "región" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "منطقه" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -73353,12 +27833,6 @@ "value" : "descartar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بستن" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -73538,12 +28012,6 @@ "value" : "reintentar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تلاش دوباره" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -73690,192 +28158,6 @@ } } }, - "location_notes.connecting_relays" : { - "comment" : "Status line while geo notes wait for a relay connection (e.g. Tor still bootstrapping)", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "جارٍ الاتصال بالمرحّلات…" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "রিলেতে সংযোগ হচ্ছে…" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbinde mit relays…" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "connecting to relays…" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "conectando a los relays…" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال اتصال به رله‌ها…" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "kumokonekta sa mga relay…" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "connexion aux relais…" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מתחבר לממסרים…" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "रिले से कनेक्ट हो रहा है…" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "menghubungkan ke relay…" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "connessione ai relay…" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "リレーに接続中…" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "릴레이에 연결 중…" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "menyambung ke relay…" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "रिलेमा जडान हुँदैछ…" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbinden met relays…" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "łączenie z relay…" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "a ligar aos relés…" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "conectando aos relés…" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "подключение к релеям…" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "ansluter till reläer…" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ரிலேயுடன் இணைக்கிறது…" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "กำลังเชื่อมต่อรีเลย์…" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "relay'lere bağlanılıyor…" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "підключення до релеїв…" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ریلے سے منسلک ہو رہا ہے…" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đang kết nối đến relay…" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在连接中继…" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在連接中繼…" - } - } - } - }, "location_notes.description" : { "extractionState" : "manual", "localizations" : { @@ -73909,12 +28191,6 @@ "value" : "añade notas permanentes cortas sobre este lugar para que otras personas las encuentren." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "یادداشت‌های کوتاه و ماندگار به این موقعیت اضافه کنید تا بازدیدکنندگان دیگر پیدا کنند." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -74061,6 +28337,364 @@ } } }, + "location_notes.empty_subtitle" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "كن أول من يضيف هنا." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই জায়গার জন্য প্রথম নোট যোগ করুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "sei die erste person, die hier eine notiz hinterlässt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "be the first to add one for this spot." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sé la primera persona en añadir una en este lugar." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "maging unang magdagdag para sa puntong ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "sois la première personne à en ajouter ici." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "היה הראשון להוסיף כאן." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस जगह के लिए पहला नोट आप जोड़ें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "jadilah orang pertama yang menambahkannya di sini." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "fai tu la prima nota qui." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここで最初のノートを残そう。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 장소에 첫 번째 노트를 남겨보세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "jadilah orang pertama yang menambahkannya di sini." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यस ठाउँमा नोट थप्ने पहिलो व्यक्ती बन।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wees de eerste die er hier een toevoegt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dodaj pierwszą notatkę dla tego miejsca" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "sê o primeiro a adicionar uma nota para este sítio." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "seja a primeira pessoa a adicionar uma aqui." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "стань первым, кто добавит здесь заметку." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "var först med en anteckning här" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த இடத்திற்கு முதலில் ஒரு குறிப்பைச் சேர்க்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เป็นคนแรกที่เพิ่มบันทึกให้จุดนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu yer için ilk notu sen ekle." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "стань першим, хто додасть тут замітку." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اس مقام کیلئے پہلا نوٹ آپ شامل کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "hãy là người đầu tiên thêm ghi chú tại đây" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "成为这里的第一条笔记。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "成為這裡的第一條筆記。" + } + } + } + }, + "location_notes.empty_title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا توجد ملاحظات بعد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এখনও কোনো নোট নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "noch keine notizen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no notes yet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "aún no hay notas" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "wala pang tala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pas encore de notes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין הערות עדיין" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अभी कोई नोट नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "belum ada catatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ancora nessuna nota" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ノートはまだありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 노트가 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "belum ada catatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अहिले नोट छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nog geen notities" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "brak notatek" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ainda sem notas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "nenhuma nota ainda" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "заметок пока нет" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inga anteckningar än" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இன்னும் குறிப்புகள் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ยังไม่มีบันทึก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "henüz not yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "заміток ще немає" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ابھی تک کوئی نوٹس نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chưa có ghi chú" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无笔记" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚無筆記" + } + } + } + }, "location_notes.error.failed_to_send" : { "extractionState" : "manual", "localizations" : { @@ -74094,12 +28728,6 @@ "value" : "no se pudo enviar la nota. %@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال یادداشت ناموفق بود. %@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -74279,12 +28907,6 @@ "value" : "no hay relays geográficos disponibles cerca de este lugar. Inténtalo de nuevo pronto." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هیچ رلهٔ جغرافیایی نزدیک این موقعیت در دسترس نیست. کمی بعد دوباره امتحان کنید." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -74431,6 +29053,569 @@ } } }, + "location_notes.header" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d ملاحظات" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d ملاحظة" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d ملاحظة" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d ملاحظة" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d ملاحظتان" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d ملاحظات" + } + } + } + } + } + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d notiz" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d notizen" + } + } + } + } + } + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d note" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d notes" + } + } + } + } + } + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d nota" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d notas" + } + } + } + } + } + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d note" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d notes" + } + } + } + } + } + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d הערות" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d הערה" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d הערות" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d הערות" + } + } + } + } + } + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d catatan" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d catatan" + } + } + } + } + } + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d nota" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d note" + } + } + } + } + } + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d件のノート" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d件のノート" + } + } + } + } + } + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d개의 노트" + } + } + } + } + } + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d नोट" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d नोटहरू" + } + } + } + } + } + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d nota" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d notas" + } + } + } + } + } + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d заметки" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d заметок" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d заметка" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d заметки" + } + } + } + } + } + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d замітки" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d заміток" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d замітка" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d замітки" + } + } + } + } + } + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + }, + "substitutions" : { + "note_count" : { + "argNum" : 2, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d 条笔记" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d 条笔记" + } + } + } + } + } + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%1$@ • %2$#@note_count@" + } + } + } + }, "location_notes.loading_notes" : { "extractionState" : "manual", "localizations" : { @@ -74464,12 +29649,6 @@ "value" : "cargando notas…" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال بارگذاری یادداشت‌ها…" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -74616,6 +29795,185 @@ } } }, + "location_notes.loading_recent" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جار تحميل الملاحظات الحديثة…" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সাম্প্রতিক নোট লোড হচ্ছে…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "aktuelle notizen werden geladen…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "loading recent notes…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cargando notas recientes…" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "nagse-load ng pinakahuling mga tala…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "chargement des notes récentes…" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "טוען הערות אחרונות…" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "हाल के नोट लोड हो रहे हैं…" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "memuat catatan terbaru…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "caricamento note recenti…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最新ノートを読み込み中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 노트 로딩 중…" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "memuat catatan terbaru…" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "हालैका नोट लोड गर्दै…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "recentste notities laden…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ładowanie ostatnich notatek…" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "a carregar notas recentes…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "carregando notas recentes…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "загрузка свежих заметок…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "laddar senaste anteckningar…" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "சமீபத்திய குறிப்புகள் ஏற்றப்படுகின்றன…" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กำลังโหลดบันทึกล่าสุด…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "son notlar yükleniyor…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "завантаження свіжих заміток…" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "حالیہ نوٹس لوڈ ہو رہے ہیں…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đang tải ghi chú gần đây…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在加载最新笔记…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在加載最新筆記…" + } + } + } + }, "location_notes.no_relays_nearby" : { "extractionState" : "manual", "localizations" : { @@ -74649,12 +30007,6 @@ "value" : "no hay relays geográficos cercanos" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هیچ رلهٔ جغرافیایی در نزدیکی نیست" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -74801,6 +30153,364 @@ } } }, + "location_notes.placeholder" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أضف ملاحظة لهذا المكان" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই স্থানের জন্য একটি নোট যোগ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "notiz für diesen ort hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "add a note for this place" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "añade una nota para este lugar" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "magdagdag ng tala para sa lugar na ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajoute une note pour cet endroit" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוסף הערה למקום הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस स्थान के लिए नोट जोड़ें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tambahkan catatan untuk tempat ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "aggiungi una nota per questo posto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この場所のノートを追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 장소에 대한 노트 추가" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tambahkan catatan untuk tempat ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यस स्थानका लागि नोट थप" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "voeg een notitie toe voor deze plek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dodaj notatkę do tego miejsca" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "adiciona uma nota para este local" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "adicione uma nota para este lugar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "добавь заметку для этого места" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "lägg till en anteckning för den här platsen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த இடத்திற்கான ஒரு குறிப்பைச் சேர்க்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เพิ่มบันทึกให้สถานที่นี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu yer için bir not ekleyin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "додай замітку для цього місця" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اس جگہ کیلئے نوٹ شامل کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thêm ghi chú cho nơi này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为此地点添加笔记" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "為此地點添加筆記" + } + } + } + }, + "location_notes.relays_paused" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المرحلات الجغرافية غير متاحة؛ الملاحظات متوقفة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "জিও রিলে অনুপলব্ধ; নোট স্থগিত" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo-relays nicht verfügbar; notizen pausiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo relays unavailable; notes paused" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "relays geográficos no disponibles; notas en pausa" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi magagamit ang mga geo relay; naka-pause ang mga tala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "relais géo indisponibles ; notes en pause" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ממסרי geo אינם זמינים; הערות הושהו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "जियो रिले अनुपलब्ध; नोट रुके हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay geo tidak tersedia; catatan dijeda" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay geo non disponibili; note in pausa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ジオリレーが利用不可: ノート一時停止" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo 릴레이를 사용할 수 없습니다; 노트가 일시 중지됩니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay geo tidak tersedia; catatan dijeda" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "georelay उपलब्ध छैन; नोट रोकिएको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo-relays niet beschikbaar; notities gepauzeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo relay niedostępne; notatki wstrzymane" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "relés geográficos indisponíveis; notas em pausa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "relays geográficos indisponíveis; notas pausadas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "геореле недоступны; заметки приостановлены" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo-reläer otillgängliga; anteckningar pausade" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo relay கிடைக்கவில்லை; குறிப்புகள் இடைநிறுத்தப்பட்டுள்ளன" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo relay ไม่พร้อมใช้งาน บันทึกถูกหยุดชั่วคราว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo röleler kullanılamıyor; notlar durduruldu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "гео-релеї недоступні; замітки призупинено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo relay دستیاب نہیں؛ نوٹس موقوف" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay địa lý không sẵn có; ghi chú bị tạm dừng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "地理中继不可用;笔记已暂停" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "地理中繼不可用;筆記已暫停" + } + } + } + }, "location_notes.relays_retry_hint" : { "extractionState" : "manual", "localizations" : { @@ -74834,12 +30544,6 @@ "value" : "las notas dependen de los relays geográficos. Comprueba la conexión e inténtalo de nuevo." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "یادداشت‌ها به رله‌های جغرافیایی متکی‌اند. اتصال را بررسی کنید و دوباره امتحان کنید." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -74986,5398 +30690,6 @@ } } }, - "mac.image_picker.cancel" : { - "comment" : "Cancel button for the macOS image picker sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إلغاء" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বাতিল" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abbrechen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancel" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancelar" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "لغو" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancel" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annuler" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ביטול" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "रद्द करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Batal" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annulla" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "キャンセル" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "취소" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Batal" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "रद्द गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annuleren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anuluj" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancelar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancelar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Отмена" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avbryt" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ரத்துசெய்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ยกเลิก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "İptal" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скасувати" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "منسوخ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hủy" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "取消" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "取消" - } - } - } - }, - "mac.image_picker.panel_message" : { - "comment" : "Message shown in the macOS NSOpenPanel when picking an image", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اختر صورة لإرسالها" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "পাঠানোর জন্য একটি ছবি বেছে নিন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wähle ein Bild zum Senden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose an image to send" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elige una imagen para enviar" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصویری برای ارسال انتخاب کنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pumili ng larawang ipapadala" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choisissez une image à envoyer" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בחר תמונה לשליחה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "भेजने के लिए एक छवि चुनें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih gambar untuk dikirim" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scegli un'immagine da inviare" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "送信する画像を選択" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "보낼 이미지 선택" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih imej untuk dihantar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "पठाउनका लागि एउटा तस्बिर छान्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kies een afbeelding om te versturen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wybierz obraz do wysłania" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolher uma imagem para enviar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolha uma imagem para enviar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выберите изображение для отправки" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Välj en bild att skicka" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அனுப்ப ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลือกภาพที่จะส่ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Göndermek için bir görsel seçin" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Виберіть зображення для надсилання" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "بھیجنے کے لیے ایک تصویر منتخب کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chọn một hình ảnh để gửi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "选择要发送的图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "選擇要傳送的圖片" - } - } - } - }, - "mac.image_picker.select" : { - "comment" : "Button that opens the macOS open-panel to pick an image", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تحديد صورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ছবি নির্বাচন করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bild auswählen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Select Image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seleccionar imagen" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "انتخاب تصویر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Piliin ang Larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sélectionner une image" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בחר תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "छवि चुनें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih Gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seleziona immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像を選択" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이미지 선택" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih Imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "तस्बिर चयन गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afbeelding selecteren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wybierz obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Selecionar imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Selecionar imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выбрать изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Välj bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "படத்தைத் தேர்ந்தெடு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลือกภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Görsel Seç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вибрати зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصویر منتخب کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chọn hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "选择图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "選擇圖片" - } - } - } - }, - "mac.image_picker.title" : { - "comment" : "Title shown in the macOS image picker sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اختر صورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "একটি ছবি বেছে নিন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bild auswählen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose an image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elige una imagen" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "یک تصویر انتخاب کنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pumili ng larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choisir une image" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בחר תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "एक छवि चुनें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scegli un'immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像を選択" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이미지 선택" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "एउटा तस्बिर छान्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kies een afbeelding" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wybierz obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolher uma imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolha uma imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выберите изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Välj en bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลือกภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bir görsel seçin" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Виберіть зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ایک تصویر منتخب کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chọn một hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "选择图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "選擇圖片" - } - } - } - }, - "media.accessibility.cancel_send" : { - "comment" : "Accessibility label for the cancel button on an in-flight media send", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "إلغاء الإرسال" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "পাঠানো বাতিল করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "senden abbrechen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cancel sending" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "cancelar envío" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "لغو ارسال" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kanselahin ang pagpapadala" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "annuler l'envoi" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ביטול שליחה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "भेजना रद्द करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "batalkan pengiriman" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "annulla invio" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "送信をキャンセル" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "전송 취소" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "batalkan penghantaran" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पठाउने रद्द गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verzenden annuleren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anuluj wysyłanie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cancelar envio" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cancelar envio" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "отменить отправку" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "avbryt sändning" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "அனுப்புவதை ரத்து செய்யவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ยกเลิกการส่ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "göndermeyi iptal et" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "скасувати надсилання" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بھیجنا منسوخ کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hủy gửi" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "取消发送" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "取消發送" - } - } - } - }, - "media.image.accessibility.hidden" : { - "comment" : "Accessibility label for a blurred incoming image", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "صورة مخفية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "লুকানো ছবি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verstecktes bild" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "hidden image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "imagen oculta" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصویر پنهان" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nakatagong larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "image masquée" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "תמונה מוסתרת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "छिपा हुआ चित्र" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gambar tersembunyi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "immagine nascosta" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "非表示の画像" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "숨겨진 이미지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imej tersembunyi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "लुकाइएको तस्बिर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verborgen afbeelding" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ukryty obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imagem oculta" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imagem oculta" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "скрытое изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dold bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "மறைக்கப்பட்ட படம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "รูปภาพที่ซ่อนอยู่" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gizli görsel" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "приховане зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "چھپی ہوئی تصویر" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hình ảnh ẩn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已隐藏的图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "隱藏的圖片" - } - } - } - }, - "media.image.accessibility.hint.open" : { - "comment" : "Accessibility hint for a revealed image; activating it opens the image full screen", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "يفتح الصورة بملء الشاشة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবিটি পূর্ণ স্ক্রিনে খোলে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öffnet das bild im vollbild" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens the image full screen" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre la imagen a pantalla completa" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصویر را تمام‌صفحه باز می‌کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "binubuksan ang larawan nang full screen" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ouvre l'image en plein écran" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "פותח את התמונה במסך מלא" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र को पूरी स्क्रीन पर खोलता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka gambar layar penuh" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "apre l'immagine a schermo intero" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像を全画面で開きます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지를 전체 화면으로 엽니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka imej skrin penuh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर पूरा स्क्रिनमा खोल्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "opent de afbeelding op volledig scherm" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "otwiera obraz na pełnym ekranie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abre a imagem em ecrã inteiro" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abre a imagem em tela cheia" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "открывает изображение на весь экран" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öppnar bilden i helskärm" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படத்தை முழுத் திரையில் திறக்கும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เปิดรูปภาพแบบเต็มหน้าจอ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görseli tam ekran açar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "відкриває зображення на весь екран" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر کو پوری اسکرین پر کھولتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mở hình ảnh toàn màn hình" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "全屏打开图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "全螢幕打開圖片" - } - } - } - }, - "media.image.accessibility.hint.reveal" : { - "comment" : "Accessibility hint for a blurred image; activating it reveals the image", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "يكشف الصورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবিটি প্রকাশ করে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zeigt das bild" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reveals the image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "revela la imagen" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصویر را نمایان می‌کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ipinapakita ang larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "révèle l'image" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "חושף את התמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र दिखाता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "menampilkan gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rivela l'immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像を表示します" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지를 표시합니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "menunjukkan imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर देखाउँछ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toont de afbeelding" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "odsłania obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "revela a imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "revela a imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "показывает изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "visar bilden" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படத்தை வெளிப்படுத்தும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แสดงรูปภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görseli gösterir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "показує зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر ظاہر کرتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hiện hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "显示图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "顯示圖片" - } - } - } - }, - "media.image.accessibility.revealed" : { - "comment" : "Accessibility label for a revealed image", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "صورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "imagen" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصویر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "image" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afbeelding" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "รูปภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görsel" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "圖片" - } - } - } - }, - "media.image.accessibility.sending" : { - "comment" : "Accessibility label for an image that is still sending", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "جارٍ إرسال الصورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি পাঠানো হচ্ছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild wird gesendet" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sending image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "enviando imagen" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال ارسال تصویر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ipinapadala ang larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "envoi de l'image" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שולח תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र भेजा जा रहा है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mengirim gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "invio immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像を送信中" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지 전송 중" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "menghantar imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर पठाइँदै" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afbeelding verzenden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wysyłanie obrazu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "a enviar imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "enviando imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "отправка изображения" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "skickar bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படத்தை அனுப்புகிறது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "กำลังส่งรูปภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görsel gönderiliyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "надсилання зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر بھیجی جا رہی ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đang gửi hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "正在发送图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "圖片發送中" - } - } - } - }, - "media.image.accessibility.unavailable" : { - "comment" : "Accessibility label for an image whose file could not be loaded", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الصورة غير متاحة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি অনুপলব্ধ" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild nicht verfügbar" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "image unavailable" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "imagen no disponible" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصویر در دسترس نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi magagamit ang larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "image indisponible" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "התמונה לא זמינה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र उपलब्ध नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gambar tidak tersedia" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "immagine non disponibile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像を利用できません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지를 사용할 수 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imej tidak tersedia" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर उपलब्ध छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afbeelding niet beschikbaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "obraz niedostępny" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imagem indisponível" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imagem indisponível" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "изображение недоступно" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild otillgänglig" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படம் கிடைக்கவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถใช้รูปภาพได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görsel kullanılamıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "зображення недоступне" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر دستیاب نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hình ảnh không khả dụng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "图片不可用" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "圖片不可用" - } - } - } - }, - "media.image.action.delete" : { - "comment" : "Context menu action that deletes a received image", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "حذف الصورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি মুছুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild löschen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "delete image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "eliminar imagen" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "حذف تصویر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "burahin ang larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "supprimer l'image" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מחק תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र हटाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hapus gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "elimina immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像を削除" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지 삭제" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "padam imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर मेटाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afbeelding verwijderen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "usuń obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "eliminar imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "apagar imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "удалить изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ta bort bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படத்தை நீக்கு" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ลบรูปภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görseli sil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "видалити зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر حذف کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "xóa hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "删除图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "刪除圖片" - } - } - } - }, - "media.image.action.hide" : { - "comment" : "Context menu action that re-blurs a revealed image", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "إخفاء الصورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি লুকান" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild verbergen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "hide image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ocultar imagen" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پنهان کردن تصویر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "itago ang larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "masquer l'image" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הסתר תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र छिपाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sembunyikan gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nascondi immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像を非表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지 숨기기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sembunyikan imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर लुकाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afbeelding verbergen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ukryj obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ocultar imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ocultar imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "скрыть изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dölj bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படத்தை மறை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ซ่อนรูปภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görseli gizle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "приховати зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر چھپائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ẩn hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "隐藏图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "隱藏圖片" - } - } - } - }, - "media.image.action.open" : { - "comment" : "Context menu action that opens an image full screen", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "فتح الصورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি খুলুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild öffnen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "open image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "abrir imagen" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "باز کردن تصویر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "buksan ang larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ouvrir l'image" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "פתח תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र खोलें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "buka gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "apri immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像を開く" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지 열기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "buka imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर खोल" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afbeelding openen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "otwórz obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abrir imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abrir imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "открыть изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öppna bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படத்தைத் திற" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เปิดรูปภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görseli aç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "відкрити зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر کھولیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mở hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "打开图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "打開圖片" - } - } - } - }, - "media.image.action.reveal" : { - "comment" : "Context menu action that reveals a blurred image", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "كشف الصورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি প্রকাশ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild anzeigen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reveal image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "revelar imagen" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمایش تصویر" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ipakita ang larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "révéler l'image" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "חשוף תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र दिखाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tampilkan gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rivela immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像を表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지 표시" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tunjukkan imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर देखाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afbeelding tonen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "odsłoń obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "revelar imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "revelar imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "показать изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "visa bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படத்தை வெளிப்படுத்து" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แสดงรูปภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görseli göster" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "показати зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر ظاہر کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hiện hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "显示图片" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "顯示圖片" - } - } - } - }, - "media.image.delete_confirm_message" : { - "comment" : "Body of the confirmation dialog before deleting a received image", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لا يمكن التراجع عن هذا — قد لا يكون المرسِل في النطاق لإرسالها مرة أخرى." - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এটি ফেরানো যাবে না — প্রেরক আবার পাঠানোর মতো পরিসরে নাও থাকতে পারেন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "das kann nicht rückgängig gemacht werden — der absender ist möglicherweise nicht in reichweite, um es erneut zu senden." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "this cannot be undone — the sender may not be in range to send it again." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "esto no se puede deshacer — puede que el remitente no esté a tu alcance para enviarla de nuevo." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این کار قابل بازگشت نیست — شاید فرستنده در دسترس نباشد تا دوباره آن را بفرستد." - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi na ito maibabalik — maaaring wala na sa saklaw ang nagpadala para maipadala itong muli." - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cette action est irréversible — l'expéditeur n'est peut-être pas à portée pour la renvoyer." - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אי אפשר לבטל את זה — ייתכן שהשולח לא בטווח כדי לשלוח אותה שוב." - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इसे पूर्ववत नहीं किया जा सकता — भेजने वाला शायद इसे दोबारा भेजने की रेंज में न हो।" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ini tidak bisa dibatalkan — pengirim mungkin tidak dalam jangkauan untuk mengirimnya lagi." - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "questa azione non può essere annullata — il mittente potrebbe non essere a portata per inviarla di nuovo." - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "この操作は取り消せません — 送信者が圏内におらず、再送信できない場合があります。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이 작업은 취소할 수 없습니다 — 보낸 사람이 범위 내에 없어 다시 보내지 못할 수 있습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ini tidak boleh dibatalkan — penghantar mungkin tiada dalam jangkauan untuk menghantarnya semula." - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "यो फिर्ता गर्न सकिँदैन — पठाउने फेरि पठाउन दायरामा नहुन सक्छ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dit kan niet ongedaan worden gemaakt — de afzender is mogelijk niet in bereik om het opnieuw te sturen." - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tej operacji nie można cofnąć — nadawca może być poza zasięgiem, aby wysłać go ponownie." - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "isto não pode ser anulado — o remetente pode não estar ao alcance para a enviar de novo." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "isso não pode ser desfeito — quem enviou pode não estar no alcance para enviar de novo." - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "это нельзя отменить — отправитель может быть вне зоны, чтобы отправить снова." - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "detta kan inte ångras — avsändaren kanske inte är inom räckhåll för att skicka den igen." - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இதை மீட்டெடுக்க முடியாது — அனுப்பியவர் மீண்டும் அனுப்பும் வரம்பில் இல்லாமல் இருக்கலாம்." - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "การกระทำนี้ไม่สามารถยกเลิกได้ — ผู้ส่งอาจไม่อยู่ในระยะที่จะส่งอีกครั้ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bu geri alınamaz — gönderen tekrar göndermek için menzilde olmayabilir." - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "це не можна скасувати — відправник може бути поза радіусом, щоб надіслати його знову." - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اسے واپس نہیں کیا جا سکتا — ممکن ہے بھیجنے والا اسے دوبارہ بھیجنے کیلئے رینج میں نہ ہو۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không thể hoàn tác — người gửi có thể không trong phạm vi để gửi lại." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "此操作无法撤销 — 发送者可能不在范围内,无法再次发送。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "此操作無法復原 — 發送者可能不在範圍內,無法再次發送。" - } - } - } - }, - "media.image.delete_confirm_title" : { - "comment" : "Title of the confirmation dialog before deleting a received image", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "حذف هذه الصورة؟" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এই ছবি মুছবেন?" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dieses bild löschen?" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "delete this image?" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿eliminar esta imagen?" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "این تصویر حذف شود؟" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "burahin ang larawang ito?" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "supprimer cette image ?" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "למחוק את התמונה הזו?" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "यह चित्र हटाएँ?" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hapus gambar ini?" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "eliminare questa immagine?" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "この画像を削除しますか?" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이 이미지를 삭제할까요?" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "padam imej ini?" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "यो तस्बिर मेटाउने?" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "deze afbeelding verwijderen?" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "usunąć ten obraz?" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "eliminar esta imagem?" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "apagar esta imagem?" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "удалить это изображение?" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ta bort den här bilden?" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இந்தப் படத்தை நீக்கவா?" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ลบรูปภาพนี้หรือไม่?" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bu görsel silinsin mi?" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "видалити це зображення?" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "یہ تصویر حذف کریں؟" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "xóa hình ảnh này?" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "删除此图片?" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "刪除此圖片?" - } - } - } - }, - "media.image.tap_to_reveal" : { - "comment" : "Caption on a blurred incoming image inviting a tap to reveal it", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "اضغط للكشف" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "প্রকাশ করতে ট্যাপ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zum anzeigen tippen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tap to reveal" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "toca para revelar" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای نمایش لمس کنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i-tap para ipakita" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "touche pour révéler" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הקש לחשיפה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "दिखाने के लिए टैप करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ketuk untuk menampilkan" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tocca per rivelare" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "タップして表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "탭하여 표시" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ketuk untuk menunjukkan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "देखाउन ट्याप गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tik om te tonen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "stuknij, aby odsłonić" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toca para revelar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "toque para revelar" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "нажми, чтобы показать" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tryck för att visa" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "வெளிப்படுத்த தட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แตะเพื่อแสดง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "göstermek için dokunun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "торкнися, щоб показати" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ظاہر کرنے کیلئے ٹیپ کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chạm để hiện" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "轻点显示" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "輕點顯示" - } - } - } - }, - "media.voice.accessibility.live" : { - "comment" : "Accessibility label announcing a live incoming voice message", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "رسالة صوتية مباشرة واردة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আগত লাইভ ভয়েস বার্তা" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "eingehende live-sprachnachricht" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "incoming live voice message" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje de voz en vivo entrante" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام صوتی زنده در حال دریافت" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "papasok na live na voice message" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "message vocal en direct entrant" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הודעה קולית חיה נכנסת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आने वाला लाइव वॉयस संदेश" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "pesan suara langsung masuk" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "messaggio vocale in diretta in arrivo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ライブ音声メッセージを受信中" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "수신 중인 라이브 음성 메시지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesej suara langsung masuk" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "आगमन लाइभ भ्वाइस सन्देश" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "inkomend live spraakbericht" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "przychodząca wiadomość głosowa na żywo" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensagem de voz ao vivo a chegar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensagem de voz ao vivo chegando" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "входящее живое голосовое сообщение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "inkommande live-röstmeddelande" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "உள்வரும் நேரலை குரல் செய்தி" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ข้อความเสียงสดขาเข้า" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "gelen canlı sesli mesaj" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "вхідне живе голосове повідомлення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "موصول ہونے والا لائیو صوتی پیغام" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tin nhắn thoại trực tiếp đang đến" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在接收实时语音消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在接收即時語音訊息" - } - } - } - }, - "media.voice.accessibility.pause" : { - "comment" : "Accessibility label for pausing voice note playback", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "إيقاف الملاحظة الصوتية مؤقتًا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ভয়েস নোট থামান" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sprachnachricht pausieren" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "pause voice note" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "pausar nota de voz" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "توقف پخش پیام صوتی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i-pause ang voice note" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mettre la note vocale en pause" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "השהיית הערה קולית" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वॉइस नोट रोकें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "jeda catatan suara" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "metti in pausa la nota vocale" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ボイスメモを一時停止" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "음성 메모 일시정지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "jeda nota suara" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "भ्वाइस नोट रोक" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "spraakbericht pauzeren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wstrzymaj notatkę głosową" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "colocar a nota de voz em pausa" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pausar mensagem de voz" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "приостановить голосовое сообщение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pausa röstmeddelande" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குரல் குறிப்பை இடைநிறுத்தவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "หยุดข้อความเสียงชั่วคราว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sesli notu duraklat" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "призупинити голосову нотатку" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "وائس نوٹ روکیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tạm dừng ghi chú giọng nói" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "暂停语音消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "暫停語音訊息" - } - } - } - }, - "media.voice.accessibility.play" : { - "comment" : "Accessibility label for playing a voice note", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تشغيل الملاحظة الصوتية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ভয়েস নোট চালান" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sprachnachricht abspielen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "play voice note" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "reproducir nota de voz" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پخش پیام صوتی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i-play ang voice note" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lire la note vocale" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "השמעת הערה קולית" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वॉइस नोट चलाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "putar catatan suara" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "riproduci la nota vocale" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ボイスメモを再生" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "음성 메모 재생" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mainkan nota suara" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "भ्वाइस नोट बजाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "spraakbericht afspelen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "odtwórz notatkę głosową" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "reproduzir a nota de voz" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "reproduzir mensagem de voz" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "воспроизвести голосовое сообщение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "spela upp röstmeddelande" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குரல் குறிப்பை இயக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เล่นข้อความเสียง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sesli notu oynat" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "відтворити голосову нотатку" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "وائس نوٹ چلائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "phát ghi chú giọng nói" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "播放语音消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "播放語音訊息" - } - } - } - }, - "media.voice.live_badge" : { - "comment" : "Badge on a voice message that is currently streaming in live", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "مباشر" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "লাইভ" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "LIVE" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LIVE" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "EN VIVO" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "زنده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "LIVE" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "DIRECT" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "חי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "लाइव" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "LANGSUNG" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "LIVE" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ライブ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "라이브" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "LANGSUNG" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "लाइभ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "LIVE" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "NA ŻYWO" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "AO VIVO" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "AO VIVO" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ЭФИР" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "LIVE" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நேரலை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "สด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "CANLI" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "НАЖИВО" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "لائیو" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "TRỰC TIẾP" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "直播" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "直播" - } - } - } - }, - "mesh_peers.accessibility.open_dm_hint" : { - "comment" : "Accessibility hint on a peer row explaining activation opens a private chat", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "يفتح دردشة خاصة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "একটি ব্যক্তিগত চ্যাট খোলে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öffnet einen privaten chat" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens a private chat" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre un chat privado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گفتگوی خصوصی را باز می‌کند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "binubuksan ang isang pribadong chat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ouvre une discussion privée" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "פותח צ'אט פרטי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी चैट खोलता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka obrolan pribadi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "apre una chat privata" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "プライベートチャットを開きます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "비공개 채팅을 엽니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka sembang peribadi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी च्याट खोल्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "opent een privéchat" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "otwiera czat prywatny" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abre uma conversa privada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abre um chat privado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "открывает личный чат" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öppnar en privat chatt" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "தனிப்பட்ட உரையாடலைத் திறக்கும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เปิดแชทส่วนตัว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "özel bir sohbet açar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "відкриває приватний чат" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نجی چیٹ کھولتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mở cuộc trò chuyện riêng tư" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "打开私聊" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "打開私聊" - } - } - } - }, - "mesh_peers.action.fingerprint" : { - "comment" : "Context menu action that shows a peer's fingerprint/verification screen", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "عرض البصمة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ফিঙ্গারপ্রিন্ট দেখান" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "fingerabdruck anzeigen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "show fingerprint" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mostrar huella" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نمایش اثرانگشت" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ipakita ang fingerprint" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afficher l'empreinte" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הצג טביעת אצבע" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "फ़िंगरप्रिंट दिखाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tampilkan sidik" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mostra impronta" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "フィンガープリントを表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "지문 표시" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tunjukkan sidik" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "फिंगरप्रिन्ट देखाऊ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "vingerafdruk tonen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pokaż odcisk" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mostrar impressão digital" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mostrar impressão digital" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "показать отпечаток" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "visa fingeravtryck" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "கைரேகையைக் காட்டு" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "แสดงลายนิ้วมือ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "parmak izini göster" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "показати відбиток" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "فنگرپرنٹ دکھائیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hiện vân tay" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "显示指纹" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "顯示指紋" - } - } - } - }, - "mesh_peers.state.blocked" : { - "comment" : "State label for a blocked peer", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "محظور" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্লক করা" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "blockiert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "bloqueado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسدود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "na-block" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bloqué" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "חסום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ब्लॉक किया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "diblokir" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bloccato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ブロック中" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "차단됨" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "diblokir" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ब्लक" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "geblokkeerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zablokowany" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bloqueado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bloqueado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "заблокирован" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "blockerad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "தடுக்கப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ถูกบล็อก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "engellendi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "заблоковано" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بلاک شدہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã chặn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已屏蔽" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已屏蔽" - } - } - } - }, - "mesh_peers.state.favorite" : { - "comment" : "State label for a favorited peer", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "مفضّل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "প্রিয়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorit" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorite" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorito" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "موردعلاقه" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "paborito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favori" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מועדף" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पसंदीदा" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorit" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "preferito" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "お気に入り" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "즐겨찾기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorit" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "मनपर्ने" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favoriet" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ulubiony" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorito" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorito" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "избранное" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favorit" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "சிறப்பு" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "คนโปรด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "favori" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "улюблений" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پسندیدہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "yêu thích" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "收藏" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "收藏" - } - } - } - }, - "mesh_peers.state.offline" : { - "comment" : "State label for a peer that is not currently reachable", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "غير متصل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "অফলাইন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "offline" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "sin conexión" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "آفلاین" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hors ligne" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא מקוון" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ऑफ़लाइन" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "オフライン" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "오프라인" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "अफलाइन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "офлайн" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "offline" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ஆஃப்லைன்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ออฟไลน์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "çevrimdışı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "офлайн" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آف لائن" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ngoại tuyến" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "离线" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "離線" - } - } - } - }, - "mesh_peers.state.unread" : { - "comment" : "State label for a peer with unread private messages", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسائل جديدة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "নতুন বার্তা" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "neue nachrichten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "new messages" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensajes nuevos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام‌های جدید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "may bagong mensahe" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nouveaux messages" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעות חדשות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "नए संदेश" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan baru" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nuovi messaggi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "新着メッセージ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "새 메시지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan baru" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "नयाँ सन्देश" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nieuwe berichten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nowe wiadomości" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "novas mensagens" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "novas mensagens" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "новые сообщения" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nya meddelanden" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "புதிய செய்திகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ข้อความใหม่" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "yeni mesajlar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "нові повідомлення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نئے پیغامات" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tin nhắn mới" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "新消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "新訊息" - } - } - } - }, - "mesh_peers.state.vouched" : { - "comment" : "State label for a peer vouched for by someone the user verified", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "موثوق" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "সমর্থিত" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verbürgt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "vouched" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "avalado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ضمانت‌شده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ginarantiyahan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "attesté" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "בערבות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "अनुशंसित" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dijamin" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "garantito" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "保証済み" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "보증됨" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dijamin" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "जमानत" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ingestaan" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "poręczony" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "atestado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "endossado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "поручились" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "intygad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "உத்தரவாதம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "รับรองแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kefil olundu" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "засвідчено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ضمانت شدہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã bảo chứng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已担保" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已擔保" - } - } - } - }, "mesh_peers.tooltip.new_messages" : { "extractionState" : "manual", "localizations" : { @@ -80411,12 +30723,6 @@ "value" : "nuevos mensajes" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام‌های جدید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -80563,3168 +30869,6 @@ } } }, - "mesh_peers.tooltip.vouched" : { - "comment" : "Tooltip for the vouched (unfilled seal) badge next to a peer", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "موثوق من قِبل شخص تحقّقت منه" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি যাচাই করেছেন এমন কারো দ্বারা সমর্থিত" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verbürgt von jemandem, den du verifiziert hast" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "vouched for by someone you verified" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "avalado por alguien que verificaste" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "توسط کسی که تأیید کرده‌اید ضمانت شده" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ginarantiyahan ng taong na-beripika mo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "attesté par une personne que tu as vérifiée" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מישהו שאימתת ערב לו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आपके किसी सत्यापित व्यक्ति द्वारा अनुशंसित" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dijamin oleh seseorang yang kamu verifikasi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "garantito da qualcuno che hai verificato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "あなたが確認した人が保証しています" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "당신이 확인한 사람이 보증함" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dijamin oleh seseorang yang anda sahkan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिमीले प्रमाणित गरेका कसैले जमानत गरेको" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ingestaan door iemand die je hebt geverifieerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "poręczony przez kogoś, kogo zweryfikowałeś" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "atestado por alguém que verificaste" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "endossado por alguém que você verificou" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "за него поручился тот, кого ты проверил" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "intygad av någon du verifierat" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "நீங்கள் சரிபார்த்த ஒருவரால் உத்தரவாதம் அளிக்கப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "รับรองโดยคนที่คุณยืนยันแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "doğruladığın biri tarafından kefil olundu" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "засвідчено кимось, кого ти підтвердив" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "کسی ایسے شخص کی ضمانت جس کی آپ نے توثیق کی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "được bảo chứng bởi người bạn đã xác minh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "由你已验证的人担保" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "由你驗證過的人擔保" - } - } - } - }, - "notices.accessibility.close" : { - "comment" : "Accessibility label for the notices close button", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إغلاق الإعلانات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নোটিশ বন্ধ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hinweise schließen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close notices" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cerrar avisos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بستن اطلاعیه‌ها" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Isara ang mga paunawa" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fermer les annonces" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "סגור מודעות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचनाएँ बंद करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tutup pengumuman" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chiudi avvisi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お知らせを閉じる" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공지 닫기" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tutup pengumuman" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचना बन्द गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mededelingen sluiten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zamknij ogłoszenia" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fechar avisos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fechar avisos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Закрыть объявления" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stäng anslag" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அறிவிப்புகளை மூடு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ปิดประกาศ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Duyuruları kapat" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Закрити оголошення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اعلانات بند کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đóng thông báo" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "关闭公告" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "關閉公告" - } - } - } - }, - "notices.accessibility.scope" : { - "comment" : "Accessibility label for the geo/mesh scope toggle", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "نطاق الإعلانات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নোটিশের পরিধি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bereich der Hinweise" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notices scope" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ámbito de los avisos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "محدودهٔ اطلاعیه‌ها" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Saklaw ng mga paunawa" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Portée des annonces" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "טווח המודעות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचनाओं का दायरा" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cakupan pengumuman" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ambito degli avvisi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お知らせの範囲" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공지 범위" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skop pengumuman" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचनाको दायरा" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bereik van mededelingen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zakres ogłoszeń" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Âmbito dos avisos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escopo dos avisos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Раздел объявлений" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anslagens omfattning" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அறிவிப்புகளின் வரம்பு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ขอบเขตประกาศ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Duyuru kapsamı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Розділ оголошень" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اعلانات کا دائرہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Phạm vi thông báo" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "公告范围" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "公告範圍" - } - } - } - }, - "notices.alert.urgent_collapsed" : { - "comment" : "Local chat line when several urgent notices arrive together", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld إعلانات عاجلة جديدة — اضغط على الدبوس للعرض" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lldটি নতুন জরুরি নোটিশ — দেখতে পিনে ট্যাপ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld neue dringende hinweise — tippe zum ansehen auf den pin" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld new urgent notices — tap the pin to view" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld avisos urgentes nuevos — toca el pin para verlos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld اطلاعیهٔ فوری جدید — برای مشاهده روی سنجاق بزنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld bagong agarang paunawa — i-tap ang pin para makita" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld nouvelles annonces urgentes — touche l'épingle pour voir" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld מודעות דחופות חדשות — הקש על הנעץ לצפייה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld नई ज़रूरी सूचनाएँ — देखने के लिए पिन टैप करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld pengumuman mendesak baru — ketuk pin untuk melihat" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld nuovi avvisi urgenti — tocca la puntina per vederli" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 新しい緊急のお知らせが%lld件 — ピンをタップして表示" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 새 긴급 공지 %lld개 — 핀을 탭하여 확인" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld pengumuman segera baharu — ketik pin untuk melihat" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld नयाँ जरुरी सूचना — हेर्न पिन ट्याप गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld nieuwe dringende mededelingen — tik op de pin om te bekijken" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld nowych pilnych ogłoszeń — dotknij pinezki, aby zobaczyć" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld novos avisos urgentes — toca no pin para ver" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld avisos urgentes novos — toque no pin para ver" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld новых срочных объявлений — нажми на булавку, чтобы посмотреть" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld nya brådskande anslag — tryck på nålen för att visa" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld புதிய அவசர அறிவிப்புகள் — பார்க்க பின்னைத் தட்டவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 ประกาศด่วนใหม่ %lld รายการ — แตะหมุดเพื่อดู" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld yeni acil duyuru — görmek için raptiyeye dokun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld нових термінових оголошень — натисни на шпильку, щоб переглянути" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld نئے فوری اعلانات — دیکھنے کیلئے پن پر ٹیپ کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld thông báo khẩn mới — chạm vào ghim để xem" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld 条新紧急公告 — 点按图钉查看" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 %lld 則新緊急公告 — 點按圖釘查看" - } - } - } - }, - "notices.alert.urgent_single" : { - "comment" : "Local chat line when one urgent notice is pinned nearby", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 إعلان عاجل من @%@: %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 @%@-এর জরুরি নোটিশ: %@" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 dringender hinweis von @%@: %@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 urgent notice from @%@: %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 aviso urgente de @%@: %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 اطلاعیهٔ فوری از @%@: %@" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 agarang paunawa mula kay @%@: %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 annonce urgente de @%@ : %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 מודעה דחופה מאת @%@: %@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 @%@ की ज़रूरी सूचना: %@" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 pengumuman mendesak dari @%@: %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 avviso urgente da @%@: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 @%@からの緊急のお知らせ: %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 @%@님의 긴급 공지: %@" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 pengumuman segera daripada @%@: %@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 @%@को जरुरी सूचना: %@" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 dringende mededeling van @%@: %@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 pilne ogłoszenie od @%@: %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 aviso urgente de @%@: %@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 aviso urgente de @%@: %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 срочное объявление от @%@: %@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 brådskande anslag från @%@: %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 @%@ இன் அவசர அறிவிப்பு: %@" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 ประกาศด่วนจาก @%@: %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 @%@ kişisinden acil duyuru: %@" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 термінове оголошення від @%@: %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 @%@ کا فوری اعلان: %@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 thông báo khẩn từ @%@: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 来自 @%@ 的紧急公告:%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "📌 來自 @%@ 的緊急公告:%@" - } - } - } - }, - "notices.description.mesh" : { - "comment" : "Explainer for the mesh tab of the notices sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "ثبّت إعلانات قصيرة لمن حولك. تنتقل من هاتف إلى هاتف حتى دون اتصال، وتختفي وحدها بعد أيام قليلة." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আশেপাশের মানুষের জন্য ছোট নোটিশ পিন করুন। অফলাইনেও ফোন থেকে ফোনে পৌঁছে যায় আর কয়েক দিন পরে নিজে থেকেই মুছে যায়।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "hefte kurze hinweise für leute in deiner nähe an. sie springen von handy zu handy, auch offline, und verschwinden nach ein paar tagen von selbst." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "fija avisos cortos para la gente cercana. saltan de teléfono a teléfono, incluso sin conexión, y desaparecen solos después de unos días." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اطلاعیه‌های کوتاه برای اطرافیان‌تان سنجاق کنید. گوشی به گوشی جابه‌جا می‌شوند، حتی آفلاین، و بعد از چند روز خودبه‌خود ناپدید می‌شوند." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mag-pin ng maiikling paunawa para sa mga taong nasa paligid mo. lumilipat ito mula sa isang telepono patungo sa iba, kahit offline, at kusang nawawala pagkatapos ng ilang araw." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "épingle de courtes annonces pour les gens autour de toi. elles passent de téléphone en téléphone, même hors ligne, et disparaissent d'elles-mêmes après quelques jours." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הצמד מודעות קצרות לאנשים סביבך. הן עוברות מטלפון לטלפון, גם בלי אינטרנט, ונעלמות מעצמן אחרי כמה ימים." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आस-पास के लोगों के लिए छोटी सूचनाएँ पिन करें। ये ऑफ़लाइन भी फ़ोन से फ़ोन तक पहुँचती हैं और कुछ दिनों बाद अपने आप मिट जाती हैं।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "sematkan pengumuman singkat untuk orang di sekitarmu. berpindah dari ponsel ke ponsel, bahkan saat offline, dan hilang sendiri setelah beberapa hari." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "appunta brevi avvisi per chi ti sta intorno. passano da telefono a telefono, anche offline, e spariscono da soli dopo qualche giorno." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "近くの人に向けて短いお知らせをピン留め。オフラインでもスマホからスマホへ伝わり、数日後に自動的に消えます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "주변 사람들을 위해 짧은 공지를 고정하세요. 오프라인에서도 휴대폰에서 휴대폰으로 전달되고 며칠 후 스스로 사라집니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "semat pengumuman ringkas untuk orang di sekeliling anda. ia berpindah dari telefon ke telefon, walaupun di luar talian, dan hilang sendiri selepas beberapa hari." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "वरपरका मानिसहरूका लागि छोटा सूचना पिन गर। अफलाइनमा पनि फोनबाट फोनमा पुग्छन् र केही दिनपछि आफैँ हराउँछन्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "prik korte mededelingen voor mensen om je heen. ze springen van telefoon naar telefoon, ook offline, en verdwijnen vanzelf na een paar dagen." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "przypinaj krótkie ogłoszenia dla ludzi w pobliżu. przeskakują z telefonu na telefon, nawet offline, i same znikają po kilku dniach." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "afixa avisos curtos para quem está por perto. saltam de telemóvel em telemóvel, mesmo offline, e desaparecem sozinhos após alguns dias." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "fixe avisos curtos para as pessoas por perto. eles pulam de celular em celular, mesmo offline, e somem sozinhos depois de alguns dias." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "закрепляй короткие объявления для людей рядом. они передаются с телефона на телефон, даже офлайн, и сами исчезают через несколько дней." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "nåla upp korta anslag för folk i närheten. de hoppar från telefon till telefon, även offline, och försvinner av sig själva efter några dagar." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அருகிலுள்ளவர்களுக்காக சிறிய அறிவிப்புகளைப் பின் செய்யவும். ஆஃப்லைனிலும் ஃபோனிலிருந்து ஃபோனுக்குப் பரவி, சில நாட்களில் தானாக மறைந்துவிடும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ปักประกาศสั้น ๆ ให้คนรอบตัว ส่งต่อจากมือถือสู่มือถือได้แม้ออฟไลน์ และหายไปเองหลังผ่านไปสองสามวัน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "çevrendekiler için kısa duyurular sabitle. çevrimdışıyken bile telefondan telefona geçer ve birkaç gün sonra kendiliğinden kaybolur." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "закріплюй короткі оголошення для людей поруч. вони передаються з телефона на телефон, навіть офлайн, і самі зникають за кілька днів." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "آس پاس کے لوگوں کیلئے مختصر اعلانات پن کریں۔ یہ آف لائن بھی فون سے فون تک پہنچتے ہیں اور کچھ دنوں بعد خود مٹ جاتے ہیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ghim thông báo ngắn cho những người quanh bạn. chúng truyền từ điện thoại này sang điện thoại khác, kể cả khi ngoại tuyến, và tự biến mất sau vài ngày." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "為周圍的人釘上簡短公告。即使離線也能在手機間傳遞,幾天後自動消失。" - } - } - } - }, - "notices.expiry.permanent" : { - "comment" : "Accessibility label for the ∞ (never expires) option in the geo notes expiry picker", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "دائم" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "স্থায়ী" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "dauerhaft" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "permanent" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "permanente" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دائمی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "permanente" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "permanent" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "קבוע" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थायी" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "permanen" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "permanente" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "無期限" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "영구" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "kekal" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थायी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "permanent" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "na stałe" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "permanente" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "permanente" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "навсегда" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "permanent" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நிரந்தரம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ถาวร" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "kalıcı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "назавжди" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "مستقل" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "vĩnh viễn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "永久" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "永久" - } - } - } - }, - "notices.fades" : { - "comment" : "Shown on notices with an expiry; placeholder is a localized relative time like 'in 23h'", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يتلاشى %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ মুছে যাবে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verblasst %@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "fades %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "se desvanece %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ محو می‌شود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "maglalaho %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "s'efface %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "דוהה %@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ मिट जाएगा" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "memudar %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "svanisce %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@に消えます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 사라짐" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "pudar %@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ मेटिन्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "vervaagt %@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "zniknie %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "desvanece %@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "desaparece %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "исчезнет %@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "tonar bort %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ மறையும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลือนหาย %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ kaybolur" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "зникне %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ مٹ جائے گا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mờ dần %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@消失" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@消失" - } - } - } - }, - "notices.source.mesh" : { - "comment" : "Source badge for notices carried by the mesh", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مش" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - } - } - }, - "notices.source.nostr" : { - "comment" : "Source badge for notices seen on internet relays", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "نت" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নেট" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "netz" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "net" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "red" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نت" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "net" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "net" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "רשת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "नेट" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "net" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "rete" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ネット" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "넷" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "net" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "नेट" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "net" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "sieć" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "rede" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "rede" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "сеть" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "nät" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "நெட்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เน็ต" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ağ" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "мережа" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "نیٹ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mạng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "网络" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "網路" - } - } - } - }, - "notices.tab.geo" : { - "comment" : "Segmented control label for geohash-scoped notices", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "جغرافي" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "এলাকা" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ژئو" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "géo" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אזור" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "क्षेत्र" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "area" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "エリア" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "지역" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "kawasan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "क्षेत्र" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "гео" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "geo" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "பகுதி" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "พื้นที่" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bölge" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "гео" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "علاقہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "khu vực" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "区域" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "區域" - } - } - } - }, - "notices.tab.mesh" : { - "comment" : "Segmented control label for mesh-local notices", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مش" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh" - } - } - } - }, - "notices.title" : { - "comment" : "Title prefix of the unified notices sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إعلانات" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নোটিশ" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "hinweise" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "notices" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "avisos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اطلاعیه‌ها" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mga paunawa" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "annonces" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מודעות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचनाएँ" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "pengumuman" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "avvisi" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お知らせ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "pengumuman" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सूचनाहरू" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "mededelingen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ogłoszenia" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "avisos" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "avisos" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "объявления" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "anslag" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அறிவிப்புகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ประกาศ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "duyurular" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "оголошення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "اعلانات" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "thông báo" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "公告" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "公告" - } - } - } - }, - "notification.redacted.body" : { - "comment" : "Lock-screen notification body shown in place of the message text when message previews are hidden", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "افتح bitchat للقراءة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "পড়তে bitchat খুলুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "öffne bitchat zum lesen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "open bitchat to read" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre bitchat para leer" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای خواندن bitchat را باز کن" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "buksan ang bitchat para makita" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ouvre bitchat pour lire" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פתח את bitchat כדי לקרוא" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "पढ़ने के लिए bitchat खोलें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "buka bitchat untuk membaca" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "apri bitchat per leggere" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "読むには bitchat を開いてください" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "읽으려면 bitchat을 열어보세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "buka bitchat untuk membaca" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "पढ्न bitchat खोल" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "open bitchat om te lezen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "otwórz bitchat, aby przeczytać" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre o bitchat para ler" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "abra o bitchat para ler" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "открой bitchat, чтобы прочитать" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "öppna bitchat för att läsa" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "படிக்க bitchat ஐத் திறக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เปิด bitchat เพื่ออ่าน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "okumak için bitchat'i aç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "відкрий bitchat, щоб прочитати" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پڑھنے کے لیے bitchat کھولیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mở bitchat để đọc" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "打开 bitchat 查看" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "開啟 bitchat 查看" - } - } - } - }, - "notification.redacted.dm.title" : { - "comment" : "Lock-screen notification title for a received direct message when message previews are hidden; deliberately names neither the sender nor the content", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 رسالة خاصة جديدة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 নতুন ডিএম" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 neue pn" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 new dm" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 nuevo md" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 پیام خصوصی جدید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 bagong dm" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 nouveau mp" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 הודעה פרטית חדשה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 नया dm" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 dm baru" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 nuovo dm" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 新しいdm" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 새 dm" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 dm baru" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 नयाँ dm" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 nieuwe dm" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 nowy dm" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 nova mensagem privada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 novo dm" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 новое личное сообщение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 nytt dm" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 புதிய dm" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 dm ใหม่" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 yeni dm" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 нове приватне повідомлення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 نیا نجی پیغام" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 dm mới" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 新的 dm" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "🔒 新的 dm" - } - } - } - }, - "notification.redacted.geohash.title" : { - "comment" : "Lock-screen notification title for activity in a location channel when message previews are hidden; deliberately omits the geohash", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 نشاط جديد قريب منك" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 আশেপাশে নতুন কার্যকলাপ" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 neue aktivität in der nähe" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 new activity nearby" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nueva actividad cerca" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 فعالیت جدید در نزدیکی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 may bagong aktibidad sa paligid" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nouvelle activité à proximité" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 פעילות חדשה בסביבה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 आसपास नई गतिविधि" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 aktivitas baru di sekitar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nuova attività nelle vicinanze" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 近くで新しい動き" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 근처에 새 활동" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 aktiviti baru berdekatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 नजिकै नयाँ गतिविधि" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nieuwe activiteit in de buurt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nowa aktywność w okolicy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nova atividade por perto" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 nova atividade por perto" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 новая активность рядом" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 ny aktivitet i närheten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 அருகில் புதிய செயல்பாடு" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 มีความเคลื่อนไหวใหม่ใกล้คุณ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 yakınında yeni hareket" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 нова активність поблизу" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 قریب نئی سرگرمی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 hoạt động mới gần bạn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 附近有新动态" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "📍 附近有新動態" - } - } - } - }, - "notification.redacted.mention.title" : { - "comment" : "Lock-screen notification title telling someone they were mentioned when message previews are hidden; deliberately omits who mentioned them", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 تمّت الإشارة إليك" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 আপনাকে মেনশন করা হয়েছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 du wurdest erwähnt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 you were mentioned" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 te han mencionado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 منشن شدی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 nabanggit ka" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 on t'a mentionné" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 הוזכרת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 आपको मेंशन किया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 kamu disebut" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 ti hanno menzionato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 メンションされました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 멘션되었습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 kamu disebut" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 तिमीलाई उल्लेख गरियो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 je bent vermeld" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 wspomniano o tobie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 mencionaram-te" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 mencionaram você" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 тебя упомянули" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 du har omnämnts" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 உங்களைக் குறிப்பிட்டுள்ளனர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 คุณถูกกล่าวถึง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 senden bahsedildi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 тебе згадали" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 آپ کا ذکر ہوا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 bạn được nhắc tới" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 你被提及" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "🫵 你被提及" - } - } - } - }, "recording %@" : { "comment" : "Voice note recording duration indicator", "localizations" : { @@ -83758,12 +30902,6 @@ "value" : "grabando %@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال ضبط %@" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -83943,12 +31081,6 @@ "value" : "guardar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ذخیره" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -84095,192 +31227,6 @@ } } }, - "system.panic.completed" : { - "comment" : "System message confirming a successful panic wipe", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم مسح جميع البيانات — تم إنشاء هوية جديدة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "সব ডেটা মুছে ফেলা হয়েছে — নতুন পরিচয় তৈরি হয়েছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "alle daten gelöscht — neue identität erstellt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "all data wiped — new identity created" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "todos los datos borrados — nueva identidad creada" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "همه داده‌ها پاک شد — هویت جدید ساخته شد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "nabura na ang lahat ng data — gumawa ng bagong pagkakakilanlan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "toutes les données effacées — nouvelle identité créée" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "כל הנתונים נמחקו — נוצרה זהות חדשה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सारा डेटा मिटा दिया गया — नई पहचान बनाई गई" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "semua data dihapus — identitas baru dibuat" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "tutti i dati cancellati — nuova identità creata" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "すべてのデータを消去しました — 新しいアイデンティティを作成しました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "모든 데이터 삭제됨 — 새 신원 생성됨" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "semua data dipadamkan — identiti baharu dicipta" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सबै डेटा मेटाइयो — नयाँ परिचय सिर्जना गरियो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "alle gegevens gewist — nieuwe identiteit aangemaakt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wszystkie dane wymazane — utworzono nową tożsamość" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "todos os dados apagados — nova identidade criada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "todos os dados apagados — nova identidade criada" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "все данные стёрты — создана новая идентичность" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "all data raderad — ny identitet skapad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "எல்லா தரவும் அழிக்கப்பட்டது — புதிய அடையாளம் உருவாக்கப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ล้างข้อมูลทั้งหมดแล้ว — สร้างตัวตนใหม่แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tüm veriler silindi — yeni kimlik oluşturuldu" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "усі дані стерто — створено нову ідентичність" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تمام ڈیٹا مٹا دیا گیا — نئی شناخت بنائی گئی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "đã xoá toàn bộ dữ liệu — đã tạo danh tính mới" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已清除所有数据 — 已创建新身份" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "已清除所有資料 — 已建立新身分" - } - } - } - }, "system.chat.blocked" : { "extractionState" : "manual", "localizations" : { @@ -84311,13 +31257,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se puede iniciar un chat con %@: esta persona está bloqueada." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "شروع گفتگو با %@ ممکن نیست: این فرد مسدود است." + "value" : "no se puede iniciar un chat con %@: el usuario está bloqueado." } }, "fil" : { @@ -84329,7 +31269,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "impossible de démarrer un chat avec %@ : cette personne est bloquée." + "value" : "impossible de démarrer un chat avec %@ : utilisateur bloqué." } }, "he" : { @@ -84353,7 +31293,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "impossibile avviare una chat con %@: questa persona è bloccata." + "value" : "impossibile avviare una chat con %@: utente bloccato." } }, "ja" : { @@ -84401,7 +31341,7 @@ "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "não é possível iniciar chat com %@: a pessoa está bloqueada." + "value" : "não é possível iniciar chat com %@: usuário bloqueado." } }, "ru" : { @@ -84466,6 +31406,364 @@ } } }, + "system.chat.requires_favorite" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يمكن بدء دردشة مع %@: يجب أن تكونا مفضلين متبادلين للتشغيل بدون اتصال." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-এর সঙ্গে চ্যাট শুরু করা যায় না: অফলাইন মেসেজিংয়ের জন্য পারস্পরিক প্রিয় দরকার।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "chat mit %@ kann nicht gestartet werden: gegenseitige favoriten für offline nötig." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot start chat with %@: mutual favorite required for offline messaging." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede iniciar un chat con %@: necesitas ser favoritos mutuos para mensajería sin conexión." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi makapagsimula ng chat kay %@: kailangan ang mutual na paborito para sa offline na mensahe." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossible de démarrer un chat avec %@ : favoris mutuels requis pour le hors ligne." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן להתחיל צ'אט עם %@: נדרשים מועדפים הדדיים לאופליין." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ के साथ चैट शुरू नहीं कर सकते: ऑफ़लाइन मैसेजिंग के लिए आपसी पसंदीदा आवश्यक है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile avviare una chat con %@: servono preferiti reciproci per l'offline." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@とはチャットできません: オフラインには相互のお気に入りが必要です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@와 채팅을 시작할 수 없습니다: 오프라인 메시지를 보내려면 서로 즐겨찾기에 추가해야 합니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak bisa mulai chat dengan %@: butuh favorit bersama untuk offline." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ सँग च्याट सुरु गर्न मिलेन: अफलाइनका लागि दुवै मनपर्ने हुनुपर्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kan chat met %@ niet starten: wederzijds favoriet vereist voor offline berichten." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie można rozpocząć czatu z %@: potrzebne wzajemne ulubione dla wiadomości offline." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não é possível iniciar o chat com %@: precisam de ser favoritos mútuos para mensagens offline." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não é possível iniciar chat com %@: vocês precisam ser favoritos mútuos para mensagens offline." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "нельзя начать чат с %@: нужны взаимные избранные для офлайна." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kan inte starta chatt med %@: ömsesidig favorit krävs för offline-meddelanden." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ உடன் உரையாடல் தொடங்க முடியாது: ஆஃப்லைன் செய்திகளுக்கு இருபுறமும் பிரியப்பட்டவர்கள் ஆக வேண்டும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่สามารถเริ่มแชทกับ %@: ต้องเป็นรายการโปรดทั้งสองฝ่ายเพื่อใช้งานออฟไลน์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ile sohbet başlatılamıyor: çevrimdışı mesajlaşma için karşılıklı favori gerekiyor." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не можна почати чат з %@: потрібне взаємне вибране для офлайна." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کے ساتھ چیٹ شروع نہیں کر سکتے: آفلائن پیغامات کیلئے باہمی پسندیدہ ہونا ضروری ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không thể bắt đầu chat với %@: cần yêu thích lẫn nhau để nhắn ngoại tuyến." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法与 %@ 开始聊天:离线消息需要互相关注。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法與 %@ 開始聊天:離線訊息需要互相關注。" + } + } + } + }, + "system.common.user" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مستخدم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্যবহারকারী" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nutzer" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "user" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "usuario" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "user" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "utilisateur" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "משתמש" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "उपयोगकर्ता" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pengguna" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "utente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ユーザー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pengguna" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "प्रयोगकर्ता" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gebruiker" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "użytkownik" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "utilizador" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "usuário" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "пользователь" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "användare" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பயனர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ผู้ใช้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kullanıcı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "користувач" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "صارف" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "người dùng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用户" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用者" + } + } + } + }, "system.dm.blocked_generic" : { "extractionState" : "manual", "localizations" : { @@ -84496,13 +31794,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se puede enviar el mensaje: esta persona está bloqueada." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال پیام ممکن نیست: این فرد مسدود است." + "value" : "no se puede enviar el mensaje: el usuario está bloqueado." } }, "fil" : { @@ -84514,7 +31806,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "envoi impossible : cette personne est bloquée." + "value" : "envoi impossible : utilisateur bloqué." } }, "he" : { @@ -84538,7 +31830,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "invio non riuscito: questa persona è bloccata." + "value" : "invio non riuscito: utente bloccato." } }, "ja" : { @@ -84586,7 +31878,7 @@ "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "não foi possível enviar: a pessoa está bloqueada." + "value" : "não foi possível enviar: usuário bloqueado." } }, "ru" : { @@ -84681,13 +31973,7 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se puede enviar un mensaje a %@: esta persona está bloqueada." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال پیام به %@ ممکن نیست: این فرد مسدود است." + "value" : "no se puede enviar un mensaje a %@: el usuario está bloqueado." } }, "fil" : { @@ -84699,7 +31985,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "impossible d'envoyer à %@ : cette personne est bloquée." + "value" : "impossible d'envoyer à %@ : utilisateur bloqué." } }, "he" : { @@ -84723,7 +32009,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "impossibile inviare a %@: questa persona è bloccata." + "value" : "impossibile inviare a %@: utente bloccato." } }, "ja" : { @@ -84771,7 +32057,7 @@ "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "não é possível enviar mensagem para %@: a pessoa está bloqueada." + "value" : "não é possível enviar mensagem para %@: usuário bloqueado." } }, "ru" : { @@ -84836,188 +32122,181 @@ } } }, - "system.gateway.sent_via_mesh" : { - "comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable", + "system.dm.unreachable" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { - "state" : "needs_review", - "value" : "أُرسل عبر بوابة mesh" + "state" : "translated", + "value" : "لا يمكن الإرسال إلى %@: المستلم غير متاح عبر mesh أو nostr." } }, "bn" : { "stringUnit" : { - "state" : "needs_review", - "value" : "মেশ গেটওয়ের মাধ্যমে পাঠানো হয়েছে" + "state" : "translated", + "value" : "%@-কে বার্তা পাঠানো যায় না - পিয়ার মেশ বা নোস্টরে পৌঁছানো যাচ্ছে না।" } }, "de" : { "stringUnit" : { - "state" : "needs_review", - "value" : "über mesh-gateway gesendet" + "state" : "translated", + "value" : "senden an %@ nicht möglich: empfänger über mesh oder nostr nicht erreichbar." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "sent via mesh gateway" + "value" : "cannot send message to %@ - peer is not reachable via mesh or nostr." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "enviado por la puerta de enlace del mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "از طریق دروازهٔ مش ارسال شد" + "value" : "no se puede enviar un mensaje a %@: el destinatario no es alcanzable por mesh ni Nostr." } }, "fil" : { "stringUnit" : { - "state" : "needs_review", - "value" : "naipadala sa pamamagitan ng mesh gateway" + "state" : "translated", + "value" : "hindi maipadala kay %@ - hindi maabot ang peer sa mesh o nostr." } }, "fr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "envoyé via la passerelle mesh" + "state" : "translated", + "value" : "impossible d'envoyer à %@ : destinataire injoignable via mesh ou nostr." } }, "he" : { "stringUnit" : { - "state" : "needs_review", - "value" : "נשלח דרך שער mesh" + "state" : "translated", + "value" : "אי אפשר לשלוח ל-%@: הנמען אינו נגיש דרך mesh או nostr." } }, "hi" : { "stringUnit" : { - "state" : "needs_review", - "value" : "मेश गेटवे के ज़रिए भेजा गया" + "state" : "translated", + "value" : "%@ को संदेश नहीं भेज सकते - पीयर मेश या नोस्ट्र पर पहुँच योग्य नहीं।" } }, "id" : { "stringUnit" : { - "state" : "needs_review", - "value" : "dikirim via gateway mesh" + "state" : "translated", + "value" : "tidak bisa mengirim ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr." } }, "it" : { "stringUnit" : { - "state" : "needs_review", - "value" : "inviato tramite gateway mesh" + "state" : "translated", + "value" : "impossibile inviare a %@: destinatario irraggiungibile via mesh o nostr." } }, "ja" : { "stringUnit" : { - "state" : "needs_review", - "value" : "meshゲートウェイ経由で送信" + "state" : "translated", + "value" : "%@に送れません: 受信者はmeshやnostrで到達できません。" } }, "ko" : { "stringUnit" : { - "state" : "needs_review", - "value" : "mesh 게이트웨이를 통해 전송됨" + "state" : "translated", + "value" : "%@에게 메시지를 보낼 수 없습니다 - mesh 또는 nostr를 통해 피어에 연결할 수 없습니다." } }, "ms" : { "stringUnit" : { - "state" : "needs_review", - "value" : "dihantar via gateway mesh" + "state" : "translated", + "value" : "tidak bisa menghantar ke %@: penerima tidak dapat dijangkau lewat mesh atau nostr." } }, "ne" : { "stringUnit" : { - "state" : "needs_review", - "value" : "mesh गेटवे मार्फत पठाइयो" + "state" : "translated", + "value" : "%@ लाई पठाउन मिलेन: प्राप्तकर्ता mesh वा nostr बाट उपलब्ध छैन" } }, "nl" : { "stringUnit" : { - "state" : "needs_review", - "value" : "verzonden via mesh-gateway" + "state" : "translated", + "value" : "kan geen bericht sturen naar %@ – peer niet bereikbaar via mesh of nostr." } }, "pl" : { "stringUnit" : { - "state" : "needs_review", - "value" : "wysłano przez bramę mesh" + "state" : "translated", + "value" : "nie można wysłać do %@ – peer nieosiągalny przez mesh ani nostr." } }, "pt" : { "stringUnit" : { - "state" : "needs_review", - "value" : "enviado através do gateway mesh" + "state" : "translated", + "value" : "não é possível enviar mensagem a %@ - o par não está acessível por mesh ou Nostr." } }, "pt-BR" : { "stringUnit" : { - "state" : "needs_review", - "value" : "enviado pelo gateway do mesh" + "state" : "translated", + "value" : "não é possível enviar mensagem para %@: destinatário inalcançável por mesh ou nostr." } }, "ru" : { "stringUnit" : { - "state" : "needs_review", - "value" : "отправлено через mesh-шлюз" + "state" : "translated", + "value" : "нельзя отправить %@: адресат недоступен через mesh или nostr." } }, "sv" : { "stringUnit" : { - "state" : "needs_review", - "value" : "skickat via mesh-gateway" + "state" : "translated", + "value" : "kan inte skicka till %@ – peer nås inte via mesh eller nostr." } }, "ta" : { "stringUnit" : { - "state" : "needs_review", - "value" : "mesh நுழைவாயில் வழியாக அனுப்பப்பட்டது" + "state" : "translated", + "value" : "%@ க்கு செய்தி அனுப்ப முடியவில்லை - peer mesh அல்லது nostr மூலமாக அணுக முடியவில்லை." } }, "th" : { "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งผ่านเกตเวย์ mesh" + "state" : "translated", + "value" : "ไม่สามารถส่งข้อความถึง %@ - ติดต่อเพียร์ผ่าน mesh หรือ nostr ไม่ได้" } }, "tr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "mesh ağ geçidi üzerinden gönderildi" + "state" : "translated", + "value" : "%@'a mesaj gönderilemiyor - eş mesh veya Nostr üzerinden ulaşılamıyor." } }, "uk" : { "stringUnit" : { - "state" : "needs_review", - "value" : "надіслано через шлюз mesh" + "state" : "translated", + "value" : "неможливо надіслати %@: одержувач недосяжний через mesh або nostr." } }, "ur" : { "stringUnit" : { - "state" : "needs_review", - "value" : "mesh گیٹ وے کے ذریعے بھیجا گیا" + "state" : "translated", + "value" : "%@ کو پیغام نہیں بھیج سکتے - peer mesh یا nostr کے ذریعے دستیاب نہیں۔" } }, "vi" : { "stringUnit" : { - "state" : "needs_review", - "value" : "đã gửi qua cổng mesh" + "state" : "translated", + "value" : "không thể gửi tin cho %@ - nút không thể liên lạc qua mesh hoặc nostr." } }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", - "value" : "已通过 mesh 网关发送" + "state" : "translated", + "value" : "无法向 %@ 发送:对方无法通过 mesh 或 Nostr 到达。" } }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", - "value" : "已透過 mesh 閘道發送" + "state" : "translated", + "value" : "無法向 %@ 發送:對方無法透過 mesh 或 Nostr 到達。" } } } @@ -85055,12 +32334,6 @@ "value" : "se bloqueó a %@ en los chats geohash" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ در گفتگوهای ژئوهش مسدود شد" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -85240,12 +32513,6 @@ "value" : "se desbloqueó a %@ en los chats geohash" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رفع مسدودی %@ در گفتگوهای ژئوهش" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -85392,5001 +32659,6 @@ } } }, - "system.group.already_member" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ عضو بالفعل" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ ইতিমধ্যে একজন সদস্য" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ ist bereits mitglied" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is already a member" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ ya es miembro" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ از قبل عضو است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "miyembro na si %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ est déjà membre" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ כבר חבר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ पहले से ही सदस्य है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ sudah menjadi anggota" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ è già un membro" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ はすでにメンバーです" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 님은 이미 멤버입니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ sudah menjadi ahli" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ पहिले नै सदस्य हो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ is al lid" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ jest już członkiem" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ já é membro" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ já é membro" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ уже участник" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ är redan medlem" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ ஏற்கனவே ஒரு உறுப்பினர்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ เป็นสมาชิกอยู่แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ zaten üye" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ уже учасник" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ پہلے ہی رکن ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ đã là thành viên" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 已是成员" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 已是成員" - } - } - } - }, - "system.group.cannot_remove_creator" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لا يمكن إزالة المُنشئ" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "নির্মাতাকে সরানো যায় না" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "der ersteller kann nicht entfernt werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "the creator cannot be removed" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede eliminar al creador" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "سازندهٔ گروه را نمی‌توان حذف کرد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi maaaring alisin ang tagalikha" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "le créateur ne peut pas être retiré" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אי אפשר להסיר את היוצר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निर्माता को हटाया नहीं जा सकता" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pembuat tidak bisa dihapus" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "il creatore non può essere rimosso" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "作成者は削除できません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "생성자는 제거할 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pencipta tidak boleh dibuang" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "सिर्जनाकर्तालाई हटाउन सकिँदैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "de maker kan niet worden verwijderd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie można usunąć twórcy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "o criador não pode ser removido" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "o criador não pode ser removido" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "создателя нельзя удалить" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "skaparen kan inte tas bort" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "உருவாக்கியவரை நீக்க முடியாது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถลบผู้สร้างได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "oluşturan kişi kaldırılamaz" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "створювача не можна видалити" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بنانے والے کو ہٹایا نہیں جا سکتا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không thể xóa người tạo" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "创建者无法被移除" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "無法移除建立者" - } - } - } - }, - "system.group.create_failed" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تعذّر إنشاء المجموعة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "গ্রুপ তৈরি করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "die gruppe konnte nicht erstellt werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not create the group" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se pudo crear el grupo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ساخت گروه ممکن نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi malikha ang grupo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossible de créer le groupe" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא ניתן ליצור את הקבוצה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह नहीं बनाया जा सका" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak bisa membuat grup" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossibile creare il gruppo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループを作成できませんでした" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹을 생성할 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak dapat mencipta kumpulan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह सिर्जना गर्न सकिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kon de groep niet aanmaken" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie udało się utworzyć grupy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não foi possível criar o grupo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não foi possível criar o grupo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не удалось создать группу" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kunde inte skapa gruppen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குழுவை உருவாக்க முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถสร้างกลุ่มได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grup oluşturulamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не вдалося створити групу" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گروپ نہیں بنایا جا سکا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không thể tạo nhóm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "无法创建群组" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "無法建立群組" - } - } - } - }, - "system.group.created" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "أُنشئت المجموعة '%@' — استخدم /group invite @name لإضافة أشخاص" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' গ্রুপ তৈরি হয়েছে — মানুষ যোগ করতে /group invite @name ব্যবহার করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppe '%@' erstellt — nutze /group invite @name, um leute hinzuzufügen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "created group '%@' — use /group invite @name to add people" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "grupo '%@' creado — usa /group invite @name para añadir personas" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گروه «%@» ساخته شد — با ‎/group invite @name افراد را اضافه کنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nalikha ang grupong '%@' — gamitin ang /group invite @name para magdagdag ng tao" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "groupe '%@' créé — utilise /group invite @name pour ajouter des personnes" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "נוצרה הקבוצה '%@' — השתמש ב-/group invite @name כדי להוסיף אנשים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह '%@' बनाया गया — लोगों को जोड़ने के लिए /group invite @name का उपयोग करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grup '%@' dibuat — gunakan /group invite @name untuk menambah orang" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppo '%@' creato — usa /group invite @name per aggiungere persone" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループ '%@' を作成しました — /group invite @name で人を追加できます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹 '%@' 을(를) 생성했습니다 — /group invite @name 으로 사람을 추가하세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kumpulan '%@' dicipta — guna /group invite @name untuk menambah orang" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह '%@' सिर्जना भयो — मानिस थप्न /group invite @name प्रयोग गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "groep '%@' aangemaakt — gebruik /group invite @name om mensen toe te voegen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "utworzono grupę '%@' — użyj /group invite @name, aby dodać osoby" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupo '%@' criado — usa /group invite @name para adicionar pessoas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupo '%@' criado — use /group invite @nome para adicionar pessoas" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "создана группа «%@» — используй /group invite @name, чтобы добавить людей" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "skapade grupp '%@' — använd /group invite @name för att lägga till personer" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' குழு உருவாக்கப்பட்டது — நபர்களைச் சேர்க்க /group invite @name ஐப் பயன்படுத்தவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "สร้างกลุ่ม '%@' แล้ว — ใช้ /group invite @name เพื่อเพิ่มคน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' grubu oluşturuldu — kişi eklemek için /group invite @name kullanın" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "створено групу '%@' — використай /group invite @name, щоб додати людей" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گروپ '%@' بنایا گیا — لوگوں کو شامل کرنے کیلئے /group invite @name استعمال کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã tạo nhóm '%@' — dùng /group invite @name để thêm người" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已创建群组 '%@' — 使用 /group invite @name 添加成员" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已建立群組「%@」— 使用 /group invite @name 添加成員" - } - } - } - }, - "system.group.creator_only" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "منشئ المجموعة فقط يمكنه فعل ذلك" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "শুধু গ্রুপের নির্মাতা এটি করতে পারেন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nur der gruppenersteller kann das tun" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "only the group creator can do that" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "solo el creador del grupo puede hacer eso" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "فقط سازندهٔ گروه می‌تواند این کار را انجام دهد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tanging ang tagalikha ng grupo ang maaaring gumawa niyan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "seul le créateur du groupe peut faire cela" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "רק יוצר הקבוצה יכול לעשות זאת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "केवल समूह निर्माता ही ऐसा कर सकता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hanya pembuat grup yang bisa melakukan itu" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "solo il creatore del gruppo può farlo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループの作成者のみが実行できます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹 생성자만 할 수 있습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hanya pencipta kumpulan boleh berbuat demikian" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह सिर्जनाकर्ताले मात्र त्यो गर्न सक्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "alleen de maker van de groep kan dat doen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tylko twórca grupy może to zrobić" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "só o criador do grupo pode fazer isso" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "só o criador do grupo pode fazer isso" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "это может сделать только создатель группы" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "endast gruppens skapare kan göra det" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குழுவை உருவாக்கியவர் மட்டுமே அதைச் செய்ய முடியும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เฉพาะผู้สร้างกลุ่มเท่านั้นที่ทำได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bunu yalnızca grubu oluşturan yapabilir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "це може лише створювач групи" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "صرف گروپ بنانے والا یہ کر سکتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chỉ người tạo nhóm mới có thể làm điều đó" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "只有群组创建者才能执行该操作" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "只有群組建立者可以這麼做" - } - } - } - }, - "system.group.full" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "المجموعة ممتلئة (الحد الأقصى %@ عضو)" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "গ্রুপ পূর্ণ (সর্বোচ্চ %@ সদস্য)" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppe ist voll (max. %@ mitglieder)" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "group is full (max %@ members)" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "el grupo está lleno (máx. %@ miembros)" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گروه پر است (حداکثر %@ عضو)" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "puno na ang grupo (max %@ miyembro)" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "le groupe est plein (max %@ membres)" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הקבוצה מלאה (מקסימום %@ חברים)" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह भरा हुआ है (अधिकतम %@ सदस्य)" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grup penuh (maks %@ anggota)" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "il gruppo è pieno (max %@ membri)" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループが満員です(最大 %@ 人)" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹이 가득 찼습니다 (최대 %@명)" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kumpulan penuh (maks %@ ahli)" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह भरिएको छ (बढीमा %@ सदस्य)" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "groep is vol (max %@ leden)" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupa jest pełna (maks. %@ członków)" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "o grupo está cheio (máx. %@ membros)" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "o grupo está cheio (máx. %@ membros)" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "группа заполнена (макс. %@ участников)" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppen är full (max %@ medlemmar)" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குழு நிரம்பிவிட்டது (அதிகபட்சம் %@ உறுப்பினர்கள்)" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "กลุ่มเต็มแล้ว (สูงสุด %@ คน)" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grup dolu (en fazla %@ üye)" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "група заповнена (макс. %@ учасників)" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گروپ بھر گیا ہے (زیادہ سے زیادہ %@ اراکین)" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhóm đã đầy (tối đa %@ thành viên)" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "群组已满(最多 %@ 名成员)" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "群組已滿(最多 %@ 位成員)" - } - } - } - }, - "system.group.identity_unavailable" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "مفاتيح هويتك ليست جاهزة بعد" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনার পরিচয় কী এখনো প্রস্তুত নয়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "deine identitätsschlüssel sind noch nicht bereit" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "your identity keys are not ready yet" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "tus claves de identidad aún no están listas" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کلیدهای هویت شما هنوز آماده نیستند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi pa handa ang iyong mga identity key" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tes clés d'identité ne sont pas encore prêtes" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מפתחות הזהות שלך עדיין לא מוכנים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आपकी पहचान कुंजियाँ अभी तैयार नहीं हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kunci identitasmu belum siap" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "le tue chiavi di identità non sono ancora pronte" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "身元確認用のキーがまだ準備できていません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "신원 키가 아직 준비되지 않았습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kunci identitimu belum sedia" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिम्रा पहिचान कुञ्जीहरू अझै तयार छैनन्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "je identiteitssleutels zijn nog niet klaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "twoje klucze tożsamości nie są jeszcze gotowe" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "as tuas chaves de identidade ainda não estão prontas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "suas chaves de identidade ainda não estão prontas" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "твои ключи личности ещё не готовы" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dina identitetsnycklar är inte redo än" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "உங்கள் அடையாள விசைகள் இன்னும் தயாராகவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "คีย์ยืนยันตัวตนของคุณยังไม่พร้อม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kimlik anahtarların henüz hazır değil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "твої ключі особи ще не готові" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آپ کی شناختی کلیدیں ابھی تیار نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "khóa danh tính của bạn chưa sẵn sàng" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你的身份密钥尚未就绪" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你的身份金鑰尚未就緒" - } - } - } - }, - "system.group.invite_failed" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تعذّر إنشاء دعوة المجموعة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "গ্রুপ আমন্ত্রণ তৈরি করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "die gruppeneinladung konnte nicht erstellt werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not build the group invite" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se pudo generar la invitación al grupo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ساخت دعوت‌نامهٔ گروه ممکن نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi mabuo ang imbitasyon sa grupo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossible de créer l'invitation au groupe" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא ניתן לבנות את הזמנת הקבוצה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह आमंत्रण नहीं बनाया जा सका" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak bisa membuat undangan grup" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossibile creare l'invito al gruppo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループ招待を作成できませんでした" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹 초대를 만들 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak dapat membina jemputan kumpulan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह निमन्त्रणा बनाउन सकिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kon de groepsuitnodiging niet aanmaken" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie udało się utworzyć zaproszenia do grupy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não foi possível criar o convite do grupo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não foi possível montar o convite do grupo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не удалось создать приглашение в группу" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kunde inte skapa gruppinbjudan" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குழு அழைப்பை உருவாக்க முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถสร้างคำเชิญกลุ่มได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grup daveti oluşturulamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не вдалося створити запрошення до групи" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گروپ کی دعوت نہیں بنائی جا سکی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không thể tạo lời mời nhóm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "无法生成群组邀请" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "無法建立群組邀請" - } - } - } - }, - "system.group.invited" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تمت دعوة %1$@ إلى '%2$@'" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@-কে '%2$@'-এ আমন্ত্রণ জানানো হয়েছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ zu '%2$@' eingeladen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "invited %1$@ to '%2$@'" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "invitaste a %1$@ a '%2$@'" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ به «%2$@» دعوت شد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inimbitahan si %1$@ sa '%2$@'" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ invité(e) dans '%2$@'" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ הוזמן ל-'%2$@'" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ को '%2$@' में आमंत्रित किया" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mengundang %1$@ ke '%2$@'" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ invitato in '%2$@'" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ を '%2$@' に招待しました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ 님을 '%2$@' 에 초대했습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "menjemput %1$@ ke '%2$@'" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ लाई '%2$@' मा निमन्त्रणा गरियो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ uitgenodigd voor '%2$@'" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zaproszono %1$@ do '%2$@'" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ convidado para '%2$@'" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ foi convidado para '%2$@'" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ приглашён в «%2$@»" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bjöd in %1$@ till '%2$@'" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ ஐ '%2$@' க்கு அழைத்தது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เชิญ %1$@ เข้า '%2$@' แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ '%2$@' grubuna davet edildi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "запрошено %1$@ до '%2$@'" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$@ کو '%2$@' میں مدعو کیا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã mời %1$@ vào '%2$@'" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已邀请 %1$@ 加入 '%2$@'" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已邀請 %1$@ 加入「%2$@」" - } - } - } - }, - "system.group.joined" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "أضافك %2$@ إلى المجموعة '%1$@' — تظهر الآن في قائمة الأشخاص" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ আপনাকে '%1$@' গ্রুপে যুক্ত করেছেন — এটি এখন আপনার মানুষের তালিকায় দেখা যাবে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du wurdest von %2$@ zur gruppe '%1$@' hinzugefügt — sie erscheint jetzt in deiner personenliste" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$@ te añadió al grupo '%1$@' — ahora aparece en tu lista de personas" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "توسط %2$@ به گروه «%1$@» اضافه شدید — حالا در فهرست افراد شما دیده می‌شود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "idinagdag ka ni %2$@ sa grupong '%1$@' — lalabas na ito sa iyong listahan ng mga tao" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tu as été ajouté(e) au groupe '%1$@' par %2$@ — il apparaît maintenant dans ta liste de personnes" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ הוסיף אותך לקבוצה '%1$@' — היא מופיעה כעת ברשימת האנשים שלך" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आपको %2$@ द्वारा समूह '%1$@' में जोड़ा गया — यह अब आपकी लोगों की सूची में दिखाई देता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kamu ditambahkan ke grup '%1$@' oleh %2$@ — kini muncul di daftar orangmu" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sei stato aggiunto al gruppo '%1$@' da %2$@ — ora appare nella tua lista di persone" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ によってグループ '%1$@' に追加されました — ピープルリストに表示されます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ 님이 그룹 '%1$@' 에 추가했습니다 — 이제 피플 목록에 표시됩니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anda ditambah ke kumpulan '%1$@' oleh %2$@ — kini ia muncul dalam senarai orangmu" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ ले तिमीलाई समूह '%1$@' मा थप्यो — अब यो तिम्रो मानिस सूचीमा देखिन्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "je bent door %2$@ toegevoegd aan groep '%1$@' — die verschijnt nu in je personenlijst" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ dodał cię do grupy '%1$@' — pojawia się teraz na liście osób" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "foste adicionado ao grupo '%1$@' por %2$@ — agora aparece na tua lista de pessoas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "você foi adicionado ao grupo '%1$@' por %2$@ — ele agora aparece na sua lista de pessoas" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ добавил тебя в группу «%1$@» — теперь она в твоём списке людей" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ lade till dig i gruppen '%1$@' — den visas nu i din personlista" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ உங்களை '%1$@' குழுவில் சேர்த்தார் — அது இப்போது உங்கள் நபர்கள் பட்டியலில் தோன்றுகிறது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ เพิ่มคุณเข้ากลุ่ม '%1$@' — ตอนนี้จะปรากฏในรายชื่อคนของคุณ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ seni '%1$@' grubuna ekledi — artık kişi listende görünüyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ додав тебе до групи '%1$@' — вона тепер у твоєму списку людей" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ نے آپ کو گروپ '%1$@' میں شامل کیا — یہ اب آپ کی لوگوں کی فہرست میں ظاہر ہوتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bạn đã được %2$@ thêm vào nhóm '%1$@' — nó giờ xuất hiện trong danh sách người của bạn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ 已将你加入群组 '%1$@' — 现在它会显示在你的成员列表中" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%2$@ 將你加入了群組「%1$@」— 現在會顯示在你的成員列表中" - } - } - } - }, - "system.group.left" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "غادرت المجموعة '%@'" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' গ্রুপ ছেড়ে দিয়েছেন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppe '%@' verlassen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "left group '%@'" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "saliste del grupo '%@'" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "از گروه «%@» خارج شدید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "umalis sa grupong '%@'" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "groupe '%@' quitté" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "עזבת את הקבוצה '%@'" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह '%@' छोड़ दिया" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "keluar dari grup '%@'" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uscito dal gruppo '%@'" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループ '%@' から退出しました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹 '%@' 에서 나갔습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "keluar dari kumpulan '%@'" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह '%@' छोडियो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "groep '%@' verlaten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "opuszczono grupę '%@'" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "saíste do grupo '%@'" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "você saiu do grupo '%@'" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ты покинул группу «%@»" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "lämnade gruppen '%@'" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' குழுவிலிருந்து வெளியேறியது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ออกจากกลุ่ม '%@' แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' grubundan ayrıldın" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "залишено групу '%@'" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گروپ '%@' چھوڑ دیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã rời nhóm '%@'" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已退出群组 '%@'" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已離開群組「%@」" - } - } - } - }, - "system.group.list_header" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "مجموعاتك:" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনার গ্রুপসমূহ:" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "deine gruppen:" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "your groups:" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "tus grupos:" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گروه‌های شما:" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mga grupo mo:" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tes groupes :" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הקבוצות שלך:" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आपके समूह:" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupmu:" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i tuoi gruppi:" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "あなたのグループ:" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "내 그룹:" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kumpulanmu:" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिम्रा समूहहरू:" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "je groepen:" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "twoje grupy:" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "os teus grupos:" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "seus grupos:" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "твои группы:" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dina grupper:" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "உங்கள் குழுக்கள்:" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "กลุ่มของคุณ:" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grupların:" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "твої групи:" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آپ کے گروپس:" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhóm của bạn:" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你的群组:" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你的群組:" - } - } - } - }, - "system.group.member_not_found" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' ليس عضوًا في هذه المجموعة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' এই গ্রুপের সদস্য নন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' ist kein mitglied dieser gruppe" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' is not a member of this group" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' no es miembro de este grupo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "«%@» عضو این گروه نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi miyembro ng grupong ito si '%@'" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' n'est pas membre de ce groupe" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' אינו חבר בקבוצה הזו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' इस समूह का सदस्य नहीं है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' bukan anggota grup ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' non è un membro di questo gruppo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' はこのグループのメンバーではありません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' 님은 이 그룹의 멤버가 아닙니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' bukan ahli kumpulan ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' यो समूहको सदस्य होइन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' is geen lid van deze groep" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' nie jest członkiem tej grupy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' não é membro deste grupo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' não é membro deste grupo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "«%@» не является участником этой группы" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' är inte medlem i den här gruppen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' இந்தக் குழுவின் உறுப்பினர் அல்ல" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' ไม่ใช่สมาชิกของกลุ่มนี้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' bu grubun üyesi değil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' не учасник цієї групи" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' اس گروپ کا رکن نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' không phải là thành viên của nhóm này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' 不是此群组的成员" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "「%@」不是此群組的成員" - } - } - } - }, - "system.group.name_too_long" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "أسماء المجموعات محدودة بـ 40 حرفًا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "গ্রুপের নাম ৪০ অক্ষরে সীমাবদ্ধ" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppennamen sind auf 40 zeichen begrenzt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "group names are limited to 40 characters" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "los nombres de grupo están limitados a 40 caracteres" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نام گروه حداکثر ۴۰ نویسه است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hanggang 40 character lamang ang pangalan ng grupo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "les noms de groupe sont limités à 40 caractères" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שמות קבוצות מוגבלים ל-40 תווים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह के नाम 40 अक्षरों तक सीमित हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nama grup dibatasi 40 karakter" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i nomi dei gruppi sono limitati a 40 caratteri" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループ名は40文字までです" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹 이름은 40자로 제한됩니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nama kumpulan terhad kepada 40 aksara" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह नाम ४० अक्षरमा सीमित छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "groepsnamen zijn beperkt tot 40 tekens" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nazwy grup są ograniczone do 40 znaków" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "os nomes de grupo estão limitados a 40 caracteres" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nomes de grupo são limitados a 40 caracteres" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "имена групп ограничены 40 символами" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gruppnamn är begränsade till 40 tecken" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குழுப் பெயர்கள் 40 எழுத்துகளுக்கு வரம்பிடப்பட்டுள்ளன" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ชื่อกลุ่มจำกัดไม่เกิน 40 ตัวอักษร" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grup adları en fazla 40 karakter olabilir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "назви груп обмежені 40 символами" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گروپ کے نام 40 حروف تک محدود ہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tên nhóm giới hạn 40 ký tự" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "群组名最多 40 个字符" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "群組名稱最多 40 個字元" - } - } - } - }, - "system.group.none" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لست في أي مجموعة — /group create لبدء واحدة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি কোনো গ্রুপে নেই — একটি শুরু করতে /group create " - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du bist in keiner gruppe — /group create , um eine zu starten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you are not in any groups — /group create to start one" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no estás en ningún grupo — /group create para crear uno" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در هیچ گروهی نیستید — با ‎/group create یکی بسازید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wala ka sa anumang grupo — /group create para magsimula" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tu n'es dans aucun groupe — /group create pour en démarrer un" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אתה לא באף קבוצה — /group create כדי להתחיל אחת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आप किसी समूह में नहीं हैं — शुरू करने के लिए /group create " - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kamu tidak ada di grup mana pun — /group create untuk memulai" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "non sei in nessun gruppo — /group create per crearne uno" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "どのグループにも参加していません — /group create で作成できます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "참여 중인 그룹이 없습니다 — /group create 으로 시작하세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anda tiada dalam mana-mana kumpulan — /group create untuk memulakan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिमी कुनै समूहमा छैनौ — सुरु गर्न /group create " - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "je zit in geen enkele groep — /group create om er een te starten" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie należysz do żadnej grupy — /group create , aby utworzyć" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não estás em nenhum grupo — /group create para começar um" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "você não está em nenhum grupo — /group create para começar um" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ты не состоишь ни в одной группе — /group create , чтобы создать" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du är inte med i någon grupp — /group create för att skapa en" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "நீங்கள் எந்தக் குழுவிலும் இல்லை — ஒன்றைத் தொடங்க /group create " - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "คุณยังไม่ได้อยู่ในกลุ่มใด — /group create เพื่อเริ่มกลุ่ม" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hiçbir grupta değilsin — bir grup oluşturmak için /group create " - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ти не в жодній групі — /group create , щоб створити" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آپ کسی گروپ میں نہیں ہیں — ایک شروع کرنے کیلئے /group create " - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bạn không ở trong nhóm nào — /group create để tạo một nhóm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你不在任何群组中 — 使用 /group create 创建一个" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你不在任何群組中 — 使用 /group create 建立一個" - } - } - } - }, - "system.group.not_in_group" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "افتح دردشة جماعية أولاً" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "প্রথমে একটি গ্রুপ চ্যাট খুলুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öffne zuerst einen gruppenchat" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "open a group chat first" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre primero un chat de grupo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ابتدا یک گفتگوی گروهی باز کنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "magbukas muna ng group chat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ouvre d'abord une discussion de groupe" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "פתח קודם צ'אט קבוצתי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पहले कोई समूह चैट खोलें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "buka obrolan grup dulu" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "apri prima una chat di gruppo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "先にグループチャットを開いてください" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "먼저 그룹 채팅을 여세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "buka sembang kumpulan dahulu" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "पहिले समूह च्याट खोल" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "open eerst een groepschat" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "najpierw otwórz czat grupowy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abre primeiro uma conversa de grupo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "abra um chat de grupo primeiro" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сначала открой групповой чат" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "öppna en gruppchatt först" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "முதலில் ஒரு குழு உரையாடலைத் திறக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เปิดแชทกลุ่มก่อน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "önce bir grup sohbeti aç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "спочатку відкрий груповий чат" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پہلے کوئی گروپ چیٹ کھولیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mở một cuộc trò chuyện nhóm trước" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "请先打开一个群聊" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "請先打開一個群組聊天" - } - } - } - }, - "system.group.peer_identity_unknown" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لا يمكن التحقق من هوية %@ بعد — انتظر إعلانه" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@-এর পরিচয় এখনো যাচাই করা যাচ্ছে না — তাদের অ্যানাউন্সের জন্য অপেক্ষা করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@s identität kann noch nicht verifiziert werden — warte auf deren announce" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot verify %@'s identity yet — wait for their announce" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "aún no se puede verificar la identidad de %@ — espera su anuncio" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هویت %@ هنوز قابل تأیید نیست — منتظر اعلام او بمانید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi pa ma-beripika ang pagkakakilanlan ni %@ — hintayin ang kanilang announce" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossible de vérifier l'identité de %@ pour l'instant — attends son announce" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא ניתן לאמת את הזהות של %@ עדיין — המתן להכרזה שלו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ की पहचान अभी सत्यापित नहीं की जा सकती — उनके अनाउंस की प्रतीक्षा करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "belum bisa memverifikasi identitas %@ — tunggu announce mereka" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossibile verificare ancora l'identità di %@ — attendi il suo announce" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ の身元をまだ確認できません — announceを待ってください" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 님의 신원을 아직 확인할 수 없습니다 — announce를 기다리세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "belum boleh mengesahkan identiti %@ — tunggu announce mereka" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ को पहिचान अझै प्रमाणित गर्न सकिँदैन — उनको घोषणा पर्ख" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kan de identiteit van %@ nog niet verifiëren — wacht op hun announce" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie można jeszcze zweryfikować tożsamości %@ — poczekaj na ich ogłoszenie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ainda não é possível verificar a identidade de %@ — aguarda o announce dele" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ainda não é possível verificar a identidade de %@ — aguarde o anúncio dessa pessoa" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "пока нельзя проверить личность %@ — дождись его анонса" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kan inte verifiera %@:s identitet än — vänta på deras announce" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ இன் அடையாளத்தை இன்னும் சரிபார்க்க முடியவில்லை — அவர்களின் அறிவிப்புக்காகக் காத்திருக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ยังไม่สามารถยืนยันตัวตนของ %@ — รอ announce ของพวกเขา" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ kimliği henüz doğrulanamıyor — duyurusunu bekle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "поки не можна підтвердити особу %@ — почекай на їхнє оголошення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ کی شناخت ابھی تصدیق نہیں ہو سکتی — ان کے اعلان کا انتظار کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chưa thể xác minh danh tính của %@ — chờ announce của họ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "暂时无法验证 %@ 的身份 — 请等待对方的广播" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "尚無法驗證 %@ 的身份 — 請等待對方的宣告" - } - } - } - }, - "system.group.peer_not_connected" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "يجب أن يكون %@ متصلاً عبر mesh لدعوته" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আমন্ত্রণ জানাতে %@-কে মেশে সংযুক্ত থাকতে হবে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ muss über mesh verbunden sein, um eingeladen zu werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ must be connected over mesh to be invited" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ debe estar conectado por mesh para ser invitado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ باید از طریق مش متصل باشد تا دعوت شود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kailangang nakakonekta sa mesh si %@ para maimbitahan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ doit être connecté en mesh pour être invité" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ חייב להיות מחובר דרך mesh כדי להזמינו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ को आमंत्रित करने के लिए मेश पर जुड़ा होना ज़रूरी है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ harus terhubung lewat mesh untuk diundang" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ deve essere connesso via mesh per essere invitato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ は招待するためにmeshで接続している必要があります" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 님을 초대하려면 mesh로 연결되어 있어야 합니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ mesti bersambung melalui mesh untuk dijemput" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निमन्त्रणा गर्न %@ mesh मार्फत जडान भएको हुनुपर्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ moet via mesh verbonden zijn om uitgenodigd te worden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ musi być połączony przez mesh, aby otrzymać zaproszenie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ tem de estar ligado por mesh para ser convidado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ precisa estar conectado pelo mesh para ser convidado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ должен быть подключён по mesh, чтобы получить приглашение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ måste vara ansluten via mesh för att bjudas in" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ அழைக்கப்படுவதற்கு mesh மூலம் இணைக்கப்பட்டிருக்க வேண்டும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ ต้องเชื่อมต่อผ่าน mesh จึงจะเชิญได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "davet edilebilmesi için %@ mesh üzerinden bağlı olmalı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ має бути з'єднаний через mesh, щоб отримати запрошення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "دعوت دینے کیلئے %@ کا mesh پر جڑا ہونا ضروری ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ phải được kết nối qua mesh để được mời" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 必须通过 mesh 连接才能被邀请" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 必須透過 mesh 連線才能被邀請" - } - } - } - }, - "system.group.peer_not_found" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' غير موجود" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' পাওয়া যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' nicht gefunden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' not found" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' no encontrado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "«%@» پیدا نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi nahanap si '%@'" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' introuvable" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' לא נמצא" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' नहीं मिला" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' tidak ditemukan" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' non trovato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' が見つかりません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' 을(를) 찾을 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' tidak dijumpai" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' भेटिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' niet gevonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie znaleziono '%@'" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' não encontrado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' não encontrado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "«%@» не найден" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' hittades inte" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' கண்டறியப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่พบ '%@'" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' bulunamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' не знайдено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' نہیں ملا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không tìm thấy '%@'" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "未找到 '%@'" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "找不到「%@」" - } - } - } - }, - "system.group.removed_from" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تمت إزالتك من المجموعة '%@'" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনাকে '%@' গ্রুপ থেকে সরানো হয়েছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du wurdest aus der gruppe '%@' entfernt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you were removed from group '%@'" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "fuiste eliminado del grupo '%@'" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "از گروه «%@» حذف شدید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inalis ka sa grupong '%@'" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tu as été retiré(e) du groupe '%@'" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הוסרת מהקבוצה '%@'" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आपको समूह '%@' से हटा दिया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kamu dikeluarkan dari grup '%@'" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sei stato rimosso dal gruppo '%@'" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループ '%@' から削除されました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹 '%@' 에서 제거되었습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anda dikeluarkan dari kumpulan '%@'" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिमीलाई समूह '%@' बाट हटाइयो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "je bent verwijderd uit groep '%@'" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "usunięto cię z grupy '%@'" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "foste removido do grupo '%@'" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "você foi removido do grupo '%@'" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "тебя удалили из группы «%@»" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du togs bort från gruppen '%@'" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' குழுவிலிருந்து நீங்கள் நீக்கப்பட்டீர்கள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "คุณถูกนำออกจากกลุ่ม '%@'" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "'%@' grubundan çıkarıldın" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "тебе видалено з групи '%@'" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آپ کو گروپ '%@' سے ہٹا دیا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bạn đã bị xóa khỏi nhóm '%@'" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你已被移出群组 '%@'" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你已被移出群組「%@」" - } - } - } - }, - "system.group.removed_member" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تمت إزالة %@ وتدوير مفتاح المجموعة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@-কে সরানো হয়েছে এবং গ্রুপ কী রোটেট করা হয়েছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ entfernt und den gruppenschlüssel rotiert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "removed %@ and rotated the group key" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "se eliminó a %@ y se rotó la clave del grupo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ حذف شد و کلید گروه تغییر کرد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inalis si %@ at ni-rotate ang group key" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ retiré(e) et clé du groupe renouvelée" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ הוסר ומפתח הקבוצה סובב" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ को हटाया और समूह कुंजी घुमाई" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "menghapus %@ dan memutar ulang kunci grup" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ rimosso e chiave del gruppo ruotata" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ を削除しグループキーをローテーションしました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 님을 제거하고 그룹 키를 교체했습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuang %@ dan memutar kunci kumpulan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ लाई हटाइयो र समूह कुञ्जी घुमाइयो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ verwijderd en de groepssleutel geroteerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "usunięto %@ i wymieniono klucz grupy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ removido e chave do grupo rodada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ foi removido e a chave do grupo foi rotacionada" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ удалён, ключ группы обновлён" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tog bort %@ och roterade gruppnyckeln" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ ஐ நீக்கி குழு விசையைச் சுழற்றியது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "นำ %@ ออกและเปลี่ยนคีย์กลุ่มแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ çıkarıldı ve grup anahtarı döndürüldü" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "видалено %@ і замінено ключ групи" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ کو ہٹا دیا اور گروپ کی کلید تبدیل کر دی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã xóa %@ và xoay khóa nhóm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已移除 %@ 并轮换了群组密钥" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已移除 %@ 並輪換了群組金鑰" - } - } - } - }, - "system.group.rotate_failed" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تعذّر تدوير مفتاح المجموعة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "গ্রুপ কী রোটেট করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "der gruppenschlüssel konnte nicht rotiert werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not rotate the group key" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se pudo rotar la clave del grupo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تغییر کلید گروه ممکن نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi ma-rotate ang group key" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossible de renouveler la clé du groupe" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא ניתן לסובב את מפתח הקבוצה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह कुंजी नहीं घुमाई जा सकी" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak bisa memutar ulang kunci grup" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossibile ruotare la chiave del gruppo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "グループキーをローテーションできませんでした" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "그룹 키를 교체할 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak dapat memutar kunci kumpulan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "समूह कुञ्जी घुमाउन सकिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kon de groepssleutel niet roteren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie udało się wymienić klucza grupy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não foi possível rodar a chave do grupo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não foi possível rotacionar a chave do grupo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не удалось обновить ключ группы" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kunde inte rotera gruppnyckeln" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குழு விசையைச் சுழற்ற முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถเปลี่ยนคีย์กลุ่มได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "grup anahtarı döndürülemedi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не вдалося замінити ключ групи" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گروپ کی کلید تبدیل نہیں کی جا سکی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không thể xoay khóa nhóm" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "无法轮换群组密钥" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "無法輪換群組金鑰" - } - } - } - }, - "system.group.send_failed" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تعذّر تشفير الرسالة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "বার্তা এনক্রিপ্ট করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "die nachricht konnte nicht verschlüsselt werden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not encrypt the message" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se pudo cifrar el mensaje" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمزنگاری پیام ممکن نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi ma-encrypt ang mensahe" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossible de chiffrer le message" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא ניתן להצפין את ההודעה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "संदेश एन्क्रिप्ट नहीं किया जा सका" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak bisa mengenkripsi pesan" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossibile cifrare il messaggio" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "メッセージを暗号化できませんでした" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "메시지를 암호화할 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak dapat mengenkripsi pesan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "सन्देश सङ्केत गर्न सकिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kon het bericht niet versleutelen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie udało się zaszyfrować wiadomości" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não foi possível encriptar a mensagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não foi possível criptografar a mensagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не удалось зашифровать сообщение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kunde inte kryptera meddelandet" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "செய்தியைக் குறியாக்கம் செய்ய முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถเข้ารหัสข้อความได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesaj şifrelenemedi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не вдалося зашифрувати повідомлення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پیغام کو خفیہ نہیں کیا جا سکا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không thể mã hóa tin nhắn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "无法加密该消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "無法加密訊息" - } - } - } - }, - "system.group.unknown" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لم تعد في هذه المجموعة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি আর এই গ্রুপে নেই" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du bist nicht mehr in dieser gruppe" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you are no longer in this group" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "ya no estás en este grupo" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "دیگر در این گروه نیستید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wala ka na sa grupong ito" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tu n'es plus dans ce groupe" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אתה כבר לא בקבוצה הזו" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "आप अब इस समूह में नहीं हैं" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kamu tidak lagi ada di grup ini" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "non sei più in questo gruppo" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "このグループには参加していません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "더 이상 이 그룹에 속해 있지 않습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "anda tidak lagi dalam kumpulan ini" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तिमी अब यो समूहमा छैनौ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "je zit niet meer in deze groep" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie należysz już do tej grupy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "já não estás neste grupo" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "você não está mais neste grupo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ты больше не в этой группе" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "du är inte längre med i den här gruppen" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "நீங்கள் இனி இந்தக் குழுவில் இல்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "คุณไม่ได้อยู่ในกลุ่มนี้แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "artık bu grupta değilsin" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ти більше не в цій групі" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "آپ اب اس گروپ میں نہیں ہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bạn không còn ở trong nhóm này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你已不在此群组中" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "你已不在此群組中" - } - } - } - }, - "system.group.usage_create" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الاستخدام: /group create " - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্যবহার: /group create " - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verwendung: /group create " - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /group create " - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /group create " - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "استفاده: ‎/group create " - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "paggamit: /group create " - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "utilisation : /group create " - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שימוש: /group create " - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "उपयोग: /group create " - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "penggunaan: /group create " - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uso: /group create " - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "使い方: /group create " - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "사용법: /group create " - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "penggunaan: /group create " - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "प्रयोग: /group create " - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gebruik: /group create " - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "użycie: /group create " - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "utilização: /group create " - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uso: /group create " - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "использование: /group create " - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "användning: /group create " - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "பயன்பாடு: /group create " - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "วิธีใช้: /group create " - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kullanım: /group create " - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "використання: /group create " - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "استعمال: /group create " - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cách dùng: /group create " - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "用法:/group create " - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "用法:/group create " - } - } - } - }, - "system.group.usage_invite" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الاستخدام: /group invite @name" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্যবহার: /group invite @name" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verwendung: /group invite @name" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /group invite @name" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /group invite @name" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "استفاده: ‎/group invite @name" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "paggamit: /group invite @name" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "utilisation : /group invite @name" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שימוש: /group invite @name" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "उपयोग: /group invite @name" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "penggunaan: /group invite @name" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uso: /group invite @name" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "使い方: /group invite @name" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "사용법: /group invite @name" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "penggunaan: /group invite @name" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "प्रयोग: /group invite @name" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gebruik: /group invite @name" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "użycie: /group invite @name" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "utilização: /group invite @name" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uso: /group invite @nome" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "использование: /group invite @name" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "användning: /group invite @name" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "பயன்பாடு: /group invite @name" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "วิธีใช้: /group invite @name" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kullanım: /group invite @name" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "використання: /group invite @name" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "استعمال: /group invite @name" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cách dùng: /group invite @name" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "用法:/group invite @name" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "用法:/group invite @name" - } - } - } - }, - "system.group.usage_remove" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الاستخدام: /group remove @name" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্যবহার: /group remove @name" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "verwendung: /group remove @name" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /group remove @name" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "uso: /group remove @name" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "استفاده: ‎/group remove @name" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "paggamit: /group remove @name" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "utilisation : /group remove @name" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שימוש: /group remove @name" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "उपयोग: /group remove @name" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "penggunaan: /group remove @name" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uso: /group remove @name" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "使い方: /group remove @name" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "사용법: /group remove @name" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "penggunaan: /group remove @name" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "प्रयोग: /group remove @name" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gebruik: /group remove @name" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "użycie: /group remove @name" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "utilização: /group remove @name" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uso: /group remove @nome" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "использование: /group remove @name" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "användning: /group remove @name" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "பயன்பாடு: /group remove @name" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "วิธีใช้: /group remove @name" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kullanım: /group remove @name" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "використання: /group remove @name" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "استعمال: /group remove @name" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cách dùng: /group remove @name" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "用法:/group remove @name" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "用法:/group remove @name" - } - } - } - }, "system.location.not_in_channel" : { "extractionState" : "manual", "localizations" : { @@ -90420,12 +32692,6 @@ "value" : "no se puede enviar: no estás en un canal de ubicación" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال ممکن نیست: در کانال موقعیت نیستید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -90605,12 +32871,6 @@ "value" : "no se pudo enviar al canal de ubicación" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ارسال به کانال موقعیت ناموفق بود" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -90757,936 +33017,6 @@ } } }, - "system.mesh.block_failed" : { - "comment" : "System message shown when a mesh peer cannot be blocked", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تعذّر حظر %@: غير موجود أو تعذّر التحقق من الهوية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@-কে ব্লক করা যায় না: পাওয়া যায়নি বা পরিচয় যাচাই করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ kann nicht blockiert werden: nicht gefunden oder identität nicht verifizierbar" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot block %@: not found or unable to verify identity" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede bloquear a %@: no encontrado o no se pudo verificar la identidad" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "مسدود کردن %@ ممکن نیست: پیدا نشد یا هویتش قابل تأیید نیست" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi ma-block si %@: hindi nahanap o hindi ma-beripika ang pagkakakilanlan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossible de bloquer %@ : introuvable ou identité invérifiable" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא ניתן לחסום את %@: לא נמצא או שלא ניתן לאמת זהות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ को ब्लॉक नहीं कर सकते: नहीं मिला या पहचान सत्यापित नहीं हो सकी" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak bisa memblokir %@: tidak ditemukan atau tidak bisa memverifikasi identitas" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossibile bloccare %@: non trovato o identità non verificabile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ をブロックできません: 見つからないか身元を確認できません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 님을 차단할 수 없습니다: 찾을 수 없거나 신원을 확인할 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak dapat memblokir %@: tidak dijumpai atau tidak dapat mengesahkan identiti" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ लाई ब्लक गर्न सकिएन: भेटिएन वा पहिचान प्रमाणित गर्न सकिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kan %@ niet blokkeren: niet gevonden of identiteit niet te verifiëren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie można zablokować %@: nie znaleziono lub nie można zweryfikować tożsamości" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não é possível bloquear %@: não encontrado ou identidade não verificável" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não é possível bloquear %@: não encontrado ou identidade não verificável" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не удалось заблокировать %@: не найден или нельзя проверить личность" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kan inte blockera %@: hittades inte eller kan inte verifiera identitet" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ ஐத் தடுக்க முடியாது: கண்டறியப்படவில்லை அல்லது அடையாளத்தைச் சரிபார்க்க முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถบล็อก %@: ไม่พบหรือไม่สามารถยืนยันตัวตน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ engellenemiyor: bulunamadı veya kimlik doğrulanamıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не можна заблокувати %@: не знайдено або не вдалося підтвердити особу" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ کو بلاک نہیں کیا جا سکتا: نہیں ملا یا شناخت کی تصدیق ممکن نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không thể chặn %@: không tìm thấy hoặc không thể xác minh danh tính" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "无法屏蔽 %@:未找到或无法验证身份" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "無法屏蔽 %@:未找到或無法驗證身份" - } - } - } - }, - "system.mesh.blocked" : { - "comment" : "System message shown when a mesh peer is blocked", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تم حظر %@. لن تتلقى رسائل منه بعد الآن" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@-কে ব্লক করা হয়েছে। আপনি আর তাদের বার্তা পাবেন না" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ blockiert. du erhältst keine nachrichten mehr von ihnen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked %@. you will no longer receive messages from them" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "se bloqueó a %@. ya no recibirás mensajes suyos" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ مسدود شد. دیگر پیامی از او دریافت نخواهید کرد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "na-block si %@. hindi ka na makakatanggap ng mensahe mula sa kanila" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ bloqué. tu ne recevras plus ses messages" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ נחסם. לא תקבל ממנו יותר הודעות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ को ब्लॉक किया। अब आपको उनसे संदेश नहीं मिलेंगे" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "memblokir %@. kamu tidak akan menerima pesan dari mereka lagi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ bloccato. non riceverai più messaggi da questa persona" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ をブロックしました。今後この相手からのメッセージは受信しません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 님을 차단했습니다. 이제 이 사용자의 메시지를 받지 않습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "memblokir %@. anda tidak akan menerima pesan daripada mereka lagi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ लाई ब्लक गरियो। अब उनीबाट सन्देश पाउने छैनौ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ geblokkeerd. je ontvangt geen berichten meer van deze persoon" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "zablokowano %@. nie będziesz już otrzymywać od nich wiadomości" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ bloqueado. deixarás de receber mensagens desta pessoa" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ foi bloqueado. você não vai mais receber mensagens dessa pessoa" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ заблокирован. ты больше не будешь получать от него сообщения" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "blockerade %@. du får inte längre meddelanden från dem" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ தடுக்கப்பட்டது. அவர்களிடமிருந்து இனி செய்திகளைப் பெறமாட்டீர்கள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "บล็อก %@ แล้ว คุณจะไม่ได้รับข้อความจากพวกเขาอีก" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ engellendi. artık ondan mesaj almayacaksın" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "заблоковано %@. ти більше не отримуватимеш від них повідомлень" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ کو بلاک کر دیا۔ اب آپ کو ان سے پیغامات نہیں ملیں گے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã chặn %@. bạn sẽ không còn nhận tin nhắn từ họ nữa" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已屏蔽 %@。你将不再收到对方的消息" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已屏蔽 %@。你將不再收到對方的訊息" - } - } - } - }, - "system.mesh.unblock_failed" : { - "comment" : "System message shown when a mesh peer cannot be unblocked", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تعذّر إلغاء حظر %@: غير موجود" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@-কে আনব্লক করা যায় না: পাওয়া যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ kann nicht entsperrt werden: nicht gefunden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot unblock %@: not found" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se puede desbloquear a %@: no encontrado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رفع مسدودی %@ ممکن نیست: پیدا نشد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi ma-unblock si %@: hindi nahanap" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossible de débloquer %@ : introuvable" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא ניתן לבטל חסימה של %@: לא נמצא" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ को अनब्लॉक नहीं कर सकते: नहीं मिला" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak bisa membuka blokir %@: tidak ditemukan" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "impossibile sbloccare %@: non trovato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ のブロックを解除できません: 見つかりません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 님의 차단을 해제할 수 없습니다: 찾을 수 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak dapat membuka blokir %@: tidak dijumpai" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ लाई अनब्लक गर्न सकिएन: भेटिएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kan %@ niet deblokkeren: niet gevonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie można odblokować %@: nie znaleziono" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não é possível desbloquear %@: não encontrado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não é possível desbloquear %@: não encontrado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не удалось разблокировать %@: не найден" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kan inte avblockera %@: hittades inte" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ இன் தடையை நீக்க முடியாது: கண்டறியப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถเลิกบล็อก %@: ไม่พบ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ engeli kaldırılamıyor: bulunamadı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не можна розблокувати %@: не знайдено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ کو ان بلاک نہیں کیا جا سکتا: نہیں ملا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không thể bỏ chặn %@: không tìm thấy" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "无法取消屏蔽 %@:未找到" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "無法取消屏蔽 %@:未找到" - } - } - } - }, - "system.mesh.unblocked" : { - "comment" : "System message shown when a mesh peer is unblocked", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "أُلغي حظر %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@-কে আনব্লক করা হয়েছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ entsperrt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "unblocked %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "se desbloqueó a %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رفع مسدودی %@" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "in-unblock si %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ débloqué" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "החסימה של %@ בוטלה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ को अनब्लॉक किया" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka blokir %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ sbloccato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ のブロックを解除しました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 님의 차단을 해제했습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "membuka blokir %@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ लाई अनब्लक गरियो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ gedeblokkeerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "odblokowano %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ desbloqueado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ foi desbloqueado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ разблокирован" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "avblockerade %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ இன் தடை நீக்கப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เลิกบล็อก %@ แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ engeli kaldırıldı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "розблоковано %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ کو ان بلاک کر دیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "đã bỏ chặn %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已取消屏蔽 %@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "已取消屏蔽 %@" - } - } - } - }, - "system.tor.blocked" : { - "comment" : "System message shown when Tor bootstrap runs out its deadline without connecting, which is what a network that blocks Tor looks like", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لم يتمكن tor من الاتصال — قد تكون هذه الشبكة تحجبه. المراسلة عبر mesh لا تزال تعمل؛ أما قنوات الموقع والتسليم عبر الإنترنت فمتوقفة حتى ينجح tor." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor সংযোগ করতে পারল না — এই নেটওয়ার্ক হয়তো এটি আটকাচ্ছে। মেশে বার্তা পাঠানো এখনও কাজ করে; Tor চালু না হওয়া পর্যন্ত লোকেশন চ্যানেল ও ইন্টারনেটে পাঠানো থেমে থাকবে।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor konnte sich nicht verbinden — dieses netzwerk blockiert es möglicherweise. mesh-nachrichten funktionieren weiter; standortkanäle und zustellung über das internet pausieren, bis tor durchkommt." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor could not connect — this network may be blocking it. mesh messaging still works; location channels and internet delivery are paused until tor gets through." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor no pudo conectarse — puede que esta red lo esté bloqueando. la mensajería por mesh sigue funcionando; los canales de ubicación y la entrega por internet quedan en pausa hasta que Tor pase." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor نتوانست وصل شود — شاید این شبکه آن را مسدود کرده است. پیام‌رسانی مش همچنان کار می‌کند؛ کانال‌های موقعیت و تحویل از طریق اینترنت تا عبور Tor متوقف است." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "hindi makakonekta ang tor — maaaring hinaharangan ito ng network na ito. gumagana pa rin ang pagmemensahe sa mesh; nakahinto ang mga channel ng lokasyon at ang paghatid sa internet hanggang makalusot ang tor." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor n'a pas pu se connecter — ce réseau le bloque peut-être. la messagerie mesh fonctionne toujours ; les canaux de localisation et la livraison par internet sont en pause jusqu'à ce que tor passe." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor לא הצליח להתחבר — ייתכן שהרשת הזאת חוסמת אותו. הודעות ב-mesh עדיין עובדות; ערוצי מיקום ומשלוח דרך האינטרנט מושהים עד ש-tor יעבור." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor कनेक्ट नहीं हो सका — यह नेटवर्क इसे रोक रहा हो सकता है। मेश पर संदेश भेजना अभी भी काम करता है; Tor जुड़ने तक लोकेशन चैनल और इंटरनेट से डिलीवरी रुकी रहेगी।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor tidak bisa terhubung — jaringan ini mungkin memblokirnya. perpesanan mesh tetap jalan; kanal lokasi dan pengiriman lewat internet dijeda sampai tor tembus." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor non è riuscito a connettersi — questa rete potrebbe bloccarlo. i messaggi sulla mesh funzionano ancora; canali posizione e consegna via internet restano in pausa finché tor non passa." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "torが接続できませんでした — このネットワークがブロックしている可能性があります。meshでのやり取りは使えます。ロケーションチャンネルとインターネット経由の配信は、torがつながるまで停止します。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor가 연결되지 않았습니다 — 이 네트워크가 차단하고 있을 수 있습니다. mesh 메시지는 계속 작동하며, 위치 채널과 인터넷 전달은 tor가 연결될 때까지 멈춥니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor tidak dapat menyambung — rangkaian ini mungkin menghalangnya. pemesejan mesh masih berfungsi; kanal lokasi dan penghantaran melalui internet dijeda sehingga tor berjaya." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor जडान हुन सकेन — यो नेटवर्कले रोकेको हुन सक्छ। mesh मा सन्देश पठाउने काम अझै चल्छ; tor नजोडिँदासम्म स्थान च्यानल र इन्टरनेटबाट पठाउने काम रोकिन्छ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor kon geen verbinding maken — dit netwerk blokkeert het misschien. berichten via de mesh werken nog; locatiekanalen en bezorging via internet staan stil tot tor erdoor komt." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor nie mógł się połączyć — ta sieć może go blokować. wiadomości w mesh nadal działają; kanały lokalizacji i dostarczanie przez internet są wstrzymane, dopóki tor nie przejdzie." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "o Tor não conseguiu ligar-se — esta rede pode estar a bloqueá-lo. as mensagens pela mesh continuam a funcionar; os canais de localização e a entrega pela internet ficam em pausa até o Tor passar." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "o tor não conseguiu conectar — esta rede pode estar bloqueando. as mensagens pelo mesh continuam funcionando; os canais de localização e a entrega pela internet ficam pausados até o tor passar." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor не смог подключиться — возможно, эта сеть его блокирует. сообщения по mesh работают; каналы локации и доставка через интернет приостановлены, пока tor не пробьётся." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor kunde inte ansluta — nätverket kan blockera det. meddelanden över mesh fungerar fortfarande; platskanaler och leverans över internet pausas tills tor kommer igenom." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor இணைய முடியவில்லை — இந்த நெட்வொர்க் அதைத் தடுக்கலாம். mesh வழி செய்திகள் இன்னும் வேலை செய்கின்றன; tor இணையும் வரை இட சேனல்களும் இணையம் வழி வழங்கலும் நிறுத்தப்படும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor เชื่อมต่อไม่ได้ — เครือข่ายนี้อาจปิดกั้นอยู่ การส่งข้อความผ่าน mesh ยังใช้ได้ ช่องตามตำแหน่งและการส่งผ่านอินเทอร์เน็ตจะหยุดไว้จนกว่า tor จะเชื่อมต่อได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor bağlanamadı — bu ağ onu engelliyor olabilir. mesh üzerinden mesajlaşma çalışmaya devam ediyor; konum kanalları ve internet üzerinden iletim, Tor geçene kadar duraklatıldı." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor не зміг підключитися — можливо, ця мережа його блокує. повідомлення через mesh працюють; канали локації та доставка через інтернет на паузі, доки tor не проб'ється." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor جڑ نہیں سکا — یہ نیٹ ورک اسے روک رہا ہو سکتا ہے۔ mesh پر پیغام رسانی اب بھی کام کرتی ہے؛ tor کے جڑنے تک لوکیشن چینلز اور انٹرنیٹ سے ڈیلیوری رکی رہے گی۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor không kết nối được — mạng này có thể đang chặn. tin nhắn qua mesh vẫn hoạt động; kênh vị trí và việc gửi qua internet tạm dừng đến khi tor kết nối được." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor 无法连接 — 此网络可能正在封锁它。mesh 消息仍可使用;位置频道和经互联网的投递会暂停,直到 tor 连上。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "tor 無法連線 — 此網路可能正在封鎖它。mesh 訊息仍可使用;位置頻道和經網際網路的投遞會暫停,直到 tor 連上。" - } - } - } - }, "system.tor.dev_bypass" : { "extractionState" : "manual", "localizations" : { @@ -91720,12 +33050,6 @@ "value" : "compilación de desarrollo: bypass de Tor activado." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نسخهٔ توسعه: دور زدن Tor فعال است." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -91905,12 +33229,6 @@ "value" : "tor se reinició. Se restauró el enrutamiento de la red." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor دوباره راه‌اندازی شد. مسیریابی شبکه برقرار شد." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -92090,12 +33408,6 @@ "value" : "tor se está reiniciando para recuperar la conectividad..." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor برای بازیابی اتصال در حال راه‌اندازی مجدد است..." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -92275,12 +33587,6 @@ "value" : "tor se inició. Todo el chat se enruta por Tor para privacidad." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tor راه‌اندازی شد. همهٔ گفتگوها برای حفظ حریم IP از طریق Tor مسیریابی می‌شوند." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -92460,12 +33766,6 @@ "value" : "iniciando Tor..." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "در حال راه‌اندازی Tor..." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -92612,936 +33912,6 @@ } } }, - "topology.caption" : { - "comment" : "Caption under the mesh topology map", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "مُقدَّرة من قوائم الجيران المتناقلة (حتى 10 لكل قرين) — جهازك مميَّز" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "গসিপ করা প্রতিবেশী তালিকা থেকে অনুমান করা (প্রতি পিয়ারে সর্বোচ্চ ১০) — আপনার ডিভাইস হাইলাইট করা" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "geschätzt aus verbreiteten nachbarlisten (bis zu 10 pro peer) — dein gerät ist hervorgehoben" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "estimado a partir de listas de vecinos difundidas (hasta 10 por peer) — tu dispositivo está resaltado" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برآورد از فهرست همسایه‌های منتشرشده (تا ۱۰ مورد برای هر همتا) — دستگاه شما پررنگ شده است" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tinantiya mula sa mga ibinahaging listahan ng kapitbahay (hanggang 10 bawat peer) — naka-highlight ang iyong device" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "estimé à partir des listes de voisins diffusées (jusqu'à 10 par pair) — ton appareil est mis en évidence" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "מוערך מרשימות שכנים שהופצו (עד 10 לכל עמית) — המכשיר שלך מודגש" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "गॉसिप की गई पड़ोसी सूचियों से अनुमानित (प्रति पीयर 10 तक) — आपका डिवाइस हाइलाइट किया गया है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "diperkirakan dari daftar tetangga yang di-gossip (hingga 10 per peer) — perangkatmu disorot" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "stimato dalle liste di vicini diffuse (fino a 10 per peer) — il tuo dispositivo è evidenziato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ゴシップされた近隣リスト(ピアあたり最大10件)から推定 — お使いのデバイスをハイライト" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "가십으로 전파된 이웃 목록(피어당 최대 10개)에서 추정 — 사용 중인 기기가 강조 표시됨" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "dianggarkan dari senarai jiran yang di-gossip (sehingga 10 setiap peer) — perantimu diserlahkan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "गसिप गरिएका छिमेकी सूचीबाट अनुमानित (प्रति सहकर्मी बढीमा १०) — तिम्रो यन्त्र हाइलाइट गरिएको छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "geschat op basis van verspreide buurlijsten (tot 10 per peer) — je apparaat is gemarkeerd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "oszacowane na podstawie rozgłaszanych list sąsiadów (do 10 na peera) — twoje urządzenie jest wyróżnione" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "estimado a partir de listas de vizinhos difundidas (até 10 por par) — o teu dispositivo está destacado" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "estimado a partir das listas de vizinhos divulgadas (até 10 por par) — seu dispositivo está destacado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "оценено по gossip-спискам соседей (до 10 на пира) — твоё устройство выделено" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uppskattat från skvallrade grannlistor (upp till 10 per peer) — din enhet är markerad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "வதந்தியாகப் பகிரப்பட்ட அண்டை பட்டியல்களிலிருந்து மதிப்பிடப்பட்டது (ஒரு peer க்கு 10 வரை) — உங்கள் சாதனம் தனிப்படுத்தப்பட்டுள்ளது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ประมาณจากรายชื่อเพื่อนบ้านที่ส่งต่อแบบ gossip (สูงสุด 10 รายต่อเพียร์) — อุปกรณ์ของคุณถูกไฮไลต์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "yayılan komşu listelerinden tahmin edildi (eş başına en fazla 10) — cihazın vurgulanmış" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "оцінено на основі поширюваних списків сусідів (до 10 на піра) — твій пристрій виділено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "گپ شپ کی گئی پڑوسیوں کی فہرستوں سے اندازہ (فی ہم منصب 10 تک) — آپ کی ڈیوائس نمایاں ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ước tính từ danh sách hàng xóm được gossip (tối đa 10 mỗi nút) — thiết bị của bạn được làm nổi bật" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "根据传播的邻居列表估算(每个同伴最多 10 个)— 你的设备已高亮显示" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "根據同伴間流傳的鄰居列表估算(每位同伴最多 10 個)— 你的裝置已高亮顯示" - } - } - } - }, - "topology.empty" : { - "comment" : "Empty state of the mesh topology map", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لا توجد روابط mesh بعد — تمتلئ الخريطة مع وصول إعلانات الأقران" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "এখনো কোনো মেশ লিঙ্ক নেই — পিয়ার অ্যানাউন্স এলে মানচিত্র পূরণ হয়" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "noch keine mesh-verbindungen — die karte füllt sich, sobald peer-announces eintreffen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no mesh links yet — the map fills in as peer announces arrive" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "aún no hay enlaces del mesh — el mapa se completa a medida que llegan los anuncios de peers" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "هنوز پیوند مشی وجود ندارد — نقشه با رسیدن اعلام همتاها پر می‌شود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wala pang mesh link — napupuno ang mapa habang dumarating ang mga announce ng peer" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "aucun lien mesh pour l'instant — la carte se remplit à mesure que les annonces des pairs arrivent" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "אין עדיין קישורי mesh — המפה מתמלאת כשהכרזות עמיתים מגיעות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "अभी तक कोई मेश लिंक नहीं — पीयर अनाउंस आने पर नक्शा भरता जाता है" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "belum ada tautan mesh — peta terisi saat announce peer berdatangan" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ancora nessun collegamento mesh — la mappa si riempie man mano che arrivano gli announce dei peer" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "まだmeshリンクがありません — ピアのannounceが届くとマップが埋まります" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "아직 mesh 링크가 없습니다 — 피어 announce가 도착하면 지도가 채워집니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "belum ada pautan mesh — peta terisi apabila announce peer tiba" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "अझै mesh लिंक छैन — सहकर्मी घोषणा आउँदै जाँदा नक्सा भरिन्छ" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nog geen mesh-verbindingen — de kaart vult zich naarmate peer-announces binnenkomen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "brak połączeń mesh — mapa wypełnia się, gdy nadchodzą ogłoszenia peerów" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ainda sem ligações mesh — o mapa preenche-se à medida que chegam os announces dos pares" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nenhuma conexão do mesh ainda — o mapa se preenche conforme os anúncios dos pares chegam" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "пока нет mesh-связей — карта заполнится по мере поступления анонсов пиров" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inga mesh-länkar än — kartan fylls i när peer-announces kommer in" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இன்னும் mesh இணைப்புகள் இல்லை — peer அறிவிப்புகள் வரும்போது வரைபடம் நிரப்பப்படும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ยังไม่มีการเชื่อมต่อ mesh — แผนที่จะเติมเต็มเมื่อ announce ของเพียร์มาถึง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "henüz mesh bağlantısı yok — eş duyuruları geldikçe harita dolar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "поки немає зв'язків mesh — карта заповнюється, коли надходять оголошення пірів" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ابھی کوئی mesh روابط نہیں — ہم منصبوں کے اعلانات آتے ہی نقشہ بھر جاتا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chưa có liên kết mesh nào — bản đồ sẽ được điền khi các announce của nút đến" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "尚无 mesh 链路 — 随着同伴广播到达,地图会逐渐填充" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "尚無 mesh 連線 — 地圖會隨著同伴宣告到達而逐漸填滿" - } - } - } - }, - "topology.refresh" : { - "comment" : "Accessibility label of the topology refresh button", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تحديث الطوبولوجيا" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "টপোলজি রিফ্রেশ করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologie aktualisieren" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "refresh topology" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "actualizar topología" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تازه‌سازی توپولوژی" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "i-refresh ang topology" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "actualiser la topologie" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "רענון טופולוגיה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "टोपोलॉजी रीफ़्रेश करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "segarkan topologi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "aggiorna topologia" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "トポロジーを更新" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "토폴로지 새로고침" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "segarkan topologi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "टोपोलोजी ताजा गर" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologie vernieuwen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "odśwież topologię" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "atualizar topologia" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "atualizar topologia" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "обновить топологию" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "uppdatera topologi" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "டோபாலஜியைப் புதுப்பிக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "รีเฟรชโทโพโลยี" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topolojiyi yenile" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "оновити топологію" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ٹوپولوجی ریفریش کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "làm mới cấu trúc" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "刷新拓扑" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "重新整理拓撲" - } - } - } - }, - "topology.summary" : { - "comment" : "Topology map summary: number of peers and links", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld قرين · %2$ld رابط" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld পিয়ার · %2$ld লিঙ্ক" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld peers · %2$ld verbindungen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$ld peers · %2$ld links" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$ld peers · %2$ld enlaces" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$ld همتا · %2$ld پیوند" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld peer · %2$ld link" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld pairs · %2$ld liens" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld עמיתים · %2$ld קישורים" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld पीयर · %2$ld लिंक" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld peer · %2$ld tautan" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld peer · %2$ld collegamenti" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld ピア · %2$ld リンク" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "피어 %1$ld개 · 링크 %2$ld개" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld peer · %2$ld pautan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld सहकर्मी · %2$ld लिंक" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld peers · %2$ld verbindingen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld peerów · %2$ld połączeń" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld pares · %2$ld ligações" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld pares · %2$ld conexões" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld пиров · %2$ld связей" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld peers · %2$ld länkar" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld peer-கள் · %2$ld இணைப்புகள்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld เพียร์ · %2$ld การเชื่อมต่อ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld eş · %2$ld bağlantı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld пірів · %2$ld зв'язків" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld ہم منصب · %2$ld روابط" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld nút · %2$ld liên kết" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld 个同伴 · %2$ld 条链路" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%1$ld 位同伴 · %2$ld 條連線" - } - } - } - }, - "topology.title" : { - "comment" : "Title of the mesh topology map sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "طوبولوجيا mesh" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "মেশ টপোলজি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh-topologie" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh topology" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "topología del mesh" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "توپولوژی مش" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh topology" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologie mesh" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "טופולוגיית mesh" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "मेश टोपोलॉजी" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologi mesh" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologia mesh" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh トポロジー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh 토폴로지" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologi mesh" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh टोपोलोजी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh-topologie" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologia mesh" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologia mesh" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "topologia do mesh" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "топология mesh" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh-topologi" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh டோபாலஜி" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "โทโพโลยี mesh" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh topolojisi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "топологія mesh" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh ٹوپولوجی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cấu trúc mesh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh 拓扑" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesh 拓撲" - } - } - } - }, "verification.my_qr.accessibility_label" : { "extractionState" : "manual", "localizations" : { @@ -93575,12 +33945,6 @@ "value" : "código QR de verificación" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کد QR تأیید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -93760,12 +34124,6 @@ "value" : "escanea para verificarme" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای تأیید من اسکن کنید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -93945,12 +34303,6 @@ "value" : "QR no disponible" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "QR در دسترس نیست" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -94097,192 +34449,6 @@ } } }, - "verification.scan.camera_unavailable" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الكاميرا غير متاحة — الصق رمز QR أدناه." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Camera unavailable — paste a QR below." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kamera nicht verfügbar — QR unten einfügen." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Camera unavailable — paste a QR below." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cámara no disponible — pega un QR abajo." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "Camera unavailable — paste a QR below." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Camera unavailable — paste a QR below." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Caméra indisponible — collez un QR ci-dessous." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "המצלמה אינה זמינה — הדבק QR למטה." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "कैमरा उपलब्ध नहीं — नीचे QR पेस्ट करें।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kamera tidak tersedia — tempel QR di bawah." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fotocamera non disponibile — incolla un QR sotto." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "カメラを利用できません — 下にQRを貼り付けてください。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "카메라를 사용할 수 없습니다 — 아래에 QR을 붙여넣으세요." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Camera unavailable — paste a QR below." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "Camera unavailable — paste a QR below." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Camera niet beschikbaar — plak hieronder een QR." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aparat niedostępny — wklej QR poniżej." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Câmara indisponível — cole um QR abaixo." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Câmera indisponível — cole um QR abaixo." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Камера недоступна — вставьте QR ниже." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kamera otillgänglig — klistra in en QR nedan." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "Camera unavailable — paste a QR below." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "Camera unavailable — paste a QR below." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kamera kullanılamıyor — aşağıya bir QR yapıştırın." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Камера недоступна — вставте QR нижче." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "Camera unavailable — paste a QR below." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Không dùng được camera — dán mã QR bên dưới." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法使用相机 — 请在下方粘贴二维码。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無法使用相機 — 請在下方貼上 QR。" - } - } - } - }, - "verification.scan.paste_prompt" : { "extractionState" : "manual", "localizations" : { @@ -94316,12 +34482,6 @@ "value" : "pega el contenido del QR para validarlo:" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "محتوای QR را برای اعتبارسنجی جای‌گذاری کنید:" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -94501,12 +34661,6 @@ "value" : "escanea el QR de un amigo" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "QR دوست‌تان را اسکن کنید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -94686,12 +34840,6 @@ "value" : "QR inválido o caducado" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "محتوای QR نامعتبر یا منقضی است" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -94871,12 +35019,6 @@ "value" : "no se encontró un peer coincidente" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "همتای مطابق پیدا نشد" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -95056,12 +35198,6 @@ "value" : "se solicitó la verificación de %@" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "درخواست تأیید برای %@ ارسال شد" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -95241,12 +35377,6 @@ "value" : "validar" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "اعتبارسنجی" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -95426,12 +35556,6 @@ "value" : "VERIFICAR" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "تأیید" - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -95611,12 +35735,6 @@ "value" : "Las notas de voz solo están disponibles en los chats de mesh." } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیام صوتی فقط در گفتگوهای مش در دسترس است." - } - }, "fil" : { "stringUnit" : { "state" : "translated", @@ -95763,1412 +35881,361 @@ } } }, - "app_info.settings.language.title" : { - "comment" : "Section header (uppercase) for the app language picker in settings", + "Images are only available in mesh chats." : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "اللغة" + "value" : "الصور متاحة فقط في محادثات الميش." } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "ভাষা" + "value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "SPRACHE" + "value" : "Bilder sind nur im Mesh-Chat verfügbar." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "LANGUAGE" + "value" : "Images are only available in mesh chats." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "IDIOMA" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "زبان" + "value" : "Las imágenes solo están disponibles en los chats de mesh." } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "WIKA" + "value" : "Ang mga larawan ay available lamang sa mga mesh chat." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "LANGUE" + "value" : "Les images sont uniquement disponibles dans les discussions mesh." } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "שפה" + "value" : "תמונות זמינות רק בצ׳אט של mesh." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "भाषा" + "value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "BAHASA" + "value" : "Gambar hanya tersedia di obrolan mesh." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "LINGUA" + "value" : "Le immagini sono disponibili solo nelle chat mesh." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "言語" + "value" : "画像はメッシュチャットでのみ利用できます。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "언어" + "value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다." } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "BAHASA" + "value" : "Imej hanya tersedia dalam sembang mesh." } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "भाषा" + "value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "TAAL" + "value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "JĘZYK" + "value" : "Obrazy są dostępne tylko na czatach mesh." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "IDIOMA" + "value" : "As imagens só estão disponíveis nos chats mesh." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "IDIOMA" + "value" : "As imagens só estão disponíveis nos chats mesh." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "ЯЗЫК" + "value" : "Изображения доступны только в mesh-чатах." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "SPRÅK" + "value" : "Bilder är bara tillgängliga i mesh-chattar." } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "மொழி" + "value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ภาษา" + "value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "DİL" + "value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "МОВА" + "value" : "Зображення доступні лише в mesh-чатах." } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "زبان" + "value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "NGÔN NGỮ" + "value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "语言" + "value" : "图片仅可在 mesh 聊天中使用。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "語言" + "value" : "圖片僅能在 mesh 聊天中使用。" } } } }, - "app_info.settings.language.picker_label" : { - "comment" : "Label of the app language picker row in settings", + "Choose an image" : { + "comment" : "A label displayed above a button that allows the user to choose an image to send.", "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "لغة التطبيق" + "value" : "اختر صورة" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "অ্যাপের ভাষা" + "value" : "একটি ছবি নির্বাচন করুন" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "App-Sprache" + "value" : "Bild auswählen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "app language" + "value" : "Choose an image" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "idioma de la app" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "زبان برنامه" + "value" : "Elige una imagen" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "wika ng app" + "value" : "Pumili ng larawan" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "langue de l'app" + "value" : "Choisir une image" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "שפת האפליקציה" + "value" : "בחר תמונה" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "ऐप की भाषा" + "value" : "एक चित्र चुनें" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "bahasa aplikasi" + "value" : "Pilih gambar" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "lingua dell'app" + "value" : "Scegli un’immagine" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "アプリの言語" + "value" : "画像を選択" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "앱 언어" + "value" : "이미지를 선택하세요" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "bahasa aplikasi" + "value" : "Pilih imej" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "एपको भाषा" + "value" : "एउटा तस्वीर चयन गर्नुहोस्" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "app-taal" + "value" : "Kies een afbeelding" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "język aplikacji" + "value" : "Wybierz obraz" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "idioma da app" + "value" : "Escolher uma imagem" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "idioma do app" + "value" : "Escolha uma imagem" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "язык приложения" + "value" : "Выберите изображение" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "appspråk" + "value" : "Välj en bild" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "செயலியின் மொழி" + "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ภาษาของแอป" + "value" : "เลือกภาพ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "uygulama dili" + "value" : "Bir görüntü seç" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "мова застосунку" + "value" : "Виберіть зображення" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "ایپ کی زبان" + "value" : "ایک تصویر منتخب کریں" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "ngôn ngữ ứng dụng" + "value" : "Chọn một hình ảnh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "应用语言" + "value" : "选择图像" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "應用程式語言" - } - } - } - }, - "app_info.settings.language.system" : { - "comment" : "Menu option that clears the in-app language override so the app follows the device language", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لغة النظام" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "সিস্টেম ডিফল্ট" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Systemstandard" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "system default" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "predeterminado del sistema" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیش‌فرض سیستم" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "default ng system" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "réglage système" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ברירת המחדל של המערכת" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सिस्टम डिफ़ॉल्ट" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "bawaan sistem" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "predefinita di sistema" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "システムのデフォルト" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "시스템 기본값" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "lalai sistem" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "प्रणाली पूर्वनिर्धारित" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "systeemstandaard" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "domyślny systemowy" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "predefinição do sistema" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "padrão do sistema" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "системный по умолчанию" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "systemstandard" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "கணினி இயல்புநிலை" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ค่าเริ่มต้นของระบบ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "sistem varsayılanı" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "системна за замовчуванням" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "سسٹم ڈیفالٹ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mặc định hệ thống" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "系统默认" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "系統預設" - } - } - } - }, - "app_info.settings.language.restart_note" : { - "comment" : "Caption shown after the user picks a different app language; the change takes effect on next launch", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "أعد تشغيل bitchat لتطبيق اللغة الجديدة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "নতুন ভাষা প্রয়োগ করতে bitchat পুনরায় চালু করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "bitchat neu starten, um die neue Sprache zu übernehmen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "restart bitchat to apply the new language" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "reinicia bitchat para aplicar el nuevo idioma" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای اعمال زبان جدید، bitchat را دوباره باز کنید" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "i-restart ang bitchat para mailapat ang bagong wika" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "redémarrez bitchat pour appliquer la nouvelle langue" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "יש להפעיל מחדש את bitchat כדי להחיל את השפה החדשה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "नई भाषा लागू करने के लिए bitchat पुनः आरंभ करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "mulai ulang bitchat untuk menerapkan bahasa baru" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "riavvia bitchat per applicare la nuova lingua" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "新しい言語を適用するには bitchat を再起動してください" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "새 언어를 적용하려면 bitchat을 다시 시작하세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "mulakan semula bitchat untuk menggunakan bahasa baharu" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "नयाँ भाषा लागू गर्न bitchat पुनः सुरु गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "herstart bitchat om de nieuwe taal toe te passen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "uruchom bitchat ponownie, aby zastosować nowy język" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "reinicie o bitchat para aplicar o novo idioma" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "reinicie o bitchat para aplicar o novo idioma" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "перезапустите bitchat, чтобы применить новый язык" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "starta om bitchat för att använda det nya språket" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "புதிய மொழியைப் பயன்படுத்த bitchat-ஐ மீண்டும் தொடங்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รีสตาร์ท bitchat เพื่อใช้ภาษาใหม่" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "yeni dili uygulamak için bitchat'i yeniden başlatın" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "перезапустіть bitchat, щоб застосувати нову мову" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "نئی زبان لاگو کرنے کے لیے bitchat دوبارہ شروع کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "khởi động lại bitchat để áp dụng ngôn ngữ mới" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "重启 bitchat 以应用新语言" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "重新啟動 bitchat 以套用新語言" - } - } - } - }, - "share_import.review.message" : { - "comment" : "Explains that shared content replaces the named destination's composer and is not sent automatically", - "extractionState" : "manual", - "localizations" : { - "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "هل تريد استبدال مسودة %@ بهذا المحتوى؟ لن يتم إرسال أي شيء تلقائيًا." } }, - "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "%@-এর খসড়াটি এই কনটেন্ট দিয়ে বদলাবেন? কিছুই স্বয়ংক্রিয়ভাবে পাঠানো হবে না।" } }, - "de" : { "stringUnit" : { "state" : "needs_review", "value" : "Entwurf für %@ durch diesen Inhalt ersetzen? Es wird nichts automatisch gesendet." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Replace the draft for %@ with this content? Nothing will be sent automatically." } }, - "es" : { "stringUnit" : { "state" : "needs_review", "value" : "¿Reemplazar el borrador de %@ con este contenido? No se enviará nada automáticamente." } }, - "fa" : { "stringUnit" : { "state" : "needs_review", "value" : "پیش‌نویس %@ با این محتوا جایگزین شود؟ چیزی به‌طور خودکار ارسال نمی‌شود." } }, - "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Palitan ng content na ito ang draft para sa %@? Walang awtomatikong ipapadala." } }, - "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Remplacer le brouillon pour %@ par ce contenu ? Rien ne sera envoyé automatiquement." } }, - "he" : { "stringUnit" : { "state" : "needs_review", "value" : "להחליף את הטיוטה עבור %@ בתוכן הזה? שום דבר לא יישלח אוטומטית." } }, - "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ का ड्राफ़्ट इस सामग्री से बदलें? कुछ भी अपने आप नहीं भेजा जाएगा।" } }, - "id" : { "stringUnit" : { "state" : "needs_review", "value" : "Ganti draf untuk %@ dengan konten ini? Tidak ada yang akan dikirim otomatis." } }, - "it" : { "stringUnit" : { "state" : "needs_review", "value" : "Sostituire la bozza per %@ con questo contenuto? Nulla verrà inviato automaticamente." } }, - "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ の下書きをこの内容で置き換えますか?自動的には送信されません。" } }, - "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "%@의 초안을 이 콘텐츠로 바꾸시겠습니까? 자동으로 전송되지 않습니다." } }, - "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gantikan draf untuk %@ dengan kandungan ini? Tiada apa-apa akan dihantar secara automatik." } }, - "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ को मस्यौदा यो सामग्रीले बदल्ने? केही पनि स्वचालित रूपमा पठाइने छैन।" } }, - "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Concept voor %@ vervangen door deze inhoud? Er wordt niets automatisch verzonden." } }, - "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Zastąpić szkic dla %@ tą treścią? Nic nie zostanie wysłane automatycznie." } }, - "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho para %@ por este conteúdo? Nada será enviado automaticamente." } }, - "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho de %@ por este conteúdo? Nada será enviado automaticamente." } }, - "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Заменить черновик для %@ этим содержимым? Ничего не будет отправлено автоматически." } }, - "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Ersätta utkastet för %@ med detta innehåll? Inget skickas automatiskt." } }, - "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ க்கான வரைவைக் இந்த உள்ளடக்கத்தால் மாற்றவா? எதுவும் தானாக அனுப்பப்படாது." } }, - "th" : { "stringUnit" : { "state" : "needs_review", "value" : "แทนที่ฉบับร่างสำหรับ %@ ด้วยเนื้อหานี้หรือไม่? จะไม่มีการส่งอัตโนมัติ" } }, - "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ taslağı bu içerikle değiştirilsin mi? Hiçbir şey otomatik olarak gönderilmez." } }, - "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Замінити чернетку для %@ цим вмістом? Нічого не буде надіслано автоматично." } }, - "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ کا مسودہ اس مواد سے بدلیں؟ کچھ بھی خودکار طور پر نہیں بھیجا جائے گا۔" } }, - "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Thay bản nháp cho %@ bằng nội dung này? Không có gì được tự động gửi." } }, - "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此内容替换 %@ 的草稿吗?内容不会自动发送。" } }, - "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此內容取代 %@ 的草稿嗎?內容不會自動傳送。" } } - } - }, - "share_import.review.title" : { - "comment" : "Title for reviewing content received from the share extension", - "extractionState" : "manual", - "localizations" : { - "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "مراجعة المحتوى المشترك" } }, - "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "শেয়ার করা কনটেন্ট পর্যালোচনা করুন" } }, - "de" : { "stringUnit" : { "state" : "needs_review", "value" : "Geteilte Inhalte prüfen" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Review shared content" } }, - "es" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar contenido compartido" } }, - "fa" : { "stringUnit" : { "state" : "needs_review", "value" : "بازبینی محتوای هم‌رسانی‌شده" } }, - "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Suriin ang ibinahaging content" } }, - "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Vérifier le contenu partagé" } }, - "he" : { "stringUnit" : { "state" : "needs_review", "value" : "בדיקת תוכן משותף" } }, - "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "शेयर की गई सामग्री की समीक्षा करें" } }, - "id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tinjau konten yang dibagikan" } }, - "it" : { "stringUnit" : { "state" : "needs_review", "value" : "Controlla il contenuto condiviso" } }, - "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "共有コンテンツを確認" } }, - "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "공유 콘텐츠 검토" } }, - "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Semak kandungan yang dikongsi" } }, - "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "साझा सामग्री समीक्षा गर्नुहोस्" } }, - "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Gedeelde inhoud bekijken" } }, - "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Sprawdź udostępnioną treść" } }, - "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Rever conteúdo partilhado" } }, - "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar conteúdo compartilhado" } }, - "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Проверить общий контент" } }, - "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Granska delat innehåll" } }, - "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "பகிரப்பட்ட உள்ளடக்கத்தை மதிப்பாய்வு செய்" } }, - "th" : { "stringUnit" : { "state" : "needs_review", "value" : "ตรวจสอบเนื้อหาที่แชร์" } }, - "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Paylaşılan içeriği gözden geçir" } }, - "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Переглянути спільний вміст" } }, - "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "شیئر کردہ مواد کا جائزہ لیں" } }, - "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Xem lại nội dung được chia sẻ" } }, - "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "查看共享内容" } }, - "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "查看分享內容" } } - } - }, - "share_import.review.use_in_composer" : { - "comment" : "Action that places reviewed shared content in the composer without sending it", - "extractionState" : "manual", - "localizations" : { - "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "استخدام في مسودة الرسالة" } }, - "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "বার্তার খসড়ায় ব্যবহার করুন" } }, - "de" : { "stringUnit" : { "state" : "needs_review", "value" : "Im Nachrichtenentwurf verwenden" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Use in composer" } }, - "es" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar en el borrador" } }, - "fa" : { "stringUnit" : { "state" : "needs_review", "value" : "استفاده در پیش‌نویس پیام" } }, - "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Gamitin sa draft" } }, - "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Utiliser dans le brouillon" } }, - "he" : { "stringUnit" : { "state" : "needs_review", "value" : "שימוש בטיוטה" } }, - "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "संदेश के ड्राफ़्ट में उपयोग करें" } }, - "id" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan di draf" } }, - "it" : { "stringUnit" : { "state" : "needs_review", "value" : "Usa nella bozza" } }, - "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "下書きで使用" } }, - "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "초안에 사용" } }, - "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan dalam draf" } }, - "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "सन्देशको मस्यौदामा प्रयोग गर्नुहोस्" } }, - "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "In concept gebruiken" } }, - "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Użyj w szkicu" } }, - "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } }, - "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } }, - "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Использовать в черновике" } }, - "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Använd i utkast" } }, - "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "செய்தி வரைவில் பயன்படுத்து" } }, - "th" : { "stringUnit" : { "state" : "needs_review", "value" : "ใช้ในฉบับร่าง" } }, - "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Taslakta kullan" } }, - "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Використати в чернетці" } }, - "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "پیغام کے مسودے میں استعمال کریں" } }, - "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Dùng trong bản nháp" } }, - "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "用于草稿" } }, - "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "用於草稿" } } - } - }, - "location_channels.quick_join.title" : { - "comment" : "Section header in the location channels sheet for the one-tap suggestion of the region channel derived from the device region", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0627\u0646\u0636\u0645\u0627\u0645 \u0633\u0631\u064a\u0639" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u09a6\u09cd\u09b0\u09c1\u09a4 \u09af\u09cb\u0997" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "schnellbeitritt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "quick join" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "acceso r\u00e1pido" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u067e\u06cc\u0648\u0633\u062a\u0646 \u0633\u0631\u06cc\u0639" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "mabilis na pagsali" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "acc\u00e8s rapide" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u05d4\u05e6\u05d8\u05e8\u05e4\u05d5\u05ea \u05de\u05d4\u05d9\u05e8\u05d4" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u091d\u091f\u092a\u091f \u091c\u0941\u0921\u093c\u0947\u0902" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "gabung cepat" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "accesso rapido" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u30af\u30a4\u30c3\u30af\u53c2\u52a0" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "\ube60\ub978 \ucc38\uc5ec" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "sertai pantas" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u091b\u093f\u091f\u094b \u0938\u093e\u092e\u0947\u0932" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "snel deelnemen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "szybkie do\u0142\u0105czenie" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "entrada r\u00e1pida" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "entrada r\u00e1pida" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0431\u044b\u0441\u0442\u0440\u044b\u0439 \u0432\u0445\u043e\u0434" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "snabbanslutning" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0bb5\u0bbf\u0bb0\u0bc8\u0bb5\u0bc1 \u0b9a\u0bc7\u0bb0\u0bcd\u0b95\u0bcd\u0b95\u0bc8" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0e40\u0e02\u0e49\u0e32\u0e23\u0e48\u0e27\u0e21\u0e14\u0e48\u0e27\u0e19" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "h\u0131zl\u0131 kat\u0131l\u0131m" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0448\u0432\u0438\u0434\u043a\u0438\u0439 \u0432\u0445\u0456\u0434" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0641\u0648\u0631\u06cc \u0634\u0645\u0648\u0644\u06cc\u062a" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tham gia nhanh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u5feb\u901f\u52a0\u5165" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u5feb\u901f\u52a0\u5165" - } - } - } - }, - "location_channels.quick_join.description" : { - "comment" : "Caption under the quick join row; %@ is the localized country/region name. States plainly that the cell is the main population center's (not the person's location), that the channel must be assumed watched, and that quick join is discovery, not circumvention", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0642\u0646\u0627\u0629 \u0627\u0644\u0645\u0646\u0637\u0642\u0629 \u0627\u0644\u062a\u064a \u064a\u062a\u062c\u0645\u0639 \u0641\u064a\u0647\u0627 \u0639\u0627\u062f\u0629\u064b \u0627\u0644\u0646\u0627\u0633 \u0645\u0646 %@ \u2014 \u0627\u0644\u062e\u0644\u064a\u0629 \u0627\u0644\u0648\u0627\u0633\u0639\u0629 \u062d\u0648\u0644 \u0623\u0643\u0628\u0631 \u0645\u0631\u0643\u0632 \u0633\u0643\u0627\u0646\u064a\u060c \u0648\u0644\u064a\u0633\u062a \u0645\u0648\u0642\u0639\u0643. \u0625\u0646\u0647\u0627 \u0639\u0627\u0645\u0629 \u0648\u0645\u0639\u0631\u0648\u0641\u0629 \u0644\u0644\u062c\u0645\u064a\u0639\u060c \u0641\u0627\u0641\u062a\u0631\u0636 \u0623\u0646\u0647\u0627 \u0645\u0631\u0627\u0642\u0628\u0629: \u0627\u0644\u0627\u0646\u0636\u0645\u0627\u0645 \u0627\u0644\u0633\u0631\u064a\u0639 \u064a\u0648\u0641\u0651\u0631 \u0639\u0644\u064a\u0643 \u0643\u062a\u0627\u0628\u0629 \u0627\u0644\u062c\u064a\u0648\u0647\u0627\u0634 \u0641\u0642\u0637\u061b \u0644\u0627 \u064a\u062e\u0641\u064a\u0643 \u0648\u0644\u0627 \u064a\u062a\u062c\u0627\u0648\u0632 \u0627\u0644\u062d\u062c\u0628." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u09af\u09c7 \u0985\u099e\u09cd\u099a\u09b2-\u099a\u09cd\u09af\u09be\u09a8\u09c7\u09b2\u09c7 %@-\u098f\u09b0 \u09ae\u09be\u09a8\u09c1\u09b7 \u09b8\u09be\u09a7\u09be\u09b0\u09a3\u09a4 \u099c\u09a1\u09bc\u09cb \u09b9\u09af\u09bc \u2014 \u09b8\u09ac\u099a\u09c7\u09af\u09bc\u09c7 \u09ac\u09a1\u09bc \u099c\u09a8\u0995\u09c7\u09a8\u09cd\u09a6\u09cd\u09b0\u09c7\u09b0 \u099a\u09be\u09b0\u09aa\u09be\u09b6\u09c7\u09b0 \u09ac\u09bf\u09b8\u09cd\u09a4\u09c3\u09a4 \u09b8\u09c7\u09b2, \u0986\u09aa\u09a8\u09be\u09b0 \u0985\u09ac\u09b8\u09cd\u09a5\u09be\u09a8 \u09a8\u09af\u09bc\u0964 \u098f\u099f\u09bf \u09aa\u09cd\u09b0\u0995\u09be\u09b6\u09cd\u09af \u0993 \u09b8\u09c1\u09aa\u09b0\u09bf\u099a\u09bf\u09a4, \u09a4\u09be\u0987 \u09a7\u09b0\u09c7 \u09a8\u09bf\u09a8 \u098f\u099f\u09bf \u09a8\u099c\u09b0\u09a6\u09be\u09b0\u09bf\u09a4\u09c7 \u0986\u099b\u09c7: \u09a6\u09cd\u09b0\u09c1\u09a4 \u09af\u09cb\u0997 \u09b6\u09c1\u09a7\u09c1 \u099c\u09bf\u0993\u09b9\u09cd\u09af\u09be\u09b6 \u099f\u09be\u0987\u09aa \u0995\u09b0\u09be \u09ac\u09be\u0981\u099a\u09be\u09af\u09bc; \u098f\u099f\u09bf \u0986\u09aa\u09a8\u09be\u0995\u09c7 \u09b2\u09c1\u0995\u09be\u09af\u09bc \u09a8\u09be, \u09ac\u09cd\u09b2\u0995\u0993 \u098f\u09a1\u09bc\u09be\u09af\u09bc \u09a8\u09be\u0964" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "der regionskanal, in dem sich leute aus %@ meist sammeln \u2014 die weite zelle um das gr\u00f6\u00dfte bev\u00f6lkerungszentrum, nicht dein standort. er ist \u00f6ffentlich und allgemein bekannt, geh also davon aus, dass er beobachtet wird: schnellbeitritt spart nur das eintippen des geohash; er versteckt dich nicht und umgeht keine sperren." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "the region channel where people from %@ tend to gather \u2014 the wide cell around the main population center, not your location. it's public and well-known, so assume it's watched: quick join saves typing a geohash; it doesn't hide you or bypass blocks." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "el canal de regi\u00f3n donde suele reunirse la gente de %@ \u2014 la celda amplia alrededor del mayor centro de poblaci\u00f3n, no tu ubicaci\u00f3n. es p\u00fablico y conocido, as\u00ed que asume que est\u00e1 vigilado: el acceso r\u00e1pido solo te ahorra escribir el geohash; no te oculta ni evita bloqueos." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u06a9\u0627\u0646\u0627\u0644 \u0645\u0646\u0637\u0642\u0647\u200c\u0627\u06cc \u06a9\u0647 \u0645\u0631\u062f\u0645 %@ \u0645\u0639\u0645\u0648\u0644\u0627\u064b \u062f\u0631 \u0622\u0646 \u062c\u0645\u0639 \u0645\u06cc\u200c\u0634\u0648\u0646\u062f \u2014 \u0633\u0644\u0648\u0644 \u067e\u0647\u0646\u0627\u0648\u0631 \u062f\u0648\u0631 \u0628\u0632\u0631\u06af\u200c\u062a\u0631\u06cc\u0646 \u0645\u0631\u06a9\u0632 \u062c\u0645\u0639\u06cc\u062a\u060c \u0646\u0647 \u0645\u0648\u0642\u0639\u06cc\u062a \u0634\u0645\u0627. \u0639\u0645\u0648\u0645\u06cc \u0648 \u0634\u0646\u0627\u062e\u062a\u0647\u200c\u0634\u062f\u0647 \u0627\u0633\u062a\u060c \u067e\u0633 \u0641\u0631\u0636 \u06a9\u0646\u06cc\u062f \u0632\u06cc\u0631 \u0646\u0638\u0631 \u0627\u0633\u062a: \u067e\u06cc\u0648\u0633\u062a\u0646 \u0633\u0631\u06cc\u0639 \u0641\u0642\u0637 \u062a\u0627\u06cc\u067e \u0698\u0626\u0648\u0647\u0634 \u0631\u0627 \u06a9\u0645 \u0645\u06cc\u200c\u06a9\u0646\u062f\u061b \u0634\u0645\u0627 \u0631\u0627 \u067e\u0646\u0647\u0627\u0646 \u0646\u0645\u06cc\u200c\u06a9\u0646\u062f \u0648 \u0641\u06cc\u0644\u062a\u0631\u0647\u0627 \u0631\u0627 \u062f\u0648\u0631 \u0646\u0645\u06cc\u200c\u0632\u0646\u062f." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "ang region channel kung saan karaniwang nagtitipon ang mga tao mula sa %@ \u2014 ang malawak na cell sa paligid ng pinakamalaking sentro ng populasyon, hindi ang iyong lokasyon. pampubliko ito at kilala, kaya ipagpalagay na binabantayan: ang mabilis na pagsali ay nagtitipid lang ng pag-type ng geohash; hindi ka nito itinatago at hindi nito nilalampasan ang mga block." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "le canal de r\u00e9gion o\u00f9 les gens de %@ se retrouvent le plus souvent \u2014 la large cellule autour du principal centre de population, pas votre position. il est public et bien connu : partez du principe qu'il est surveill\u00e9. l'acc\u00e8s rapide \u00e9vite seulement de taper le geohash ; il ne vous cache pas et ne contourne aucun blocage." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u05e2\u05e8\u05d5\u05e5 \u05d4\u05d0\u05d6\u05d5\u05e8 \u05e9\u05d1\u05d5 \u05d0\u05e0\u05e9\u05d9\u05dd \u05de%@ \u05d1\u05d3\u05e8\u05da \u05db\u05dc\u05dc \u05de\u05ea\u05e7\u05d1\u05e6\u05d9\u05dd \u2014 \u05d4\u05ea\u05d0 \u05d4\u05e8\u05d7\u05d1 \u05e1\u05d1\u05d9\u05d1 \u05de\u05e8\u05db\u05d6 \u05d4\u05d0\u05d5\u05db\u05dc\u05d5\u05e1\u05d9\u05d9\u05d4 \u05d4\u05d2\u05d3\u05d5\u05dc \u05d1\u05d9\u05d5\u05ea\u05e8, \u05dc\u05d0 \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05e9\u05dc\u05da. \u05d4\u05d5\u05d0 \u05e6\u05d9\u05d1\u05d5\u05e8\u05d9 \u05d5\u05de\u05d5\u05db\u05e8, \u05d0\u05d6 \u05d9\u05e9 \u05dc\u05d4\u05e0\u05d9\u05d7 \u05e9\u05d4\u05d5\u05d0 \u05de\u05e0\u05d5\u05d8\u05e8: \u05d4\u05e6\u05d8\u05e8\u05e4\u05d5\u05ea \u05de\u05d4\u05d9\u05e8\u05d4 \u05e8\u05e7 \u05d7\u05d5\u05e1\u05db\u05ea \u05d4\u05e7\u05dc\u05d3\u05ea geohash; \u05d4\u05d9\u05d0 \u05dc\u05d0 \u05de\u05e1\u05ea\u05d9\u05e8\u05d4 \u05d0\u05d5\u05ea\u05da \u05d5\u05dc\u05d0 \u05e2\u05d5\u05e7\u05e4\u05ea \u05d7\u05e1\u05d9\u05de\u05d5\u05ea." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0935\u0939 \u0915\u094d\u0937\u0947\u0924\u094d\u0930 \u091a\u0948\u0928\u0932 \u091c\u0939\u093e\u0901 %@ \u0915\u0947 \u0932\u094b\u0917 \u0906\u092e\u0924\u094c\u0930 \u092a\u0930 \u091c\u0941\u091f\u0924\u0947 \u0939\u0948\u0902 \u2014 \u0938\u092c\u0938\u0947 \u092c\u0921\u093c\u0947 \u0906\u092c\u093e\u0926\u0940 \u0915\u0947\u0902\u0926\u094d\u0930 \u0915\u0947 \u091a\u093e\u0930\u094b\u0902 \u0913\u0930 \u0915\u0940 \u091a\u094c\u0921\u093c\u0940 \u0938\u0947\u0932, \u0906\u092a\u0915\u0940 \u0932\u094b\u0915\u0947\u0936\u0928 \u0928\u0939\u0940\u0902\u0964 \u092f\u0939 \u0938\u093e\u0930\u094d\u0935\u091c\u0928\u093f\u0915 \u0914\u0930 \u091c\u093e\u0928\u093e-\u092a\u0939\u091a\u093e\u0928\u093e \u0939\u0948, \u0907\u0938\u0932\u093f\u090f \u092e\u093e\u0928 \u0932\u0947\u0902 \u0915\u093f \u0907\u0938 \u092a\u0930 \u0928\u091c\u093c\u0930 \u0939\u0948: \u091d\u091f\u092a\u091f \u091c\u0941\u0921\u093c\u0928\u093e \u0938\u093f\u0930\u094d\u092b\u093c geohash \u091f\u093e\u0907\u092a \u0915\u0930\u0928\u0947 \u0938\u0947 \u092c\u091a\u093e\u0924\u093e \u0939\u0948; \u092f\u0939 \u0906\u092a\u0915\u094b \u091b\u093f\u092a\u093e\u0924\u093e \u0928\u0939\u0940\u0902 \u0914\u0930 \u0928 \u0939\u0940 \u092c\u094d\u0932\u0949\u0915 \u0939\u091f\u093e\u0924\u093e \u0939\u0948\u0964" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "channel wilayah tempat orang-orang dari %@ biasanya berkumpul \u2014 sel luas di sekitar pusat populasi terbesar, bukan lokasimu. channel ini publik dan dikenal luas, jadi anggap saja dipantau: gabung cepat hanya menghemat pengetikan geohash; tidak menyembunyikanmu dan tidak menembus blokir." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "il canale di regione dove di solito si ritrovano le persone di %@ \u2014 la cella ampia attorno al principale centro abitato, non la tua posizione. \u00e8 pubblico e ben noto, quindi dai per scontato che sia sorvegliato: l'accesso rapido ti evita solo di digitare il geohash; non ti nasconde e non aggira i blocchi." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@\u306e\u4eba\u3005\u304c\u96c6\u307e\u308a\u3084\u3059\u3044\u5730\u57df\u30c1\u30e3\u30f3\u30cd\u30eb \u2014 \u6700\u5927\u306e\u4eba\u53e3\u96c6\u4e2d\u5730\u3092\u56f2\u3080\u5e83\u3044\u30bb\u30eb\u3067\u3001\u3042\u306a\u305f\u306e\u4f4d\u7f6e\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002\u516c\u958b\u3055\u308c\u3066\u3044\u3066\u8ab0\u3082\u304c\u77e5\u308b\u5834\u6240\u306a\u306e\u3067\u3001\u76e3\u8996\u3055\u308c\u3066\u3044\u308b\u524d\u63d0\u3067\u3002\u30af\u30a4\u30c3\u30af\u53c2\u52a0\u306f\u30b8\u30aa\u30cf\u30c3\u30b7\u30e5\u5165\u529b\u3092\u7701\u304f\u3060\u3051\u3067\u3001\u3042\u306a\u305f\u3092\u96a0\u3057\u305f\u308a\u30d6\u30ed\u30c3\u30af\u3092\u56de\u907f\u3057\u305f\u308a\u306f\u3057\u307e\u305b\u3093\u3002" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ \uc0ac\ub78c\ub4e4\uc774 \uc8fc\ub85c \ubaa8\uc774\ub294 \uc9c0\uc5ed \ucc44\ub110 \u2014 \ucd5c\ub300 \uc778\uad6c \ubc00\uc9d1\uc9c0 \uc8fc\ubcc0\uc758 \ub113\uc740 \uc140\uc774\uba70, \ub2f9\uc2e0\uc758 \uc704\uce58\uac00 \uc544\ub2d9\ub2c8\ub2e4. \uacf5\uac1c\ub418\uc5b4 \uc788\uace0 \ub110\ub9ac \uc54c\ub824\uc838 \uc788\uc73c\ub2c8 \uac10\uc2dc\ub41c\ub2e4\uace0 \uac00\uc815\ud558\uc138\uc694: \ube60\ub978 \ucc38\uc5ec\ub294 \uc9c0\uc624\ud574\uc2dc \uc785\ub825\ub9cc \uc904\uc5ec\uc904 \ubfd0, \ub2f9\uc2e0\uc744 \uc228\uae30\uac70\ub098 \ucc28\ub2e8\uc744 \uc6b0\ud68c\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "saluran wilayah tempat orang dari %@ biasanya berkumpul \u2014 sel luas di sekitar pusat penduduk terbesar, bukan lokasi anda. ia terbuka dan dikenali ramai, jadi anggap ia dipantau: sertai pantas cuma menjimatkan menaip geohash; ia tidak menyembunyikan anda dan tidak memintas sekatan." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ \u0915\u093e \u092e\u093e\u0928\u093f\u0938\u0939\u0930\u0942 \u092a\u094d\u0930\u093e\u092f\u0903 \u092d\u0947\u0932\u093e \u0939\u0941\u0928\u0947 \u0915\u094d\u0937\u0947\u0924\u094d\u0930 \u091a\u094d\u092f\u093e\u0928\u0932 \u2014 \u0938\u092c\u0948\u092d\u0928\u094d\u0926\u093e \u0920\u0942\u0932\u094b \u091c\u0928\u0938\u0902\u0916\u094d\u092f\u093e \u0915\u0947\u0928\u094d\u0926\u094d\u0930 \u0935\u0930\u093f\u092a\u0930\u093f\u0915\u094b \u092b\u0930\u093e\u0915\u093f\u0932\u094b \u0938\u0947\u0932, \u0924\u092a\u093e\u0908\u0902\u0915\u094b \u0938\u094d\u0925\u093e\u0928 \u0939\u094b\u0907\u0928\u0964 \u092f\u094b \u0938\u093e\u0930\u094d\u0935\u091c\u0928\u093f\u0915 \u0930 \u0938\u092c\u0948\u0932\u093e\u0908 \u0925\u093e\u0939\u093e \u092d\u090f\u0915\u094b \u0939\u0941\u0928\u093e\u0932\u0947 \u0928\u093f\u0917\u0930\u093e\u0928\u0940\u092e\u093e \u091b \u092d\u0928\u0940 \u092e\u093e\u0928\u094d\u0928\u0941\u0939\u094b\u0938\u094d: \u091b\u093f\u091f\u094b \u0938\u093e\u092e\u0947\u0932\u0932\u0947 geohash \u091f\u093e\u0907\u092a \u0917\u0930\u094d\u0928\u092c\u093e\u091f \u092e\u093e\u0924\u094d\u0930 \u091c\u094b\u0917\u093e\u0909\u0901\u091b; \u092f\u0938\u0932\u0947 \u0924\u092a\u093e\u0908\u0902\u0932\u093e\u0908 \u0932\u0941\u0915\u093e\u0909\u0901\u0926\u0948\u0928 \u0930 \u092c\u094d\u0932\u0915 \u091b\u0932\u094d\u0926\u0948\u0928\u0964" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "het regiokanaal waar mensen uit %@ meestal samenkomen \u2014 de brede cel rond het grootste bevolkingscentrum, niet jouw locatie. het is openbaar en algemeen bekend, ga er dus van uit dat er wordt meegekeken: snel deelnemen bespaart alleen het intypen van de geohash; het verbergt je niet en omzeilt geen blokkades." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "kana\u0142 regionu, w kt\u00f3rym zwykle zbieraj\u0105 si\u0119 ludzie z %@ \u2014 szeroka kom\u00f3rka wok\u00f3\u0142 najwi\u0119kszego skupiska ludno\u015bci, nie twoja lokalizacja. jest publiczny i powszechnie znany, wi\u0119c za\u0142\u00f3\u017c, \u017ce jest obserwowany: szybkie do\u0142\u0105czenie oszcz\u0119dza tylko wpisywania geohasha; nie ukrywa ci\u0119 i nie omija blokad." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "o canal de regi\u00e3o onde as pessoas de %@ costumam reunir-se \u2014 a c\u00e9lula ampla em torno do maior centro populacional, n\u00e3o a sua localiza\u00e7\u00e3o. \u00e9 p\u00fablico e conhecido, por isso presuma que est\u00e1 vigiado: a entrada r\u00e1pida apenas evita escrever o geohash; n\u00e3o o esconde nem contorna bloqueios." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "o canal de regi\u00e3o onde as pessoas de %@ costumam se reunir \u2014 a c\u00e9lula ampla em torno do maior centro populacional, n\u00e3o a sua localiza\u00e7\u00e3o. \u00e9 p\u00fablico e conhecido, ent\u00e3o presuma que est\u00e1 vigiado: a entrada r\u00e1pida s\u00f3 poupa digitar o geohash; n\u00e3o esconde voc\u00ea nem contorna bloqueios." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0440\u0435\u0433\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u043a\u0430\u043d\u0430\u043b, \u0433\u0434\u0435 \u043e\u0431\u044b\u0447\u043d\u043e \u0441\u043e\u0431\u0438\u0440\u0430\u044e\u0442\u0441\u044f \u043b\u044e\u0434\u0438 \u0438\u0437 %@ \u2014 \u0448\u0438\u0440\u043e\u043a\u0430\u044f \u044f\u0447\u0435\u0439\u043a\u0430 \u0432\u043e\u043a\u0440\u0443\u0433 \u043a\u0440\u0443\u043f\u043d\u0435\u0439\u0448\u0435\u0433\u043e \u0446\u0435\u043d\u0442\u0440\u0430 \u043d\u0430\u0441\u0435\u043b\u0435\u043d\u0438\u044f, \u0430 \u043d\u0435 \u0432\u0430\u0448\u0435 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435. \u043e\u043d \u043f\u0443\u0431\u043b\u0438\u0447\u043d\u044b\u0439 \u0438 \u0432\u0441\u0435\u043c \u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0439, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0441\u0447\u0438\u0442\u0430\u0439\u0442\u0435, \u0447\u0442\u043e \u0437\u0430 \u043d\u0438\u043c \u043d\u0430\u0431\u043b\u044e\u0434\u0430\u044e\u0442: \u0431\u044b\u0441\u0442\u0440\u044b\u0439 \u0432\u0445\u043e\u0434 \u043b\u0438\u0448\u044c \u0438\u0437\u0431\u0430\u0432\u043b\u044f\u0435\u0442 \u043e\u0442 \u0432\u0432\u043e\u0434\u0430 \u0433\u0435\u043e\u0445\u044d\u0448\u0430; \u043e\u043d \u043d\u0435 \u0441\u043a\u0440\u044b\u0432\u0430\u0435\u0442 \u0432\u0430\u0441 \u0438 \u043d\u0435 \u043e\u0431\u0445\u043e\u0434\u0438\u0442 \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "regionkanalen d\u00e4r folk fr\u00e5n %@ oftast samlas \u2014 den vida cellen kring det st\u00f6rsta befolkningscentrumet, inte din plats. den \u00e4r offentlig och v\u00e4lk\u00e4nd, s\u00e5 utg\u00e5 fr\u00e5n att den bevakas: snabbanslutning sparar bara geohash-skrivandet; den d\u00f6ljer dig inte och kringg\u00e5r inga blockeringar." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ \u0bae\u0b95\u0bcd\u0b95\u0bb3\u0bcd \u0baa\u0bca\u0ba4\u0bc1\u0bb5\u0bbe\u0b95\u0b95\u0bcd \u0b95\u0bc2\u0b9f\u0bc1\u0bae\u0bcd \u0bae\u0ba3\u0bcd\u0b9f\u0bb2 \u0b9a\u0bc7\u0ba9\u0bb2\u0bcd \u2014 \u0bae\u0bbf\u0b95\u0baa\u0bcd\u0baa\u0bc6\u0bb0\u0bbf\u0baf \u0bae\u0b95\u0bcd\u0b95\u0bb3\u0bcd\u0ba4\u0bca\u0b95\u0bc8 \u0bae\u0bc8\u0baf\u0ba4\u0bcd\u0ba4\u0bc8\u0b9a\u0bcd \u0b9a\u0bc1\u0bb1\u0bcd\u0bb1\u0bbf\u0baf\u0bc1\u0bb3\u0bcd\u0bb3 \u0b85\u0b95\u0ba9\u0bcd\u0bb1 \u0b9a\u0bc6\u0bb2\u0bcd, \u0b89\u0b99\u0bcd\u0b95\u0bb3\u0bcd \u0b87\u0bb0\u0bc1\u0baa\u0bcd\u0baa\u0bbf\u0b9f\u0bae\u0bcd \u0b85\u0bb2\u0bcd\u0bb2. \u0b87\u0ba4\u0bc1 \u0baa\u0bca\u0ba4\u0bc1\u0bb5\u0bbe\u0ba9\u0ba4\u0bc1, \u0ba8\u0ba9\u0bcd\u0b95\u0bc1 \u0b85\u0bb1\u0bbf\u0baf\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1; \u0b8e\u0ba9\u0bb5\u0bc7 \u0b95\u0ba3\u0bcd\u0b95\u0bbe\u0ba3\u0bbf\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bc1\u0b95\u0bbf\u0bb1\u0ba4\u0bc1 \u0b8e\u0ba9\u0bcd\u0bb1\u0bc7 \u0b95\u0bb0\u0bc1\u0ba4\u0bc1\u0b99\u0bcd\u0b95\u0bb3\u0bcd: \u0bb5\u0bbf\u0bb0\u0bc8\u0bb5\u0bc1 \u0b9a\u0bc7\u0bb0\u0bcd\u0b95\u0bcd\u0b95\u0bc8 geohash \u0ba4\u0b9f\u0bcd\u0b9f\u0b9a\u0bcd\u0b9a\u0bc8 \u0bae\u0b9f\u0bcd\u0b9f\u0bc1\u0bae\u0bc7 \u0ba4\u0bb5\u0bbf\u0bb0\u0bcd\u0b95\u0bcd\u0b95\u0bbf\u0bb1\u0ba4\u0bc1; \u0b89\u0b99\u0bcd\u0b95\u0bb3\u0bc8 \u0bae\u0bb1\u0bc8\u0b95\u0bcd\u0b95\u0bbe\u0ba4\u0bc1, \u0ba4\u0b9f\u0bc8\u0b95\u0bb3\u0bc8\u0baf\u0bc1\u0bae\u0bcd \u0ba4\u0bbe\u0ba3\u0bcd\u0b9f\u0bbe\u0ba4\u0bc1." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0e41\u0e0a\u0e19\u0e41\u0e19\u0e25\u0e20\u0e39\u0e21\u0e34\u0e20\u0e32\u0e04\u0e17\u0e35\u0e48\u0e1c\u0e39\u0e49\u0e04\u0e19\u0e08\u0e32\u0e01 %@ \u0e21\u0e31\u0e01\u0e21\u0e32\u0e23\u0e27\u0e21\u0e01\u0e31\u0e19 \u2014 \u0e40\u0e0b\u0e25\u0e25\u0e4c\u0e01\u0e27\u0e49\u0e32\u0e07\u0e23\u0e2d\u0e1a\u0e28\u0e39\u0e19\u0e22\u0e4c\u0e01\u0e25\u0e32\u0e07\u0e1b\u0e23\u0e30\u0e0a\u0e32\u0e01\u0e23\u0e17\u0e35\u0e48\u0e43\u0e2b\u0e0d\u0e48\u0e17\u0e35\u0e48\u0e2a\u0e38\u0e14 \u0e44\u0e21\u0e48\u0e43\u0e0a\u0e48\u0e15\u0e33\u0e41\u0e2b\u0e19\u0e48\u0e07\u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13 \u0e21\u0e31\u0e19\u0e40\u0e1b\u0e34\u0e14\u0e2a\u0e32\u0e18\u0e32\u0e23\u0e13\u0e30\u0e41\u0e25\u0e30\u0e40\u0e1b\u0e47\u0e19\u0e17\u0e35\u0e48\u0e23\u0e39\u0e49\u0e08\u0e31\u0e01 \u0e43\u0e2b\u0e49\u0e16\u0e37\u0e2d\u0e27\u0e48\u0e32\u0e16\u0e39\u0e01\u0e08\u0e31\u0e1a\u0e15\u0e32\u0e21\u0e2d\u0e07: \u0e40\u0e02\u0e49\u0e32\u0e23\u0e48\u0e27\u0e21\u0e14\u0e48\u0e27\u0e19\u0e41\u0e04\u0e48\u0e0a\u0e48\u0e27\u0e22\u0e43\u0e2b\u0e49\u0e44\u0e21\u0e48\u0e15\u0e49\u0e2d\u0e07\u0e1e\u0e34\u0e21\u0e1e\u0e4c geohash \u0e44\u0e21\u0e48\u0e44\u0e14\u0e49\u0e0b\u0e48\u0e2d\u0e19\u0e15\u0e31\u0e27\u0e04\u0e38\u0e13\u0e41\u0e25\u0e30\u0e44\u0e21\u0e48\u0e44\u0e14\u0e49\u0e02\u0e49\u0e32\u0e21\u0e01\u0e32\u0e23\u0e1a\u0e25\u0e47\u0e2d\u0e01" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ insanlar\u0131n\u0131n genellikle topland\u0131\u011f\u0131 b\u00f6lge kanal\u0131 \u2014 en b\u00fcy\u00fck n\u00fcfus merkezinin \u00e7evresindeki geni\u015f h\u00fccre, sizin konumunuz de\u011fil. herkese a\u00e7\u0131k ve iyi bilinir, bu y\u00fczden izlendi\u011fini varsay\u0131n: h\u0131zl\u0131 kat\u0131l\u0131m yaln\u0131zca geohash yazmaktan kurtar\u0131r; sizi gizlemez ve engelleri a\u015fmaz." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0440\u0435\u0433\u0456\u043e\u043d\u0430\u043b\u044c\u043d\u0438\u0439 \u043a\u0430\u043d\u0430\u043b, \u0434\u0435 \u0437\u0430\u0437\u0432\u0438\u0447\u0430\u0439 \u0437\u0431\u0438\u0440\u0430\u044e\u0442\u044c\u0441\u044f \u043b\u044e\u0434\u0438 \u0437 %@ \u2014 \u0448\u0438\u0440\u043e\u043a\u0430 \u043a\u043e\u043c\u0456\u0440\u043a\u0430 \u043d\u0430\u0432\u043a\u043e\u043b\u043e \u043d\u0430\u0439\u0431\u0456\u043b\u044c\u0448\u043e\u0433\u043e \u0446\u0435\u043d\u0442\u0440\u0443 \u043d\u0430\u0441\u0435\u043b\u0435\u043d\u043d\u044f, \u0430 \u043d\u0435 \u0442\u0432\u043e\u0454 \u043c\u0456\u0441\u0446\u0435\u0437\u043d\u0430\u0445\u043e\u0434\u0436\u0435\u043d\u043d\u044f. \u0432\u0456\u043d \u043f\u0443\u0431\u043b\u0456\u0447\u043d\u0438\u0439 \u0456 \u0432\u0441\u0456\u043c \u0432\u0456\u0434\u043e\u043c\u0438\u0439, \u0442\u043e\u0436 \u0432\u0432\u0430\u0436\u0430\u0439, \u0449\u043e \u0437\u0430 \u043d\u0438\u043c \u0441\u0442\u0435\u0436\u0430\u0442\u044c: \u0448\u0432\u0438\u0434\u043a\u0438\u0439 \u0432\u0445\u0456\u0434 \u043b\u0438\u0448\u0435 \u043f\u043e\u0437\u0431\u0430\u0432\u043b\u044f\u0454 \u0432\u0432\u0435\u0434\u0435\u043d\u043d\u044f \u0433\u0435\u043e\u0445\u0435\u0448\u0443; \u0432\u0456\u043d \u043d\u0435 \u0445\u043e\u0432\u0430\u0454 \u0442\u0435\u0431\u0435 \u0439 \u043d\u0435 \u043e\u0431\u0445\u043e\u0434\u0438\u0442\u044c \u0431\u043b\u043e\u043a\u0443\u0432\u0430\u043d\u043d\u044f." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0648\u06c1 \u0639\u0644\u0627\u0642\u0627\u0626\u06cc \u0686\u06cc\u0646\u0644 \u062c\u06c1\u0627\u06ba %@ \u06a9\u06d2 \u0644\u0648\u06af \u0639\u0645\u0648\u0645\u0627\u064b \u062c\u0645\u0639 \u06c1\u0648\u062a\u06d2 \u06c1\u06cc\u06ba \u2014 \u0633\u0628 \u0633\u06d2 \u0628\u0691\u06d2 \u0622\u0628\u0627\u062f\u06cc \u06a9\u06d2 \u0645\u0631\u06a9\u0632 \u06a9\u06d2 \u06af\u0631\u062f \u0648\u0633\u06cc\u0639 \u0633\u06cc\u0644\u060c \u0622\u067e \u06a9\u0627 \u0645\u0642\u0627\u0645 \u0646\u06c1\u06cc\u06ba\u06d4 \u06cc\u06c1 \u0639\u0648\u0627\u0645\u06cc \u0627\u0648\u0631 \u0645\u0639\u0631\u0648\u0641 \u06c1\u06d2\u060c \u0627\u0633 \u0644\u06cc\u06d2 \u0641\u0631\u0636 \u06a9\u0631\u06cc\u06ba \u06a9\u06c1 \u0627\u0633 \u067e\u0631 \u0646\u0638\u0631 \u06c1\u06d2: \u0641\u0648\u0631\u06cc \u0634\u0645\u0648\u0644\u06cc\u062a \u0635\u0631\u0641 geohash \u0679\u0627\u0626\u067e \u06a9\u0631\u0646\u06d2 \u0633\u06d2 \u0628\u0686\u0627\u062a\u06cc \u06c1\u06d2\u061b \u06cc\u06c1 \u0622\u067e \u06a9\u0648 \u0686\u06be\u067e\u0627\u062a\u06cc \u0646\u06c1\u06cc\u06ba \u0627\u0648\u0631 \u0646\u06c1 \u0628\u0646\u062f\u0634\u06cc\u06ba \u0639\u0628\u0648\u0631 \u06a9\u0631\u062a\u06cc \u06c1\u06d2\u06d4" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "k\u00eanh khu v\u1ef1c n\u01a1i m\u1ecdi ng\u01b0\u1eddi t\u1eeb %@ th\u01b0\u1eddng t\u1ee5 h\u1ecdp \u2014 \u00f4 r\u1ed9ng quanh trung t\u00e2m d\u00e2n c\u01b0 l\u1edbn nh\u1ea5t, kh\u00f4ng ph\u1ea3i v\u1ecb tr\u00ed c\u1ee7a b\u1ea1n. k\u00eanh n\u00e0y c\u00f4ng khai v\u00e0 ai c\u0169ng bi\u1ebft, n\u00ean h\u00e3y m\u1eb7c \u0111\u1ecbnh l\u00e0 n\u00f3 b\u1ecb theo d\u00f5i: tham gia nhanh ch\u1ec9 gi\u00fap kh\u1ecfi g\u00f5 geohash; n\u00f3 kh\u00f4ng che gi\u1ea5u b\u1ea1n v\u00e0 kh\u00f4ng v\u01b0\u1ee3t ch\u1eb7n." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@\u7684\u4eba\u4eec\u901a\u5e38\u805a\u96c6\u7684\u533a\u57df\u9891\u9053\u2014\u2014\u56f4\u7ed5\u6700\u5927\u4eba\u53e3\u4e2d\u5fc3\u7684\u5bbd\u9614\u5355\u5143\u683c\uff0c\u800c\u4e0d\u662f\u4f60\u7684\u4f4d\u7f6e\u3002\u5b83\u516c\u5f00\u4e14\u4f17\u6240\u5468\u77e5\uff0c\u8bf7\u9ed8\u8ba4\u5b83\u88ab\u76d1\u89c6\uff1a\u5feb\u901f\u52a0\u5165\u53ea\u662f\u7701\u53bb\u8f93\u5165 geohash\uff1b\u5b83\u4e0d\u4f1a\u9690\u85cf\u4f60\uff0c\u4e5f\u4e0d\u4f1a\u7ed5\u8fc7\u5c01\u9501\u3002" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@\u7684\u4eba\u5011\u901a\u5e38\u805a\u96c6\u7684\u5340\u57df\u983b\u9053\u2014\u2014\u570d\u7e5e\u6700\u5927\u4eba\u53e3\u4e2d\u5fc3\u7684\u5bec\u95ca\u55ae\u5143\u683c\uff0c\u800c\u4e0d\u662f\u4f60\u7684\u4f4d\u7f6e\u3002\u5b83\u516c\u958b\u4e14\u773e\u6240\u5468\u77e5\uff0c\u8acb\u9810\u8a2d\u5b83\u53d7\u5230\u76e3\u8996\uff1a\u5feb\u901f\u52a0\u5165\u53ea\u662f\u7701\u53bb\u8f38\u5165 geohash\uff1b\u5b83\u4e0d\u6703\u96b1\u85cf\u4f60\uff0c\u4e5f\u4e0d\u6703\u7e5e\u904e\u5c01\u9396\u3002" - } - } - } - }, - "location_channels.quick_join.join_label" : { - "comment" : "Accessibility label for the quick join row; %@ is the localized country/region name", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0627\u0644\u0627\u0646\u0636\u0645\u0627\u0645 \u0625\u0644\u0649 \u0642\u0646\u0627\u0629 \u0645\u0646\u0637\u0642\u0629 %@" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@-\u098f\u09b0 \u0985\u099e\u09cd\u099a\u09b2-\u099a\u09cd\u09af\u09be\u09a8\u09c7\u09b2\u09c7 \u09af\u09cb\u0997 \u09a6\u09bf\u09a8" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "dem regionskanal von %@ beitreten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "join the %@ region channel" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "unirse al canal de regi\u00f3n de %@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u067e\u06cc\u0648\u0633\u062a\u0646 \u0628\u0647 \u06a9\u0627\u0646\u0627\u0644 \u0645\u0646\u0637\u0642\u0647\u200c\u0627\u06cc %@" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "sumali sa region channel ng %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "rejoindre le canal de r\u00e9gion de %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u05d4\u05e6\u05d8\u05e8\u05e4\u05d5\u05ea \u05dc\u05e2\u05e8\u05d5\u05e5 \u05d4\u05d0\u05d6\u05d5\u05e8 \u05e9\u05dc %@" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ \u0915\u0947 \u0915\u094d\u0937\u0947\u0924\u094d\u0930 \u091a\u0948\u0928\u0932 \u0938\u0947 \u091c\u0941\u0921\u093c\u0947\u0902" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "gabung ke channel wilayah %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "entra nel canale di regione di %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@\u306e\u5730\u57df\u30c1\u30e3\u30f3\u30cd\u30eb\u306b\u53c2\u52a0" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ \uc9c0\uc5ed \ucc44\ub110 \ucc38\uc5ec" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "sertai saluran wilayah %@" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ \u0915\u094b \u0915\u094d\u0937\u0947\u0924\u094d\u0930 \u091a\u094d\u092f\u093e\u0928\u0932\u092e\u093e \u0938\u093e\u092e\u0947\u0932 \u0939\u0941\u0928\u0941\u0939\u094b\u0938\u094d" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "deelnemen aan het regiokanaal van %@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "do\u0142\u0105cz do kana\u0142u regionu %@" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "entrar no canal de regi\u00e3o de %@" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "entrar no canal de regi\u00e3o de %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0432\u043e\u0439\u0442\u0438 \u0432 \u0440\u0435\u0433\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u043a\u0430\u043d\u0430\u043b %@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "g\u00e5 med i regionkanalen f\u00f6r %@" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ \u0bae\u0ba3\u0bcd\u0b9f\u0bb2 \u0b9a\u0bc7\u0ba9\u0bb2\u0bbf\u0bb2\u0bcd \u0b9a\u0bc7\u0bb0\u0bcd" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0e40\u0e02\u0e49\u0e32\u0e23\u0e48\u0e27\u0e21\u0e41\u0e0a\u0e19\u0e41\u0e19\u0e25\u0e20\u0e39\u0e21\u0e34\u0e20\u0e32\u0e04\u0e02\u0e2d\u0e07 %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ b\u00f6lge kanal\u0131na kat\u0131l" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u0443\u0432\u0456\u0439\u0442\u0438 \u0434\u043e \u0440\u0435\u0433\u0456\u043e\u043d\u0430\u043b\u044c\u043d\u043e\u0433\u043e \u043a\u0430\u043d\u0430\u043b\u0443 %@" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ \u06a9\u06d2 \u0639\u0644\u0627\u0642\u0627\u0626\u06cc \u0686\u06cc\u0646\u0644 \u0645\u06cc\u06ba \u0634\u0627\u0645\u0644 \u06c1\u0648\u06ba" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tham gia k\u00eanh khu v\u1ef1c c\u1ee7a %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u52a0\u5165%@\u7684\u533a\u57df\u9891\u9053" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "\u52a0\u5165%@\u7684\u5340\u57df\u983b\u9053" + "value" : "選擇圖像" } } } diff --git a/bitchat/Models/BitchatMessage+Media.swift b/bitchat/Models/BitchatMessage+Media.swift index 16e35a79..aa2a59f0 100644 --- a/bitchat/Models/BitchatMessage+Media.swift +++ b/bitchat/Models/BitchatMessage+Media.swift @@ -6,13 +6,19 @@ // For more information, see // -import BitFoundation import Foundation extension BitchatMessage { enum Media { case voice(URL) case image(URL) + + var url: URL { + switch self { + case .voice(let url), .image(let url): + return url + } + } } // Cache the directory lookup to avoid repeated FileManager calls during view rendering @@ -24,7 +30,7 @@ extension BitchatMessage { do { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) - try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes) + try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) self.filesDir = filesDir } catch { filesDir = nil diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift b/bitchat/Models/BitchatMessage.swift similarity index 77% rename from localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift rename to bitchat/Models/BitchatMessage.swift index 064cd5da..002858cc 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift +++ b/bitchat/Models/BitchatMessage.swift @@ -6,72 +6,44 @@ // For more information, see // -import class Foundation.DateFormatter - -import struct Foundation.AttributedString -import struct Foundation.Data -import struct Foundation.Date -import struct Foundation.TimeInterval -import struct Foundation.UUID +import Foundation +import BitFoundation /// Represents a user-visible message in the BitChat system. /// Handles both broadcast messages and private encrypted messages, /// with support for mentions, replies, and delivery tracking. /// - Note: This is the primary data model for chat messages -public final class BitchatMessage: Codable { - public let id: String - public let sender: String - public let content: String - public let timestamp: Date - public let isRelay: Bool - public let originalSender: String? - public let isPrivate: Bool - public let recipientNickname: String? - public let senderPeerID: PeerID? - public let mentions: [String]? // Array of mentioned nicknames - public var deliveryStatus: DeliveryStatus // Delivery tracking - /// True when this message reached us across a mesh bridge (signed by its - /// author for an internet rendezvous) rather than over local radio. - public let isBridged: Bool - +final class BitchatMessage: Codable { + let id: String + let sender: String + let content: String + let timestamp: Date + let isRelay: Bool + let originalSender: String? + let isPrivate: Bool + let recipientNickname: String? + let senderPeerID: PeerID? + let mentions: [String]? // Array of mentioned nicknames + var deliveryStatus: DeliveryStatus? // Delivery tracking + // Cached formatted text (not included in Codable) private var _cachedFormattedText: [String: AttributedString] = [:] - public func getCachedFormattedText(isDark: Bool, isSelf: Bool, variant: String = "") -> AttributedString? { - return _cachedFormattedText["\(variant)\(isDark)-\(isSelf)"] + func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? { + return _cachedFormattedText["\(isDark)-\(isSelf)"] } - - public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool, variant: String = "") { - _cachedFormattedText["\(variant)\(isDark)-\(isSelf)"] = text + + func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) { + _cachedFormattedText["\(isDark)-\(isSelf)"] = text } // Codable implementation enum CodingKeys: String, CodingKey { case id, sender, content, timestamp, isRelay, originalSender case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus - case isBridged } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - sender = try container.decode(String.self, forKey: .sender) - content = try container.decode(String.self, forKey: .content) - timestamp = try container.decode(Date.self, forKey: .timestamp) - isRelay = try container.decode(Bool.self, forKey: .isRelay) - originalSender = try container.decodeIfPresent(String.self, forKey: .originalSender) - isPrivate = try container.decode(Bool.self, forKey: .isPrivate) - recipientNickname = try container.decodeIfPresent(String.self, forKey: .recipientNickname) - senderPeerID = try container.decodeIfPresent(PeerID.self, forKey: .senderPeerID) - mentions = try container.decodeIfPresent([String].self, forKey: .mentions) - // Archives written while the field was optional omit it for public - // messages; absent means the message never entered a send pipeline. - deliveryStatus = try container.decodeIfPresent(DeliveryStatus.self, forKey: .deliveryStatus) ?? .notSentYet - // Absent in archives written before bridging existed. - isBridged = try container.decodeIfPresent(Bool.self, forKey: .isBridged) ?? false - } - - public init( + + init( id: String? = nil, sender: String, content: String, @@ -82,8 +54,7 @@ public final class BitchatMessage: Codable { recipientNickname: String? = nil, senderPeerID: PeerID? = nil, mentions: [String]? = nil, - deliveryStatus: DeliveryStatus? = nil, - isBridged: Bool = false + deliveryStatus: DeliveryStatus? = nil ) { self.id = id ?? UUID().uuidString self.sender = sender @@ -95,15 +66,14 @@ public final class BitchatMessage: Codable { self.recipientNickname = recipientNickname self.senderPeerID = senderPeerID self.mentions = mentions - self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : .notSentYet) - self.isBridged = isBridged + self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil) } } // MARK: - Equatable Conformance extension BitchatMessage: Equatable { - public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool { + static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool { return lhs.id == rhs.id && lhs.sender == rhs.sender && lhs.content == rhs.content && @@ -114,8 +84,7 @@ extension BitchatMessage: Equatable { lhs.recipientNickname == rhs.recipientNickname && lhs.senderPeerID == rhs.senderPeerID && lhs.mentions == rhs.mentions && - lhs.deliveryStatus == rhs.deliveryStatus && - lhs.isBridged == rhs.isBridged + lhs.deliveryStatus == rhs.deliveryStatus } } @@ -147,7 +116,6 @@ extension BitchatMessage { if recipientNickname != nil { flags |= 0x08 } if senderPeerID != nil { flags |= 0x10 } if mentions != nil && !mentions!.isEmpty { flags |= 0x20 } - if isBridged { flags |= 0x40 } data.append(flags) @@ -240,7 +208,6 @@ extension BitchatMessage { let hasRecipientNickname = (flags & 0x08) != 0 let hasSenderPeerID = (flags & 0x10) != 0 let hasMentions = (flags & 0x20) != 0 - let isBridged = (flags & 0x40) != 0 // Timestamp guard offset + 8 <= dataCopy.count else { @@ -349,8 +316,7 @@ extension BitchatMessage { isPrivate: isPrivate, recipientNickname: recipientNickname, senderPeerID: senderPeerID, - mentions: mentions, - isBridged: isBridged + mentions: mentions ) } } @@ -365,7 +331,26 @@ extension BitchatMessage { return formatter }() - public var formattedTimestamp: String { + var formattedTimestamp: String { Self.timestampFormatter.string(from: timestamp) } } + +extension Array where Element == BitchatMessage { + /// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest) + func cleanedAndDeduped() -> [Element] { + let arr = filter { $0.content.trimmed.isEmpty == false } + guard arr.count > 1 else { + return arr + } + var seen = Set() + var dedup: [BitchatMessage] = [] + for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) { + if !seen.contains(m.id) { + dedup.append(m) + seen.insert(m.id) + } + } + return dedup + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift b/bitchat/Models/BitchatPacket.swift similarity index 53% rename from localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift rename to bitchat/Models/BitchatPacket.swift index db286ae3..004f5414 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift +++ b/bitchat/Models/BitchatPacket.swift @@ -6,25 +6,26 @@ // For more information, see // -import struct Foundation.Data +import Foundation +import BitFoundation /// The core packet structure for all BitChat protocol messages. /// Encapsulates all data needed for routing through the mesh network, /// including TTL for hop limiting and optional encryption. /// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented -public struct BitchatPacket: Codable { - public let version: UInt8 - public let type: UInt8 - public let senderID: Data - public let recipientID: Data? - public let timestamp: UInt64 - public let payload: Data - public var signature: Data? - public var ttl: UInt8 - public var route: [Data]? - public var isRSR: Bool +struct BitchatPacket: Codable { + let version: UInt8 + let type: UInt8 + let senderID: Data + let recipientID: Data? + let timestamp: UInt64 + let payload: Data + var signature: Data? + var ttl: UInt8 + var route: [Data]? + var isRSR: Bool - public init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) { + init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) { self.version = version self.type = type self.senderID = senderID @@ -36,19 +37,47 @@ public struct BitchatPacket: Codable { self.route = route self.isRSR = isRSR } - - public func toBinaryData(padding: Bool = true) -> Data? { + + // Convenience initializer for new binary format + init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) { + self.version = 1 + self.type = type + // Convert hex string peer ID to binary data (8 bytes) + var senderData = Data() + var tempID = senderID.id + while tempID.count >= 2 { + let hexByte = String(tempID.prefix(2)) + if let byte = UInt8(hexByte, radix: 16) { + senderData.append(byte) + } + tempID = String(tempID.dropFirst(2)) + } + self.senderID = senderData + self.recipientID = nil + self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds + self.payload = payload + self.signature = nil + self.ttl = ttl + self.route = nil + self.isRSR = isRSR + } + + var data: Data? { + BinaryProtocol.encode(self) + } + + func toBinaryData(padding: Bool = true) -> Data? { BinaryProtocol.encode(self, padding: padding) } // Backward-compatible helper (defaults to padded encoding) - public func toBinaryData() -> Data? { + func toBinaryData() -> Data? { toBinaryData(padding: true) } /// Create binary representation for signing (without signature and TTL fields) /// TTL is excluded because it changes during packet relay operations - public func toBinaryDataForSigning() -> Data? { + func toBinaryDataForSigning() -> Data? { // Create a copy without signature and with fixed TTL for signing // TTL must be excluded because it changes during relay let unsignedPacket = BitchatPacket( @@ -66,7 +95,7 @@ public struct BitchatPacket: Codable { return BinaryProtocol.encode(unsignedPacket) } - public static func from(_ data: Data) -> BitchatPacket? { + static func from(_ data: Data) -> BitchatPacket? { BinaryProtocol.decode(data) } } diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index afa7c53d..6b69d019 100644 --- a/bitchat/Models/BitchatPeer.swift +++ b/bitchat/Models/BitchatPeer.swift @@ -7,6 +7,7 @@ struct BitchatPeer: Equatable { let peerID: PeerID // Hex-encoded peer ID let noisePublicKey: Data let nickname: String + let lastSeen: Date let isConnected: Bool let isReachable: Bool @@ -15,10 +16,6 @@ struct BitchatPeer: Equatable { // Nostr identity (if known) var nostrPublicKey: String? - - /// Device-local alias (petname). Never sent over the wire; when set it - /// outranks the peer-claimed `nickname` for display only. - var localPetname: String? // Connection state enum ConnectionState { @@ -33,22 +30,13 @@ struct BitchatPeer: Equatable { return .bluetoothConnected } else if isReachable { return .meshReachable - } else if favoriteStatus?.isMutual == true, reachableNostrPublicKey != nil { - // Mutual favorites can communicate via Nostr when offline — but - // only with a stored recipient key. NostrTransport applies the - // same rule when computing reachable peers; without a key, - // "available" would be a lie, and the DM header's - // "end-to-end encrypted" caption keys off this state. + } else if favoriteStatus?.isMutual == true { + // Mutual favorites can communicate via Nostr when offline return .nostrAvailable } else { return .offline } } - - /// The Nostr key a private envelope would actually be sealed to, if any. - var reachableNostrPublicKey: String? { - nostrPublicKey ?? favoriteStatus?.peerNostrPublicKey - } var isFavorite: Bool { favoriteStatus?.isFavorite ?? false @@ -64,10 +52,7 @@ struct BitchatPeer: Equatable { // Display helpers var displayName: String { - if let localPetname, !localPetname.isEmpty { - return localPetname - } - return nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname + nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname } var statusIcon: String { @@ -92,17 +77,16 @@ struct BitchatPeer: Equatable { peerID: PeerID, noisePublicKey: Data, nickname: String, - lastSeen _: Date = Date(), + lastSeen: Date = Date(), isConnected: Bool = false, - isReachable: Bool = false, - localPetname: String? = nil + isReachable: Bool = false ) { self.peerID = peerID self.noisePublicKey = noisePublicKey self.nickname = nickname + self.lastSeen = lastSeen self.isConnected = isConnected self.isReachable = isReachable - self.localPetname = localPetname // Load favorite status - will be set later by the manager self.favoriteStatus = nil diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index 2b077759..0bf25537 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -11,78 +11,48 @@ import Foundation // MARK: - CommandInfo Enum enum CommandInfo: String, Identifiable { - // Raw values must match the aliases CommandProcessor actually accepts — - // the suggestion panel is the app's only command-discovery surface, and - // suggesting a spelling the processor rejects teaches users dead ends. case block case clear - case group - case help case hug - case message = "msg" + case message = "dm" case slap - case pay case unblock case who - case favorite = "fav" - case unfavorite = "unfav" - case ping - case trace - case drop - + case favorite + case unfavorite + var id: String { rawValue } - + var alias: String { "/" + rawValue } - + var placeholder: String? { switch self { - case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace: + case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: return "<" + String(localized: "content.input.nickname_placeholder") + ">" - case .group: - return "<" + String(localized: "content.input.group_placeholder") + ">" - case .pay: - return "<" + String(localized: "content.input.token_placeholder") + ">" - case .drop: - return "<" + String(localized: "content.input.note_placeholder") + ">" - case .clear, .help, .who: + case .clear, .who: return nil } } - + var description: String { switch self { case .block: String(localized: "content.commands.block") case .clear: String(localized: "content.commands.clear") - case .group: String(localized: "content.commands.group") - case .help: String(localized: "content.commands.help") case .hug: String(localized: "content.commands.hug") case .message: String(localized: "content.commands.message") - case .pay: String(localized: "content.commands.pay") case .slap: String(localized: "content.commands.slap") case .unblock: String(localized: "content.commands.unblock") case .who: String(localized: "content.commands.who") case .favorite: String(localized: "content.commands.favorite") case .unfavorite: String(localized: "content.commands.unfavorite") - case .ping: String(localized: "content.commands.ping") - case .trace: String(localized: "content.commands.trace") - case .drop: String(localized: "content.commands.drop") } } - + static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { - var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who] - // Cashu tokens are bearer instruments: in a public geohash any nearby - // stranger can redeem one, so don't *suggest* /pay there (the - // processor still allows it behind an explicit "public" confirm). - // Payments make sense in every DM and in mesh public. - if !isGeoPublic { - commands.append(.pay) - } - // The processor rejects favorites, groups, and mesh diagnostics in - // geohash contexts, so only suggest them where they work: mesh. + let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who] if isGeoPublic || isGeoDM { - return commands + return baseCommands + [.favorite, .unfavorite] } - return commands + [.favorite, .unfavorite, .ping, .trace, .group] + return baseCommands } } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessagePadding.swift b/bitchat/Models/MessagePadding.swift similarity index 78% rename from localPackages/BitFoundation/Sources/BitFoundation/MessagePadding.swift rename to bitchat/Models/MessagePadding.swift index e9703e97..c9acd795 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessagePadding.swift +++ b/bitchat/Models/MessagePadding.swift @@ -6,19 +6,10 @@ // For more information, see // -import struct Foundation.Data +import Foundation /// Provides privacy-preserving message padding to obscure actual content length. -/// -/// PKCS#7-style: every pad byte equals the pad length, which is what `unpad` -/// verifies. The bytes are not random. -/// -/// Two limits are worth knowing before relying on this for traffic analysis -/// resistance. Only Noise frames are padded at all (see the outbound packet -/// policy); everything else travels at its natural length. And because the pad -/// length has to fit in one byte, `pad` declines any request needing more than -/// 255 bytes — so a frame far below its target bucket is emitted unpadded -/// rather than padded to a smaller bucket. +/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis. struct MessagePadding { // Standard block sizes for padding static let blockSizes = [256, 512, 1024, 2048] diff --git a/bitchat/Models/NoisePayload.swift b/bitchat/Models/NoisePayload.swift index 2d2f3c67..9d88a991 100644 --- a/bitchat/Models/NoisePayload.swift +++ b/bitchat/Models/NoisePayload.swift @@ -30,7 +30,7 @@ struct NoisePayload { // Safely get the first byte let firstByte = data[data.startIndex] - guard let type = NoisePayloadType.decoded(rawValue: firstByte) else { + guard let type = NoisePayloadType(rawValue: firstByte) else { return nil } diff --git a/bitchat/Models/RequestSyncPacket.swift b/bitchat/Models/RequestSyncPacket.swift index 0c6cb3cd..047cc20b 100644 --- a/bitchat/Models/RequestSyncPacket.swift +++ b/bitchat/Models/RequestSyncPacket.swift @@ -1,21 +1,10 @@ -import BitFoundation import Foundation // REQUEST_SYNC payload TLV (type, length16, value) // - 0x01: P (uint8) — Golomb-Rice parameter // - 0x02: M (uint32, big-endian) — hash range (N * 2^P) // - 0x03: data (opaque) — GR bitstream bytes (MSB-first) -// - 0x04: types (SyncTypeFlags) — packet types the filter covers -// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor -// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte) -// fragment stream IDs; restricts the fragment diff to exactly those -// streams (targeted resync for stalled reassemblies) struct RequestSyncPacket { - /// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as - /// 16 hex chars plus a comma separator, so the largest encoded value is - /// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap. - static let maxFragmentIdFilterCount = 60 - let p: Int let m: UInt32 let data: Data @@ -23,29 +12,6 @@ struct RequestSyncPacket { let sinceTimestamp: UInt64? let fragmentIdFilter: String? - /// Encodes 8-byte fragment stream IDs as the 0x06 filter string, - /// dropping malformed IDs and capping at `maxFragmentIdFilterCount`. - static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? { - let tokens = fragmentIDs - .filter { $0.count == 8 } - .prefix(maxFragmentIdFilterCount) - .map { $0.hexEncodedString() } - guard !tokens.isEmpty else { return nil } - return tokens.joined(separator: ",") - } - - /// Decodes a 0x06 filter string back into 8-byte fragment stream IDs, - /// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`. - static func decodeFragmentIdFilter(_ filter: String?) -> Set? { - guard let filter else { return nil } - var ids: Set = [] - for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) { - guard token.count == 16, let id = Data(hexString: String(token)) else { continue } - ids.insert(id) - } - return ids.isEmpty ? nil : ids - } - init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) { self.p = p self.m = m @@ -122,9 +88,7 @@ struct RequestSyncPacket { sinceTimestamp = ts } case 0x06: - // Same acceptance cap as the GCS payload; an oversized filter - // is ignored rather than failing the whole request. - if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) { + if let fid = String(data: v, encoding: .utf8) { fragmentIdFilter = fid } default: @@ -132,7 +96,7 @@ struct RequestSyncPacket { } } - guard let pp = p, let mm = m, let dd = payload, pp >= 1, pp <= GCSFilter.maxP, mm > 0 else { return nil } + guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil } return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter) } } diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index a42b86a0..faa18f31 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -78,7 +78,6 @@ /// import BitLogger -import BitFoundation import Foundation import CryptoKit @@ -93,7 +92,6 @@ enum NoisePattern { case XX // Most versatile, mutual authentication case IK // Initiator knows responder's static key case NK // Anonymous initiator - case X // One-way: single message to a known static key (no response) } enum NoiseRole { @@ -323,13 +321,6 @@ final class NoiseCipherState { throw NoiseError.replayDetected } - // The 4-byte nonce prefix has been stripped, so the remaining bytes - // must still hold at least the 16-byte Poly1305 tag. The up-front - // `ciphertext.count >= 16` guard is not sufficient here (it counts - // the nonce), and `prefix(count - 16)` would trap on a short payload. - guard actualCiphertext.count >= 16 else { - throw NoiseError.invalidCiphertext - } // Split ciphertext and tag encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16) tag = actualCiphertext.suffix(16) @@ -602,7 +593,7 @@ final class NoiseHandshakeState { switch pattern { case .XX: break // No pre-message keys - case .IK, .NK, .X: + case .IK, .NK: if role == .initiator, let remoteStatic = remoteStaticPublic { symmetricState.mixHash(remoteStatic.rawRepresentation) } else if role == .responder, let localStatic = localStaticPublic { @@ -723,7 +714,7 @@ final class NoiseHandshakeState { return messageBuffer } - func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data { + func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data { guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete @@ -905,7 +896,6 @@ extension NoisePattern { case .XX: return "XX" case .IK: return "IK" case .NK: return "NK" - case .X: return "X" } } @@ -927,10 +917,6 @@ extension NoisePattern { [.e, .es], // -> e, es [.e, .ee] // <- e, ee ] - case .X: - return [ - [.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message) - ] } } } diff --git a/bitchat/Noise/NoiseSecurityConstants.swift b/bitchat/Noise/NoiseSecurityConstants.swift index ac98eaf6..17781352 100644 --- a/bitchat/Noise/NoiseSecurityConstants.swift +++ b/bitchat/Noise/NoiseSecurityConstants.swift @@ -6,60 +6,14 @@ // For more information, see // -import BitFoundation import Foundation enum NoiseSecurityConstants { // Maximum message size to prevent memory exhaustion static let maxMessageSize = 65535 // 64KB as per Noise spec - - /// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes) - /// added by `NoiseCipherState` around every transport plaintext. - static let transportCiphertextOverhead = 20 - - /// Private files are an explicit BitChat extension to the ordinary Noise - /// message-size ceiling. They remain bounded by the same framed-file cap - /// used by the binary and fragment decoders. Only the `.privateFile` - /// typed-payload path is allowed to use this larger budget. - private static let privateFileOuterPacketOverhead = - (BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes - + BinaryProtocol.senderIDSize - + BinaryProtocol.recipientIDSize - static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes - - privateFileOuterPacketOverhead - - transportCiphertextOverhead - static let maxPrivateFileCiphertextSize = - maxPrivateFilePlaintextSize + transportCiphertextOverhead // Maximum handshake message size static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern - - // Noise XX message 1 contains only the initiator's 32-byte ephemeral key. - static let xxInitialMessageSize = 32 - - // Bounds an ordinary initiator whose message 1 or 2 is lost. - static let ordinaryHandshakeTimeout: TimeInterval = 10 - - // Bounds the receive-only rollback quarantine created by an unauthenticated - // inbound message 1. A lost message 3 must not strand outbound traffic. - static let ordinaryResponderHandshakeTimeout: TimeInterval = 20 - - // A released client may immediately retry after both crossed initiators - // yielded. Give that unilateral retry a brief head start before the - // patched side spends its one bounded recovery. - static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2 - - // Rate-limited recovery remains actionable without spinning. - static let handshakeRateLimitRecoveryDelay: TimeInterval = 60 - - // Covers only reordering between a winning message 3 and the losing - // crossed message 1. - static let recentInitiatorCompletionGracePeriod: TimeInterval = 1 - - // After unauthenticated responder rollback, reject another attempt long - // enough that paced message 1 traffic cannot keep outbound paused. A - // legitimate peer converges through the one manager-owned local retry. - static let ordinaryReconnectRollbackCooldown: TimeInterval = 60 // Session timeout - sessions older than this should be renegotiated static let sessionTimeout: TimeInterval = 86400 // 24 hours @@ -67,6 +21,12 @@ enum NoiseSecurityConstants { // Maximum number of messages before rekey (2^64 - 1 is the nonce limit) static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages + // Handshake timeout - abandon incomplete handshakes + static let handshakeTimeout: TimeInterval = 60 // 1 minute + + // Maximum concurrent sessions per peer + static let maxSessionsPerPeer = 3 + // Rate limiting static let maxHandshakesPerMinute = 10 static let maxMessagesPerSecond = 100 diff --git a/bitchat/Noise/NoiseSecurityError.swift b/bitchat/Noise/NoiseSecurityError.swift index 8ffba1eb..aff73c8b 100644 --- a/bitchat/Noise/NoiseSecurityError.swift +++ b/bitchat/Noise/NoiseSecurityError.swift @@ -14,4 +14,5 @@ enum NoiseSecurityError: Error { case messageTooLarge case invalidPeerID case rateLimitExceeded + case handshakeTimeout } diff --git a/bitchat/Noise/NoiseSecurityValidator.swift b/bitchat/Noise/NoiseSecurityValidator.swift index e9028199..355d8fd7 100644 --- a/bitchat/Noise/NoiseSecurityValidator.swift +++ b/bitchat/Noise/NoiseSecurityValidator.swift @@ -14,19 +14,6 @@ struct NoiseSecurityValidator { static func validateMessageSize(_ data: Data) -> Bool { return data.count <= NoiseSecurityConstants.maxMessageSize } - - static func validateCiphertextSize(_ data: Data) -> Bool { - data.count <= NoiseSecurityConstants.maxMessageSize - + NoiseSecurityConstants.transportCiphertextOverhead - } - - static func validatePrivateFileMessageSize(_ data: Data) -> Bool { - data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize - } - - static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool { - data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize - } /// Validate handshake message size static func validateHandshakeMessageSize(_ data: Data) -> Bool { diff --git a/bitchat/Noise/NoiseSession.swift b/bitchat/Noise/NoiseSession.swift index 3ffa1ffc..1df2f0ba 100644 --- a/bitchat/Noise/NoiseSession.swift +++ b/bitchat/Noise/NoiseSession.swift @@ -66,10 +66,7 @@ class NoiseSession { // Only initiator writes the first message if role == .initiator { - guard let handshake = handshakeState else { - throw NoiseSessionError.invalidState - } - let message = try handshake.writeMessage() + let message = try handshakeState!.writeMessage() sentHandshakeMessages.append(message) return message } else { diff --git a/bitchat/Noise/NoiseSessionError.swift b/bitchat/Noise/NoiseSessionError.swift index ad098bbc..24e172e7 100644 --- a/bitchat/Noise/NoiseSessionError.swift +++ b/bitchat/Noise/NoiseSessionError.swift @@ -11,11 +11,4 @@ enum NoiseSessionError: Error, Equatable { case notEstablished case sessionNotFound case alreadyEstablished - case peerIdentityMismatch -} - -/// The manager owns the exact attempt's one bounded recovery. Packet handling -/// must not launch its historical second, immediate restart for this failure. -struct NoiseManagedHandshakeFailure: Error { - let underlying: Error } diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index 80b3cc42..9029b228 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -11,107 +11,20 @@ import CryptoKit import Foundation import BitFoundation -struct NoiseHandshakeProcessingResult { - let response: Data? - let didEstablishAuthenticatedSession: Bool -} - -struct NoiseHandshakeInitiation: Equatable, Sendable { - let payload: Data - let attemptID: UUID -} - -struct NoiseHandshakeRecoveryRequest: Equatable, Sendable { - let peerID: PeerID - fileprivate let recoveryID: UUID -} - -enum NoiseHandshakeRecoveryPreparation { - case ordinary(NoiseHandshakeInitiation) - case transferred -} - -/// Why a quarantined transport became the active session again. -enum NoiseSessionRestoreReason: Equatable, Sendable { - /// The replacement attempt failed terminally (claimed-identity mismatch, - /// or a failure that owns no convergence retry). The counterpart never - /// finished replacement keys, so the restored generation is immediately - /// valid for outbound traffic. - case terminal - /// The responder window expired — or a recoverable failure occurred — - /// and this manager owns one mandatory convergence retry. The counterpart - /// may already hold replacement keys that discarded the restored ones, so - /// outbound queue drains must wait for the retry to conclude. - case pendingConvergence -} - final class NoiseSessionManager { private var sessions: [PeerID: NoiseSession] = [:] - /// Opaque identity for each exact entry in `sessions`. The generation is - /// created and removed under the same barrier as the session itself, so a - /// caller can never authenticate data with one session and lease another. - private var sessionGenerations: [PeerID: UUID] = [:] - /// One-time handoff tokens prevent a prepared XX message 1 from leaving - /// after an inbound collision has already changed this peer's role. - private var ordinaryInitiationIDs: [PeerID: UUID] = [:] - private var ordinaryInitiatorTimeouts: [PeerID: DispatchWorkItem] = [:] - private var ordinaryInitiatorRetryNotifications: [PeerID: Bool] = [:] - private var ordinaryResponderTimeouts: [PeerID: DispatchWorkItem] = [:] - private var ordinaryResponderDeadlines: [PeerID: DispatchTime] = [:] - private var ordinaryResponderRetryNotifications: [PeerID: Bool] = [:] - private var ordinaryRespondersCreatedByYield: Set = [] - private var recentOrdinaryInitiatorCompletions: [PeerID: Date] = [:] - private struct QuarantinedTransport { - let session: NoiseSession - let generation: UUID - /// Duplicate message 1 packets replace the incomplete responder but - /// never extend the original rollback window indefinitely. - let rollbackDeadline: DispatchTime - } - /// An unauthenticated inbound message 1 cannot keep old sending keys live, - /// but it also must not permanently destroy a victim session. The old - /// transport remains receive-only while the ordinary responder proves the - /// claimed static identity, then is discarded on success or restored on - /// bounded failure. - private var quarantinedTransports: [PeerID: QuarantinedTransport] = [:] - private var quarantineRollbackCooldownUntil: [PeerID: Date] = [:] - private var suppressedInitiationRecoveryTimeouts: [PeerID: DispatchWorkItem] = [:] - private var delayedHandshakeRecoveryWorkItems: [PeerID: DispatchWorkItem] = [:] - private var handshakeRecoveryCallbackIDs: [PeerID: UUID] = [:] - private var pendingHandshakeRecoveryIDs: [PeerID: UUID] = [:] + private let localStaticKey: Curve25519.KeyAgreement.PrivateKey + private let keychain: KeychainManagerProtocol private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession - private let localPeerID: PeerID - private let ordinaryHandshakeTimeout: TimeInterval - private let ordinaryResponderHandshakeTimeout: TimeInterval - private let recentInitiatorCompletionGracePeriod: TimeInterval - private let ordinaryReconnectRollbackCooldown: TimeInterval private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) // Callbacks - var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)? - var onSessionRestored: ((PeerID, UUID, NoiseSessionRestoreReason) -> Void)? + var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)? - var onHandshakeRecoveryRequired: ((NoiseHandshakeRecoveryRequest) -> Void)? - init( - localStaticKey: Curve25519.KeyAgreement.PrivateKey, - keychain: KeychainManagerProtocol, - ordinaryHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryHandshakeTimeout, - ordinaryResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, - recentInitiatorCompletionGracePeriod: TimeInterval = - NoiseSecurityConstants.recentInitiatorCompletionGracePeriod, - ordinaryReconnectRollbackCooldown: TimeInterval = - NoiseSecurityConstants.ordinaryReconnectRollbackCooldown - ) { - self.localPeerID = PeerID(publicKey: localStaticKey.publicKey.rawRepresentation) - self.ordinaryHandshakeTimeout = ordinaryHandshakeTimeout - self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout - self.recentInitiatorCompletionGracePeriod = - recentInitiatorCompletionGracePeriod - self.ordinaryReconnectRollbackCooldown = - ordinaryReconnectRollbackCooldown + init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { + self.localStaticKey = localStaticKey + self.keychain = keychain self.sessionFactory = { peerID, role in SecureNoiseSession( peerID: peerID, @@ -125,24 +38,11 @@ final class NoiseSessionManager { #if DEBUG init( localStaticKey: Curve25519.KeyAgreement.PrivateKey, - keychain _: KeychainManagerProtocol, - ordinaryHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryHandshakeTimeout, - ordinaryResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, - recentInitiatorCompletionGracePeriod: TimeInterval = - NoiseSecurityConstants.recentInitiatorCompletionGracePeriod, - ordinaryReconnectRollbackCooldown: TimeInterval = - NoiseSecurityConstants.ordinaryReconnectRollbackCooldown, + keychain: KeychainManagerProtocol, sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession ) { - self.localPeerID = PeerID(publicKey: localStaticKey.publicKey.rawRepresentation) - self.ordinaryHandshakeTimeout = ordinaryHandshakeTimeout - self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout - self.recentInitiatorCompletionGracePeriod = - recentInitiatorCompletionGracePeriod - self.ordinaryReconnectRollbackCooldown = - ordinaryReconnectRollbackCooldown + self.localStaticKey = localStaticKey + self.keychain = keychain self.sessionFactory = sessionFactory } #endif @@ -154,180 +54,21 @@ final class NoiseSessionManager { return sessions[peerID] } } - - /// Whether this peer has an inbound ordinary XX responder that still - /// needs message 3 before its receive keys can become authoritative. - func isAwaitingResponderHandshakeCompletion(for peerID: PeerID) -> Bool { - managerQueue.sync { - guard let session = sessions[peerID] else { return false } - return session.role == .responder - && session.getState() == .handshaking - } - } - - /// Transfers one bounded recovery generation to whatever ordinary XX - /// handshake currently owns the peer, or starts the generation's single - /// retry. The request token prevents stale transport callbacks from - /// creating additional attempts. - func prepareHandshakeRecovery( - _ request: NoiseHandshakeRecoveryRequest, - authorizeAttempt: () throws -> Void - ) throws -> NoiseHandshakeRecoveryPreparation? { - try managerQueue.sync(flags: .barrier) { - let peerID = request.peerID - guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else { - return nil - } - - if let current = sessions[peerID], - current.getState() == .handshaking { - if current.role == .initiator { - scheduleOrdinaryInitiatorTimeoutLocked( - current, - for: peerID, - notifyOnTimeout: true - ) - } else { - // A recovery may transfer to a responder that raced ahead - // of the callback. Preserve an existing quarantine deadline - // so duplicate unauthenticated message 1 packets cannot - // extend the outbound pause. - scheduleOrdinaryResponderTimeoutLocked( - current, - for: peerID, - notifyOnTimeout: true, - createdByYield: true, - rearmDeadline: quarantinedTransports[peerID] == nil - ) - } - return .transferred - } - - do { - try authorizeAttempt() - } catch { - redispatchHandshakeRecoveryLocked( - request, - after: NoiseSecurityConstants.handshakeRateLimitRecoveryDelay - ) - throw error - } - - let next = sessionFactory(peerID, .initiator) - let payload: Data - do { - payload = try next.startHandshake() - } catch { - next.reset() - redispatchHandshakeRecoveryLocked( - request, - after: NoiseSecurityConstants.handshakeCollisionRecoveryDelay - ) - throw error - } - - // Start before retiring a working transport. If preparation fails, - // the established session and its generation are untouched. - if let current = sessions.removeValue(forKey: peerID) { - current.reset() - } - if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { - quarantined.session.reset() - } - sessionGenerations.removeValue(forKey: peerID) - ordinaryInitiationIDs.removeValue(forKey: peerID) - cancelOrdinaryInitiatorTimeoutLocked(for: peerID) - cancelOrdinaryResponderTimeoutLocked(for: peerID) - cancelSuppressedInitiationRecoveryLocked(for: peerID) - recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) - quarantineRollbackCooldownUntil.removeValue(forKey: peerID) - - let attemptID = UUID() - sessions[peerID] = next - sessionGenerations[peerID] = UUID() - ordinaryInitiationIDs[peerID] = attemptID - // This is the one retry owned by `request`; it may time out but - // must not recursively mint another generation. - scheduleOrdinaryInitiatorTimeoutLocked( - next, - for: peerID, - notifyOnTimeout: false - ) - consumeHandshakeRecoveryLocked(request) - return .ordinary( - NoiseHandshakeInitiation( - payload: payload, - attemptID: attemptID - ) - ) - } - } - - func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) { - managerQueue.sync(flags: .barrier) { - consumeHandshakeRecoveryLocked(request) - } - } func removeSession(for peerID: PeerID) { managerQueue.sync(flags: .barrier) { - removeSessionLocked(for: peerID) + if let session = sessions.removeValue(forKey: peerID) { + session.reset() // Clear sensitive data before removing + } } } - private func removeSessionLocked(for peerID: PeerID) { - if let session = sessions.removeValue(forKey: peerID) { - session.reset() // Clear sensitive data before removing - } - if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { - quarantined.session.reset() - } - sessionGenerations.removeValue(forKey: peerID) - ordinaryInitiationIDs.removeValue(forKey: peerID) - cancelOrdinaryInitiatorTimeoutLocked(for: peerID) - cancelOrdinaryResponderTimeoutLocked(for: peerID) - recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) - quarantineRollbackCooldownUntil.removeValue(forKey: peerID) - cancelSuppressedInitiationRecoveryLocked(for: peerID) - cancelDelayedHandshakeRecoveryLocked(for: peerID) - } - func removeAllSessions() { managerQueue.sync(flags: .barrier) { for (_, session) in sessions { session.reset() } - for (_, quarantined) in quarantinedTransports { - quarantined.session.reset() - } - for (_, timeout) in ordinaryInitiatorTimeouts { - timeout.cancel() - } - for (_, timeout) in ordinaryResponderTimeouts { - timeout.cancel() - } - for (_, timeout) in suppressedInitiationRecoveryTimeouts { - timeout.cancel() - } - for (_, timeout) in delayedHandshakeRecoveryWorkItems { - timeout.cancel() - } sessions.removeAll() - sessionGenerations.removeAll() - ordinaryInitiationIDs.removeAll() - ordinaryInitiatorTimeouts.removeAll() - ordinaryInitiatorRetryNotifications.removeAll() - ordinaryResponderTimeouts.removeAll() - ordinaryResponderDeadlines.removeAll() - ordinaryResponderRetryNotifications.removeAll() - ordinaryRespondersCreatedByYield.removeAll() - recentOrdinaryInitiatorCompletions.removeAll() - quarantinedTransports.removeAll() - quarantineRollbackCooldownUntil.removeAll() - suppressedInitiationRecoveryTimeouts.removeAll() - delayedHandshakeRecoveryWorkItems.removeAll() - handshakeRecoveryCallbackIDs.removeAll() - pendingHandshakeRecoveryIDs.removeAll() } } @@ -343,916 +84,109 @@ final class NoiseSessionManager { // Remove any existing non-established session if let existingSession = sessions[peerID], !existingSession.isEstablished() { - removeSessionLocked(for: peerID) + _ = sessions.removeValue(forKey: peerID) } - recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) // Create new initiator session let session = sessionFactory(peerID, .initiator) sessions[peerID] = session - sessionGenerations[peerID] = UUID() do { let handshakeData = try session.startHandshake() - scheduleOrdinaryInitiatorTimeoutLocked( - session, - for: peerID, - notifyOnTimeout: false - ) - cancelDelayedHandshakeRecoveryLocked(for: peerID) return handshakeData } catch { // Clean up failed session _ = sessions.removeValue(forKey: peerID) - sessionGenerations.removeValue(forKey: peerID) - ordinaryInitiationIDs.removeValue(forKey: peerID) - cancelOrdinaryInitiatorTimeoutLocked(for: peerID) - session.reset() SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) throw error } } } - - /// Atomically starts an ordinary initiator only when no other session - /// already owns the peer. BLE discovery can report the same peer on - /// multiple links; combining the absence check, authorization, creation, - /// and handoff token prevents two different message 1 packets escaping. - func initiateHandshakeIfAbsent( - with peerID: PeerID, - notifyOnTimeout: Bool, - authorize: () throws -> Void - ) throws -> NoiseHandshakeInitiation? { - try managerQueue.sync(flags: .barrier) { - guard sessions[peerID] == nil, - quarantinedTransports[peerID] == nil else { - return nil - } - try authorize() - - let session = sessionFactory(peerID, .initiator) - do { - let payload = try session.startHandshake() - let attemptID = UUID() - sessions[peerID] = session - sessionGenerations[peerID] = UUID() - ordinaryInitiationIDs[peerID] = attemptID - recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) - quarantineRollbackCooldownUntil.removeValue(forKey: peerID) - scheduleOrdinaryInitiatorTimeoutLocked( - session, - for: peerID, - notifyOnTimeout: notifyOnTimeout - ) - cancelDelayedHandshakeRecoveryLocked(for: peerID) - return NoiseHandshakeInitiation( - payload: payload, - attemptID: attemptID - ) - } catch { - session.reset() - SecureLogger.error( - .handshakeFailed( - peerID: peerID.id, - error: error.localizedDescription - ) - ) - throw error - } - } - } - - /// Prepares an ordinary reconnect before atomically retiring the current - /// transport. Authorization and handshake-start failure leave the working - /// session untouched. Once this returns, encryption observes only the new - /// handshaking session and must queue until it establishes. - func initiateReconnectHandshake( - with peerID: PeerID, - notifyOnTimeout: Bool, - authorize: () throws -> Void - ) throws -> NoiseHandshakeInitiation { - try managerQueue.sync(flags: .barrier) { - guard let established = sessions[peerID], - established.isEstablished() else { - throw NoiseSessionError.notEstablished - } - try authorize() - - let next = sessionFactory(peerID, .initiator) - let payload: Data - do { - payload = try next.startHandshake() - } catch { - next.reset() - SecureLogger.error( - .handshakeFailed( - peerID: peerID.id, - error: error.localizedDescription - ) - ) - throw error - } - - // Retire only after the replacement initiator has successfully - // produced message 1. Do not use the broad removal helper here: - // this transition owns the exact new session installed below. - _ = sessions.removeValue(forKey: peerID) - sessionGenerations.removeValue(forKey: peerID) - ordinaryInitiationIDs.removeValue(forKey: peerID) - cancelOrdinaryInitiatorTimeoutLocked(for: peerID) - cancelOrdinaryResponderTimeoutLocked(for: peerID) - cancelSuppressedInitiationRecoveryLocked(for: peerID) - cancelDelayedHandshakeRecoveryLocked(for: peerID) - recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) - quarantineRollbackCooldownUntil.removeValue(forKey: peerID) - established.reset() - - let attemptID = UUID() - sessions[peerID] = next - sessionGenerations[peerID] = UUID() - ordinaryInitiationIDs[peerID] = attemptID - scheduleOrdinaryInitiatorTimeoutLocked( - next, - for: peerID, - notifyOnTimeout: notifyOnTimeout - ) - return NoiseHandshakeInitiation( - payload: payload, - attemptID: attemptID - ) - } - } - - /// Claims a prepared message 1 exactly once. A crossed inbound initiation - /// that already made this peer a responder invalidates the token. - func claimHandshakeInitiation( - _ initiation: NoiseHandshakeInitiation, - for peerID: PeerID - ) -> Data? { - managerQueue.sync(flags: .barrier) { - guard ordinaryInitiationIDs[peerID] == initiation.attemptID, - let session = sessions[peerID], - session.role == .initiator, - session.getState() == .handshaking else { - return nil - } - ordinaryInitiationIDs.removeValue(forKey: peerID) - let notifyOnTimeout = - ordinaryInitiatorRetryNotifications[peerID] ?? false - // Preparation and on-wire exchange have independent bounds. Once - // BLE owns these exact bytes, give the peer the full response - // window without changing retry ownership. - scheduleOrdinaryInitiatorTimeoutLocked( - session, - for: peerID, - notifyOnTimeout: notifyOnTimeout - ) - return initiation.payload - } - } func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { - try handleIncomingHandshakeWithResult( - from: peerID, - message: message - ).response - } - - /// Processes one exact handshake candidate and reports whether that - /// candidate completed authenticated establishment. The peer's retained - /// session may already be established while a replacement is only on - /// message one, so callers must not infer candidate completion from the - /// peer-level session table. - func handleIncomingHandshakeWithResult( - from peerID: PeerID, - message: Data - ) throws -> NoiseHandshakeProcessingResult { - // Process everything within the synchronized block to prevent race conditions. - // Return establishment metadata and publish the callback only after the - // manager barrier is released, avoiding both a deadlock and a window in - // which `processHandshakeMessage` returns before authentication state. - let result: ( - response: Data?, - establishedSession: ( - remoteKey: Curve25519.KeyAgreement.PublicKey, - generation: UUID - )? - ) = try managerQueue.sync(flags: .barrier) { - var yieldedInitiatorShouldRetry = false - var didYieldLocalInitiator = false - var inheritedResponderShouldRetry = false - var inheritedResponderWasCreatedByYield = false - let existingAtIngress = sessions[peerID] - let isFreshInitiation = - message.count == NoiseSecurityConstants.xxInitialMessageSize - || existingAtIngress == nil - || ( - existingAtIngress?.isEstablished() == true - && message.count - > NoiseSecurityConstants.xxInitialMessageSize - ) - - if isFreshInitiation { - if let cooldownUntil = quarantineRollbackCooldownUntil[peerID] { - if cooldownUntil > Date(), - sessions[peerID]?.isEstablished() == true { - SecureLogger.debug( - "Ignoring unauthenticated reconnect initiation during rollback cooldown for \(peerID)", - category: .session - ) - return (nil, nil) - } - quarantineRollbackCooldownUntil.removeValue(forKey: peerID) - } - - if suppressedInitiationRecoveryTimeouts[peerID] != nil, - localPeerID < peerID.toShort(), - sessions[peerID]?.isEstablished() == true { - SecureLogger.debug( - "Coalescing duplicate initiation while convergence recovery is pending for \(peerID)", - category: .session - ) - return (nil, nil) - } - - if let completedAt = recentOrdinaryInitiatorCompletions[peerID] { - let stillInGrace = Date().timeIntervalSince(completedAt) - < recentInitiatorCompletionGracePeriod - if stillInGrace, - localPeerID < peerID.toShort(), - let established = sessions[peerID], - established.isEstablished() { - recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) - scheduleSuppressedInitiationRecoveryLocked( - established, - completedAt: completedAt, - for: peerID - ) - SecureLogger.debug( - "Deferring delayed crossed initiation from \(peerID) after initiator completion", - category: .session - ) - return (nil, nil) - } - recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) - } - - if let ordinaryInitiator = sessions[peerID], - ordinaryInitiator.role == .initiator, - ordinaryInitiator.getState() == .handshaking { - if localPeerID < peerID.toShort() { - SecureLogger.debug( - "Ignoring crossed ordinary initiation from \(peerID); keeping deterministic initiator role", - category: .session - ) - return (nil, nil) - } - yieldedInitiatorShouldRetry = - ordinaryInitiatorRetryNotifications[peerID] ?? false - didYieldLocalInitiator = true - _ = sessions.removeValue(forKey: peerID) - sessionGenerations.removeValue(forKey: peerID) - ordinaryInitiationIDs.removeValue(forKey: peerID) - cancelOrdinaryInitiatorTimeoutLocked(for: peerID) - cancelOrdinaryResponderTimeoutLocked(for: peerID) - ordinaryInitiator.reset() - } - } - - let session: NoiseSession - + // Process everything within the synchronized block to prevent race conditions + return try managerQueue.sync(flags: .barrier) { + var shouldCreateNew = false + var existingSession: NoiseSession? = nil + if let existing = sessions[peerID] { - if existing.isEstablished(), - !isFreshInitiation { - // An established ordinary XX transport has no remaining - // handshake messages to consume. Unauthenticated garbage - // must not tear down its working keys. - SecureLogger.debug( - "Ignoring non-initial handshake bytes for established peer \(peerID)", - category: .session - ) - return (nil, nil) - } - if isFreshInitiation { - if existing.isEstablished(), - let generation = sessionGenerations[peerID] { - // Message 1 is unauthenticated. Remove the old - // transport from every outbound/generation API now, - // retaining it only as receive-only rollback state. - _ = sessions.removeValue(forKey: peerID) - sessionGenerations.removeValue(forKey: peerID) - ordinaryInitiationIDs.removeValue(forKey: peerID) - cancelOrdinaryInitiatorTimeoutLocked(for: peerID) - cancelOrdinaryResponderTimeoutLocked(for: peerID) - if let prior = quarantinedTransports.updateValue( - QuarantinedTransport( - session: existing, - generation: generation, - rollbackDeadline: .now() - + ordinaryResponderHandshakeTimeout - ), - forKey: peerID - ) { - prior.session.reset() - } - } else { - inheritedResponderShouldRetry = - ordinaryResponderRetryNotifications[peerID] ?? false - inheritedResponderWasCreatedByYield = - ordinaryRespondersCreatedByYield.contains(peerID) - _ = sessions.removeValue(forKey: peerID) - sessionGenerations.removeValue(forKey: peerID) - ordinaryInitiationIDs.removeValue(forKey: peerID) - // Keep the first responder/quarantine deadline. A - // repeated message 1 may refresh message 2, but never - // refreshes the attacker's outbound-pause budget. - cancelOrdinaryResponderTimeoutLocked( - for: peerID, - preserveDeadline: true - ) - existing.reset() - } - - let replacement = sessionFactory(peerID, .responder) - sessions[peerID] = replacement - sessionGenerations[peerID] = UUID() - session = replacement + // If we have an established session, the peer must have cleared their session + // for a good reason (e.g., decryption failure, restart, etc.) + // We should accept the new handshake to re-establish encryption + if existing.isEstablished() { + SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session) + _ = sessions.removeValue(forKey: peerID) + shouldCreateNew = true } else { - session = existing + // If we're in the middle of a handshake and receive a new initiation, + // reset and start fresh (the other side may have restarted) + if existing.getState() == .handshaking && message.count == 32 { + _ = sessions.removeValue(forKey: peerID) + shouldCreateNew = true + } else { + existingSession = existing + } } } else { + shouldCreateNew = true + } + + // Get or create session + let session: NoiseSession + if shouldCreateNew { let newSession = sessionFactory(peerID, .responder) sessions[peerID] = newSession - sessionGenerations[peerID] = UUID() session = newSession + } else { + session = existingSession! } - + + // Process the handshake message within the synchronized block do { - if isFreshInitiation, - session.role == .responder { - scheduleOrdinaryResponderTimeoutLocked( - session, - for: peerID, - notifyOnTimeout: - yieldedInitiatorShouldRetry - || inheritedResponderShouldRetry, - createdByYield: - didYieldLocalInitiator - || inheritedResponderWasCreatedByYield - ) - } - let response = try session.processHandshakeMessage(message) - // Check the exact session that processed this message. A - // preserved peer-level session can remain established while a - // replacement candidate is still unauthenticated. - var establishedSession: ( - remoteKey: Curve25519.KeyAgreement.PublicKey, - generation: UUID - )? + // Check if session is established after processing if session.isEstablished() { - guard let remoteKey = session.getRemoteStaticPublicKey(), - authenticatedRemoteKey(remoteKey, matches: peerID) else { - throw NoiseSessionError.peerIdentityMismatch + if let remoteKey = session.getRemoteStaticPublicKey() { + // Schedule callback outside the synchronized block to prevent deadlock + DispatchQueue.global().async { [weak self] in + self?.onSessionEstablished?(peerID, remoteKey) + } } - - if let quarantined = quarantinedTransports.removeValue( - forKey: peerID - ) { - quarantined.session.reset() - } - ordinaryInitiationIDs.removeValue(forKey: peerID) - cancelOrdinaryInitiatorTimeoutLocked(for: peerID) - cancelOrdinaryResponderTimeoutLocked(for: peerID) - quarantineRollbackCooldownUntil.removeValue(forKey: peerID) - if session.role == .initiator { - recentOrdinaryInitiatorCompletions[peerID] = Date() - } else { - recentOrdinaryInitiatorCompletions.removeValue( - forKey: peerID - ) - } - cancelSuppressedInitiationRecoveryLocked(for: peerID) - cancelDelayedHandshakeRecoveryLocked(for: peerID) - guard let generation = sessionGenerations[peerID] else { - throw NoiseEncryptionError.sessionNotEstablished - } - establishedSession = (remoteKey, generation) } - return (response, establishedSession) + return response } catch { - var shouldRequestRecovery = false - var shouldSuppressImmediateHandlerRestart = false - if session.role == .initiator { - shouldRequestRecovery = - ordinaryInitiatorRetryNotifications[peerID] ?? false - shouldSuppressImmediateHandlerRestart = true - } else { - shouldRequestRecovery = - ordinaryResponderRetryNotifications[peerID] ?? false - shouldSuppressImmediateHandlerRestart = - ordinaryRespondersCreatedByYield.contains(peerID) - } - - if let storedSession = sessions[peerID], - storedSession === session { - _ = sessions.removeValue(forKey: peerID) - sessionGenerations.removeValue(forKey: peerID) - } - ordinaryInitiationIDs.removeValue(forKey: peerID) - cancelOrdinaryInitiatorTimeoutLocked(for: peerID) - cancelOrdinaryResponderTimeoutLocked(for: peerID) - recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) - session.reset() - - let isIdentityMismatch = - (error as? NoiseSessionError) == .peerIdentityMismatch - - let restoredGeneration: UUID? - if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { - sessions[peerID] = quarantined.session - sessionGenerations[peerID] = quarantined.generation - restoredGeneration = quarantined.generation - markRollbackCooldownLocked(for: peerID) - } else { - restoredGeneration = nil - } - - // An identity mismatch is terminal: the counterpart failed to - // prove the claimed static key, so it never finished keys that - // could have replaced the restored ones. Every other restore - // that owns (or joins) a convergence retry must keep transport - // queues parked until that retry concludes — the counterpart - // may already have discarded the restored sending keys. - let restoreReason: NoiseSessionRestoreReason = - !isIdentityMismatch - && (shouldRequestRecovery - || pendingHandshakeRecoveryIDs[peerID] != nil) - ? .pendingConvergence - : .terminal - + // Reset the session on handshake failure so next attempt can start fresh + _ = sessions.removeValue(forKey: peerID) + // Schedule callback outside the synchronized block to prevent deadlock DispatchQueue.global().async { [weak self] in - if let restoredGeneration { - self?.onSessionRestored?(peerID, restoredGeneration, restoreReason) - } self?.onSessionFailed?(peerID, error) } - if pendingHandshakeRecoveryIDs[peerID] != nil { - shouldSuppressImmediateHandlerRestart = true - } - if shouldRequestRecovery, !isIdentityMismatch { - requestHandshakeRecovery( - for: peerID, - after: NoiseSecurityConstants - .handshakeCollisionRecoveryDelay - ) - } else if isIdentityMismatch, - let recoveryID = pendingHandshakeRecoveryIDs[peerID] { - redispatchHandshakeRecoveryLocked( - NoiseHandshakeRecoveryRequest( - peerID: peerID, - recoveryID: recoveryID - ), - after: NoiseSecurityConstants - .handshakeCollisionRecoveryDelay - ) - } SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) - if shouldSuppressImmediateHandlerRestart, !isIdentityMismatch { - throw NoiseManagedHandshakeFailure(underlying: error) - } throw error } } - - if let established = result.establishedSession { - onSessionEstablished?(peerID, established.remoteKey, established.generation) - } - return NoiseHandshakeProcessingResult( - response: result.response, - didEstablishAuthenticatedSession: result.establishedSession != nil - ) - } - - private func scheduleOrdinaryInitiatorTimeoutLocked( - _ session: NoiseSession, - for peerID: PeerID, - notifyOnTimeout: Bool - ) { - cancelOrdinaryInitiatorTimeoutLocked(for: peerID) - ordinaryInitiatorRetryNotifications[peerID] = notifyOnTimeout - let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak session] in - guard let self, - let session, - let current = self.sessions[peerID], - current === session, - current.role == .initiator, - current.getState() == .handshaking else { - return - } - - _ = self.sessions.removeValue(forKey: peerID) - self.sessionGenerations.removeValue(forKey: peerID) - self.ordinaryInitiationIDs.removeValue(forKey: peerID) - self.ordinaryInitiatorTimeouts.removeValue(forKey: peerID) - self.ordinaryInitiatorRetryNotifications.removeValue(forKey: peerID) - self.recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) - session.reset() - SecureLogger.debug( - "Ordinary initiator handshake with \(peerID) timed out", - category: .session - ) - - if notifyOnTimeout { - self.requestHandshakeRecovery(for: peerID) - } - } - ordinaryInitiatorTimeouts[peerID] = timeout - managerQueue.asyncAfter( - deadline: .now() + ordinaryHandshakeTimeout, - execute: timeout - ) - } - - private func cancelOrdinaryInitiatorTimeoutLocked(for peerID: PeerID) { - ordinaryInitiatorTimeouts.removeValue(forKey: peerID)?.cancel() - ordinaryInitiatorRetryNotifications.removeValue(forKey: peerID) - } - - private func scheduleOrdinaryResponderTimeoutLocked( - _ session: NoiseSession, - for peerID: PeerID, - notifyOnTimeout: Bool, - createdByYield: Bool, - rearmDeadline: Bool = false - ) { - let previousDeadline = ordinaryResponderDeadlines[peerID] - cancelOrdinaryResponderTimeoutLocked( - for: peerID, - preserveDeadline: true - ) - ordinaryResponderRetryNotifications[peerID] = notifyOnTimeout - if createdByYield { - ordinaryRespondersCreatedByYield.insert(peerID) - } - - let deadline: DispatchTime - if let rollbackDeadline = - quarantinedTransports[peerID]?.rollbackDeadline { - deadline = rollbackDeadline - } else if !rearmDeadline, let previousDeadline { - deadline = previousDeadline - } else { - deadline = .now() + ordinaryResponderHandshakeTimeout - } - ordinaryResponderDeadlines[peerID] = deadline - - let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak session] in - guard let self, - let session, - let current = self.sessions[peerID], - current === session, - current.role == .responder, - current.getState() == .handshaking else { - return - } - - _ = self.sessions.removeValue(forKey: peerID) - self.sessionGenerations.removeValue(forKey: peerID) - self.ordinaryInitiationIDs.removeValue(forKey: peerID) - self.ordinaryResponderTimeouts.removeValue(forKey: peerID) - self.ordinaryResponderDeadlines.removeValue(forKey: peerID) - self.ordinaryResponderRetryNotifications.removeValue(forKey: peerID) - self.ordinaryRespondersCreatedByYield.remove(peerID) - self.recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) - session.reset() - - let restored: QuarantinedTransport? - if let quarantined = - self.quarantinedTransports.removeValue(forKey: peerID) { - self.sessions[peerID] = quarantined.session - self.sessionGenerations[peerID] = quarantined.generation - self.markRollbackCooldownLocked(for: peerID) - restored = quarantined - } else { - restored = nil - } - - SecureLogger.debug( - restored == nil - ? "Ordinary responder handshake with \(peerID) timed out" - : "Ordinary responder handshake with \(peerID) timed out; restored quarantined transport", - category: .session - ) - if let restored { - // The mandatory convergence retry below owns the outbound - // resume: the timed-out counterpart may hold replacement keys - // that already discarded the restored generation's, so queue - // drains under it would be silently undecryptable. - DispatchQueue.global().async { [weak self] in - self?.onSessionRestored?( - peerID, - restored.generation, - .pendingConvergence - ) - } - } - - // A rollback always owns one local convergence attempt. The retry - // retires the restored session atomically, so an attacker cannot - // pace unauthenticated message 1 packets to pause outbound forever. - if notifyOnTimeout || restored != nil { - self.requestHandshakeRecovery(for: peerID) - } - } - ordinaryResponderTimeouts[peerID] = timeout - managerQueue.asyncAfter(deadline: deadline, execute: timeout) - } - - private func cancelOrdinaryResponderTimeoutLocked( - for peerID: PeerID, - preserveDeadline: Bool = false - ) { - ordinaryResponderTimeouts.removeValue(forKey: peerID)?.cancel() - if !preserveDeadline { - ordinaryResponderDeadlines.removeValue(forKey: peerID) - } - ordinaryResponderRetryNotifications.removeValue(forKey: peerID) - ordinaryRespondersCreatedByYield.remove(peerID) - } - - private func markRollbackCooldownLocked(for peerID: PeerID) { - quarantineRollbackCooldownUntil[peerID] = - Date().addingTimeInterval(ordinaryReconnectRollbackCooldown) - } - - private func scheduleSuppressedInitiationRecoveryLocked( - _ establishedSession: NoiseSession, - completedAt: Date, - for peerID: PeerID - ) { - cancelSuppressedInitiationRecoveryLocked(for: peerID) - let elapsed = max(0, Date().timeIntervalSince(completedAt)) - let remainingGrace = max( - 0, - recentInitiatorCompletionGracePeriod - elapsed - ) - let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak establishedSession] in - guard let self, - let establishedSession, - let current = self.sessions[peerID], - current === establishedSession, - current.isEstablished() else { - return - } - - self.suppressedInitiationRecoveryTimeouts.removeValue( - forKey: peerID - ) - self.requestHandshakeRecovery(for: peerID) - } - suppressedInitiationRecoveryTimeouts[peerID] = timeout - managerQueue.asyncAfter( - deadline: .now() + remainingGrace, - execute: timeout - ) - } - - private func cancelSuppressedInitiationRecoveryLocked(for peerID: PeerID) { - suppressedInitiationRecoveryTimeouts.removeValue(forKey: peerID)? - .cancel() - } - - #if DEBUG - /// Fires a pending suppressed-initiation recovery immediately instead of - /// waiting out the completion-grace timer, so tests can inject a grace - /// period too large to lose against a starved runner and still exercise - /// the recovery path deterministically. - func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) { - managerQueue.sync(flags: .barrier) { - guard let pending = suppressedInitiationRecoveryTimeouts - .removeValue(forKey: peerID) else { - return - } - pending.cancel() - guard let current = sessions[peerID], - current.isEstablished() else { - return - } - requestHandshakeRecovery(for: peerID) - } - } - #endif - - private func requestHandshakeRecovery( - for peerID: PeerID, - after delay: TimeInterval = 0 - ) { - cancelDelayedHandshakeRecoveryLocked(for: peerID) - let request = NoiseHandshakeRecoveryRequest( - peerID: peerID, - recoveryID: UUID() - ) - pendingHandshakeRecoveryIDs[peerID] = request.recoveryID - scheduleHandshakeRecoveryCallbackLocked(request, after: delay) - } - - private func redispatchHandshakeRecoveryLocked( - _ request: NoiseHandshakeRecoveryRequest, - after delay: TimeInterval - ) { - guard pendingHandshakeRecoveryIDs[request.peerID] - == request.recoveryID else { - return - } - scheduleHandshakeRecoveryCallbackLocked(request, after: delay) - } - - private func scheduleHandshakeRecoveryCallbackLocked( - _ request: NoiseHandshakeRecoveryRequest, - after delay: TimeInterval - ) { - let peerID = request.peerID - guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else { - return - } - - delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel() - let callbackID = UUID() - handshakeRecoveryCallbackIDs[peerID] = callbackID - let callback = DispatchWorkItem(flags: .barrier) { [weak self] in - guard let self, - self.pendingHandshakeRecoveryIDs[peerID] - == request.recoveryID, - self.handshakeRecoveryCallbackIDs[peerID] == callbackID else { - return - } - self.delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID) - self.handshakeRecoveryCallbackIDs.removeValue(forKey: peerID) - let handler = self.onHandshakeRecoveryRequired - DispatchQueue.global().async { - handler?(request) - } - } - delayedHandshakeRecoveryWorkItems[peerID] = callback - managerQueue.asyncAfter( - deadline: .now() + max(0, delay), - execute: callback - ) - } - - private func consumeHandshakeRecoveryLocked( - _ request: NoiseHandshakeRecoveryRequest - ) { - let peerID = request.peerID - guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else { - return - } - delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel() - handshakeRecoveryCallbackIDs.removeValue(forKey: peerID) - pendingHandshakeRecoveryIDs.removeValue(forKey: peerID) - } - - private func cancelDelayedHandshakeRecoveryLocked(for peerID: PeerID) { - delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel() - handshakeRecoveryCallbackIDs.removeValue(forKey: peerID) - pendingHandshakeRecoveryIDs.removeValue(forKey: peerID) - } - - /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are - /// also accepted by internal callers when they exactly match the static - /// key. Anything else fails closed: an identifier that can't be checked - /// against the remote static key must never complete a handshake. - private func authenticatedRemoteKey( - _ remoteKey: Curve25519.KeyAgreement.PublicKey, - matches claimedPeerID: PeerID - ) -> Bool { - let rawKey = remoteKey.rawRepresentation - if claimedPeerID.isShort { - return PeerID(publicKey: rawKey) == claimedPeerID - } - if let claimedNoiseKey = claimedPeerID.noiseKey { - return claimedNoiseKey == rawKey - } - return false } // MARK: - Encryption/Decryption func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data { - try managerQueue.sync { - guard let session = sessions[peerID] else { - throw NoiseSessionError.sessionNotFound - } - return try session.encrypt(plaintext) - } - } - - /// Encrypts only if `expected` still names the current established entry. - /// A rekey between capability proof and media encryption therefore fails - /// closed instead of sending on an unproven reconnect session. - func encrypt( - _ plaintext: Data, - for peerID: PeerID, - expectedSessionGeneration expected: UUID - ) throws -> Data { - try managerQueue.sync { - guard let session = sessions[peerID], - session.isEstablished(), - sessionGenerations[peerID] == expected else { - throw NoiseEncryptionError.sessionNotEstablished - } - return try session.encrypt(plaintext) + guard let session = getSession(for: peerID) else { + throw NoiseSessionError.sessionNotFound } + + return try session.encrypt(plaintext) } func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data { - try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext - } - - func sessionGeneration(for peerID: PeerID) -> UUID? { - managerQueue.sync { - guard sessions[peerID]?.isEstablished() == true else { return nil } - return sessionGenerations[peerID] - } - } - - /// Decrypts while holding the manager's read lease. Session promotion and - /// removal require its barrier, so the returned generation always names - /// the exact session object that authenticated these bytes. - func decryptWithSessionGeneration( - _ ciphertext: Data, - from peerID: PeerID, - establishedGenerationIsReady: (UUID) -> Bool = { _ in true }, - authorizeDecrypt: () throws -> Void = {} - ) throws -> (plaintext: Data, sessionGeneration: UUID) { - try managerQueue.sync { - if let session = sessions[peerID], - session.isEstablished(), - let generation = sessionGenerations[peerID] { - // Keep the generation lease across the transport-readiness - // check and decrypt. Promotion/restoration needs this queue's - // barrier, so no new receive nonce can be consumed before BLE - // installs state for the exact generation. - guard establishedGenerationIsReady(generation) else { - throw NoiseEncryptionError.transportGenerationNotReady - } - try authorizeDecrypt() - return (try session.decrypt(ciphertext), generation) - } - - // Quarantine is receive-only: old keys cannot encrypt, advertise - // an established generation, or authorize outbound state, but - // legitimate in-flight ciphertext from the retained peer may - // still advance and later resume on rollback. - if let responder = sessions[peerID], - responder.role == .responder, - responder.getState() == .handshaking, - let quarantined = quarantinedTransports[peerID] { - try authorizeDecrypt() - return ( - try quarantined.session.decrypt(ciphertext), - quarantined.generation - ) - } - - if sessions[peerID] == nil, quarantinedTransports[peerID] == nil { - throw NoiseSessionError.sessionNotFound - } - throw NoiseEncryptionError.sessionNotEstablished - } - } - - func hasReceiveSession(for peerID: PeerID) -> Bool { - managerQueue.sync { - if sessions[peerID]?.isEstablished() == true { - return true - } - guard let responder = sessions[peerID], - responder.role == .responder, - responder.getState() == .handshaking else { - return false - } - return quarantinedTransports[peerID]?.session.isEstablished() == true - } - } - - /// Runs a state commit under a read lease for the exact established - /// session. Rekey, reconnect, and removal all need the same barrier. - func withCurrentSessionGeneration( - for peerID: PeerID, - expected: UUID, - _ body: () -> Result - ) -> Result? { - managerQueue.sync { - guard sessions[peerID]?.isEstablished() == true, - sessionGenerations[peerID] == expected else { return nil } - return body() + guard let session = getSession(for: peerID) else { + throw NoiseSessionError.sessionNotFound } + + return try session.decrypt(ciphertext) } // MARK: - Key Management @@ -1279,11 +213,11 @@ final class NoiseSessionManager { } } - func initiateRekey(for peerID: PeerID) throws -> NoiseHandshakeInitiation { - try initiateReconnectHandshake( - with: peerID, - notifyOnTimeout: true, - authorize: {} - ) + func initiateRekey(for peerID: PeerID) throws { + // Remove old session + removeSession(for: peerID) + + // Initiate new handshake + _ = try initiateHandshake(with: peerID) } } diff --git a/bitchat/Noise/SecureNoiseSession.swift b/bitchat/Noise/SecureNoiseSession.swift index 8f95842c..0204b108 100644 --- a/bitchat/Noise/SecureNoiseSession.swift +++ b/bitchat/Noise/SecureNoiseSession.swift @@ -24,12 +24,8 @@ final class SecureNoiseSession: NoiseSession { throw NoiseSecurityError.sessionExhausted } - // Ordinary Noise messages keep the protocol ceiling. Finalized media - // is the sole typed-payload extension and remains under the framed-file - // cap enforced again at the service and file-decoder layers. - let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first) - && NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext) - guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else { + // Validate message size + guard NoiseSecurityValidator.validateMessageSize(plaintext) else { throw NoiseSecurityError.messageTooLarge } @@ -46,11 +42,8 @@ final class SecureNoiseSession: NoiseSession { throw NoiseSecurityError.sessionExpired } - // The payload type is encrypted, so a large candidate can only be - // bounded here; `NoiseEncryptionService.decrypt` authenticates it and - // then requires the resulting type to be `.privateFile`. - guard NoiseSecurityValidator.validateCiphertextSize(ciphertext) - || NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else { + // Validate message size + guard NoiseSecurityValidator.validateMessageSize(ciphertext) else { throw NoiseSecurityError.messageTooLarge } diff --git a/bitchat/Nostr/Base64URLCoding.swift b/bitchat/Nostr/Base64URLCoding.swift deleted file mode 100644 index bbc04ea6..00000000 --- a/bitchat/Nostr/Base64URLCoding.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -enum Base64URLCoding { - static func encode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - static func decode(_ string: String) -> Data? { - var base64 = string - let padding = (4 - (base64.count % 4)) % 4 - if padding > 0 { - base64 += String(repeating: "=", count: padding) - } - base64 = base64 - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - return Data(base64Encoded: base64) - } -} diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift index ca115536..653fc285 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -7,11 +7,6 @@ import UIKit import AppKit #endif -extension Notification.Name { - /// Posted after the geo relay directory successfully refreshes its entries. - static let geoRelayDirectoryDidRefresh = Notification.Name("bitchat.geoRelayDirectoryDidRefresh") -} - /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. struct GeoRelayDirectoryDependencies { var userDefaults: UserDefaults @@ -32,23 +27,6 @@ struct GeoRelayDirectoryDependencies { var retrySleep: (TimeInterval) async -> Void var activeNotificationName: Notification.Name? var autoStart: Bool - var validationPolicy: GeoRelayDirectoryValidationPolicy -} - -struct GeoRelayDirectoryValidationPolicy: Sendable { - let maximumBytes: Int - let maximumRows: Int - let maximumEntries: Int - let minimumRemoteEntries: Int - let minimumRetainedFraction: Double - - static let live = GeoRelayDirectoryValidationPolicy( - maximumBytes: 512 * 1024, - maximumRows: 5_000, - maximumEntries: 5_000, - minimumRemoteEntries: 50, - minimumRetainedFraction: 0.5 - ) } private extension GeoRelayDirectoryDependencies { @@ -61,57 +39,21 @@ private extension GeoRelayDirectoryDependencies { #else let activeNotificationName: Notification.Name? = nil #endif - let validationPolicy = GeoRelayDirectoryValidationPolicy.live return Self( userDefaults: .standard, notificationCenter: .default, now: Date.init, - // Runtime refreshes only from bitchat's reviewed copy. Upstream - // georelays/main is imported by a validator-backed pull request, - // so an upstream mutation cannot immediately retarget clients. - remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!, + remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!, fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds, refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds, - // Only wait for Tor when Tor is switched on. With it off, the fetch - // is meant to go direct through the same unproxied session the relay - // sockets already use — and `TorManager` has been shut down, so - // awaiting readiness would spend the whole bootstrap timeout on - // every refresh and freeze the directory on its cached copy. - // - // Deliberately keyed on the preference rather than live readiness: - // if Tor is wanted but not ready, this must keep returning false so - // the fetch is skipped instead of silently leaking the IP. - awaitTorReady: { - guard NetworkActivationService.persistedTorPreference() else { return true } - return await TorManager.shared.awaitReady() - }, + awaitTorReady: { await TorManager.shared.awaitReady() }, makeFetchData: { let session = TorURLSession.shared.session return { request in - let (bytes, response) = try await session.bytes(for: request) - guard let response = response as? HTTPURLResponse, - (200...299).contains(response.statusCode), - response.url == request.url else { - throw URLError(.badServerResponse) - } - - let maximumBytes = validationPolicy.maximumBytes - guard response.expectedContentLength <= Int64(maximumBytes) else { - throw URLError(.dataLengthExceedsMaximum) - } - var data = Data() - if response.expectedContentLength > 0 { - data.reserveCapacity(Int(response.expectedContentLength)) - } - for try await byte in bytes { - guard data.count < maximumBytes else { - throw URLError(.dataLengthExceedsMaximum) - } - data.append(byte) - } + let (data, _) = try await session.data(for: request) return data } }, @@ -129,11 +71,7 @@ private extension GeoRelayDirectoryDependencies { ) let dir = base.appendingPathComponent("bitchat", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - // v2 ignores caches populated from the old direct-upstream - // trust path and subjects every load to strict validation. - let legacyCache = dir.appendingPathComponent("georelays_cache.csv") - try? FileManager.default.removeItem(at: legacyCache) - return dir.appendingPathComponent("georelays_cache_v2.csv") + return dir.appendingPathComponent("georelays_cache.csv") } catch { return nil } @@ -151,8 +89,7 @@ private extension GeoRelayDirectoryDependencies { try? await Task.sleep(nanoseconds: nanoseconds) }, activeNotificationName: activeNotificationName, - autoStart: true, - validationPolicy: validationPolicy + autoStart: true ) } } @@ -183,7 +120,7 @@ final class GeoRelayDirectory { } private enum DetachedFetchOutcome: Sendable { - case success(entries: [Entry], csv: Data) + case success(entries: [Entry], csv: String) case torNotReady case invalidData case network(String) @@ -228,16 +165,33 @@ final class GeoRelayDirectory { } /// Returns up to `count` relay URLs (wss://) closest to the given coordinate. - /// Ties break by host so every device with the same directory picks the - /// same relay set — publishers and subscribers must agree on relays. func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { guard !entries.isEmpty, count > 0 else { return [] } - return entries - .map { (entry: $0, distance: haversineKm(lat, lon, $0.lat, $0.lon)) } - .sorted { ($0.distance, $0.entry.host) < ($1.distance, $1.entry.host) } - .prefix(count) - .map { "wss://\($0.entry.host)" } + if entries.count <= count { + return entries + .sorted { a, b in + haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon) + } + .map { "wss://\($0.host)" } + } + + var best: [(entry: Entry, distance: Double)] = [] + best.reserveCapacity(count) + + for entry in entries { + let distance = haversineKm(lat, lon, entry.lat, entry.lon) + if best.count < count { + let idx = best.firstIndex { $0.distance > distance } ?? best.count + best.insert((entry, distance), at: idx) + } else if let worstDistance = best.last?.distance, distance < worstDistance { + let idx = best.firstIndex { $0.distance > distance } ?? best.count + best.insert((entry, distance), at: idx) + best.removeLast() + } + } + + return best.map { "wss://\($0.entry.host)" } } // MARK: - Remote Fetch @@ -270,8 +224,6 @@ final class GeoRelayDirectory { ) let awaitTorReady = dependencies.awaitTorReady let fetchData = dependencies.makeFetchData() - let validationPolicy = dependencies.validationPolicy - let baselineEntries = Set(entries) Task { [weak self] in guard let self else { return } @@ -279,9 +231,7 @@ final class GeoRelayDirectory { let outcome = await Self.fetchRemoteOutcome( request: request, awaitTorReady: awaitTorReady, - fetchData: fetchData, - validationPolicy: validationPolicy, - baselineEntries: baselineEntries + fetchData: fetchData ) switch outcome { @@ -300,9 +250,7 @@ final class GeoRelayDirectory { nonisolated private static func fetchRemoteOutcome( request: URLRequest, awaitTorReady: @escaping @Sendable () async -> Bool, - fetchData: @escaping @Sendable (URLRequest) async throws -> Data, - validationPolicy: GeoRelayDirectoryValidationPolicy, - baselineEntries: Set + fetchData: @escaping @Sendable (URLRequest) async throws -> Data ) async -> DetachedFetchOutcome { await Task.detached(priority: .utility) { let ready = await awaitTorReady() @@ -310,16 +258,16 @@ final class GeoRelayDirectory { do { let data = try await fetchData(request) - guard let parsed = Self.validatedEntries( - from: data, - policy: validationPolicy, - minimumEntries: validationPolicy.minimumRemoteEntries, - baselineEntries: baselineEntries - ) else { + guard let text = String(data: data, encoding: .utf8) else { return .invalidData } - return .success(entries: parsed, csv: data) + let parsed = Self.parseCSV(text) + guard !parsed.isEmpty else { + return .invalidData + } + + return .success(entries: parsed, csv: text) } catch { return .network(error.localizedDescription) } @@ -333,7 +281,7 @@ final class GeoRelayDirectory { } @MainActor - private func handleFetchSuccess(entries parsed: [Entry], csv: Data) { + private func handleFetchSuccess(entries parsed: [Entry], csv: String) { entries = parsed persistCache(csv) dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey) @@ -341,8 +289,6 @@ final class GeoRelayDirectory { isFetching = false retryAttempt = 0 cancelRetry() - // Let waiters (e.g. location notes stuck in a "no relays" state) retry. - dependencies.notificationCenter.post(name: .geoRelayDirectoryDidRefresh, object: nil) } @MainActor @@ -385,8 +331,9 @@ final class GeoRelayDirectory { cleanupState.retryTask = nil } - private func persistCache(_ data: Data) { + private func persistCache(_ text: String) { guard let url = dependencies.cacheURL() else { return } + guard let data = text.data(using: .utf8) else { return } do { try dependencies.writeData(data, url) } catch { @@ -399,12 +346,9 @@ final class GeoRelayDirectory { // Prefer cached file if present if let cache = dependencies.cacheURL(), let data = dependencies.readData(cache), - let entries = Self.validatedEntries( - from: data, - policy: dependencies.validationPolicy, - minimumEntries: 1 - ) { - return entries + let text = String(data: data, encoding: .utf8) { + let arr = Self.parseCSV(text) + if !arr.isEmpty { return arr } } // Try bundled resource(s) @@ -412,157 +356,41 @@ final class GeoRelayDirectory { for url in bundleCandidates { if let data = dependencies.readData(url), - let entries = Self.validatedEntries( - from: data, - policy: dependencies.validationPolicy, - minimumEntries: 1 - ) { - return entries + let text = String(data: data, encoding: .utf8) { + let arr = Self.parseCSV(text) + if !arr.isEmpty { return arr } } } // Try filesystem path (development/test) if let cwd = dependencies.currentDirectoryPath(), let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), - let entries = Self.validatedEntries( - from: data, - policy: dependencies.validationPolicy, - minimumEntries: 1 - ) { - return entries + let text = String(data: data, encoding: .utf8) { + return Self.parseCSV(text) } SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session) return [] } - /// Parses the fixed three-column format as an all-or-nothing trust unit. - /// One malformed or conflicting row rejects the complete dataset rather - /// than silently shrinking or partially replacing the current directory. - nonisolated static func validatedEntries( - from data: Data, - policy: GeoRelayDirectoryValidationPolicy, - minimumEntries: Int, - baselineEntries: Set? = nil - ) -> [Entry]? { - guard !data.isEmpty, data.count <= policy.maximumBytes, - let text = String(data: data, encoding: .utf8), - !text.hasPrefix("\u{feff}") else { - return nil - } - + nonisolated static func parseCSV(_ text: String) -> [Entry] { + var result: Set = [] let lines = text.split(whereSeparator: { $0.isNewline }) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard let header = lines.first, - lines.count - 1 <= policy.maximumRows else { - return nil + for (idx, raw) in lines.enumerated() { + guard let line = raw.trimmedOrNilIfEmpty else { continue } + if idx == 0 && line.lowercased().contains("relay url") { continue } + let parts = line.split(separator: ",").map { $0.trimmed } + guard parts.count >= 3 else { continue } + var host = parts[0] + host = host.replacingOccurrences(of: "https://", with: "") + host = host.replacingOccurrences(of: "http://", with: "") + host = host.replacingOccurrences(of: "wss://", with: "") + host = host.replacingOccurrences(of: "ws://", with: "") + host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue } + result.insert(Entry(host: host, lat: lat, lon: lon)) } - - let headerParts = header - .split(separator: ",", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - let supportedHeaders = [ - ["relay url", "latitude", "longitude"], - ["relay url", "lat", "lon"] - ] - guard supportedHeaders.contains(headerParts) else { - return nil - } - - var entriesByHost: [String: Entry] = [:] - for line in lines.dropFirst() { - let parts = line - .split(separator: ",", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard parts.count == 3, - let host = validatedDirectoryAddress(parts[0]), - let latitude = Double(parts[1]), latitude.isFinite, - (-90.0...90.0).contains(latitude), - let longitude = Double(parts[2]), longitude.isFinite, - (-180.0...180.0).contains(longitude) else { - return nil - } - - let entry = Entry(host: host, lat: latitude, lon: longitude) - if let existing = entriesByHost[host], existing != entry { - // One endpoint cannot truthfully occupy two coordinates. Do - // not let row ordering choose which location clients trust. - return nil - } - entriesByHost[host] = entry - guard entriesByHost.count <= policy.maximumEntries else { return nil } - } - - let parsedEntries = Set(entriesByHost.values) - guard parsedEntries.count >= minimumEntries else { return nil } - - if let baselineEntries { - guard (0...1).contains(policy.minimumRetainedFraction) else { return nil } - let requiredOverlap = Int( - ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction) - ) - guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else { - return nil - } - } - - return parsedEntries.sorted { - ($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon) - } - } - - nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? { - let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty, - value.unicodeScalars.allSatisfy({ - $0.isASCII && !CharacterSet.controlCharacters.contains($0) - }) else { - return nil - } - - let candidate = value.contains("://") ? value : "wss://\(value)" - guard let components = URLComponents(string: candidate), - let scheme = components.scheme?.lowercased(), - scheme == "wss" || scheme == "https", - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path.isEmpty || components.path == "/", - let rawHost = components.host else { - return nil - } - - let host = rawHost.lowercased() - guard !host.isEmpty, host.count <= 253, - host.unicodeScalars.allSatisfy({ $0.isASCII }), - !host.hasSuffix("."), - host != "localhost", - !host.hasSuffix(".localhost"), - !host.hasSuffix(".local"), - !host.hasSuffix(".internal") else { - return nil - } - - let labels = host.split(separator: ".", omittingEmptySubsequences: false) - let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") - guard labels.count >= 2, - !labels.allSatisfy({ $0.allSatisfy(\.isNumber) }), - labels.allSatisfy({ label in - (1...63).contains(label.count) && - label.first != "-" && - label.last != "-" && - label.unicodeScalars.allSatisfy { allowed.contains($0) } - }) else { - return nil - } - - if let port = components.port { - guard (1...65_535).contains(port) else { return nil } - if port != 443 { return "\(host):\(port)" } - } - return host + return Array(result) } // MARK: - Observers & Timers diff --git a/bitchat/Nostr/NostrIdentity.swift b/bitchat/Nostr/NostrIdentity.swift index 9d41ca54..72fb7bbf 100644 --- a/bitchat/Nostr/NostrIdentity.swift +++ b/bitchat/Nostr/NostrIdentity.swift @@ -1,18 +1,19 @@ import Foundation import P256K -/// Manages the secp256k1 identity used by BitChat's Nostr relay features, -/// including the proprietary private-envelope transport. +/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging struct NostrIdentity: Codable { let privateKey: Data let publicKey: Data let npub: String // Bech32-encoded public key - + let createdAt: Date + /// Memberwise initializer - init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) { + init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) { self.privateKey = privateKey self.publicKey = publicKey self.npub = npub + self.createdAt = createdAt } /// Generate a new Nostr identity @@ -38,6 +39,12 @@ struct NostrIdentity: Codable { self.privateKey = privateKeyData self.publicKey = xOnlyPubkey self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) + self.createdAt = Date() + } + + /// Get signing key for event signatures + func signingKey() throws -> P256K.Signing.PrivateKey { + try P256K.Signing.PrivateKey(dataRepresentation: privateKey) } /// Get Schnorr signing key for Nostr event signatures diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/bitchat/Nostr/NostrIdentityBridge.swift index 51d14e84..01d929e0 100644 --- a/bitchat/Nostr/NostrIdentityBridge.swift +++ b/bitchat/Nostr/NostrIdentityBridge.swift @@ -1,4 +1,3 @@ -import BitFoundation import Foundation import CryptoKit @@ -15,7 +14,7 @@ final class NostrIdentityBridge { private let keychain: KeychainManagerProtocol - init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) { + init(keychain: KeychainManagerProtocol = KeychainManager()) { self.keychain = keychain } @@ -37,6 +36,14 @@ final class NostrIdentityBridge { return nostrIdentity } + /// Associate a Nostr identity with a Noise public key (for favorites) + func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) { + let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" + if let data = nostrPubkey.data(using: .utf8) { + keychain.save(key: key, data: data, service: keychainService, accessible: nil) + } + } + /// Get Nostr public key associated with a Noise public key func getNostrPublicKey(for noisePublicKey: Data) -> String? { let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" @@ -49,19 +56,31 @@ final class NostrIdentityBridge { /// Clear all Nostr identity associations and current identity func clearAllAssociations() { - // Must go through the injected keychain, not raw SecItem calls: - // under test that keychain is in-memory, and a direct delete here - // would wipe the developer's real Nostr identity on every test run. - keychain.deleteAll(service: keychainService) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecSuccess, let items = result as? [[String: Any]] { + for item in items { + var deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService + ] + if let account = item[kSecAttrAccount as String] as? String { + deleteQuery[kSecAttrAccount as String] = account + } + SecItemDelete(deleteQuery as CFDictionary) + } + } else if status == errSecItemNotFound { + // nothing persisted; no action needed + } deviceSeedCache = nil - // Also drop the in-memory derived per-geohash identities. These hold the - // actual secp256k1 private keys; if left cached, post-panic geohash - // messages would still be signed with pre-panic keys (linkable across the - // wipe) until the app is force-quit. - cacheLock.lock() - derivedIdentityCache.removeAll() - cacheLock.unlock() } // MARK: - Per-Geohash Identities (Location Channels) @@ -76,22 +95,16 @@ final class NostrIdentityBridge { deviceSeedCache = existing return existing } - // CryptoKit key generation cannot fail, unlike SecRandomCopyBytes — - // a discarded failure here would persist an all-zero identity seed. - let seed = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) } + var seed = Data(count: 32) + _ = seed.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) + } // Ensure availability after first unlock to prevent unintended rotation when locked keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) deviceSeedCache = seed return seed } - /// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge - /// rendezvous cell. Distinct HMAC label keeps it unlinkable from the - /// geohash-chat identity for the same cell string. - func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity { - try deriveIdentity(forGeohash: "bridge|" + cell) - } - /// Derive a deterministic, unlinkable Nostr identity for a given geohash. /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing /// if the candidate is not a valid secp256k1 private key. diff --git a/bitchat/Nostr/NostrPoW.swift b/bitchat/Nostr/NostrPoW.swift deleted file mode 100644 index 006bba6d..00000000 --- a/bitchat/Nostr/NostrPoW.swift +++ /dev/null @@ -1,215 +0,0 @@ -import BitFoundation -import CryptoKit -import Foundation - -/// NIP-13 proof-of-work for Nostr events. -/// -/// Outgoing kind-20000 geohash messages mine a `["nonce", "", ""]` -/// tag so the event ID carries at least `target` leading zero bits. Inbound -/// events are scored (never hard-rejected — the network has clients that do -/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the -/// per-sender public rate limit, everything else keeps the strict limits. -enum NostrPoW { - - // MARK: - Tuning - - /// Difficulty (leading zero bits of the event ID) mined onto outgoing - /// geohash messages. 8 bits is ~256 hash attempts — typically well under - /// 100 ms on any supported device. - static let targetBits = 8 - - /// Inbound events whose validated NIP-13 difficulty is at least this many - /// bits skip the per-sender rate-limit bucket (the content-flood bucket - /// still applies). See `MessageRateLimiter.allow`. - static let rateLimitBypassBits = 8 - - /// Hard cap on mining wall-clock time. When it hits, the committed target - /// steps down until a difficulty reachable in a small extra budget is - /// found and the message is sent anyway — mining never blocks sending. - static let miningTimeCap: TimeInterval = 2.0 - - /// Budget for each stepped-down attempt after the main cap (or a task - /// cancellation) hits. - private static let fallbackTimeCap: TimeInterval = 0.15 - - /// The hot loop checks the deadline and task cancellation every this many - /// hash attempts. - private static let checkInterval: UInt64 = 1024 - - /// The nonce value is a fixed-width hex counter so the serialized event - /// template can be mutated in place without reallocation. - private static let nonceLength = 16 - - // MARK: - Scoring - - /// Number of leading zero bits in a byte sequence (NIP-13 difficulty of - /// an event-ID hash). - static func leadingZeroBits>(_ bytes: Bytes) -> Int { - var total = 0 - for byte in bytes { - if byte == 0 { - total += 8 - } else { - total += byte.leadingZeroBitCount - break - } - } - return total - } - - /// Validated NIP-13 difficulty of an inbound event. - /// - /// The committed target in the nonce tag is what counts: the actual - /// leading zero bits of the ID must meet it (otherwise the claim is void - /// and the event scores 0), and work beyond the commitment earns no extra - /// credit — this stops spammers who mine a low target from getting lucky - /// high scores. Events without a well-formed commitment score 0. - static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int { - guard let nonceTag = tags.last(where: { $0.first == "nonce" }), - nonceTag.count >= 3, - let committed = Int(nonceTag[2]), - committed > 0, committed <= 256, - let idData = Data(hexString: idHex) - else { - return 0 - } - return leadingZeroBits(idData) >= committed ? committed : 0 - } - - // MARK: - Mining - - /// Mine a `["nonce", value, target]` tag for the given unsigned-event - /// fields. Nonisolated async: runs off the calling actor. - /// - /// Bounded by `miningTimeCap`: when the cap hits — or the surrounding - /// task is cancelled — the committed target steps down (halving to 0, - /// which any hash satisfies) so the event still ships promptly with an - /// honest commitment at the difficulty actually reached. Returns nil only - /// if canonical serialization fails; the caller then sends unmined. - static func mineNonceTag( - pubkey: String, - createdAt: Int, - kind: Int, - tags: [[String]], - content: String, - targetBits: Int = NostrPoW.targetBits - ) async -> [String]? { - var target = min(max(targetBits, 0), 256) - var budget = miningTimeCap - while true { - if let tag = mineAttempt( - pubkey: pubkey, - createdAt: createdAt, - kind: kind, - baseTags: tags, - content: content, - targetBits: target, - budget: budget - ) { - return tag - } - // Target 0 succeeds on the first hash, so reaching it with nil - // means serialization itself failed — give up on mining. - if target == 0 { return nil } - target /= 2 - budget = fallbackTimeCap - } - } - - /// One bounded mining pass at a fixed committed target. Allocation-light: - /// the canonical serialization is built once and only the fixed-width - /// nonce bytes are rewritten per attempt (the event ID is recomputed for - /// every attempt, per NIP-13). Returns nil on timeout/cancellation or if - /// the template could not be built. - private static func mineAttempt( - pubkey: String, - createdAt: Int, - kind: Int, - baseTags: [[String]], - content: String, - targetBits: Int, - budget: TimeInterval - ) -> [String]? { - let targetString = String(targetBits) - guard let template = serializedTemplate( - pubkey: pubkey, - createdAt: createdAt, - kind: kind, - baseTags: baseTags, - content: content, - targetString: targetString - ) else { - return nil - } - var buffer = template.buffer - let nonceRange = template.nonceRange - - let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000) - let hexDigits = [UInt8]("0123456789abcdef".utf8) - var nonce = UInt64.random(in: .min ... .max) - var attempts: UInt64 = 0 - - while true { - // Write the nonce as 16 lowercase hex chars, in place. - var value = nonce - var index = nonceRange.upperBound - while index > nonceRange.lowerBound { - index -= 1 - buffer[index] = hexDigits[Int(value & 0xF)] - value >>= 4 - } - - if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits { - // Identical to the bytes just written into the buffer. - return ["nonce", String(format: "%016llx", nonce), targetString] - } - - nonce &+= 1 - attempts &+= 1 - if attempts % checkInterval == 0, - Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline { - return nil - } - } - } - - /// Canonical NIP-01 serialization of the event with a placeholder nonce, - /// plus the byte range of the nonce value inside it. - /// - /// The range is located by serializing twice with two same-length - /// placeholders and diffing the buffers — the only differing bytes are - /// the nonce value, so this stays correct however `JSONSerialization` - /// escapes the surrounding fields (and even if the content contains the - /// placeholder text itself). - private static func serializedTemplate( - pubkey: String, - createdAt: Int, - kind: Int, - baseTags: [[String]], - content: String, - targetString: String - ) -> (buffer: Data, nonceRange: Range)? { - func serialize(noncePlaceholder: String) -> Data? { - var tags = baseTags - tags.append(["nonce", noncePlaceholder, targetString]) - let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content] - return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) - } - - guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)), - let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)), - zeros.count == effs.count - else { - return nil - } - - var firstDiff = -1 - var lastDiff = -1 - for index in 0..= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil } - return (zeros, firstDiff..<(firstDiff + nonceLength)) - } -} diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 2e3e35d8..4a05eaf7 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -7,114 +7,70 @@ import Security // Note: This file depends on Data extension from BinaryEncodingUtils.swift // Make sure BinaryEncodingUtils.swift is included in the target -/// BitChat's private-envelope protocol transported over Nostr relays. -/// -/// This construction is deliberately BitChat-specific and is **not** NIP-17, -/// NIP-44, or NIP-59 compatible, even though it historically reuses those -/// NIPs' kind numbers (1059/13/14) and a `v2:` content prefix. It uses Nostr -/// events and secp256k1 identities, but the XChaCha20-Poly1305 payload layout -/// and key derivation are proprietary and interoperate only with BitChat -/// clients. +/// NIP-17 Protocol Implementation for Private Direct Messages struct NostrProtocol { - + /// Nostr event kinds enum EventKind: Int { case metadata = 0 case textNote = 1 - // BitChat's proprietary private-envelope layers. These reuse the - // NIP-17/NIP-59 kind numbers (14/13/1059) for historical reasons, but - // the encrypted payloads are BitChat-specific and not NIP-compatible. - case dm = 14 // unsigned inner message (inside ciphertext) - case seal = 13 // sender-signed seal (inside ciphertext) - case giftWrap = 1059 // public outer envelope (one-time key) + case dm = 14 // NIP-17 DM rumor kind + case seal = 13 // NIP-17 sealed event + case giftWrap = 1059 // NIP-59 gift wrap case ephemeralEvent = 20000 case geohashPresence = 20001 - case deletion = 5 // NIP-09 event deletion request - /// Sealed courier envelope parked on relays under its rotating - /// recipient tag (`#x`). Regular (stored) kind so it survives until - /// its NIP-40 expiration — the whole point is store-and-forward. - case courierDrop = 1401 } - - /// Bound work before Base64-decoding either encrypted layer of an inbound - /// private envelope, and before parsing each decrypted nested JSON layer. - /// Real envelopes are normally a few KiB; 64 KiB leaves ample headroom - /// without letting an addressed relay event drive unbounded allocation. - static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024 - - /// Create a BitChat private envelope for relay transport (outer kind 1059). + + /// Create a NIP-17 private message static func createPrivateMessage( content: String, recipientPubkey: String, senderIdentity: NostrIdentity ) throws -> NostrEvent { - try createPrivateMessage( - content: content, - recipientPubkey: recipientPubkey, - senderIdentity: senderIdentity, - messageTags: [] - ) - } - - private static func createPrivateMessage( - content: String, - recipientPubkey: String, - senderIdentity: NostrIdentity, - messageTags: [[String]] - ) throws -> NostrEvent { - // 1. Create the rumor (unsigned inner event) + + // Creating private message + + // 1. Create the rumor (unsigned event) let rumor = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), - kind: .dm, - tags: messageTags, + kind: .dm, // NIP-17: DM rumor kind 14 + tags: [], content: content ) - - // 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S - // real identity key so the recipient can authenticate who sent the - // message; signing with a throwaway key leaves DMs - // forgeable/impersonatable. - let senderKey = try senderIdentity.schnorrSigningKey() + + // 2. Create ephemeral key for this message + let ephemeralKey = try P256K.Schnorr.PrivateKey() + // Created ephemeral key for seal + + // 3. Seal the rumor (encrypt to recipient) let sealedEvent = try createSeal( rumor: rumor, recipientPubkey: recipientPubkey, - senderKey: senderKey + senderKey: ephemeralKey ) - - // 3. Wrap the sealed event with a throwaway ephemeral key (the wrap - // layer hides the sender's identity from relays; createGiftWrap mints - // its own ephemeral key internally). + + // 4. Gift wrap the sealed event (encrypt to recipient again) let giftWrap = try createGiftWrap( seal: sealedEvent, - recipientPubkey: recipientPubkey + recipientPubkey: recipientPubkey, + senderKey: ephemeralKey ) - + + // Created gift wrap + return giftWrap } - - /// Decrypt a received BitChat private envelope. - /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized outer timestamp) + + /// Decrypt a received NIP-17 message + /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp) static func decryptPrivateMessage( giftWrap: NostrEvent, recipientIdentity: NostrIdentity ) throws -> (content: String, senderPubkey: String, timestamp: Int) { - - // 0. Validate the untrusted outer envelope before any decryption work. - // Every BitChat client (released iOS and current Android) publishes - // exactly one outer recipient `p` tag on a validly signed kind-1059 - // wrap; anything else is malformed or misbound. - guard giftWrap.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else { - SecureLogger.error("❌ Rejecting DM: oversized outer envelope ciphertext", category: .session) - throw NostrError.invalidCiphertext - } - guard giftWrap.kind == EventKind.giftWrap.rawValue, - giftWrap.tags == [["p", recipientIdentity.publicKeyHex]], - giftWrap.isValidSignature() else { - SecureLogger.error("❌ Rejecting DM: malformed or misbound outer envelope", category: .session) - throw NostrError.invalidEvent - } - + + // Starting decryption + // 1. Unwrap the gift wrap let seal: NostrEvent do { @@ -128,18 +84,7 @@ struct NostrProtocol { throw error } - // 2. Authenticate the seal. The seal MUST be signed by the sender's real - // identity key; without this check a DM is forgeable by anyone who - // knows the recipient's npub. Every BitChat sender emits a tagless - // kind-13 seal, so bind the decrypted layer to that exact shape. - guard seal.kind == EventKind.seal.rawValue, - seal.tags.isEmpty, - seal.isValidSignature() else { - SecureLogger.error("❌ Rejecting DM: seal is malformed or its signature is missing/invalid", category: .session) - throw NostrError.invalidEvent - } - - // 3. Open the seal + // 2. Open the seal let rumor: NostrEvent do { rumor = try openSeal( @@ -151,96 +96,10 @@ struct NostrProtocol { SecureLogger.error("❌ Failed to open seal: \(error)", category: .session) throw error } - - // 4. The rumor is intentionally unsigned; sender authentication comes - // from the seal. The sender claimed inside the rumor must match the - // key that actually signed the seal, otherwise the sender field is - // unauthenticated and spoofable. Also bind the inner kind and tag - // shape to what BitChat clients actually emit. - guard rumor.kind == EventKind.dm.rawValue, - validInnerMessageTags(rumor.tags, recipientPubkey: recipientIdentity.publicKeyHex), - rumor.sig == nil, - seal.pubkey == rumor.pubkey else { - SecureLogger.error("❌ Rejecting DM: rumor is malformed or does not match seal signer", category: .session) - throw NostrError.invalidEvent - } - - // Return the seal signer's pubkey as the authenticated sender. - return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at) + + return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at) } - /// Released iOS envelopes use no inner tags, while current Android - /// envelopes place exactly the authenticated recipient's `p` tag on the - /// unsigned inner event. Accept only those two historical shapes; - /// alternate recipients, duplicate tags, and extra tags are rejected. - private static func validInnerMessageTags( - _ tags: [[String]], - recipientPubkey: String - ) -> Bool { - tags.isEmpty || tags == [["p", recipientPubkey]] - } - - #if DEBUG - static func createPrivateMessageWithInvalidSealSignatureForTesting( - content: String, - recipientPubkey: String, - senderIdentity: NostrIdentity - ) throws -> NostrEvent { - let rumor = NostrEvent( - pubkey: senderIdentity.publicKeyHex, - createdAt: Date(), - kind: .dm, - tags: [], - content: content - ) - var seal = try createSeal( - rumor: rumor, - recipientPubkey: recipientPubkey, - senderKey: senderIdentity.schnorrSigningKey() - ) - seal.sig = String(repeating: "0", count: 128) - return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) - } - - static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting( - content: String, - recipientPubkey: String, - rumorIdentity: NostrIdentity, - sealSignerIdentity: NostrIdentity - ) throws -> NostrEvent { - let rumor = NostrEvent( - pubkey: rumorIdentity.publicKeyHex, - createdAt: Date(), - kind: .dm, - tags: [], - content: content - ) - let seal = try createSeal( - rumor: rumor, - recipientPubkey: recipientPubkey, - senderKey: sealSignerIdentity.schnorrSigningKey() - ) - return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) - } - - /// Reproduces historical wire shapes (current Android places exactly one - /// recipient `p` tag on the unsigned inner event) without making the - /// production encoder depend on that quirk. - static func createPrivateMessageWithInnerTagsForTesting( - content: String, - recipientPubkey: String, - senderIdentity: NostrIdentity, - innerMessageTags: [[String]] - ) throws -> NostrEvent { - try createPrivateMessage( - content: content, - recipientPubkey: recipientPubkey, - senderIdentity: senderIdentity, - messageTags: innerMessageTags - ) - } - #endif - /// Create a geohash-scoped ephemeral public message (kind 20000) static func createEphemeralGeohashEvent( content: String, @@ -249,63 +108,6 @@ struct NostrProtocol { nickname: String? = nil, teleported: Bool = false ) throws -> NostrEvent { - let event = NostrEvent( - pubkey: senderIdentity.publicKeyHex, - createdAt: Date(), - kind: .ephemeralEvent, - tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported), - content: content - ) - let schnorrKey = try senderIdentity.schnorrSigningKey() - return try event.sign(with: schnorrKey) - } - - /// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work - /// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is - /// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the - /// surrounding task is cancelled) the event ships at the highest - /// committed difficulty still met, and if mining is impossible it ships - /// unmined — sending is never blocked. - static func createMinedEphemeralGeohashEvent( - content: String, - geohash: String, - senderIdentity: NostrIdentity, - nickname: String? = nil, - teleported: Bool = false, - powTargetBits: Int = NostrPoW.targetBits - ) async throws -> NostrEvent { - var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported) - // Fix created_at up front: the mined nonce commits to the full - // serialized event, so the signed event must reuse the exact value. - let createdAt = Int(Date().timeIntervalSince1970) - if let nonceTag = await NostrPoW.mineNonceTag( - pubkey: senderIdentity.publicKeyHex, - createdAt: createdAt, - kind: EventKind.ephemeralEvent.rawValue, - tags: tags, - content: content, - targetBits: powTargetBits - ) { - tags.append(nonceTag) - } - let event = NostrEvent( - pubkey: senderIdentity.publicKeyHex, - createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)), - kind: .ephemeralEvent, - tags: tags, - content: content - ) - let schnorrKey = try senderIdentity.schnorrSigningKey() - return try event.sign(with: schnorrKey) - } - - /// Tags for a kind-20000 geohash message (shared by the plain and mined - /// variants). - private static func ephemeralGeohashTags( - geohash: String, - nickname: String?, - teleported: Bool - ) -> [[String]] { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) @@ -313,7 +115,15 @@ struct NostrProtocol { if teleported { tags.append(["t", "teleport"]) } - return tags + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: tags, + content: content + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) } /// Create a geohash presence heartbeat (kind 20001) @@ -334,115 +144,17 @@ struct NostrProtocol { return try event.sign(with: schnorrKey) } - // MARK: - Mesh bridge (rendezvous) events - - /// Create a mesh-bridge public message (kind 20000) for a geohash-cell - /// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash - /// channel subscriptions (which filter on `#g`); `m` is - /// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the - /// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0 - /// parsers, which key their dedup on `m[1]` unconditionally and need it - /// per-message-unique. Current parsers key bridge rows by the authenticated - /// event ID and recompute elements 2-3 only as a radio-copy hint; the mesh - /// coordinates are public and cannot authenticate the Nostr signer. - static func createBridgeMeshEvent( - content: String, - cell: String, - senderIdentity: NostrIdentity, - nickname: String? = nil, - meshSenderID: String? = nil, - meshTimestampMs: UInt64? = nil - ) throws -> NostrEvent { - var tags = [["r", cell]] - if let nickname = nickname?.trimmedOrNilIfEmpty { - tags.append(["n", nickname]) - } - if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs { - let stableID = MeshMessageIdentity.stableID( - senderIDHex: meshSenderID, - timestampMs: meshTimestampMs, - content: content - ) - tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)]) - } - let event = NostrEvent( - pubkey: senderIdentity.publicKeyHex, - createdAt: Date(), - kind: .ephemeralEvent, - tags: tags, - content: content - ) - let schnorrKey = try senderIdentity.schnorrSigningKey() - return try event.sign(with: schnorrKey) - } - - /// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous - /// cell: empty content, `r` tag only — the bridge analogue of geohash - /// presence, counted into "people across the bridge". - static func createBridgePresenceEvent( - cell: String, - senderIdentity: NostrIdentity - ) throws -> NostrEvent { - let event = NostrEvent( - pubkey: senderIdentity.publicKeyHex, - createdAt: Date(), - kind: .geohashPresence, - tags: [["r", cell]], - content: "" - ) - let schnorrKey = try senderIdentity.schnorrSigningKey() - return try event.sign(with: schnorrKey) - } - - /// Create a courier drop (kind 1401): an opaque sealed courier envelope - /// parked on relays. `x` is the hex recipient tag the recipient (or a - /// gateway acting for them) subscribes for; the NIP-40 expiration tracks - /// the envelope expiry so honoring relays garbage-collect the drop. The - /// signing identity should be a throwaway — the envelope authenticates - /// its sender internally via Noise-X, and linking drops to a stable - /// publisher key would leak courier traffic patterns. - static func createCourierDropEvent( - envelope: Data, - recipientTagHex: String, - expiresAt: Date, - senderIdentity: NostrIdentity - ) throws -> NostrEvent { - let tags = [ - ["x", recipientTagHex], - ["expiration", String(Int(expiresAt.timeIntervalSince1970))] - ] - let event = NostrEvent( - pubkey: senderIdentity.publicKeyHex, - createdAt: Date(), - kind: .courierDrop, - tags: tags, - content: envelope.base64EncodedString() - ) - let schnorrKey = try senderIdentity.schnorrSigningKey() - return try event.sign(with: schnorrKey) - } - /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. - /// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays - /// drop the note in step with a bridged board post's expiry. static func createGeohashTextNote( content: String, geohash: String, senderIdentity: NostrIdentity, - nickname: String? = nil, - expiresAt: Date? = nil, - urgent: Bool = false + nickname: String? = nil ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) } - if let expiresAt { - tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))]) - } - if urgent { - tags.append(["t", "urgent"]) - } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), @@ -453,25 +165,7 @@ struct NostrProtocol { let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } - - /// Create a NIP-09 deletion request for one of our own events. Relays that - /// honor NIP-09 drop the referenced event; it must be signed by the same - /// key that signed the original. - static func createDeleteEvent( - ofEventID eventID: String, - senderIdentity: NostrIdentity - ) throws -> NostrEvent { - let event = NostrEvent( - pubkey: senderIdentity.publicKeyHex, - createdAt: Date(), - kind: .deletion, - tags: [["e", eventID]], - content: "" - ) - let schnorrKey = try senderIdentity.schnorrSigningKey() - return try event.sign(with: schnorrKey) - } - + // MARK: - Private Methods private static func createSeal( @@ -501,9 +195,10 @@ struct NostrProtocol { private static func createGiftWrap( seal: NostrEvent, - recipientPubkey: String + recipientPubkey: String, + senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal ) throws -> NostrEvent { - + let sealJSON = try seal.jsonString() // Create new ephemeral key for gift wrap @@ -542,19 +237,14 @@ struct NostrProtocol { recipientKey: recipientKey ) - // Check UTF-8 size before allocating Data or invoking the general - // JSON parser on attacker-influenced plaintext. - guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else { - throw NostrError.invalidCiphertext - } guard let data = decrypted.data(using: .utf8), let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } - + let seal = try NostrEvent(from: sealDict) // Unwrapped seal - + return seal } @@ -569,23 +259,16 @@ struct NostrProtocol { recipientKey: recipientKey ) - guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else { - throw NostrError.invalidCiphertext - } guard let data = decrypted.data(using: .utf8), let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } - + return try NostrEvent(from: rumorDict) } - - // MARK: - BitChat private-envelope encryption - // - // Not NIP-44: the `v2:` prefix, base64url(nonce24 || ciphertext || tag) - // layout, XChaCha20-Poly1305 cipher, and HKDF parameters are all - // BitChat-specific. - + + // MARK: - Encryption (NIP-44 v2) + private static func encrypt( plaintext: String, recipientPubkey: String, @@ -596,25 +279,22 @@ struct NostrProtocol { throw NostrError.invalidPublicKey } + // Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned) + // Derive shared secret let sharedSecret = try deriveSharedSecret( privateKey: senderKey, publicKey: recipientPubkeyData ) - // Derive the BitChat private-envelope symmetric key (HKDF-SHA256) - let key = try derivePrivateEnvelopeKey(from: sharedSecret) - + // Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info) + let key = try deriveNIP44V2Key(from: sharedSecret) + // 24-byte random nonce for XChaCha20-Poly1305 var nonce24 = Data(count: 24) - let randomStatus = nonce24.withUnsafeMutableBytes { ptr in + _ = nonce24.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) } - // Never encrypt with an unrandomized nonce: nonce reuse under the same - // key breaks XChaCha20-Poly1305 confidentiality and authenticity. - guard randomStatus == errSecSuccess else { - throw NostrError.cryptographicFailure - } - + let pt = Data(plaintext.utf8) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) @@ -623,7 +303,7 @@ struct NostrProtocol { combined.append(nonce24) combined.append(sealed.ciphertext) combined.append(sealed.tag) - return "v2:" + Base64URLCoding.encode(combined) + return "v2:" + base64URLEncode(combined) } private static func decrypt( @@ -631,14 +311,10 @@ struct NostrProtocol { senderPubkey: String, recipientKey: P256K.Schnorr.PrivateKey ) throws -> String { - // Expect BitChat's historical `v2:` private-envelope framing, and - // bound work before Base64 decoding attacker-sized input. - guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes, - ciphertext.hasPrefix("v2:") else { - throw NostrError.invalidCiphertext - } + // Expect NIP-44 v2 format + guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } let encoded = String(ciphertext.dropFirst(3)) - guard let data = Base64URLCoding.decode(encoded), + guard let data = base64URLDecode(encoded), data.count > (24 + 16), let senderPubkeyData = Data(hexString: senderPubkey) else { throw NostrError.invalidCiphertext @@ -652,7 +328,7 @@ struct NostrProtocol { // Try decryption with even-Y then odd-Y when sender pubkey is x-only func attemptDecrypt(using pubKeyData: Data) throws -> Data { let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) - let key = try derivePrivateEnvelopeKey(from: ss) + let key = try deriveNIP44V2Key(from: ss) return try XChaCha20Poly1305Compat.open( ciphertext: Data(ct), tag: Data(tag), @@ -662,25 +338,18 @@ struct NostrProtocol { } // If 32 bytes (x-only) try both parities, otherwise single try - let plaintext: Data if senderPubkeyData.count == 32 { let even = Data([0x02]) + senderPubkeyData if let pt = try? attemptDecrypt(using: even) { - plaintext = pt - } else { - let odd = Data([0x03]) + senderPubkeyData - plaintext = try attemptDecrypt(using: odd) + return String(data: pt, encoding: .utf8) ?? "" } + let odd = Data([0x03]) + senderPubkeyData + let pt = try attemptDecrypt(using: odd) + return String(data: pt, encoding: .utf8) ?? "" } else { - plaintext = try attemptDecrypt(using: senderPubkeyData) + let pt = try attemptDecrypt(using: senderPubkeyData) + return String(data: pt, encoding: .utf8) ?? "" } - - // Authenticated plaintext that is not valid UTF-8 is a malformed - // envelope, not an empty message. - guard let decoded = String(data: plaintext, encoding: .utf8) else { - throw NostrError.invalidCiphertext - } - return decoded } private static func deriveSharedSecret( @@ -740,8 +409,38 @@ struct NostrProtocol { let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } // ECDH shared secret derived - // Return raw ECDH shared secret; HKDF is applied by - // derivePrivateEnvelopeKey + // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key + return sharedSecretData + } + + // Direct version that doesn't try to add prefixes + private static func deriveSharedSecretDirect( + privateKey: P256K.Schnorr.PrivateKey, + publicKey: Data + ) throws -> Data { + // Direct shared secret calculation + + // Convert Schnorr private key to KeyAgreement private key + let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( + dataRepresentation: privateKey.dataRepresentation + ) + + // Use the public key as-is (should already have prefix) + let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( + dataRepresentation: publicKey, + format: .compressed + ) + + // Perform ECDH + let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( + with: keyAgreementPublicKey, + format: .compressed + ) + + // Convert SharedSecret to Data + let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } + + // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key return sharedSecretData } @@ -801,10 +500,6 @@ struct NostrEvent: Codable { let content = dict["content"] as? String else { throw NostrError.invalidEvent } - - guard Self.isWithinInboundTagLimits(tags) else { - throw NostrError.invalidEvent - } self.id = dict["id"] as? String ?? "" self.pubkey = pubkey @@ -814,21 +509,6 @@ struct NostrEvent: Codable { self.content = content self.sig = dict["sig"] as? String } - - /// Bounds untrusted relay tag arrays so attackers cannot force large - /// allocations or expensive joins on the inbound hot path. - static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool { - guard tags.count <= TransportConfig.nostrMaxEventTags else { return false } - - for tag in tags { - guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false } - guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else { - return false - } - } - - return true - } func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent { let (eventId, eventIdHash) = try calculateEventId() @@ -836,12 +516,9 @@ struct NostrEvent: Codable { // Sign with Schnorr (BIP-340) var messageBytes = [UInt8](eventIdHash) var auxRand = [UInt8](repeating: 0, count: 32) - let auxStatus = auxRand.withUnsafeMutableBytes { ptr in + _ = auxRand.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) } - guard auxStatus == errSecSuccess else { - throw NostrError.cryptographicFailure - } let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand) let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() @@ -896,23 +573,36 @@ struct NostrEvent: Codable { enum NostrError: Error { case invalidPublicKey + case invalidPrivateKey case invalidEvent case invalidCiphertext - case cryptographicFailure + case signingFailed + case encryptionFailed } -// MARK: - BitChat private-envelope key derivation +// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url) private extension NostrProtocol { - /// The HKDF info string retains the historical "nip44-v2" label for wire - /// compatibility with deployed clients, but this is not the NIP-44 key - /// schedule: NIP-44 derives a conversation key via HKDF-extract with that - /// label as the *salt* and uses ChaCha20 with per-message expanded keys. - static func derivePrivateEnvelopeKey(from sharedSecretData: Data) throws -> Data { + static func base64URLEncode(_ data: Data) -> String { + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func base64URLDecode(_ s: String) -> Data? { + var str = s + let pad = (4 - (str.count % 4)) % 4 + if pad > 0 { str += String(repeating: "=", count: pad) } + str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + return Data(base64Encoded: str) + } + + static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data { let derivedKey = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: sharedSecretData), salt: Data(), - info: Data("nip44-v2".utf8), + info: "nip44-v2".data(using: .utf8)!, outputByteCount: 32 ) return derivedKey.withUnsafeBytes { Data($0) } diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 6601b863..ebfeadaa 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -48,14 +48,7 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol { let base: URLSession func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { - let task = base.webSocketTask(with: url) - // Byte bound per inbound frame; without it the per-relay buffer cap - // (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a - // hostile relay could pile up cap × 1 MiB (URLSession default) per - // connection. See TransportConfig.nostrInboundMaxFrameBytes for the - // sizing rationale. Oversized frames fail the receive with an error. - task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes - return URLSessionWebSocketTaskAdapter(base: task) + URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url)) } } @@ -73,21 +66,6 @@ struct NostrRelayManagerDependencies { var makeSession: () -> NostrRelaySessionProtocol var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void var now: () -> Date - /// Uniform random value in [0, 1) used to jitter reconnect backoff. - /// Injectable so tests can pin or sweep the jitter deterministically. - var jitterUnit: () -> Double - /// Where relay-settings changes are observed. Injectable so a test can use - /// its own center instead of racing the process-wide one. - var notificationCenter: NotificationCenter = .default - /// Relays added by hand, merged with the built-in set. Injectable so tests - /// do not have to write to shared preferences. - var customRelays: () -> [String] = { NostrRelaySettings.customRelays() } - /// Whether a location channel is currently open. Mirrors the third arm of - /// `NetworkActivationService`'s gate: teleporting into a geohash needs no - /// location permission, and without this the relays would stay filtered out - /// for someone who denied location and has no mutual favorites. - var isInLocationChannel: () -> Bool = { false } - var selectedChannelPublisher: AnyPublisher = Empty().eraseToAnyPublisher() } private extension NostrRelayManagerDependencies { @@ -115,13 +93,7 @@ private extension NostrRelayManagerDependencies { scheduleAfter: { delay, action in DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) }, - now: Date.init, - jitterUnit: { Double.random(in: 0..<1) }, - isInLocationChannel: { - if case .location = LocationChannelManager.shared.selectedChannel { return true } - return false - }, - selectedChannelPublisher: LocationChannelManager.shared.$selectedChannel.eraseToAnyPublisher() + now: Date.init ) } } @@ -130,10 +102,7 @@ private extension NostrRelayManagerDependencies { @MainActor final class NostrRelayManager: ObservableObject { static let shared = NostrRelayManager() - // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info. - // Entries are removed only on OK acks (or panic wipe); relays that never - // ack leave entries behind for the process lifetime. Observability-only - // state, bounded in practice by outbound DM volume. + // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info private(set) static var pendingGiftWrapIDs = Set() static func registerPendingGiftWrap(id: String) { pendingGiftWrapIDs.insert(id) @@ -144,6 +113,7 @@ final class NostrRelayManager: ObservableObject { let url: String var isConnected: Bool = false var lastError: Error? + var lastConnectedAt: Date? var messagesSent: Int = 0 var messagesReceived: Int = 0 var reconnectAttempts: Int = 0 @@ -151,47 +121,19 @@ final class NostrRelayManager: ObservableObject { var nextReconnectTime: Date? } - // Built-in relays carry private-message envelopes, so avoid relays known to - // reject the kinds they use. - nonisolated private static let builtInRelays = [ + // Default relay list (can be customized) + private static let defaultRelays = [ "wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net", - "wss://offchain.pub" + "wss://offchain.pub", + "wss://nostr21.com" // For local testing, you can add: "ws://localhost:8080" ] - /// Exposed so the relay settings UI can reject re-adding a built-in. - /// `nonisolated` because it is an immutable constant with no actor state. - nonisolated static let builtInRelayURLs = Set( - builtInRelays.compactMap { NostrRelayURL.normalized($0) } - ) - - /// The relays private messages target: the built-in set plus any added by - /// hand. Four hardcoded hostnames are four names for a censor to block, so - /// the added ones are what keeps this reachable without a new build. - /// - /// Cached rather than computed per access: `allowedRelayList` consults the - /// set once per candidate URL, and recomputing would mean a `UserDefaults` - /// read and a fresh normalize-and-dedupe pass inside that loop. Refreshed - /// from `reloadDefaultRelays()` on construction and whenever the relay - /// settings change. - private var defaultRelays: [String] = [] - private var defaultRelaySet: Set = [] - - private func reloadDefaultRelays() { - var seen = Set() - defaultRelays = (Self.builtInRelays + dependencies.customRelays()) - .compactMap { NostrRelayURL.normalized($0) } - .filter { seen.insert($0).inserted } - defaultRelaySet = Set(defaultRelays) - } - + private static let defaultRelaySet = Set(defaultRelays) + @Published private(set) var relays: [Relay] = [] @Published private(set) var isConnected = false - /// Whether a relay that carries private messages is connected. DMs - /// target the default (gift-wrap-capable) relay set, so a connected - /// geohash/custom relay alone must not count — sends would still queue. - @Published private(set) var isDMRelayConnected = false private let dependencies: NostrRelayManagerDependencies private var allowDefaultRelays: Bool = false @@ -199,66 +141,19 @@ final class NostrRelayManager: ObservableObject { private var hasLocationPermission: Bool = false private var connections: [String: NostrRelayConnectionProtocol] = [:] private var subscriptions: [String: Set] = [:] // relay URL -> active subscription IDs - // Not-yet-flushed REQs per relay, bounded by a per-relay cap (oldest by - // insertion order evicted) and an age sweep on connect attempts. Dicts are - // unordered, so each entry carries an insertion sequence and queue time. - private struct PendingSubscription { - let messageString: String // encoded REQ JSON - let queuedAt: Date - let sequence: UInt64 - } - private var pendingSubscriptions: [String: [String: PendingSubscription]] = [:] // relay URL -> (subscription id -> pending REQ) - private var pendingSubscriptionSequence: UInt64 = 0 + private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var messageHandlers: [String: (NostrEvent) -> Void] = [:] - private struct InboundEventKey: Hashable { - let subscriptionID: String - let eventID: String - } - private let recentInboundEventKeyLimit = TransportConfig.nostrInboundEventDedupCap - private let recentInboundEventKeyTrimTarget = TransportConfig.nostrInboundEventDedupTrimTarget - private var recentInboundEventKeys = Set() - private var recentInboundEventKeyOrder: [InboundEventKey] = [] - private var duplicateInboundEventDropCount = 0 - private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:] - private var inboundEventLogCount = 0 - // Coalesce duplicate subscribe requests for the same id within a short window. - private let subscribeCoalesceInterval: TimeInterval = 1.0 + // Coalesce duplicate subscribe requests for the same id within a short window private var subscribeCoalesce: [String: Date] = [:] - private var pendingTorConnectionURLs = Set() - private var awaitingTorForConnections = false - private var torReadyWaitAttempts = 0 private var cancellables = Set() - private struct SubscriptionRequestState: Equatable { - let messageString: String - let relayURLs: Set - } - private var subscriptionRequestState: [String: SubscriptionRequestState] = [:] - - // Track EOSE per subscription to signal when initial stored events are - // done. Completion is scoped to relays the REQ actually reached: targets - // still mid-connect must not hold the callback hostage until the fallback - // timer (a dead relay of five used to pin "loading" for the full 10s). + // Track EOSE per subscription to signal when initial stored events are done private struct EOSETracker { - /// Targets the REQ has not been delivered to yet (still connecting). - var awaitingSend: Set - /// Relays that received the REQ and have not sent EOSE yet. - var awaitingEOSE: Set - /// True once any relay received the REQ (or answered with EOSE) — - /// completion with zero sends would mean "done" without ever asking. - var didSend = false + var pendingRelays: Set var callback: () -> Void - let epoch: Int - - /// Done when every relay that got the REQ has resolved, provided at - /// least one did — or when every target dropped out entirely. - var isComplete: Bool { - (didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty) - } + var timer: Timer? } private var eoseTrackers: [String: EOSETracker] = [:] - private var eoseTrackerEpoch = 0 - private var pendingEOSECallbacks: [String: () -> Void] = [:] // Message queue for reliability // Pending sends held only for relays that are not yet connected. @@ -268,18 +163,6 @@ final class NostrRelayManager: ObservableObject { } private var messageQueue: [PendingSend] = [] private let messageQueueLock = NSLock() - /// Non-queued sends whose callers require relay durability. A WebSocket - /// write only proves bytes left this process; NIP-01 `OK` is the relay's - /// accept/reject acknowledgment. - private struct ConfirmedSendState { - let token: UUID - var awaitingRelays: Set - let completion: (Bool) -> Void - } - private var confirmedSends: [String: ConfirmedSendState] = [:] - // Total pending sends dropped at the queue cap; drives the sampled - // overflow warning (first + every Nth drop). - private var pendingSendDropCount = 0 private let encoder = JSONEncoder() private var shouldUseTor: Bool { dependencies.userTorEnabled() } @@ -291,42 +174,11 @@ final class NostrRelayManager: ObservableObject { // Bump generation to invalidate scheduled reconnects when we reset/disconnect private var connectionGeneration: Int = 0 - - // Per-relay off-main inbound pipeline: raw socket frames are parsed and - // Schnorr-verified in arrival order OFF the main actor (this is the single - // signature verification for the whole inbound path — downstream handlers - // receive only verified events), then hop back to the main actor for dedup - // recording and handler dispatch. - // - // Each relay connection owns its OWN AsyncStream + consumer task, so N - // relays verify in parallel while every relay's frames stay in arrival - // order (a single subscription's events for a relay all arrive on that - // relay's socket, so per-relay ordering preserves per-subscription - // ordering). A burst of EVENT frames from one busy/malicious relay only - // blocks that relay's own verification backlog — DMs, OKs, EOSEs, and - // events from every other relay keep flowing on their own pipelines. - // - // Each stream is bounded (`.bufferingNewest`) so a relay flooding faster - // than its verification drains sheds its own oldest frames instead of - // growing memory without bound; it can never starve other relays. - // - // Continuations live in a lock-guarded, `Sendable` router (see - // `InboundFrameRouter` at file scope) so the raw socket receive callback - // (which is NOT main-actor isolated) can route a frame to the right relay - // stream without a per-frame main hop, while the main actor owns pipeline - // creation/teardown. The expensive work (Schnorr verify) is what runs - // off-main; the yield stays cheap. - private let inboundRouter = InboundFrameRouter() - - convenience init() { - self.init(dependencies: .live()) - } - - internal init(dependencies: NostrRelayManagerDependencies) { - self.dependencies = dependencies + + init() { + self.dependencies = .live() hasMutualFavorites = dependencies.hasMutualFavorites() hasLocationPermission = dependencies.hasLocationPermission() - reloadDefaultRelays() applyDefaultRelayPolicy(force: true) // Deterministic JSON shape for outbound requests self.encoder.outputFormatting = .sortedKeys @@ -348,98 +200,58 @@ final class NostrRelayManager: ObservableObject { self.applyDefaultRelayPolicy() } .store(in: &cancellables) - dependencies.selectedChannelPublisher + } + + internal init(dependencies: NostrRelayManagerDependencies) { + self.dependencies = dependencies + hasMutualFavorites = dependencies.hasMutualFavorites() + hasLocationPermission = dependencies.hasLocationPermission() + applyDefaultRelayPolicy(force: true) + // Deterministic JSON shape for outbound requests + self.encoder.outputFormatting = .sortedKeys + dependencies.mutualFavoritesPublisher .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.applyDefaultRelayPolicy() + .sink { [weak self] favorites in + guard let self = self else { return } + self.hasMutualFavorites = !favorites.isEmpty + self.applyDefaultRelayPolicy() } .store(in: &cancellables) - // Adding or removing a relay by hand changes the target set, so - // reconcile connections now rather than at the next send. - dependencies.notificationCenter - .publisher(for: NostrRelaySettings.didChangeNotification) + dependencies.locationPermissionPublisher .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { return } - // Reconcile against the previous set: a removed relay is no - // longer in `defaultRelays`, so nothing downstream would ever - // close its socket or drop its queued sends. - let previous = self.defaultRelaySet - self.reloadDefaultRelays() - self.dropRelays(previous.subtracting(self.defaultRelaySet)) - self.applyDefaultRelayPolicy(force: true) + .sink { [weak self] state in + guard let self = self else { return } + let authorized = (state == .authorized) + if authorized == self.hasLocationPermission { return } + self.hasLocationPermission = authorized + self.applyDefaultRelayPolicy() } .store(in: &cancellables) } - - deinit { - inboundRouter.finishAll() - } - - /// Ensure a serial off-main consumer pipeline exists for a relay. Called on - /// the main actor when a socket is (re)armed for receiving. Idempotent. - /// - /// Ordering within the relay is deliberate and security/performance-critical: - /// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap - /// duplicate LOOKUP — duplicate fan-in from multiple relays dominates - /// real traffic and must never pay for Schnorr verification. - /// 2. `isValidSignature()` runs here, off the main actor — the ONLY - /// signature verification on the inbound path (JSON re-serialization + - /// SHA-256 + secp256k1 Schnorr per event). - /// 3. `deliverVerifiedInboundEvent` (main hop): authoritative - /// check-and-RECORD plus handler dispatch. Recording only after - /// verification means a forged-signature copy can never poison the - /// dedup cache and suppress the genuine event. - private func ensureRelayInboundPipeline(for relayUrl: String) { - let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in - Task.detached(priority: .userInitiated) { - for await frame in stream { - guard let parsed = ParsedInbound(frame.message) else { continue } - guard let self else { return } - switch parsed { - case .event(let subId, let event): - guard await self.precheckInboundEvent( - subscriptionID: subId, - eventID: event.id, - relayUrl: relayUrl - ) else { - continue - } - guard event.isValidSignature() else { - SecureLogger.warning( - "⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)", - category: .session - ) - continue - } - await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl) - case .eose, .ok, .notice: - await self.handleParsedMessage(parsed, from: relayUrl) - } - } - } - } - if started { - SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session) - } - } - - /// Tear down a relay's inbound pipeline (socket gone or state wiped). The - /// consumer drains any already-buffered frames before finishing, so - /// in-flight verified events are still delivered. - private func teardownRelayInboundPipeline(for relayUrl: String) { - inboundRouter.finishPipeline(for: relayUrl) - } - - private func teardownAllRelayInboundPipelines() { - inboundRouter.finishAll() - } - + /// Connect to all configured relays func connect() { // Global network policy gate guard dependencies.activationAllowed() else { return } - connectToRelays(relays.map(\.url), shouldLog: true) + if shouldUseTor { + // Ensure Tor is started early and wait for readiness off-main; then hop back to connect. + dependencies.awaitTorReady { [weak self] ready in + guard let self = self else { return } + if !ready { + SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) + return + } + SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session) + for relay in self.relays { + self.connectToRelay(relay.url) + } + } + } else { + SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session) + for relay in self.relays { + connectToRelay(relay.url) + } + } } /// Disconnect from all relays @@ -449,84 +261,11 @@ final class NostrRelayManager: ObservableObject { task.cancel(with: .goingAway, reason: nil) } connections.removeAll() - // Sockets are gone; drop every relay's inbound verify pipeline. - teardownAllRelayInboundPipelines() - markRelaySocketsClosed(resetState: false) - // Sockets are gone, so per-relay subscription state is cleared — but - // durable intent (subscriptionRequestState, messageHandlers, parked - // EOSE callbacks) is kept so REQs replay when relays reconnect - // (e.g. background → foreground). + // Clear known subscriptions and any queued subs since connections are gone subscriptions.removeAll() pendingSubscriptions.removeAll() - // Settle in-flight initial loads instead of leaving callers hanging. - let trackers = eoseTrackers - eoseTrackers.removeAll() - for (_, tracker) in trackers { - tracker.callback() - } - let confirmed = confirmedSends.values.map(\.completion) - confirmedSends.removeAll() - confirmed.forEach { $0(false) } - pendingTorConnectionURLs.removeAll() - awaitingTorForConnections = false - torReadyWaitAttempts = 0 updateConnectionStatus() } - - /// Panic wipe reset: close sockets and drop every user/session-specific - /// relay intent without invoking old callbacks. Unlike `disconnect()`, this - /// must not preserve subscription replay state because geohash DM handlers - /// can capture pre-wipe Nostr private keys. - func resetForPanicWipe() { - connectionGeneration &+= 1 - for (_, task) in connections { - task.cancel(with: .goingAway, reason: nil) - } - connections.removeAll() - teardownAllRelayInboundPipelines() - markRelaySocketsClosed(resetState: true) - subscriptions.removeAll() - pendingSubscriptions.removeAll() - messageHandlers.removeAll() - subscriptionRequestState.removeAll() - subscribeCoalesce.removeAll() - eoseTrackers.removeAll() - pendingEOSECallbacks.removeAll() - pendingTorConnectionURLs.removeAll() - awaitingTorForConnections = false - torReadyWaitAttempts = 0 - recentInboundEventKeys.removeAll() - recentInboundEventKeyOrder.removeAll() - duplicateInboundEventDropCount = 0 - duplicateInboundEventDropCountBySubscription.removeAll() - inboundEventLogCount = 0 - Self.pendingGiftWrapIDs.removeAll() - confirmedSends.removeAll() - - messageQueueLock.lock() - messageQueue.removeAll() - pendingSendDropCount = 0 - messageQueueLock.unlock() - - updateConnectionStatus() - } - - private func markRelaySocketsClosed(resetState: Bool) { - let now = dependencies.now() - for index in relays.indices { - relays[index].isConnected = false - relays[index].nextReconnectTime = nil - if resetState { - relays[index].lastError = nil - relays[index].lastDisconnectedAt = nil - relays[index].messagesSent = 0 - relays[index].messagesReceived = 0 - relays[index].reconnectAttempts = 0 - } else { - relays[index].lastDisconnectedAt = now - } - } - } /// Ensure connections exist to the given relay URLs (idempotent). func ensureConnections(to relayUrls: [String]) { @@ -534,12 +273,22 @@ final class NostrRelayManager: ObservableObject { guard dependencies.activationAllowed() else { return } let targets = allowedRelayList(from: relayUrls) guard !targets.isEmpty else { return } + if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { + // Defer until Tor is fully ready; avoid queuing connection attempts early + dependencies.awaitTorReady { [weak self] ready in + guard let self = self else { return } + if ready { self.ensureConnections(to: relayUrls) } + } + return + } var existing = Set(relays.map { $0.url }) for url in targets where !existing.contains(url) { relays.append(Relay(url: url)) existing.insert(url) } - connectToRelays(targets) + for url in targets where connections[url] == nil { + connectToRelay(url) + } } /// Send an event to specified relays (or all if none specified) @@ -547,17 +296,14 @@ final class NostrRelayManager: ObservableObject { // Global network policy gate guard dependencies.activationAllowed() else { return } if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { - // Fail-closed: nothing touches the network until Tor is up. Queue the - // event locally so it survives a slow bootstrap (queued sends flush - // when relays connect), then kick off connection setup, which itself - // waits for Tor readiness. - let targetRelays = allowedRelayList(from: relayUrls ?? defaultRelays) - guard !targetRelays.isEmpty else { return } - enqueuePendingSend(event, pendingRelays: Set(targetRelays)) - ensureConnections(to: targetRelays) + // Defer sends until Tor is ready to avoid premature queueing + dependencies.awaitTorReady { [weak self] ready in + guard let self = self else { return } + if ready { self.sendEvent(event, to: relayUrls) } + } return } - let requestedRelays = relayUrls ?? defaultRelays + let requestedRelays = relayUrls ?? Self.defaultRelays let targetRelays = allowedRelayList(from: requestedRelays) guard !targetRelays.isEmpty else { return } ensureConnections(to: targetRelays) @@ -565,127 +311,16 @@ final class NostrRelayManager: ObservableObject { // Attempt immediate send to relays with active connections; queue the rest var stillPending = Set() for relayUrl in targetRelays { - if let connection = connectedConnection(for: relayUrl) { + if let connection = connections[relayUrl] { sendToRelay(event: event, connection: connection, relayUrl: relayUrl) } else { stillPending.insert(relayUrl) } } if !stillPending.isEmpty { - enqueuePendingSend(event, pendingRelays: stillPending) - } - } - - /// Attempts an event only on currently connected target relays and - /// reports whether at least one relay explicitly accepted it via NIP-01 - /// `OK`. A successful WebSocket write alone is not durable acceptance. - /// Unlike `sendEvent`, this never enters the process-local pending queue; - /// callers use it when success unlocks durable state or user-visible - /// delivery progress. - func sendEventImmediately( - _ event: NostrEvent, - to relayUrls: [String]? = nil, - completion: @escaping (Bool) -> Void - ) { - guard dependencies.activationAllowed() else { - completion(false) - return - } - guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else { - completion(false) - return - } - - let requestedRelays = relayUrls ?? defaultRelays - let targetRelays = allowedRelayList(from: requestedRelays) - let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in - guard let connection = connectedConnection(for: relayUrl) else { return nil } - return (relayUrl, connection) - } - guard !connectedTargets.isEmpty else { - completion(false) - return - } - - let token = UUID() - let eventID = event.id - if let replaced = confirmedSends.removeValue(forKey: eventID) { - replaced.completion(false) - } - confirmedSends[eventID] = ConfirmedSendState( - token: token, - awaitingRelays: Set(connectedTargets.map(\.0)), - completion: completion - ) - dependencies.scheduleAfter(TransportConfig.nostrConfirmedSendAckTimeoutSeconds) { [weak self] in - Task { @MainActor [weak self] in - self?.timeoutConfirmedSend(eventID: eventID, token: token) - } - } - - for (relayUrl, connection) in connectedTargets { - sendToRelay(event: event, connection: connection, relayUrl: relayUrl) { [weak self] succeeded in - guard let self else { return } - // Success only means the bytes reached the socket; wait for - // the matching relay OK. A failed write settles this target - // as rejected because no OK can arrive for it. - if !succeeded { - self.resolveConfirmedSend( - eventID: eventID, - relayURL: relayUrl, - accepted: false, - token: token - ) - } - } - } - } - - private func resolveConfirmedSend( - eventID: String, - relayURL: String, - accepted: Bool, - token: UUID? = nil - ) { - guard var state = confirmedSends[eventID], - token == nil || state.token == token, - state.awaitingRelays.remove(relayURL) != nil else { return } - if accepted { - confirmedSends.removeValue(forKey: eventID) - state.completion(true) - } else if state.awaitingRelays.isEmpty { - confirmedSends.removeValue(forKey: eventID) - state.completion(false) - } else { - confirmedSends[eventID] = state - } - } - - private func timeoutConfirmedSend(eventID: String, token: UUID) { - guard let state = confirmedSends[eventID], state.token == token else { return } - confirmedSends.removeValue(forKey: eventID) - state.completion(false) - } - - private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set) { - messageQueueLock.lock() - messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays)) - let overflow = messageQueue.count - TransportConfig.nostrPendingSendQueueCap - if overflow > 0 { - messageQueue.removeFirst(overflow) - } - messageQueueLock.unlock() - guard overflow > 0 else { return } - // Dropped events are ephemeral (presence/geo), so no status surfacing - // is needed — but the drops should be visible. Sampled so a sustained - // relay stall can't flood the log. - pendingSendDropCount += overflow - if pendingSendDropCount == 1 || - pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) { - SecureLogger.warning( - "📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)", - category: .session - ) + messageQueueLock.lock() + messageQueue.append(PendingSend(event: event, pendingRelays: stillPending)) + messageQueueLock.unlock() } } @@ -698,7 +333,7 @@ final class NostrRelayManager: ObservableObject { // Flush only for a specific relay for i in (0.. NostrRelayConnectionProtocol? { - guard let connection = connections[relayUrl], - relays.first(where: { $0.url == relayUrl })?.isConnected == true else { - return nil - } - return connection - } /// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays. func subscribe( @@ -745,12 +372,24 @@ final class NostrRelayManager: ObservableObject { ) { // Global network policy gate guard dependencies.activationAllowed() else { return } - // Coalesce rapid duplicate subscribe requests even while Tor readiness is pending. + // Coalesce rapid duplicate subscribe requests only if a handler already exists let now = dependencies.now() - if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval { - return + if messageHandlers[id] != nil { + if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 { + return + } } subscribeCoalesce[id] = now + if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { + // Defer subscription setup until Tor is ready; avoid queuing subs early + dependencies.awaitTorReady { [weak self] ready in + guard let self = self else { return } + if ready { + self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE) + } + } + return + } messageHandlers[id] = handler let req = NostrRequest.subscribe(id: id, filters: [filter]) @@ -765,31 +404,37 @@ final class NostrRelayManager: ObservableObject { // SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session) // Target specific relays if provided; else default. Filter permanently failed relays. - let baseUrls = relayUrls ?? defaultRelays - let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) } - let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls)) - if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) { - return - } - subscriptionRequestState[id] = requestState - + let baseUrls = relayUrls ?? Self.defaultRelays + let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) } + let urls = allowedRelayList(from: candidateUrls) // Always queue subscriptions; sending happens when a relay reports connected - var existingSet = Set(relays.map { $0.url }) + let existingSet = Set(relays.map { $0.url }) for url in urls where !existingSet.contains(url) { relays.append(Relay(url: url)) - existingSet.insert(url) } for url in urls { - queuePendingSubscription(id: id, messageString: messageString, for: url) + var map = self.pendingSubscriptions[url] ?? [:] + map[id] = messageString + self.pendingSubscriptions[url] = map } // Initialize EOSE tracking if requested if let onEOSE = onEOSE { if urls.isEmpty { onEOSE() - } else if shouldWaitForTorBeforeConnecting { - parkEOSECallbackUntilTorReady(id: id, callback: onEOSE) } else { - startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE) + var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) + // Fallback timeout to avoid hanging if a relay never sends EOSE + tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + Task { @MainActor in + guard let self = self else { return } + if let t = self.eoseTrackers[id] { + t.timer?.invalidate() + self.eoseTrackers.removeValue(forKey: id) + onEOSE() + } + } + } + eoseTrackers[id] = tracker } } SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session) @@ -807,60 +452,48 @@ final class NostrRelayManager: ObservableObject { } private func applyDefaultRelayPolicy(force: Bool = false) { - let shouldAllow = hasMutualFavorites || hasLocationPermission || dependencies.isInLocationChannel() + let shouldAllow = hasMutualFavorites || hasLocationPermission if !force && shouldAllow == allowDefaultRelays { return } allowDefaultRelays = shouldAllow if shouldAllow { var existing = Set(relays.map { $0.url }) - for url in defaultRelays where !existing.contains(url) { + for url in Self.defaultRelays where !existing.contains(url) { relays.append(Relay(url: url)) existing.insert(url) } if dependencies.activationAllowed() { - ensureConnections(to: defaultRelays) + ensureConnections(to: Self.defaultRelays) } } else { - dropRelays(defaultRelaySet) - } - } - - /// Closes and forgets a set of relays: connection, inbound pipeline, - /// subscriptions, queued sends addressed only to them, and the published row. - private func dropRelays(_ urls: Set) { - guard !urls.isEmpty else { return } - for url in urls { - if let connection = connections[url] { - connection.cancel(with: .goingAway, reason: nil) + for url in Self.defaultRelays { + if let connection = connections[url] { + connection.cancel(with: .goingAway, reason: nil) + } + connections.removeValue(forKey: url) + subscriptions.removeValue(forKey: url) + pendingSubscriptions.removeValue(forKey: url) } - connections.removeValue(forKey: url) - teardownRelayInboundPipeline(for: url) - subscriptions.removeValue(forKey: url) - pendingSubscriptions.removeValue(forKey: url) - } - messageQueueLock.lock() - for index in (0.. [String] { var seen = Set() var result: [String] = [] - for rawURL in urls { - guard let url = NostrRelayURL.normalized(rawURL) else { continue } - if !allowDefaultRelays && defaultRelaySet.contains(url) { continue } + for url in urls { + if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue } if seen.insert(url).inserted { result.append(url) } @@ -871,16 +504,8 @@ final class NostrRelayManager: ObservableObject { /// Unsubscribe from a subscription func unsubscribe(id: String) { messageHandlers.removeValue(forKey: id) - removeRecentInboundEvents(forSubscriptionID: id) - duplicateInboundEventDropCountBySubscription.removeValue(forKey: id) // Allow immediate re-subscription by clearing coalescer timestamp subscribeCoalesce.removeValue(forKey: id) - subscriptionRequestState.removeValue(forKey: id) - pendingEOSECallbacks.removeValue(forKey: id) - eoseTrackers.removeValue(forKey: id) - for url in Array(pendingSubscriptions.keys) { - pendingSubscriptions[url]?.removeValue(forKey: id) - } let req = NostrRequest.close(id: id) let message = try? encoder.encode(req) @@ -900,246 +525,6 @@ final class NostrRelayManager: ObservableObject { } // MARK: - Private Methods - - private var shouldWaitForTorBeforeConnecting: Bool { - shouldUseTor && !dependencies.torIsReady() - } - - private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) { - guard dependencies.activationAllowed() else { return } - sweepStalePendingSubscriptions() - let targets = allowedRelayList(from: relayUrls).filter { - connections[$0] == nil && !isPermanentlyFailed($0) - } - guard !targets.isEmpty else { return } - - if shouldWaitForTorBeforeConnecting { - queueConnectionsUntilTorReady(targets) - return - } - - if shouldLog { - let route = shouldUseTor ? "via Tor" : "direct" - SecureLogger.debug("🌐 Connecting to \(targets.count) Nostr relay(s) (\(route))", category: .session) - } - - for url in targets { - connectToRelay(url) - } - } - - private func queueConnectionsUntilTorReady(_ relayUrls: [String]) { - let targets = allowedRelayList(from: relayUrls).filter { - connections[$0] == nil && !isPermanentlyFailed($0) - } - guard !targets.isEmpty else { return } - - pendingTorConnectionURLs.formUnion(targets) - guard !awaitingTorForConnections else { return } - - awaitingTorForConnections = true - let generation = connectionGeneration - dependencies.awaitTorReady { [weak self] ready in - guard let self else { return } - guard generation == self.connectionGeneration else { return } - - let pending = Array(self.pendingTorConnectionURLs) - self.pendingTorConnectionURLs.removeAll() - self.awaitingTorForConnections = false - - guard ready else { - self.torReadyWaitAttempts += 1 - if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts { - SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session) - self.queueConnectionsUntilTorReady(pending) - } else { - // Still fail-closed (no network), but unblock any callers - // waiting on EOSE so the UI doesn't hang indefinitely. - // Queued subscriptions/sends are kept and flush if a later - // trigger (e.g. app foreground) brings Tor up. - SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session) - self.torReadyWaitAttempts = 0 - self.unblockPendingEOSECallbacks(reason: "tor-unavailable") - } - return - } - - self.torReadyWaitAttempts = 0 - self.connectToRelays(pending, shouldLog: true) - } - } - - /// Park an EOSE callback while Tor is not yet ready, and schedule the same - /// fallback timeout `startEOSETracking` uses. Without it, a parked callback - /// would only be unblocked by Tor-readiness retry exhaustion (several - /// awaitReady timeouts, i.e. minutes), leaving callers hanging far past the - /// normal EOSE fallback. If Tor recovers first the callback is promoted to - /// a real EOSE tracker (`startPendingEOSETrackingIfNeeded`), and if retry - /// exhaustion fires first it is drained by `unblockPendingEOSECallbacks`; - /// either way it leaves `pendingEOSECallbacks` and this timer is a no-op. - private func parkEOSECallbackUntilTorReady(id: String, callback: @escaping () -> Void) { - pendingEOSECallbacks[id] = callback - let generation = connectionGeneration - dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - // Stale timers from a previous connection generation are void. - guard generation == self.connectionGeneration else { return } - // Already fired (unsubscribe, retry-exhaustion unblock) or - // promoted to a real EOSE tracker: nothing to do. - guard let callback = self.pendingEOSECallbacks.removeValue(forKey: id) else { return } - SecureLogger.warning("Unblocking Tor-parked EOSE callback for \(id) after fallback timeout", category: .session) - callback() - } - } - } - - /// Fire and clear all EOSE callbacks that are parked waiting for Tor. - /// Callers treat EOSE as "initial fetch finished"; firing with no data is - /// safe and prevents indefinite hangs when Tor cannot bootstrap. - private func unblockPendingEOSECallbacks(reason: String) { - guard !pendingEOSECallbacks.isEmpty else { return } - let callbacks = pendingEOSECallbacks - pendingEOSECallbacks.removeAll() - SecureLogger.warning("Unblocking \(callbacks.count) pending EOSE callback(s) without data (\(reason))", category: .session) - for (_, callback) in callbacks { - callback() - } - } - - private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool { - guard !requestState.relayURLs.isEmpty else { return true } - return requestState.relayURLs.allSatisfy { url in - pendingSubscriptions[url]?[id]?.messageString == requestState.messageString || - subscriptions[url]?.contains(id) == true - } - } - - private func queuePendingSubscription(id: String, messageString: String, for url: String) { - var map = pendingSubscriptions[url] ?? [:] - pendingSubscriptionSequence &+= 1 - map[id] = PendingSubscription( - messageString: messageString, - queuedAt: dependencies.now(), - sequence: pendingSubscriptionSequence - ) - // Bound per-relay pending REQs; evict oldest by insertion order. The - // durable intent stays in subscriptionRequestState, so an evicted REQ - // is still replayed if its subscription is active when the relay - // (re)connects. - var evictedCount = 0 - while map.count > TransportConfig.nostrPendingSubscriptionsPerRelayCap, - let oldest = map.min(by: { $0.value.sequence < $1.value.sequence }) { - map.removeValue(forKey: oldest.key) - evictedCount += 1 - } - if evictedCount > 0 { - // Bounds proof: the cap eviction actually removed entries. - SecureLogger.warning( - "📋 Evicted \(evictedCount) pending sub(s) over cap for \(url)", - category: .session - ) - } - pendingSubscriptions[url] = map - } - - /// Drop pending REQs older than the TTL. Runs on connect attempts (the - /// natural maintenance path: connect/ensureConnections/reconnects all - /// funnel through connectToRelays) so stale entries for relays that never - /// come up cannot accumulate without bound. - private func sweepStalePendingSubscriptions() { - let now = dependencies.now() - for (url, map) in pendingSubscriptions { - let fresh = map.filter { - now.timeIntervalSince($0.value.queuedAt) <= TransportConfig.nostrPendingSubscriptionTTLSeconds - } - guard fresh.count != map.count else { continue } - // Bounds proof: the age sweep actually removed entries. Warning - // (not debug) — stale pending REQs mean a relay never came up. - SecureLogger.warning( - "📋 Swept \(map.count - fresh.count) stale pending sub(s) for \(url)", - category: .session - ) - pendingSubscriptions[url] = fresh.isEmpty ? nil : fresh - } - } - - private func startEOSETracking(id: String, relayURLs: Set, callback: @escaping () -> Void) { - eoseTrackerEpoch += 1 - let epoch = eoseTrackerEpoch - eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch) - // Fallback timeout to avoid hanging if a relay never sends EOSE. - dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - guard let tracker = self.eoseTrackers[id], tracker.epoch == epoch else { return } - self.eoseTrackers.removeValue(forKey: id) - tracker.callback() - } - } - } - - private func startPendingEOSETrackingIfNeeded(id: String) { - guard eoseTrackers[id] == nil, - let callback = pendingEOSECallbacks.removeValue(forKey: id), - let requestState = subscriptionRequestState[id] - else { - return - } - - if requestState.relayURLs.isEmpty { - callback() - } else { - startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback) - } - } - - private func shouldDeliverInboundEvent(subscriptionID: String, eventID: String) -> Bool { - guard !eventID.isEmpty else { return true } - let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID) - guard recentInboundEventKeys.insert(key).inserted else { - recordDuplicateInboundEventDrop(subscriptionID: subscriptionID) - return false - } - recentInboundEventKeyOrder.append(key) - - if recentInboundEventKeyOrder.count > recentInboundEventKeyLimit { - let removeCount = recentInboundEventKeyOrder.count - recentInboundEventKeyTrimTarget - for staleKey in recentInboundEventKeyOrder.prefix(removeCount) { - recentInboundEventKeys.remove(staleKey) - } - recentInboundEventKeyOrder.removeFirst(removeCount) - } - return true - } - - private func recordDuplicateInboundEventDrop(subscriptionID: String) { - duplicateInboundEventDropCount += 1 - let subscriptionCount = (duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0) + 1 - duplicateInboundEventDropCountBySubscription[subscriptionID] = subscriptionCount - - if duplicateInboundEventDropCount == 1 || - duplicateInboundEventDropCount.isMultiple(of: TransportConfig.nostrDuplicateEventLogInterval) { - SecureLogger.debug( - "Dropped duplicate Nostr event deliveries total=\(duplicateInboundEventDropCount) sub=\(subscriptionID) sub_total=\(subscriptionCount)", - category: .session - ) - } - } - - private func removeRecentInboundEvents(forSubscriptionID subscriptionID: String) { - guard !recentInboundEventKeyOrder.isEmpty else { return } - var retainedKeys: [InboundEventKey] = [] - retainedKeys.reserveCapacity(recentInboundEventKeyOrder.count) - for key in recentInboundEventKeyOrder { - if key.subscriptionID == subscriptionID { - recentInboundEventKeys.remove(key) - } else { - retainedKeys.append(key) - } - } - recentInboundEventKeyOrder = retainedKeys - } private func connectToRelay(_ urlString: String) { // Global network policy gate @@ -1165,8 +550,12 @@ final class NostrRelayManager: ObservableObject { // Attempting to connect to Nostr relay via the proxied session // If Tor is enforced but not ready, delay connection until it is. - if shouldWaitForTorBeforeConnecting { - queueConnectionsUntilTorReady([urlString]) + if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { + dependencies.awaitTorReady { [weak self] ready in + guard let self = self else { return } + if ready { self.connectToRelay(urlString) } + else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) } + } return } @@ -1175,18 +564,13 @@ final class NostrRelayManager: ObservableObject { connections[urlString] = task task.resume() - - // Bring up this relay's own serial verify pipeline before arming the - // socket, so inbound frames have somewhere to land. - ensureRelayInboundPipeline(for: urlString) - + // Start receiving messages receiveMessage(from: task, relayUrl: urlString) // Send initial ping to verify connection task.sendPing { [weak self] error in DispatchQueue.main.async { - guard self?.connections[urlString] === task else { return } if error == nil { SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session) self?.updateRelayStatus(urlString, isConnected: true) @@ -1196,56 +580,31 @@ final class NostrRelayManager: ObservableObject { SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session) self?.updateRelayStatus(urlString, isConnected: false, error: error) // Trigger disconnection handler for proper backoff - self?.handleDisconnection( - relayUrl: urlString, - error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil), - connection: task - ) + self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil)) } } } } - /// Send queued subscriptions and replay durable ones for a relay that just - /// (re)connected. Relays drop subscriptions with the socket, so every - /// active subscription targeting this relay must be re-sent. + /// Send any queued subscriptions for a relay that just connected. private func flushPendingSubscriptions(for relayUrl: String) { + guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return } guard let connection = connections[relayUrl] else { return } - var toSend = (pendingSubscriptions[relayUrl] ?? [:]).mapValues(\.messageString) - for (id, state) in subscriptionRequestState where state.relayURLs.contains(relayUrl) && toSend[id] == nil { - toSend[id] = state.messageString - } - for (id, messageString) in toSend { - if self.subscriptions[relayUrl]?.contains(id) == true { - // Already subscribed on this relay (e.g. a tracker promoted - // after an earlier flush): its EOSE is coming, count it. - markEOSESubscribed(id: id, relayUrl: relayUrl) - continue - } - startPendingEOSETrackingIfNeeded(id: id) - // Mark at send *initiation*, not in the async completion: a fast - // relay's EOSE could otherwise complete the tracker while this - // relay — REQ already on the wire — still sat in awaitingSend. - // If the send fails the socket is going down with it, and the - // disconnect settle (or the fallback timer) releases the wait. - markEOSESubscribed(id: id, relayUrl: relayUrl) - connection.send(.string(messageString)) { [weak self, weak connection] error in - Task { @MainActor [weak self] in - guard let self else { return } - if let error = error { - // Keep the pending entry; the next (re)connect retries it. - SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session) - } else { - // A stale completion from a socket that has since been - // replaced must not mark the subscription active, or - // the next connection would skip replaying it. - guard let connection, self.connections[relayUrl] === connection else { return } - self.subscriptions[relayUrl, default: []].insert(id) - self.pendingSubscriptions[relayUrl]?.removeValue(forKey: id) + for (id, messageString) in map { + if self.subscriptions[relayUrl]?.contains(id) == true { continue } + connection.send(.string(messageString)) { error in + if let error = error { + SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session) + } else { + Task { @MainActor in + var subs = self.subscriptions[relayUrl] ?? Set() + subs.insert(id) + self.subscriptions[relayUrl] = subs } } } } + pendingSubscriptions[relayUrl] = nil } private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) { @@ -1254,23 +613,22 @@ final class NostrRelayManager: ObservableObject { switch result { case .success(let message): - // Hand the raw frame to this relay's serial inbound pipeline: - // parsing and signature verification run off-main, in arrival - // order, independently of every other relay's pipeline. Routing - // through the lock-guarded router keeps this off the main actor - // (no per-frame main hop). - self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl) - - + // Parse off-main to reduce UI jank, then hop back for state updates + Task.detached(priority: .utility) { + guard let parsed = ParsedInbound(message) else { return } + await MainActor.run { + self.handleParsedMessage(parsed, from: relayUrl) + } + } + // Continue receiving Task { @MainActor in - guard self.connections[relayUrl] === task else { return } self.receiveMessage(from: task, relayUrl: relayUrl) } case .failure(let error): DispatchQueue.main.async { - self.handleDisconnection(relayUrl: relayUrl, error: error, connection: task) + self.handleDisconnection(relayUrl: relayUrl, error: error) } } } @@ -1280,63 +638,26 @@ final class NostrRelayManager: ObservableObject { // Note: declared at file scope below to avoid MainActor isolation inside this class // and keep parsing off the main actor. - /// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap - /// duplicate LOOKUP (no recording) so duplicate fan-in from multiple - /// relays never pays for Schnorr verification. Recording happens only - /// after the signature verifies (`deliverVerifiedInboundEvent`), so a - /// forged-signature copy can never poison the dedup cache and suppress - /// the genuine event. - private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool { - if let index = relays.firstIndex(where: { $0.url == relayUrl }) { - relays[index].messagesReceived += 1 - } - guard !eventID.isEmpty else { return true } - let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID) - if recentInboundEventKeys.contains(key) { - recordDuplicateInboundEventDrop(subscriptionID: subscriptionID) - return false - } - return true - } - - /// Second main-actor hop, after off-main signature verification: - /// authoritative check-and-record (the serial pipeline means the same - /// event is never in flight twice, but the record must stay atomic with - /// delivery) and handler dispatch. - private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) { - guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else { - return - } - if event.kind != 1059 { - // Per-event logging floods dev builds in busy geohashes; sample it. - inboundEventLogCount += 1 - if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { - SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) - } - } - if let handler = self.messageHandlers[subId] { - handler(event) - } else { - SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) - } - } - - // Handle parsed non-EVENT messages on MainActor (state updates and handlers) + // Handle parsed message on MainActor (state updates and handlers) private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { switch parsed { - case .event: - // Events flow through the serial inbound pipeline (precheck → - // off-main signature verification → deliverVerifiedInboundEvent) - // and never reach this fallback. - assertionFailure("inbound EVENT bypassed the verified pipeline") + case .event(let subId, let event): + if event.kind != 1059 { + SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) + } + if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { + self.relays[index].messagesReceived += 1 + } + if let handler = self.messageHandlers[subId] { + handler(event) + } else { + SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) + } case .eose(let subId): if var tracker = eoseTrackers[subId] { - // An EOSE proves the relay received the REQ even if the local - // send completion hasn't run yet. - tracker.awaitingSend.remove(relayUrl) - tracker.awaitingEOSE.remove(relayUrl) - tracker.didSend = true - if tracker.isComplete { + tracker.pendingRelays.remove(relayUrl) + if tracker.pendingRelays.isEmpty { + tracker.timer?.invalidate() eoseTrackers.removeValue(forKey: subId) tracker.callback() } else { @@ -1344,16 +665,15 @@ final class NostrRelayManager: ObservableObject { } } case .ok(let eventId, let success, let reason): - resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success) if success { _ = Self.pendingGiftWrapIDs.remove(eventId) SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session) } else { let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil if isGiftWrap { - SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session) + SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session) } else { - SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session) + SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session) } } case .notice: @@ -1361,12 +681,7 @@ final class NostrRelayManager: ObservableObject { } } - private func sendToRelay( - event: NostrEvent, - connection: NostrRelayConnectionProtocol, - relayUrl: String, - completion: ((Bool) -> Void)? = nil - ) { + private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) { let req = NostrRequest.event(event) do { @@ -1379,20 +694,17 @@ final class NostrRelayManager: ObservableObject { DispatchQueue.main.async { if let error = error { SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session) - completion?(false) } else { // SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session) // Update relay stats if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { self?.relays[index].messagesSent += 1 } - completion?(true) } } } } catch { SecureLogger.error("Failed to encode event: \(error)", category: .session) - completion?(false) } } @@ -1401,6 +713,7 @@ final class NostrRelayManager: ObservableObject { relays[index].isConnected = isConnected relays[index].lastError = error if isConnected { + relays[index].lastConnectedAt = dependencies.now() relays[index].reconnectAttempts = 0 // Reset on successful connection relays[index].nextReconnectTime = nil } else { @@ -1416,67 +729,19 @@ final class NostrRelayManager: ObservableObject { private func updateConnectionStatus() { isConnected = relays.contains { $0.isConnected } - // Relay URLs are normalized before entries are created, so direct - // set membership is sound. - isDMRelayConnected = relays.contains { $0.isConnected && defaultRelaySet.contains($0.url) } } - /// A relay that drops before sending EOSE must not stall initial-load - /// callbacks; treat it as done and let the remaining relays (or the - /// fallback timeout) drive completion. - private func settleEOSETrackers(droppingRelay relayUrl: String) { - for (id, var tracker) in eoseTrackers - where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) { - tracker.awaitingSend.remove(relayUrl) - tracker.awaitingEOSE.remove(relayUrl) - if tracker.isComplete { - eoseTrackers.removeValue(forKey: id) - tracker.callback() - } else { - eoseTrackers[id] = tracker - } - } - } - - /// Whether any of `relayUrls` currently holds a live connection. Lets - /// subscribers distinguish "loaded, empty" from "never reached a relay" - /// when an EOSE fallback fires. - func isAnyRelayConnected(among relayUrls: [String]) -> Bool { - let targets = Set(relayUrls) - return relays.contains { targets.contains($0.url) && $0.isConnected } - } - - /// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on - /// this relay instead of the never-connected remainder. - private func markEOSESubscribed(id: String, relayUrl: String) { - guard var tracker = eoseTrackers[id], - tracker.awaitingSend.remove(relayUrl) != nil else { return } - tracker.awaitingEOSE.insert(relayUrl) - tracker.didSend = true - eoseTrackers[id] = tracker - } - - private func handleDisconnection( - relayUrl: String, - error: Error, - connection: NostrRelayConnectionProtocol? = nil - ) { - if let connection, connections[relayUrl] !== connection { return } - connections.removeValue(forKey: relayUrl) - teardownRelayInboundPipeline(for: relayUrl) - subscriptions.removeValue(forKey: relayUrl) - let awaitingConfirmation = confirmedSends.compactMap { eventID, state in - state.awaitingRelays.contains(relayUrl) ? eventID : nil - } - for eventID in awaitingConfirmation { - resolveConfirmedSend(eventID: eventID, relayURL: relayUrl, accepted: false) - } - updateRelayStatus(relayUrl, isConnected: false, error: error) - settleEOSETrackers(droppingRelay: relayUrl) + private func handleDisconnection(relayUrl: String, error: Error) { // If networking is disallowed, do not schedule reconnection if !dependencies.activationAllowed() { + connections.removeValue(forKey: relayUrl) + subscriptions.removeValue(forKey: relayUrl) + updateRelayStatus(relayUrl, isConnected: false, error: error) return } + connections.removeValue(forKey: relayUrl) + subscriptions.removeValue(forKey: relayUrl) + updateRelayStatus(relayUrl, isConnected: false, error: error) // Check if this is a DNS or handshake error; treat as permanent let errorDescription = error.localizedDescription.lowercased() @@ -1507,26 +772,16 @@ final class NostrRelayManager: ObservableObject { return } - // Calculate backoff interval with ±jitterRatio random jitter so relays - // that dropped together don't all reconnect at the same instant. - let baseBackoffInterval = min( + // Calculate backoff interval + let backoffInterval = min( initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)), maxBackoffInterval ) - let jitterRatio = TransportConfig.nostrRelayBackoffJitterRatio - let jitterFactor = 1.0 + (dependencies.jitterUnit() * 2.0 - 1.0) * jitterRatio - let backoffInterval = baseBackoffInterval * jitterFactor - + let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval) relays[index].nextReconnectTime = nextReconnectTime - - // Reconnects are bounded by maxReconnectAttempts and exponentially - // backed off, so this is low-frequency: plain debug, no sampling. - SecureLogger.debug( - "🔄 Reconnect \(relayUrl) in \(String(format: "%.1f", backoffInterval))s (base \(String(format: "%.1f", baseBackoffInterval))s, attempt \(relays[index].reconnectAttempts)/\(maxReconnectAttempts))", - category: .session - ) - + + // Schedule reconnection with exponential backoff let gen = connectionGeneration dependencies.scheduleAfter(backoffInterval) { [weak self] in @@ -1546,8 +801,7 @@ final class NostrRelayManager: ObservableObject { /// Manually retry connection to a specific relay func retryConnection(to relayUrl: String) { - let normalizedRelayUrl = NostrRelayURL.normalized(relayUrl) ?? relayUrl - guard let index = relays.firstIndex(where: { $0.url == normalizedRelayUrl }) else { return } + guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return } // Reset reconnection attempts relays[index].reconnectAttempts = 0 @@ -1555,14 +809,13 @@ final class NostrRelayManager: ObservableObject { relays[index].lastError = nil // Disconnect if connected - if let connection = connections[normalizedRelayUrl] { + if let connection = connections[relayUrl] { connection.cancel(with: .goingAway, reason: nil) - connections.removeValue(forKey: normalizedRelayUrl) - teardownRelayInboundPipeline(for: normalizedRelayUrl) + connections.removeValue(forKey: relayUrl) } - + // Attempt immediate reconnection - connectToRelay(normalizedRelayUrl) + connectToRelay(relayUrl) } /// Get detailed status for all relays @@ -1585,31 +838,6 @@ final class NostrRelayManager: ObservableObject { pendingSubscriptions[relayUrl]?.count ?? 0 } - func debugPendingSubscriptionIDs(for relayUrl: String) -> Set { - guard let map = pendingSubscriptions[relayUrl] else { return [] } - return Set(map.keys) - } - - var debugMessageHandlerCount: Int { - messageHandlers.count - } - - var debugSubscriptionRequestCount: Int { - subscriptionRequestState.count - } - - var debugPendingEOSECallbackCount: Int { - pendingEOSECallbacks.count - } - - var debugDuplicateInboundEventDropCount: Int { - duplicateInboundEventDropCount - } - - func debugDuplicateInboundEventDropCount(forSubscriptionID subscriptionID: String) -> Int { - duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0 - } - func debugFlushMessageQueue() { flushMessageQueue(for: nil) } @@ -1634,13 +862,6 @@ final class NostrRelayManager: ObservableObject { // MARK: - Failure classification private func isPermanentlyFailed(_ url: String) -> Bool { guard let r = relays.first(where: { $0.url == url }) else { return false } - // Failures decay: after a cooldown the relay gets another chance, so a - // long network outage or transient relay trouble can't blacklist it - // for the rest of the process lifetime. - if let lastDisconnect = r.lastDisconnectedAt, - dependencies.now().timeIntervalSince(lastDisconnect) >= TransportConfig.nostrRelayFailureCooldownSeconds { - return false - } if r.reconnectAttempts >= maxReconnectAttempts { return true } if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain { if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost { @@ -1653,77 +874,6 @@ final class NostrRelayManager: ObservableObject { // MARK: - Off-main inbound parsing helpers (file scope, non-isolated) -/// A single raw socket frame awaiting off-main parse + Schnorr verification. -private struct InboundFrame: Sendable { - let message: URLSessionWebSocketTask.Message -} - -/// Lock-guarded registry of per-relay inbound streams. -/// -/// The raw WebSocket receive callback is not main-actor isolated, so it needs a -/// `Sendable` path to route a frame to the correct relay's stream without a -/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is -/// driven from the main actor; frame delivery (`yield`) can come from any -/// thread. All access is serialized by a single lock — contention is negligible -/// because the guarded critical section is only a dictionary lookup + yield. -private final class InboundFrameRouter: @unchecked Sendable { - private let lock = NSLock() - private var continuations: [String: AsyncStream.Continuation] = [:] - private var tasks: [String: Task] = [:] - - /// Start a relay's stream + consumer if one does not already exist. - /// Returns true when a new pipeline was created. The bounded - /// `.bufferingNewest` policy makes a single relay shed its OWN oldest - /// frames under a flood, never other relays' frames. Buffered memory per - /// relay is bounded (not eliminated) at the frame cap times the per-frame - /// byte cap (`maximumMessageSize`) — see TransportConfig. - func startPipeline( - for relayUrl: String, - makeConsumer: (AsyncStream) -> Task - ) -> Bool { - lock.lock() - defer { lock.unlock() } - if continuations[relayUrl] != nil { return false } - let (stream, continuation) = AsyncStream.makeStream( - bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap) - ) - continuations[relayUrl] = continuation - tasks[relayUrl] = makeConsumer(stream) - return true - } - - /// Route a frame to a relay's stream. No-op if the relay has no live - /// pipeline (socket already torn down) — the frame is simply dropped, which - /// is safe for best-effort Nostr inbound. - func yield(_ frame: InboundFrame, to relayUrl: String) { - lock.lock() - let continuation = continuations[relayUrl] - lock.unlock() - continuation?.yield(frame) - } - - /// Finish a relay's stream. The consumer drains any already-buffered frames - /// before exiting, so in-flight verified events are still delivered. - func finishPipeline(for relayUrl: String) { - lock.lock() - let continuation = continuations.removeValue(forKey: relayUrl) - tasks.removeValue(forKey: relayUrl) - lock.unlock() - continuation?.finish() - } - - func finishAll() { - lock.lock() - let allContinuations = continuations - continuations.removeAll() - tasks.removeAll() - lock.unlock() - for continuation in allContinuations.values { - continuation.finish() - } - } -} - private enum ParsedInbound { case event(subId: String, event: NostrEvent) case ok(eventId: String, success: Bool, reason: String) @@ -1731,7 +881,7 @@ private enum ParsedInbound { case notice(String) init?(_ message: URLSessionWebSocketTask.Message) { - guard let data = message.dataWithinInboundLimit, + guard let data = message.data, let array = try? JSONSerialization.jsonObject(with: data) as? [Any], array.count >= 2, let type = array[0] as? String else { @@ -1743,7 +893,8 @@ private enum ParsedInbound { if array.count >= 3, let subId = array[1] as? String, let eventDict = array[2] as? [String: Any], - let event = try? NostrEvent(from: eventDict) { + let event = try? NostrEvent(from: eventDict), + event.isValidSignature() { self = .event(subId: subId, event: event) return } @@ -1776,19 +927,11 @@ private enum ParsedInbound { } private extension URLSessionWebSocketTask.Message { - /// Prefer rejecting oversized frames before UTF-8/Data materialization - /// where we can (string length), and always before JSON parse. - var dataWithinInboundLimit: Data? { - let maxBytes = TransportConfig.nostrMaxInboundMessageBytes + var data: Data? { switch self { - case .string(let text): - guard text.utf8.count <= maxBytes else { return nil } - return text.data(using: .utf8) - case .data(let data): - guard data.count <= maxBytes else { return nil } - return data - @unknown default: - return nil + case .string(let text): text.data(using: .utf8) + case .data(let data): data + @unknown default: nil } } } @@ -1900,29 +1043,6 @@ struct NostrFilter: Encodable { filter.limit = limit return filter } - - // For the mesh bridge: rendezvous messages (kind 20000) and presence - // (kind 20001) tagged `#r` with one or more cells (own + neighbors). - static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter { - var filter = NostrFilter() - filter.kinds = [20000, 20001] - filter.since = since?.timeIntervalSince1970.toInt() - filter.tagFilters = ["r": cells] - filter.limit = limit - return filter - } - - // For courier drops: sealed envelopes (kind 1401) parked under rotating - // recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent - // UTC days x recipients) in one filter. - static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter { - var filter = NostrFilter() - filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue] - filter.since = since?.timeIntervalSince1970.toInt() - filter.tagFilters = ["x": recipientTagsHex] - filter.limit = limit - return filter - } } // Dynamic coding key for tag filters diff --git a/bitchat/Nostr/NostrRelaySettings.swift b/bitchat/Nostr/NostrRelaySettings.swift deleted file mode 100644 index 19cf1c46..00000000 --- a/bitchat/Nostr/NostrRelaySettings.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// NostrRelaySettings.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitLogger -import Foundation - -/// Relays someone has added by hand, alongside the built-in set. -/// -/// The built-in relays are four well-known clearnet hostnames, so a censor -/// blocking four names ends internet-delivered private messages for everyone. -/// Adding relays — including `.onion` addresses, or a relay run by whoever -/// needs it — is the escape hatch that does not require shipping a new build. -/// -/// Stored normalized so comparisons against connection keys and the built-in -/// set are exact, and bounded so a long list cannot turn every send into a -/// fan-out across dozens of sockets. -enum NostrRelaySettings { - /// Enough to add a personal relay, an onion address, and a couple of - /// regional fallbacks without letting the connection fan-out grow unbounded. - static let maxCustomRelays = 8 - - private static let storageKey = "nostr.customRelays" - - static let didChangeNotification = Notification.Name("bitchat.nostrRelaySettingsDidChange") - - enum AddFailure: Error, Equatable { - case malformed - case alreadyPresent - case limitReached - } - - /// Normalized relay URLs, in the order they were added. - static func customRelays(in defaults: UserDefaults = .standard) -> [String] { - let stored = defaults.stringArray(forKey: storageKey) ?? [] - // Re-normalize on read: a value written by an older build, or edited - // outside the app, must not reach the connection layer unchecked. - var seen = Set() - return stored.compactMap { NostrRelayURL.normalized($0) } - .filter { seen.insert($0).inserted } - } - - /// Adds a relay, returning the normalized URL or why it was rejected. - @discardableResult - static func add( - _ rawValue: String, - builtIn: Set, - in defaults: UserDefaults = .standard - ) -> Result { - // Bare hostnames are the common way people quote a relay, and wss is - // the only sensible assumption for one. - guard let normalized = NostrRelayURL.normalized(rawValue, defaultScheme: "wss") else { - return .failure(.malformed) - } - - var current = customRelays(in: defaults) - guard !current.contains(normalized), !builtIn.contains(normalized) else { - return .failure(.alreadyPresent) - } - guard current.count < maxCustomRelays else { - return .failure(.limitReached) - } - - current.append(normalized) - write(current, in: defaults) - return .success(normalized) - } - - static func remove(_ url: String, in defaults: UserDefaults = .standard) { - // Same default scheme as `add`, so a relay entered as a bare hostname - // can be removed the way it was typed. - guard let normalized = NostrRelayURL.normalized(url, defaultScheme: "wss") else { return } - let remaining = customRelays(in: defaults).filter { $0 != normalized } - write(remaining, in: defaults) - } - - /// Panic-wipe hook: an added relay names somewhere someone chose to route - /// through, which is exactly the kind of trace a wipe should not leave. - static func reset(in defaults: UserDefaults = .standard) { - defaults.removeObject(forKey: storageKey) - NotificationCenter.default.post(name: didChangeNotification, object: nil) - } - - private static func write(_ relays: [String], in defaults: UserDefaults) { - defaults.set(relays, forKey: storageKey) - NotificationCenter.default.post(name: didChangeNotification, object: nil) - } -} diff --git a/bitchat/Nostr/NostrRelayURL.swift b/bitchat/Nostr/NostrRelayURL.swift deleted file mode 100644 index eb2e2afc..00000000 --- a/bitchat/Nostr/NostrRelayURL.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation - -enum NostrRelayURL { - static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? { - var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty else { return nil } - - if !value.contains("://"), let defaultScheme { - value = "\(defaultScheme)://\(value)" - } - - guard var components = URLComponents(string: value), - let rawScheme = components.scheme?.lowercased(), - let rawHost = components.host?.lowercased(), - !rawHost.isEmpty else { - return nil - } - - switch rawScheme { - case "wss", "https": - components.scheme = "wss" - if components.port == 443 { - components.port = nil - } - case "ws", "http": - components.scheme = "ws" - if components.port == 80 { - components.port = nil - } - default: - return nil - } - - components.host = rawHost - if components.path == "/" { - components.path = "" - } - components.fragment = nil - - return components.string - } -} diff --git a/bitchat/Nostr/XChaCha20Poly1305Compat.swift b/bitchat/Nostr/XChaCha20Poly1305Compat.swift index 006cf899..13794508 100644 --- a/bitchat/Nostr/XChaCha20Poly1305Compat.swift +++ b/bitchat/Nostr/XChaCha20Poly1305Compat.swift @@ -132,3 +132,4 @@ private extension Data { replaceSubrange(offset..<(offset+4), with: bytes) } } + diff --git a/bitchat/PrivacyInfo.xcprivacy b/bitchat/PrivacyInfo.xcprivacy deleted file mode 100644 index 00a28dfb..00000000 --- a/bitchat/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,41 +0,0 @@ - - - - - NSPrivacyTracking - - NSPrivacyTrackingDomains - - NSPrivacyCollectedDataTypes - - NSPrivacyAccessedAPITypes - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryFileTimestamp - NSPrivacyAccessedAPITypeReasons - - C617.1 - 3B52.1 - - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategorySystemBootTime - NSPrivacyAccessedAPITypeReasons - - 35F9.1 - - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryUserDefaults - NSPrivacyAccessedAPITypeReasons - - CA92.1 - 1C8F.1 - - - - - diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BinaryEncodingUtils.swift b/bitchat/Protocols/BinaryEncodingUtils.swift similarity index 86% rename from localPackages/BitFoundation/Sources/BitFoundation/BinaryEncodingUtils.swift rename to bitchat/Protocols/BinaryEncodingUtils.swift index 43d3ec5e..8157d6a9 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BinaryEncodingUtils.swift +++ b/bitchat/Protocols/BinaryEncodingUtils.swift @@ -5,15 +5,15 @@ // Binary encoding utilities for efficient protocol messages // -import struct Foundation.Data -import struct Foundation.Date +import Foundation +import BitFoundation // MARK: - Binary Encoding Utilities extension Data { // MARK: Writing - @inlinable public mutating func appendUInt8(_ value: UInt8) { + @inlinable mutating func appendUInt8(_ value: UInt8) { self.append(value) } @@ -35,7 +35,7 @@ extension Data { } } - public mutating func appendString(_ string: String, maxLength: Int = 255) { + mutating func appendString(_ string: String, maxLength: Int = 255) { guard let data = string.data(using: .utf8) else { return } let length = Swift.min(data.count, maxLength) @@ -48,7 +48,7 @@ extension Data { self.append(data.prefix(length)) } - public mutating func appendData(_ data: Data, maxLength: Int = 65535) { + mutating func appendData(_ data: Data, maxLength: Int = 65535) { let length = Swift.min(data.count, maxLength) if maxLength <= 255 { @@ -60,12 +60,12 @@ extension Data { self.append(data.prefix(length)) } - public mutating func appendDate(_ date: Date) { + mutating func appendDate(_ date: Date) { let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds self.appendUInt64(timestamp) } - public mutating func appendUUID(_ uuid: String) { + mutating func appendUUID(_ uuid: String) { // Convert UUID string to 16 bytes var uuidData = Data(count: 16) @@ -86,7 +86,7 @@ extension Data { // MARK: Reading - @inlinable public func readUInt8(at offset: inout Int) -> UInt8? { + @inlinable func readUInt8(at offset: inout Int) -> UInt8? { guard offset >= 0 && offset < self.count else { return nil } let value = self[offset] offset += 1 @@ -120,7 +120,7 @@ extension Data { return value } - public func readString(at offset: inout Int, maxLength: Int = 255) -> String? { + func readString(at offset: inout Int, maxLength: Int = 255) -> String? { let length: Int if maxLength <= 255 { @@ -139,7 +139,7 @@ extension Data { return String(data: stringData, encoding: .utf8) } - public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? { + func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? { let length: Int if maxLength <= 255 { @@ -158,12 +158,12 @@ extension Data { return data } - public func readDate(at offset: inout Int) -> Date? { + func readDate(at offset: inout Int) -> Date? { guard let timestamp = readUInt64(at: &offset) else { return nil } return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) } - public func readUUID(at offset: inout Int) -> String? { + func readUUID(at offset: inout Int) -> String? { guard offset + 16 <= self.count else { return nil } let uuidData = self[offset.. Data? { + func readFixedBytes(at offset: inout Int, count: Int) -> Data? { guard offset + count <= self.count else { return nil } let data = self[offset.. Data { + // Find the first null byte + if let nullIndex = self.firstIndex(of: 0) { + return self.prefix(nullIndex) + } + return self + } +} /// Implements binary encoding and decoding for BitChat protocol messages. /// Provides static methods for converting between BitchatPacket objects and /// their binary wire format representation. /// - Note: All multi-byte values use network byte order (big-endian) -public struct BinaryProtocol { - public static let v1HeaderSize = 14 +struct BinaryProtocol { + static let v1HeaderSize = 14 static let v2HeaderSize = 16 - public static let senderIDSize = 8 - public static let recipientIDSize = 8 - public static let signatureSize = 64 + static let senderIDSize = 8 + static let recipientIDSize = 8 + static let signatureSize = 64 // Field offsets within packet header - public struct Offsets { - public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8) + struct Offsets { + static let version = 0 + static let type = 1 + static let ttl = 2 + static let timestamp = 3 + static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8) } - public static func headerSize(for version: UInt8) -> Int? { + static func headerSize(for version: UInt8) -> Int? { switch version { case 1: return v1HeaderSize case 2: return v2HeaderSize @@ -120,11 +133,11 @@ public struct BinaryProtocol { return version == 2 ? 4 : 2 } - public struct Flags { - public static let hasRecipient: UInt8 = 0x01 - public static let hasSignature: UInt8 = 0x02 - public static let isCompressed: UInt8 = 0x04 - public static let hasRoute: UInt8 = 0x08 + struct Flags { + static let hasRecipient: UInt8 = 0x01 + static let hasSignature: UInt8 = 0x02 + static let isCompressed: UInt8 = 0x04 + static let hasRoute: UInt8 = 0x08 static let isRSR: UInt8 = 0x10 } @@ -253,7 +266,7 @@ public struct BinaryProtocol { } // Decode binary data to BitchatPacket - public static func decode(_ data: Data) -> BitchatPacket? { + static func decode(_ data: Data) -> BitchatPacket? { // Try decode as-is first (robust when padding wasn't applied) if let pkt = decodeCore(data) { return pkt } // If that fails, try after removing padding diff --git a/bitchat/Protocols/BitchatFilePacket.swift b/bitchat/Protocols/BitchatFilePacket.swift index 7745b6c4..26a27ae2 100644 --- a/bitchat/Protocols/BitchatFilePacket.swift +++ b/bitchat/Protocols/BitchatFilePacket.swift @@ -7,7 +7,6 @@ // import Foundation -import BitFoundation import BitLogger /// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files). @@ -154,90 +153,3 @@ struct BitchatFilePacket { ) } } - -/// Wire-compatible identity for private media exchanged by clients using the -/// current iOS entropy-bearing filenames, without extending the deployed file -/// TLV. Android clients reject unknown file tags, so eligible senders and -/// receivers derive the receipt key from fields already on the wire. -/// -/// Locally-created image and voice-note filenames contain a UUID or live-voice -/// burst ID. Including the normalized direction keeps a reused filename -/// distinct across chats while allowing short and full Noise-key peer IDs to -/// converge. Android and older-iOS timestamp-only names remain ineligible and -/// retain their legacy random local IDs (transfer-compatible, no receipts). -enum PrivateMediaMessageIdentity { - private static let domain = Data("bitchat-private-media-message-v1".utf8) - private static let idPrefix = "media-" - private static let digestHexLength = 32 - - static func isStableID(_ candidate: String) -> Bool { - guard candidate.hasPrefix(idPrefix) else { return false } - let digest = candidate.dropFirst(idPrefix.count) - guard digest.utf8.count == digestHexLength else { return false } - return digest.utf8.allSatisfy { byte in - (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte) - || (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte) - } - } - - static func stableID( - senderPeerID: PeerID, - recipientPeerID: PeerID, - fileName: String? - ) -> String? { - guard let fileName, !fileName.isEmpty else { return nil } - let leafName = (fileName as NSString).lastPathComponent - guard leafName == fileName else { return nil } - - let path = leafName as NSString - let stem = path.deletingPathExtension - let fileExtension = path.pathExtension.lowercased() - switch true { - case stem.hasPrefix("img_"): - guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil } - case stem.hasPrefix("voice_"): - guard fileExtension == "m4a" else { return nil } - default: - return nil - } - let entropyToken = stem.split(separator: "_").last.map(String.init) - let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil - let voiceBurstID = stem.hasPrefix("voice_") - ? String(stem.dropFirst("voice_".count)) - : "" - let hasBurstEntropy = voiceBurstID.count == 16 - && voiceBurstID.allSatisfy(\.isHexDigit) - guard hasUUIDEntropy || hasBurstEntropy else { - return nil - } - - let fields = [ - Data(senderPeerID.toShort().bare.utf8), - Data(recipientPeerID.toShort().bare.utf8), - Data(leafName.utf8) - ] - var input = domain - for field in fields { - guard let length = UInt32(exactly: field.count) else { return nil } - var bigEndianLength = length.bigEndian - withUnsafeBytes(of: &bigEndianLength) { - input.append(contentsOf: $0) - } - input.append(field) - } - - return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))" - } - - static func stableID( - for packet: BitchatFilePacket, - senderPeerID: PeerID, - recipientPeerID: PeerID - ) -> String? { - stableID( - senderPeerID: senderPeerID, - recipientPeerID: recipientPeerID, - fileName: packet.fileName - ) - } -} diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 608e85c2..a13f3056 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -18,7 +18,7 @@ /// - Efficient binary message encoding /// - Message fragmentation for large payloads /// - TTL-based routing for mesh networks -/// - Privacy features: message padding and randomized relay jitter +/// - Privacy features like padding and timing obfuscation /// - Integration points for end-to-end encryption /// /// ## Protocol Design @@ -38,24 +38,18 @@ /// 7. **Decoding**: Binary data parsed back to message objects /// /// ## Security Considerations -/// - Noise frames are padded (to 256/512/1024/2048-byte blocks) to obscure -/// content length; other packet types are not padded, so their payload -/// length is observable -/// - Randomized relay jitter reduces the traffic-analysis signal; there is no -/// cover traffic or per-message timing obfuscation +/// - Message padding obscures actual content length +/// - Timing obfuscation prevents traffic analysis /// - Integration with Noise Protocol for E2E encryption -/// - The 8-byte sender ID in every header IS a persistent identifier: it is -/// derived from the long-lived Noise static key and rotates only on a panic -/// wipe. Treat headers as linkable across sessions. +/// - No persistent identifiers in protocol headers /// /// ## Message Types /// - **Announce/Leave**: Peer presence notifications -/// - **Message**: Public chat messages +/// - **Message**: User chat messages (broadcast or directed) /// - **Fragment**: Multi-part message handling -/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and -/// all private payloads (messages, delivery acks, read receipts) -/// - **CourierEnvelope**: Sealed store-and-forward mail -/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer +/// - **Delivery/Read**: Message acknowledgments +/// - **Noise**: Encrypted channel establishment +/// - **Version**: Protocol version negotiation /// /// ## Future Extensions /// The protocol is designed to be extensible: @@ -68,6 +62,40 @@ import Foundation import CoreBluetooth import BitFoundation +// MARK: - Message Types + +/// Simplified BitChat protocol message types. +/// Reduced from 24 types to just 6 essential ones. +/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads. +enum MessageType: UInt8 { + // Public messages (unencrypted) + case announce = 0x01 // "I'm here" with nickname + case message = 0x02 // Public chat message + case leave = 0x03 // "I'm leaving" + case requestSync = 0x21 // GCS filter-based sync request (local-only) + + // Noise encryption + case noiseHandshake = 0x10 // Handshake (init or response determined by payload) + case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.) + + // Fragmentation (simplified) + case fragment = 0x20 // Single fragment type for large messages + case fileTransfer = 0x22 // Binary file/audio/image payloads + + var description: String { + switch self { + case .announce: return "announce" + case .message: return "message" + case .leave: return "leave" + case .requestSync: return "requestSync" + case .noiseHandshake: return "noiseHandshake" + case .noiseEncrypted: return "noiseEncrypted" + case .fragment: return "fragment" + case .fileTransfer: return "fileTransfer" + } + } +} + // MARK: - Noise Payload Types /// Types of payloads embedded within noiseEncrypted messages. @@ -78,53 +106,17 @@ enum NoisePayloadType: UInt8 { case privateMessage = 0x01 // Private chat message case readReceipt = 0x02 // Message was read case delivered = 0x03 // Message was delivered - // Private groups (0x04/0x05 reserved by other features) - case groupInvite = 0x06 // Creator-signed group state (invite) - case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update) - // Live voice (push-to-talk) - case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket) - // Finalized private media. `0x20` is the value already deployed by the - // Android client. The complete BitchatFilePacket is encrypted inside - // Noise before the outer noiseEncrypted packet is fragmented. - case privateFile = 0x20 - // Versioned peer state authenticated by the surrounding Noise session. - // This is intentionally distinct from the public announce: announce - // capabilities are discovery hints, while this payload proves possession - // of the advertised Noise static key before downgrade state is pinned. - case authenticatedPeerState = 0x21 // Verification (QR-based OOB binding) case verifyChallenge = 0x10 // Verification challenge case verifyResponse = 0x11 // Verification response - // Transitive verification (web of trust) - case vouch = 0x12 // Batch of vouch attestations - - /// #1434 briefly used 0x09 before release. Accept it while prerelease - /// builds age out, but never emit it. Decoders canonicalize both values to - /// `.privateFile` so the compatibility alias cannot leak into app logic. - static let prereleasePrivateFileRawValue: UInt8 = 0x09 - - static func decoded(rawValue: UInt8) -> NoisePayloadType? { - rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue) - } - - static func isPrivateFile(rawValue: UInt8?) -> Bool { - guard let rawValue else { return false } - return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue - } - + var description: String { switch self { case .privateMessage: return "privateMessage" case .readReceipt: return "readReceipt" case .delivered: return "delivered" - case .groupInvite: return "groupInvite" - case .groupKeyUpdate: return "groupKeyUpdate" - case .voiceFrame: return "voiceFrame" - case .privateFile: return "privateFile" - case .authenticatedPeerState: return "authenticatedPeerState" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" - case .vouch: return "vouch" } } } @@ -140,6 +132,35 @@ enum LazyHandshakeState { case failed(Error) // Handshake failed } +// MARK: - Delivery Status + +// Delivery status for messages +enum DeliveryStatus: Codable, Equatable, Hashable { + case sending + case sent // Left our device + case delivered(to: String, at: Date) // Confirmed by recipient + case read(by: String, at: Date) // Seen by recipient + case failed(reason: String) + case partiallyDelivered(reached: Int, total: Int) // For rooms + + var displayText: String { + switch self { + case .sending: + return "Sending..." + case .sent: + return "Sent" + case .delivered(let nickname, _): + return "Delivered to \(nickname)" + case .read(let nickname, _): + return "Read by \(nickname)" + case .failed(let reason): + return "Failed: \(reason)" + case .partiallyDelivered(let reached, let total): + return "Delivered to \(reached)/\(total)" + } + } +} + // MARK: - Delegate Protocol protocol BitchatDelegate: AnyObject { @@ -156,12 +177,6 @@ protocol BitchatDelegate: AnyObject { // Low-level events for better separation of concerns func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) - // Encrypted group broadcast (opaque envelope; decrypted by the group coordinator) - func didReceiveGroupMessage(payload: Data, timestamp: Date) - - // Public live-voice burst packet (signature-verified by the transport) - func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) - // Bluetooth state updates for user notifications func didUpdateBluetoothState(_ state: CBManagerState) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) @@ -181,14 +196,6 @@ extension BitchatDelegate { // Default empty implementation } - func didReceiveGroupMessage(payload: Data, timestamp: Date) { - // Default empty implementation - } - - func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) { - // Default empty implementation - } - func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { // Default empty implementation } diff --git a/bitchat/Protocols/BoardPackets.swift b/bitchat/Protocols/BoardPackets.swift deleted file mode 100644 index d265a1c1..00000000 --- a/bitchat/Protocols/BoardPackets.swift +++ /dev/null @@ -1,348 +0,0 @@ -// -// BoardPackets.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import CryptoKit -import Foundation - -// MARK: - Board wire format (MessageType.boardPost payloads) -// -// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC: -// - 0x01: kind (u8) — 0x01 post, 0x02 tombstone -// - 0x02: postID (16B random) -// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars) -// - 0x04: content (UTF-8, 1...512 bytes) [post] -// - 0x05: authorSigningKey (32B Ed25519 public key) -// - 0x06: authorNickname (UTF-8, max 64 bytes) -// - 0x07: createdAt (u64 big-endian, ms) [post] -// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post] -// - 0x09: flags (u8, bit0 = urgent) [post] -// - 0x0A: signature (64B Ed25519) -// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone] -// Unknown TLVs are skipped for forward compatibility. - -enum BoardWireConstants { - static let postIDLength = 16 - static let signingKeyLength = 32 - static let signatureLength = 64 - static let contentMaxBytes = 512 - static let nicknameMaxBytes = 64 - static let geohashMaxLength = 12 - /// Posts may live at most 7 days past their creation timestamp. - static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000 - static let postSigningContext = "bitchat-board-v1" - static let tombstoneSigningContext = "bitchat-board-del-v1" - static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz") -} - -private enum BoardTLVType: UInt8 { - case kind = 0x01 - case postID = 0x02 - case geohash = 0x03 - case content = 0x04 - case authorSigningKey = 0x05 - case authorNickname = 0x06 - case createdAt = 0x07 - case expiresAt = 0x08 - case flags = 0x09 - case signature = 0x0A - case deletedAt = 0x0B -} - -private enum BoardWireKind: UInt8 { - case post = 0x01 - case tombstone = 0x02 -} - -/// A signed, persistent bulletin-board notice. -struct BoardPostPacket: Equatable { - let postID: Data - /// Empty string scopes the post to the mesh-local board. - let geohash: String - let content: String - let authorSigningKey: Data - let authorNickname: String - let createdAt: UInt64 - let expiresAt: UInt64 - let flags: UInt8 - let signature: Data - - static let urgentFlag: UInt8 = 0x01 - - var isUrgent: Bool { flags & Self.urgentFlag != 0 } - - /// Canonical bytes covered by the Ed25519 signature. Variable-length - /// fields are length-prefixed so no two field combinations can collide. - static func signingBytes( - postID: Data, - geohash: String, - content: String, - authorSigningKey: Data, - authorNickname: String, - createdAt: UInt64, - expiresAt: UInt64, - flags: UInt8 - ) -> Data { - var out = Data() - BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out) - out.append(postID) - BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out) - BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out) - out.append(authorSigningKey) - BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out) - BoardWireEncoding.appendUInt64(createdAt, to: &out) - BoardWireEncoding.appendUInt64(expiresAt, to: &out) - out.append(flags) - return out - } - - var signingBytes: Data { - Self.signingBytes( - postID: postID, - geohash: geohash, - content: content, - authorSigningKey: authorSigningKey, - authorNickname: authorNickname, - createdAt: createdAt, - expiresAt: expiresAt, - flags: flags - ) - } - - func verifySignature() -> Bool { - BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey) - } -} - -/// A signed deletion marker. Only the author's key can produce one; receivers -/// keep it until the post's original expiry so the delete outruns the post. -struct BoardTombstonePacket: Equatable { - let postID: Data - let authorSigningKey: Data - let deletedAt: UInt64 - let signature: Data - - static func signingBytes(postID: Data, deletedAt: UInt64) -> Data { - var out = Data() - BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out) - out.append(postID) - BoardWireEncoding.appendUInt64(deletedAt, to: &out) - return out - } - - var signingBytes: Data { - Self.signingBytes(postID: postID, deletedAt: deletedAt) - } - - func verifySignature() -> Bool { - BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey) - } -} - -/// Decoded board payload: either a live post or a tombstone. -enum BoardWire: Equatable { - case post(BoardPostPacket) - case tombstone(BoardTombstonePacket) - - func encode() -> Data { - var out = Data() - func putTLV(_ t: BoardTLVType, _ v: Data) { - out.append(t.rawValue) - let len = UInt16(v.count) - out.append(UInt8((len >> 8) & 0xFF)) - out.append(UInt8(len & 0xFF)) - out.append(v) - } - switch self { - case .post(let post): - putTLV(.kind, Data([BoardWireKind.post.rawValue])) - putTLV(.postID, post.postID) - putTLV(.geohash, Data(post.geohash.utf8)) - putTLV(.content, Data(post.content.utf8)) - putTLV(.authorSigningKey, post.authorSigningKey) - putTLV(.authorNickname, Data(post.authorNickname.utf8)) - putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt)) - putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt)) - putTLV(.flags, Data([post.flags])) - putTLV(.signature, post.signature) - case .tombstone(let tombstone): - putTLV(.kind, Data([BoardWireKind.tombstone.rawValue])) - putTLV(.postID, tombstone.postID) - putTLV(.authorSigningKey, tombstone.authorSigningKey) - putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt)) - putTLV(.signature, tombstone.signature) - } - return out - } - - /// Structural decode; the caller must still verify the signature before - /// ingesting (`verifySignature()`). - static func decode(from data: Data) -> BoardWire? { - var off = data.startIndex - var kind: BoardWireKind? - var postID: Data? - var geohash: String? - var content: String? - var contentBytes = 0 - var authorSigningKey: Data? - var authorNickname: String? - var nicknameBytes = 0 - var createdAt: UInt64? - var expiresAt: UInt64? - var flags: UInt8? - var signature: Data? - var deletedAt: UInt64? - - while off + 3 <= data.endIndex { - let t = data[off]; off += 1 - let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2 - guard off + len <= data.endIndex else { return nil } - let v = data.subdata(in: off..<(off + len)); off += len - switch BoardTLVType(rawValue: t) { - case .kind: - guard v.count == 1 else { return nil } - kind = BoardWireKind(rawValue: v[v.startIndex]) - case .postID: - guard v.count == BoardWireConstants.postIDLength else { return nil } - postID = v - case .geohash: - guard v.count <= BoardWireConstants.geohashMaxLength else { return nil } - geohash = String(data: v, encoding: .utf8) - case .content: - guard v.count <= BoardWireConstants.contentMaxBytes else { return nil } - contentBytes = v.count - content = String(data: v, encoding: .utf8) - case .authorSigningKey: - guard v.count == BoardWireConstants.signingKeyLength else { return nil } - authorSigningKey = v - case .authorNickname: - guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil } - nicknameBytes = v.count - authorNickname = String(data: v, encoding: .utf8) - case .createdAt: - createdAt = BoardWireEncoding.uint64(from: v) - case .expiresAt: - expiresAt = BoardWireEncoding.uint64(from: v) - case .flags: - guard v.count == 1 else { return nil } - flags = v[v.startIndex] - case .signature: - guard v.count == BoardWireConstants.signatureLength else { return nil } - signature = v - case .deletedAt: - deletedAt = BoardWireEncoding.uint64(from: v) - case nil: - continue // forward compatible; ignore unknown TLVs - } - } - - guard let postID, let authorSigningKey, let signature else { return nil } - - switch kind { - case .post: - guard let geohash, let content, let authorNickname, - let createdAt, let expiresAt, let flags, - contentBytes >= 1, - nicknameBytes <= BoardWireConstants.nicknameMaxBytes, - isValidGeohashField(geohash), - expiresAt > createdAt, - expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else { - return nil - } - return .post(BoardPostPacket( - postID: postID, - geohash: geohash, - content: content, - authorSigningKey: authorSigningKey, - authorNickname: authorNickname, - createdAt: createdAt, - expiresAt: expiresAt, - flags: flags, - signature: signature - )) - case .tombstone: - guard let deletedAt else { return nil } - return .tombstone(BoardTombstonePacket( - postID: postID, - authorSigningKey: authorSigningKey, - deletedAt: deletedAt, - signature: signature - )) - case nil: - return nil - } - } - - func verifySignature() -> Bool { - switch self { - case .post(let post): return post.verifySignature() - case .tombstone(let tombstone): return tombstone.verifySignature() - } - } - - /// Cheap TLV peek for relay policy: is this payload an urgent post? - /// Avoids a full decode on the hot relay path. - static func urgentFlag(in data: Data) -> Bool { - var off = data.startIndex - while off + 3 <= data.endIndex { - let t = data[off]; off += 1 - let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2 - guard off + len <= data.endIndex else { return false } - if t == BoardTLVType.flags.rawValue, len == 1 { - return data[off] & BoardPostPacket.urgentFlag != 0 - } - off += len - } - return false - } - - /// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash - /// base32 alphabet. - private static func isValidGeohashField(_ geohash: String) -> Bool { - geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) } - } -} - -enum BoardWireEncoding { - static func appendContext(_ context: String, to out: inout Data) { - let bytes = Data(context.utf8) - out.append(UInt8(min(bytes.count, 255))) - out.append(bytes.prefix(255)) - } - - static func appendLengthPrefixed(_ value: Data, to out: inout Data) { - let len = UInt16(min(value.count, Int(UInt16.max))) - out.append(UInt8((len >> 8) & 0xFF)) - out.append(UInt8(len & 0xFF)) - out.append(value.prefix(Int(UInt16.max))) - } - - static func appendUInt64(_ value: UInt64, to out: inout Data) { - var be = value.bigEndian - withUnsafeBytes(of: &be) { out.append(contentsOf: $0) } - } - - static func uint64Data(_ value: UInt64) -> Data { - var out = Data() - appendUInt64(value, to: &out) - return out - } - - static func uint64(from data: Data) -> UInt64? { - guard data.count == 8 else { return nil } - var value: UInt64 = 0 - for byte in data { value = (value << 8) | UInt64(byte) } - return value - } - - static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool { - guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { - return false - } - return key.isValidSignature(signature, for: message) - } -} diff --git a/bitchat/Protocols/Geohash.swift b/bitchat/Protocols/Geohash.swift index d0061c5c..f436ea27 100644 --- a/bitchat/Protocols/Geohash.swift +++ b/bitchat/Protocols/Geohash.swift @@ -10,11 +10,11 @@ enum Geohash { return map }() - /// Validates a geohash string at any channel precision (1-12 characters). + /// Validates a geohash string for building-level precision (8 characters). /// - Parameter geohash: The geohash string to validate - /// - Returns: true if a non-empty base32 geohash of at most 12 characters - static func isValidGeohash(_ geohash: String) -> Bool { - guard (1...12).contains(geohash.count) else { return false } + /// - Returns: true if valid 8-character base32 geohash, false otherwise + static func isValidBuildingGeohash(_ geohash: String) -> Bool { + guard geohash.count == 8 else { return false } return geohash.lowercased().allSatisfy { base32Map[$0] != nil } } diff --git a/bitchat/Protocols/LocationChannel.swift b/bitchat/Protocols/LocationChannel.swift index cbe1c55f..b7c0d5b6 100644 --- a/bitchat/Protocols/LocationChannel.swift +++ b/bitchat/Protocols/LocationChannel.swift @@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { case .city: return 5 case .province: return 4 case .region: return 2 - } + } } var displayName: String { diff --git a/bitchat/Protocols/MeshMessageIdentity.swift b/bitchat/Protocols/MeshMessageIdentity.swift deleted file mode 100644 index 1256697f..00000000 --- a/bitchat/Protocols/MeshMessageIdentity.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// MeshMessageIdentity.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import Foundation - -/// Content-derived identity for public mesh messages. -/// -/// The BLE wire carries no message ID for public broadcasts, so every device -/// recomputes the same stable ID from the signed wire fields (sender ID, -/// millisecond timestamp, content). That gives the mesh bridge a -/// cross-device-consistent radio identity with zero wire change. Bridge events -/// carry this value only as a hint for detecting a radio copy that is already -/// present: sender/timestamp/content are public, so a different Nostr signer -/// can copy them and must never be allowed to reserve the genuine event's -/// authenticated dedup slot. -enum MeshMessageIdentity { - /// Matches the wire truncation in `BLEService.sendMessage`. - static func millisecondTimestamp(_ date: Date) -> UInt64 { - UInt64(date.timeIntervalSince1970 * 1000) - } - - static func stableID(senderIDHex: String, timestampMs: UInt64, content: String) -> String { - let input = senderIDHex.lowercased() + "|" + String(timestampMs) + "|" + content.trimmed - return String(Data(input.utf8).sha256Hex().prefix(32)) - } -} diff --git a/bitchat/Protocols/NostrCarrierPacket.swift b/bitchat/Protocols/NostrCarrierPacket.swift deleted file mode 100644 index 8e7b7c06..00000000 --- a/bitchat/Protocols/NostrCarrierPacket.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// NostrCarrierPacket.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import Foundation - -/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed -/// Nostr event ferried over the mesh between a mesh-only peer and an -/// internet gateway peer. -/// -/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer): -/// a mesh-only sender asks the gateway to publish its locally signed -/// geohash event to Nostr relays. -/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway -/// rebroadcasts inbound relay events so mesh-only peers see the channel. -/// -/// The carried event is public geohash chat — already plaintext on Nostr — -/// so the carrier adds no encryption. It IS signed by the originator's -/// per-geohash identity, so neither the gateway nor any mesh relay can forge -/// or alter it undetected: gateways and receivers verify the Schnorr -/// signature before acting on it. -/// -/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the -/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped -/// for forward compatibility. -struct NostrCarrierPacket: Equatable { - enum Direction: UInt8 { - case toGateway = 0x01 - case fromGateway = 0x02 - /// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to - /// publish its signed rendezvous event. Directed, like `toGateway`. - case toBridge = 0x03 - /// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous - /// event from a remote island. Broadcast, like `fromGateway`. - /// Old clients fail the Direction decode on 0x03/0x04 and drop the - /// carrier quietly — bridge traffic degrades to invisible, not junk. - case fromBridge = 0x04 - } - - let direction: Direction - let geohash: String - /// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags, - /// content, sig). - let eventJSON: Data - - /// BLE airtime cap for a carried event. - static let maxEventJSONBytes = 16 * 1024 - static let maxGeohashLength = 12 - - private enum TLVType: UInt8 { - case direction = 0x01 - case geohash = 0x02 - case eventJSON = 0x03 - } - - init?(direction: Direction, geohash: String, eventJSON: Data) { - let geohashBytes = Data(geohash.utf8) - guard !geohashBytes.isEmpty, - geohashBytes.count <= Self.maxGeohashLength, - !eventJSON.isEmpty, - eventJSON.count <= Self.maxEventJSONBytes else { - return nil - } - self.direction = direction - self.geohash = geohash - self.eventJSON = eventJSON - } - - init?(direction: Direction, geohash: String, event: NostrEvent) { - guard let json = try? event.jsonString(), !json.isEmpty else { return nil } - self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8)) - } - - /// Decodes the carried event. Callers MUST still verify - /// `event.isValidSignature()` before publishing or displaying it. - func event() -> NostrEvent? { - guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else { - return nil - } - return try? NostrEvent(from: dict) - } - - func encode() -> Data? { - var data = Data() - data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12) - - func appendTLV(_ type: TLVType, _ value: Data) { - data.append(type.rawValue) - data.append(UInt8((value.count >> 8) & 0xFF)) - data.append(UInt8(value.count & 0xFF)) - data.append(value) - } - - appendTLV(.direction, Data([direction.rawValue])) - appendTLV(.geohash, Data(geohash.utf8)) - appendTLV(.eventJSON, eventJSON) - return data - } - - static func decode(_ data: Data) -> NostrCarrierPacket? { - // Defensive slice re-base (Data slices keep parent indices). - let data = Data(data) - var offset = 0 - var direction: Direction? - var geohash: String? - var eventJSON: Data? - - while offset + 3 <= data.count { - let typeRaw = data[offset] - let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2]) - offset += 3 - guard offset + length <= data.count else { return nil } - let value = data.subdata(in: offset.. Data? { @@ -72,24 +48,6 @@ struct AnnouncementPacket { } } - // TLV for capabilities (optional) - if let capabilities = capabilities { - let capabilityBytes = capabilities.encoded() - guard capabilityBytes.count <= 255 else { return nil } - data.append(TLVType.capabilities.rawValue) - data.append(UInt8(capabilityBytes.count)) - data.append(capabilityBytes) - } - - // TLV for bridge rendezvous cell (optional; old clients skip it) - if let bridgeGeohash = bridgeGeohash, - let cellData = bridgeGeohash.data(using: .utf8), - !cellData.isEmpty, cellData.count <= 12 { - data.append(TLVType.bridgeGeohash.rawValue) - data.append(UInt8(cellData.count)) - data.append(cellData) - } - return data } @@ -99,8 +57,6 @@ struct AnnouncementPacket { var noisePublicKey: Data? var signingPublicKey: Data? var directNeighbors: [Data]? - var capabilities: PeerCapabilities? - var bridgeGeohash: String? while offset + 2 <= data.count { let typeRaw = data[offset] @@ -131,12 +87,6 @@ struct AnnouncementPacket { } directNeighbors = neighbors } - case .capabilities: - capabilities = PeerCapabilities(encoded: Data(value)) - case .bridgeGeohash: - if length <= 12 { - bridgeGeohash = String(data: value, encoding: .utf8) - } } } else { // Unknown TLV; skip (tolerant decoder for forward compatibility) @@ -149,92 +99,7 @@ struct AnnouncementPacket { nickname: nickname, noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, - directNeighbors: directNeighbors, - capabilities: capabilities, - bridgeGeohash: bridgeGeohash - ) - } -} - -/// State that is authoritative only because it is carried inside an -/// established Noise session. The public announce remains useful for -/// discovery, but its self-signature cannot prove possession of the copied -/// Noise public key it contains. -/// -/// Wire format (v1): -/// `[version=0x01][type][length][value]...` -/// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities` -/// - TLV `0x02`: 32-byte Ed25519 signing public key -/// -/// Unknown TLVs are skipped for forward compatibility. Unknown versions, -/// duplicates, non-canonical capability fields, and malformed lengths are -/// rejected without changing authenticated state. -struct AuthenticatedPeerStatePacket: Equatable { - static let currentVersion: UInt8 = 1 - static let signingPublicKeyLength = 32 - - let capabilities: PeerCapabilities - let signingPublicKey: Data - - private enum TLVType: UInt8 { - case capabilities = 0x01 - case signingPublicKey = 0x02 - } - - func encode() -> Data? { - guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil } - let capabilityBytes = capabilities.encoded() - guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil } - - var data = Data([Self.currentVersion]) - data.append(TLVType.capabilities.rawValue) - data.append(UInt8(capabilityBytes.count)) - data.append(capabilityBytes) - data.append(TLVType.signingPublicKey.rawValue) - data.append(UInt8(signingPublicKey.count)) - data.append(signingPublicKey) - return data - } - - static func decode(from data: Data) -> AuthenticatedPeerStatePacket? { - guard data.first == Self.currentVersion else { return nil } - - var offset = 1 - var capabilities: PeerCapabilities? - var signingPublicKey: Data? - - while offset < data.count { - guard offset + 2 <= data.count else { return nil } - let typeRaw = data[offset] - let length = Int(data[offset + 1]) - offset += 2 - guard offset + length <= data.count else { return nil } - let value = Data(data[offset..<(offset + length)]) - offset += length - - guard let type = TLVType(rawValue: typeRaw) else { - continue - } - switch type { - case .capabilities: - guard capabilities == nil, - !value.isEmpty, - value.count <= 8 else { return nil } - let decoded = PeerCapabilities(encoded: value) - guard decoded.encoded() == value else { return nil } - capabilities = decoded - - case .signingPublicKey: - guard signingPublicKey == nil, - value.count == Self.signingPublicKeyLength else { return nil } - signingPublicKey = value - } - } - - guard let capabilities, let signingPublicKey else { return nil } - return AuthenticatedPeerStatePacket( - capabilities: capabilities, - signingPublicKey: signingPublicKey + directNeighbors: directNeighbors ) } } diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift deleted file mode 100644 index d48891d7..00000000 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ /dev/null @@ -1,13 +0,0 @@ -import BitFoundation - -extension PeerCapabilities { - /// Capabilities this build advertises in its announce packets. - /// Each feature adds its bit here when it ships. - static let localSupported: PeerCapabilities = [ - .vouch, - .prekeys, - .groups, - .privateMedia, - .privateMediaReceipts - ] -} diff --git a/bitchat/Protocols/VoiceBurstPacket.swift b/bitchat/Protocols/VoiceBurstPacket.swift deleted file mode 100644 index 3d534175..00000000 --- a/bitchat/Protocols/VoiceBurstPacket.swift +++ /dev/null @@ -1,220 +0,0 @@ -// -// VoiceBurstPacket.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Security - -/// Audio codec of a live voice burst. START packets carry it so receivers can -/// reject bursts they can't decode instead of feeding garbage to the decoder. -enum VoiceBurstCodec: UInt8 { - /// AAC-LC, 16 kHz, mono, ~16 kbps — matches the voice-note recorder, so - /// the finalized `.m4a` and the live frames come from the same encoder - /// settings. - case aacLC16kMono = 0x01 -} - -/// One packet of a live push-to-talk voice burst (the inner payload of -/// `NoisePayloadType.voiceFrame`, and — for public mesh bursts — the payload -/// of `MessageType.voiceFrame`). -/// -/// Wire format: -/// ``` -/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload…] -/// ``` -/// - flags 0x01 (START): payload = [codec: UInt8] -/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE] -/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst -/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame] -struct VoiceBurstPacket: Equatable { - enum Kind: Equatable { - case start(codec: VoiceBurstCodec) - case frames([Data]) - case end(totalDataPackets: UInt16, durationMs: UInt32) - case canceled - } - - static let burstIDSize = 8 - private static let headerSize = burstIDSize + 2 + 1 - /// Sanity cap on frames per packet; real packets carry 1-2 frames. - static let maxFramesPerPacket = 8 - - private enum Flags { - static let start: UInt8 = 0x01 - static let end: UInt8 = 0x02 - static let canceled: UInt8 = 0x04 - } - - let burstID: Data - let seq: UInt16 - let kind: Kind - - init?(burstID: Data, seq: UInt16, kind: Kind) { - guard burstID.count == Self.burstIDSize else { return nil } - if case .frames(let frames) = kind { - guard !frames.isEmpty, - frames.count <= Self.maxFramesPerPacket, - frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) }) - else { return nil } - } - self.burstID = burstID - self.seq = seq - self.kind = kind - } - - func encode() -> Data { - var data = Data(capacity: Self.headerSize + payloadSize) - data.append(burstID) - data.append(UInt8((seq >> 8) & 0xFF)) - data.append(UInt8(seq & 0xFF)) - switch kind { - case .start(let codec): - data.append(Flags.start) - data.append(codec.rawValue) - case .frames(let frames): - data.append(0) - for frame in frames { - let length = UInt16(frame.count) - data.append(UInt8((length >> 8) & 0xFF)) - data.append(UInt8(length & 0xFF)) - data.append(frame) - } - case .end(let totalDataPackets, let durationMs): - data.append(Flags.end) - data.append(UInt8((totalDataPackets >> 8) & 0xFF)) - data.append(UInt8(totalDataPackets & 0xFF)) - for shift in stride(from: 24, through: 0, by: -8) { - data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF)) - } - case .canceled: - data.append(Flags.canceled) - } - return data - } - - static func decode(_ data: Data) -> VoiceBurstPacket? { - // Work on a re-based copy so subscripting is offset-safe. - let data = Data(data) - guard data.count >= headerSize else { return nil } - - let burstID = data.prefix(burstIDSize) - let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1]) - let flags = data[burstIDSize + 2] - let payload = data.dropFirst(headerSize) - - let kind: Kind - switch flags { - case Flags.start: - guard let codecByte = payload.first, - let codec = VoiceBurstCodec(rawValue: codecByte) - else { return nil } - kind = .start(codec: codec) - case Flags.end: - guard payload.count >= 6 else { return nil } - let bytes = Array(payload) - let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1]) - let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } - kind = .end(totalDataPackets: total, durationMs: duration) - case Flags.canceled: - kind = .canceled - case 0: - var frames: [Data] = [] - var offset = payload.startIndex - while offset < payload.endIndex { - guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil } - let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)]) - offset = payload.index(offset, offsetBy: 2) - guard length > 0, - payload.distance(from: offset, to: payload.endIndex) >= length, - frames.count < maxFramesPerPacket - else { return nil } - let end = payload.index(offset, offsetBy: length) - frames.append(Data(payload[offset.. Data { - var bytes = Data(count: burstIDSize) - let result = bytes.withUnsafeMutableBytes { - SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!) - } - guard result == errSecSuccess else { - return Data((0.. [Data] { - let frameCost = 2 + frame.count - guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] } - - var packets: [Data] = [] - if !pendingFrames.isEmpty, - VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget - || pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket { - packets.append(contentsOf: flush()) - } - pendingFrames.append(frame) - pendingSize += frameCost - return packets - } - - /// Emits any buffered frames as a final data packet. - mutating func flush() -> [Data] { - guard !pendingFrames.isEmpty, - let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames)) - else { - pendingFrames = [] - pendingSize = 0 - return [] - } - pendingFrames = [] - pendingSize = 0 - nextSeq &+= 1 - dataPacketCount &+= 1 - return [packet.encode()] - } -} diff --git a/bitchat/Protocols/VouchAttestation.swift b/bitchat/Protocols/VouchAttestation.swift deleted file mode 100644 index a765a11b..00000000 --- a/bitchat/Protocols/VouchAttestation.swift +++ /dev/null @@ -1,225 +0,0 @@ -// -// VouchAttestation.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import CryptoKit -import Foundation - -/// A signed statement that the *sender of the enclosing Noise payload* has -/// verified the identity described here ("transitive verification"). -/// -/// The voucher's identity is deliberately implicit: attestations only travel -/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the -/// receiver verifies the Ed25519 signature against the session peer's -/// announce-bound signing key and stores the vouch keyed by that peer's -/// fingerprint. Nothing in the attestation names the voucher, so a captured -/// attestation cannot be replayed by a third party whose signing key doesn't -/// match. -/// -/// Wire format — single attestation (TLV, 1-byte type + 1-byte length): -/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key -/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity -/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970 -/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over -/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp` -/// -/// Unknown TLV types are skipped for forward compatibility. -/// -/// Batch format (the `vouch` Noise payload body): -/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`. -struct VouchAttestation: Equatable { - static let signingContext = "bitchat-vouch-v1" - /// Receiver-side expiry for attestations. - static let maxAge: TimeInterval = 30 * 24 * 60 * 60 - /// Tolerated clock skew for attestations timestamped in the future. - static let maxClockSkew: TimeInterval = 60 * 60 - /// Upper bound of attestations carried/accepted in one batch payload. - static let maxBatchCount = 16 - - static let fingerprintSize = 32 - static let signingKeySize = 32 - static let signatureSize = 64 - - let voucheeFingerprint: Data // 32 bytes - let voucheeSigningKey: Data // 32 bytes - let timestampMs: UInt64 - let signature: Data // 64 bytes - - private enum TLVType: UInt8 { - case voucheeFingerprint = 0x01 - case voucheeSigningKey = 0x02 - case timestamp = 0x03 - case signature = 0x04 - } - - var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() } - - var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) } - - /// The exact bytes the voucher signs. - static func signableBytes( - voucheeFingerprint: Data, - voucheeSigningKey: Data, - timestampMs: UInt64 - ) -> Data { - var message = Data(signingContext.utf8) - message.append(voucheeFingerprint) - message.append(voucheeSigningKey) - var timestampBE = timestampMs.bigEndian - withUnsafeBytes(of: ×tampBE) { message.append(contentsOf: $0) } - return message - } - - var signableBytes: Data { - Self.signableBytes( - voucheeFingerprint: voucheeFingerprint, - voucheeSigningKey: voucheeSigningKey, - timestampMs: timestampMs - ) - } - - /// Builds and signs an attestation. `sign` is the voucher's Ed25519 - /// signing primitive (e.g. `Transport.noiseSignData`). - static func build( - voucheeFingerprint: Data, - voucheeSigningKey: Data, - timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), - sign: (Data) -> Data? - ) -> VouchAttestation? { - guard voucheeFingerprint.count == fingerprintSize, - voucheeSigningKey.count == signingKeySize else { return nil } - let message = signableBytes( - voucheeFingerprint: voucheeFingerprint, - voucheeSigningKey: voucheeSigningKey, - timestampMs: timestampMs - ) - guard let signature = sign(message), signature.count == signatureSize else { return nil } - return VouchAttestation( - voucheeFingerprint: voucheeFingerprint, - voucheeSigningKey: voucheeSigningKey, - timestampMs: timestampMs, - signature: signature - ) - } - - /// Verifies the Ed25519 signature against the voucher's announce-bound - /// signing key. - func verifySignature(voucherSigningKey: Data) -> Bool { - guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else { - return false - } - return publicKey.isValidSignature(signature, for: signableBytes) - } - - /// Whether the attestation is outside its validity window (older than - /// `maxAge`, or timestamped implausibly far in the future). - func isExpired(now: Date = Date()) -> Bool { - let age = now.timeIntervalSince(timestamp) - return age > Self.maxAge || age < -Self.maxClockSkew - } - - // MARK: - Encoding - - func encode() -> Data? { - guard voucheeFingerprint.count == Self.fingerprintSize, - voucheeSigningKey.count == Self.signingKeySize, - signature.count == Self.signatureSize else { return nil } - var data = Data() - func appendTLV(_ type: TLVType, _ value: Data) { - data.append(type.rawValue) - data.append(UInt8(value.count)) - data.append(value) - } - appendTLV(.voucheeFingerprint, voucheeFingerprint) - appendTLV(.voucheeSigningKey, voucheeSigningKey) - var timestampBE = timestampMs.bigEndian - appendTLV(.timestamp, withUnsafeBytes(of: ×tampBE) { Data($0) }) - appendTLV(.signature, signature) - return data - } - - static func decode(from data: Data) -> VouchAttestation? { - var fingerprint: Data? - var signingKey: Data? - var timestampMs: UInt64? - var signature: Data? - - var offset = data.startIndex - while offset < data.endIndex { - guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil, - offset + 1 < data.endIndex else { return nil } - let type = data[offset] - let length = Int(data[offset + 1]) - let valueStart = offset + 2 - guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else { - return nil - } - let value = Data(data[valueStart.. Data? { - guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil } - var data = Data() - data.append(UInt8(attestations.count)) - for attestation in attestations { - guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil } - var lengthBE = UInt16(encoded.count).bigEndian - withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) } - data.append(encoded) - } - return data - } - - /// Decodes a batch payload, dropping malformed entries and ignoring - /// anything beyond `maxBatchCount` (sender-declared count is not trusted). - static func decodeList(from data: Data) -> [VouchAttestation] { - guard data.count > 1 else { return [] } - let declaredCount = Int(data[data.startIndex]) - let limit = min(declaredCount, maxBatchCount) - var attestations: [VouchAttestation] = [] - var offset = data.startIndex + 1 - while attestations.count < limit, offset < data.endIndex { - guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break } - let length = Int(data[offset]) << 8 | Int(data[offset + 1]) - guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break } - if let attestation = decode(from: Data(data[lengthEnd.. (suggestions: [String], range: NSRange?) { let textToPosition = String(text.prefix(cursorPosition)) @@ -55,10 +62,10 @@ final class AutocompleteService { let fullRange = match.range(at: 0) let captureRange = match.range(at: 1) - let prefix = nsText.substring(with: captureRange).normalizedNickname.lowercased() - + let prefix = nsText.substring(with: captureRange).lowercased() + let suggestions = peers - .filter { $0.normalizedNickname.lowercased().hasPrefix(prefix) } + .filter { $0.lowercased().hasPrefix(prefix) } .sorted() .prefix(5) .map { "@\($0)" } @@ -66,6 +73,26 @@ final class AutocompleteService { return suggestions.isEmpty ? nil : (Array(suggestions), fullRange) } + private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? { + guard let regex = commandRegex else { return nil } + + let nsText = text as NSString + let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length)) + + guard let match = matches.last else { return nil } + + let fullRange = match.range(at: 0) + let captureRange = match.range(at: 1) + let prefix = nsText.substring(with: captureRange).lowercased() + + let suggestions = commands + .filter { $0.hasPrefix("/\(prefix)") } + .sorted() + .prefix(5) + + return suggestions.isEmpty ? nil : (Array(suggestions), fullRange) + } + private func needsArgument(command: String) -> Bool { switch command { case "/who", "/clear": diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift deleted file mode 100644 index 08b603a4..00000000 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ /dev/null @@ -1,298 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// Narrow environment for `BLEAnnounceHandler`. -/// -/// All queue hops (collections barrier, BLE-queue link-state reads, main-actor -/// UI notification, delayed re-announce) live inside the closures supplied by -/// `BLEService`, keeping the handler queue-agnostic and synchronously testable. -struct BLEAnnounceHandlerEnvironment { - /// Local peer identity at the time the announce is handled. - let localPeerID: () -> PeerID - /// TTL value used for direct (non-relayed) packets. - let messageTTL: UInt8 - /// Current time source. - let now: () -> Date - /// Noise and signing public keys already recorded for the peer, if any - /// (single registry read so both come from one consistent snapshot). - let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?) - /// Signing key from the persisted cryptographic identity for the peer, if - /// any. Registry pins do not survive app restarts or offline-peer - /// eviction; this fallback keeps the TOFU signing-key pin effective for - /// returning peers. - let persistedSigningPublicKey: (PeerID) -> Data? - /// Ed25519 key previously bound to this Noise identity by an authenticated - /// peer-state payload, if any (persistent identity-state read). - let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data? - /// Verifies the packet signature against the announced signing key. - let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool - /// Direct link state for the peer (BLE-queue read). - let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) - /// Whether the link this packet arrived on is already bound to a - /// different peer ID (ingress-registry + BLE-queue read). Directness - /// rides on the unsigned TTL, so a replayed announce can look "direct" - /// on the replayer's link; that link must not shortcut an absent peer - /// into "connected". - let linkBoundToOtherPeer: (_ packet: BitchatPacket, _ peerID: PeerID) -> Bool - /// Runs the registry mutation phase under the collections barrier. - let withRegistryBarrier: (() -> Void) -> Void - /// Upserts the verified announce into the peer registry. - /// Returns `nil` when the registry refuses the announce because it carries - /// a signing key different from the one already pinned for this peer. - /// Must only be called from inside `withRegistryBarrier`. - let upsertVerifiedAnnounce: ( - _ peerID: PeerID, - _ announcement: AnnouncementPacket, - _ isConnected: Bool, - _ now: Date - ) -> BLEPeerAnnounceUpdate? - /// Debounced reconnect-log decision. - /// Must only be called from inside `withRegistryBarrier`. - let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool - /// Records verified direct-neighbor claims in the mesh topology. - let updateTopology: (_ peerID: PeerID, _ neighbors: [Data]) -> Void - /// Persists the announced cryptographic identity for offline verification. - let persistIdentity: (AnnouncementPacket) -> Void - /// Announce-back dedup check. - let dedupContains: (String) -> Bool - /// Announce-back dedup marking. - let dedupMarkProcessed: (String) -> Void - /// Delivers the announce UI events as one ordered main-actor hop: - /// `.peerConnected` (if flagged) → initial gossip sync scheduling (if - /// flagged) → peer-ID snapshot + data publish + `.peerListUpdated`. - /// A single closure keeps the original in-order delivery guarantee that - /// separate unstructured tasks would not provide. - let deliverAnnounceUIEvents: ( - _ peerID: PeerID, - _ notifyPeerConnected: Bool, - _ scheduleInitialSync: Bool - ) -> Void - /// Tracks the announce packet for gossip sync. - let trackPacketSeen: (BitchatPacket) -> Void - /// Reciprocates the announce for bidirectional discovery. - let sendAnnounceBack: () -> Void - /// Schedules a delayed re-announce (afterglow) after the given delay. - let scheduleAfterglow: (TimeInterval) -> Void -} - -/// Outcome of an accepted announce, surfaced so the service can run -/// follow-up work (e.g. courier handover) that keys off the announce. -struct BLEAnnounceHandlingResult { - let peerID: PeerID - let announcement: AnnouncementPacket - let isDirectAnnounce: Bool - let isVerified: Bool -} - -/// Orchestrates inbound announce packets: preflight validation, signature -/// trust, registry/topology updates, identity persistence, UI notification, -/// gossip tracking, and the reciprocal announce response. -final class BLEAnnounceHandler { - private let environment: BLEAnnounceHandlerEnvironment - - init(environment: BLEAnnounceHandlerEnvironment) { - self.environment = environment - } - - @discardableResult - func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? { - let env = environment - let now = env.now() - let preflight = BLEAnnouncePreflightPolicy.evaluate( - packet: packet, - from: peerID, - localPeerID: env.localPeerID(), - now: now - ) - - let announcement: AnnouncementPacket - switch preflight { - case .accept(let acceptance): - announcement = acceptance.announcement - case .reject(.malformed): - SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session) - return nil - case .reject(.senderMismatch(let derivedFromKey)): - SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) - return nil - case .reject(.selfAnnounce): - return nil - case .reject(.stale(let ageSeconds)): - SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) - return nil - } - - // Suppress announce logs to reduce noise - - // Precompute signature verification outside barrier to reduce contention - var existingPeerKeys = env.existingPeerKeys(peerID) - if existingPeerKeys.signingPublicKey == nil { - // The registry entry (and its signing-key pin) is dropped on app - // restart and offline-peer eviction, but the persisted - // cryptographic identity survives both. Fall back to it so a - // returning peer is not treated as first contact — otherwise an - // attacker could replay the peer's noiseKey/peerID with their own - // signing key and re-pin the identity (TOFU downgrade). - existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID) - } - let hasSignature = packet.signature != nil - let signatureValid: Bool - if hasSignature { - signatureValid = env.verifySignature(packet, announcement.signingPublicKey) - if !signatureValid { - SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security) - } - } else { - signatureValid = false - } - let trustDecision = BLEAnnounceTrustPolicy.evaluate( - hasSignature: hasSignature, - signatureValid: signatureValid, - existingNoisePublicKey: existingPeerKeys.noisePublicKey, - announcedNoisePublicKey: announcement.noisePublicKey, - existingSigningPublicKey: existingPeerKeys.signingPublicKey, - authenticatedSigningPublicKey: env.authenticatedSigningPublicKey( - announcement.noisePublicKey - ), - announcedSigningPublicKey: announcement.signingPublicKey - ) - if case .reject(.keyMismatch) = trustDecision { - SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) - } - if case .reject(.signingKeyMismatch) = trustDecision { - SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security) - } - if case .reject(.authenticatedSigningKeyMismatch) = trustDecision { - SecureLogger.warning( - "⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…", - category: .security - ) - } - var verifiedAnnounce = trustDecision.isVerified - - var isNewPeer = false - var isReconnectedPeer = false - let directLinkState = env.linkState(peerID) - let isDirectAnnounce = packet.ttl == env.messageTTL - // A "direct" announce arriving on a link that another peer already - // owns is either a rotation heal or a replay with its TTL restored; - // both are ambiguous, so only the rebind (which containment-checks - // the claimed identity) may promote it — never this shortcut. - // - // Known limitation: denying the shortcut cannot prevent forged - // presence outright. A rebind that passes the containment checks - // promotes the claimed peer to connected — it must, or a legitimate - // rotation on an open link would read as disconnected — so a replay - // that wins the rebind (absent victim, cooldown clear) still forges - // presence. That residue is presence display only: DMs stay gated on - // canDeliverSecurely (no Noise session means retain + courier, see - // MessageRouter.sendPrivate). What this check buys: the ambiguous - // announce alone never flips presence — forging requires winning the - // containment-checked rebind (never steals an identity that owns a - // live link; at most one rebind per link per cooldown window). - let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID) - - env.withRegistryBarrier { - let hasPeripheralConnection = directLinkState.hasPeripheral - let hasCentralSubscription = directLinkState.hasCentral - - // Require verified announce; ignore otherwise (no backward compatibility) - if !verifiedAnnounce { - SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security) - // Reset flags to prevent post-barrier code from acting on unverified announces - isNewPeer = false - isReconnectedPeer = false - return - } - - // The registry re-checks the signing-key pin inside the barrier. - // The pre-barrier trust check reads the registry outside the - // barrier, so this closes the race where two announces for the - // same peer are evaluated concurrently. - guard let update = env.upsertVerifiedAnnounce( - peerID, - announcement, - hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer), - now - ) else { - SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security) - verifiedAnnounce = false - isNewPeer = false - isReconnectedPeer = false - return - } - isNewPeer = update.isNewPeer - isReconnectedPeer = update.wasDisconnected - - // Log connection status only for direct connectivity changes; debounce to reduce spam - if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { - let now = env.now() - if update.isNewPeer { - SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session) - } else if update.wasDisconnected { - if env.shouldEmitReconnectLog(peerID, now) { - SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session) - } - } else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname { - SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session) - } - } - } - - // Update topology with verified neighbor claims (only for authenticated announces) - if verifiedAnnounce, let neighbors = announcement.directNeighbors { - env.updateTopology(peerID, neighbors) - } - - // Persist cryptographic identity and signing key for robust offline - // verification — only for verified announces. Persisting unverified - // announces would let an attacker who replays a victim's noisePublicKey - // overwrite the victim's stored signing key/nickname (identity poisoning). - if verifiedAnnounce { - env.persistIdentity(announcement) - } - - let announceBackID = "announce-back-\(peerID)" - let shouldSendBack = !env.dedupContains(announceBackID) - if shouldSendBack { - env.dedupMarkProcessed(announceBackID) - } - let responsePlan = BLEAnnounceResponsePolicy.plan( - isDirectAnnounce: isDirectAnnounce, - isNewPeer: isNewPeer, - isReconnectedPeer: isReconnectedPeer, - shouldSendAnnounceBack: shouldSendBack - ) - - // Only notify of connection for new or reconnected peers when it is a - // direct announce; the list update always follows in the same hop. - env.deliverAnnounceUIEvents( - peerID, - responsePlan.shouldNotifyPeerConnected, - responsePlan.shouldNotifyPeerConnected && responsePlan.shouldScheduleInitialSync - ) - - // Track for sync (include our own and others' announces) - env.trackPacketSeen(packet) - - if responsePlan.shouldSendAnnounceBack { - // Reciprocate announce for bidirectional discovery - // Force send to ensure the peer receives our announce - env.sendAnnounceBack() - } - - // Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop - if responsePlan.shouldScheduleAfterglow { - let delay = Double.random(in: 0.3...0.6) - env.scheduleAfterglow(delay) - } - - return BLEAnnounceHandlingResult( - peerID: peerID, - announcement: announcement, - isDirectAnnounce: isDirectAnnounce, - isVerified: verifiedAnnounce - ) - } -} diff --git a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift deleted file mode 100644 index 67712046..00000000 --- a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift +++ /dev/null @@ -1,140 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEAnnouncePreflightAcceptance { - let announcement: AnnouncementPacket - let derivedPeerID: PeerID -} - -enum BLEAnnouncePreflightRejection: Equatable { - case malformed - case senderMismatch(derivedPeerID: PeerID) - case selfAnnounce - case stale(ageSeconds: Double) -} - -enum BLEAnnouncePreflightDecision { - case accept(BLEAnnouncePreflightAcceptance) - case reject(BLEAnnouncePreflightRejection) -} - -enum BLEAnnouncePreflightPolicy { - static func evaluate( - packet: BitchatPacket, - from peerID: PeerID, - localPeerID: PeerID, - now: Date - ) -> BLEAnnouncePreflightDecision { - guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { - return .reject(.malformed) - } - - let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey) - guard derivedPeerID == peerID else { - return .reject(.senderMismatch(derivedPeerID: derivedPeerID)) - } - - guard peerID != localPeerID else { - return .reject(.selfAnnounce) - } - - guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else { - return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds( - timestampMilliseconds: packet.timestamp, - now: now - ))) - } - - return .accept(BLEAnnouncePreflightAcceptance( - announcement: announcement, - derivedPeerID: derivedPeerID - )) - } -} - -enum BLEAnnounceTrustRejection: Equatable { - case missingSignature - case invalidSignature - case keyMismatch - case signingKeyMismatch - case authenticatedSigningKeyMismatch -} - -enum BLEAnnounceTrustDecision: Equatable { - case verified - case reject(BLEAnnounceTrustRejection) - - var isVerified: Bool { - self == .verified - } -} - -enum BLEAnnounceTrustPolicy { - static func evaluate( - hasSignature: Bool, - signatureValid: Bool, - existingNoisePublicKey: Data?, - announcedNoisePublicKey: Data, - existingSigningPublicKey: Data? = nil, - authenticatedSigningPublicKey: Data? = nil, - announcedSigningPublicKey: Data - ) -> BLEAnnounceTrustDecision { - if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { - return .reject(.keyMismatch) - } - - // Strongest binding first: an Ed25519 key bound to this Noise identity - // inside an authenticated Noise session can never be replaced by a - // merely self-signed announce. - if let authenticatedSigningPublicKey, - announcedSigningPublicKey != authenticatedSigningPublicKey { - return .reject(.authenticatedSigningKeyMismatch) - } - - // TOFU signing-key pinning. The packet signature only proves the - // announce is self-consistent — it is verified against the Ed25519 key - // carried *inside the same announce*. Since peerIDs derive from the - // broadcast (public) noise key, an attacker can replay a victim's - // peerID+noiseKey with their own signing key and a valid - // self-signature. Once we have bound a signing key to this peer, - // refuse to silently replace it. - if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey { - return .reject(.signingKeyMismatch) - } - - guard hasSignature else { - return .reject(.missingSignature) - } - - guard signatureValid else { - return .reject(.invalidSignature) - } - - return .verified - } -} - -struct BLEAnnounceResponsePlan: Equatable { - let shouldNotifyPeerConnected: Bool - let shouldScheduleInitialSync: Bool - let shouldSendAnnounceBack: Bool - let shouldScheduleAfterglow: Bool -} - -enum BLEAnnounceResponsePolicy { - static func plan( - isDirectAnnounce: Bool, - isNewPeer: Bool, - isReconnectedPeer: Bool, - shouldSendAnnounceBack: Bool - ) -> BLEAnnounceResponsePlan { - let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer) - - return BLEAnnounceResponsePlan( - shouldNotifyPeerConnected: shouldNotifyPeerConnected, - shouldScheduleInitialSync: shouldNotifyPeerConnected, - shouldSendAnnounceBack: shouldSendAnnounceBack, - shouldScheduleAfterglow: isNewPeer - ) - } -} diff --git a/bitchat/Services/BLE/BLEAnnounceThrottle.swift b/bitchat/Services/BLE/BLEAnnounceThrottle.swift deleted file mode 100644 index 4b064136..00000000 --- a/bitchat/Services/BLE/BLEAnnounceThrottle.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation - -/// Thread-safe announce admission state. -/// -/// Announce requests originate from the Bluetooth delegate queue, the -/// concurrent message queue, and the maintenance timer. Keeping the timestamp -/// behind a lock makes admission and maintenance snapshots atomic when those -/// request sources race. -final class BLEAnnounceThrottle: @unchecked Sendable { - private let lock = NSLock() - private var lastSent: Date - private let normalMinimumInterval: TimeInterval - private let forcedMinimumInterval: TimeInterval - - init( - lastSent: Date = .distantPast, - normalMinimumInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval, - forcedMinimumInterval: TimeInterval = TransportConfig.bleForceAnnounceMinIntervalSeconds - ) { - self.lastSent = lastSent - self.normalMinimumInterval = normalMinimumInterval - self.forcedMinimumInterval = forcedMinimumInterval - } - - func elapsed(since now: Date) -> TimeInterval { - lock.withLock { now.timeIntervalSince(lastSent) } - } - - func shouldSend(force: Bool, now: Date) -> Bool { - lock.withLock { - let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval - guard now.timeIntervalSince(lastSent) >= minimumInterval else { - return false - } - - lastSent = now - return true - } - } - - /// Forgets the last-sent timestamp. A panic rotation calls this so the - /// new identity's first announce cannot be swallowed by the old - /// identity's throttle debt — otherwise a panic within the forced - /// minimum interval of the last announce leaves the rotated identity - /// invisible until the next maintenance cycle. - func reset() { - lock.withLock { lastSent = .distantPast } - } -} diff --git a/bitchat/Services/BLE/BLEConnectionScheduler.swift b/bitchat/Services/BLE/BLEConnectionScheduler.swift deleted file mode 100644 index d1b15d69..00000000 --- a/bitchat/Services/BLE/BLEConnectionScheduler.swift +++ /dev/null @@ -1,293 +0,0 @@ -import Foundation - -struct BLEConnectionCandidate { - let peripheral: Peripheral - let peripheralID: String - let rssi: Int - let name: String - let isConnectable: Bool - let discoveredAt: Date -} - -struct BLEExistingConnectionState { - let isConnecting: Bool - let isConnected: Bool - let lastConnectionAttempt: Date? -} - -enum BLEPeripheralConnectionState { - case disconnected - case connecting - case connected -} - -enum BLEDiscoveryDecision: Equatable { - case ignore - case queued - case scheduleRetry(after: TimeInterval) - case cancelStaleConnection - case connectNow -} - -enum BLEConnectionQueueDecision { - case none - case retryAfter(TimeInterval) - case connect(BLEConnectionCandidate) -} - -final class BLEConnectionScheduler { - private let maxCentralLinks: Int - private let connectRateLimitInterval: TimeInterval - private let candidateCap: Int - private let weakLinkCooldownSeconds: TimeInterval - private let weakLinkRSSICutoff: Int - - private var lastGlobalConnectAttempt: Date = .distantPast - private var candidates: [BLEConnectionCandidate] = [] - private var failureCounts: [String: Int] = [:] - private var recentConnectTimeouts: [String: Date] = [:] - // Tracked separately from connect timeouts: a peer we held a connection - // with and lost (walked out of range) usually comes back, so it only gets - // a brief rediscovery ignore — not the timeout backoff/cooldown treatment - // reserved for peers that never answered a connect attempt. - private var recentDisconnects: [String: Date] = [:] - private var lastIsolatedAt: Date? - - private let initialDynamicRSSIThreshold: Int - private(set) var dynamicRSSIThreshold: Int - - var candidateCount: Int { - candidates.count - } - - init( - maxCentralLinks: Int = TransportConfig.bleMaxCentralLinks, - connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval, - candidateCap: Int = TransportConfig.bleConnectionCandidatesMax, - weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds, - weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff, - dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault - ) { - self.maxCentralLinks = maxCentralLinks - self.connectRateLimitInterval = connectRateLimitInterval - self.candidateCap = candidateCap - self.weakLinkCooldownSeconds = weakLinkCooldownSeconds - self.weakLinkRSSICutoff = weakLinkRSSICutoff - self.initialDynamicRSSIThreshold = dynamicRSSIThreshold - self.dynamicRSSIThreshold = dynamicRSSIThreshold - } - - func handleDiscovery( - _ candidate: BLEConnectionCandidate, - connectedOrConnectingCount: Int, - existingState: BLEExistingConnectionState?, - peripheralState: BLEPeripheralConnectionState, - now: Date - ) -> BLEDiscoveryDecision { - guard candidate.isConnectable else { return .ignore } - - if candidate.rssi <= dynamicRSSIThreshold { - enqueue(candidate) - return .queued - } - - if connectedOrConnectingCount >= maxCentralLinks { - enqueue(candidate) - return .queued - } - - if let retryDelay = rateLimitRetryDelay(now: now) { - enqueue(candidate) - return .scheduleRetry(after: retryDelay) - } - - if let existingState { - if existingState.isConnected || existingState.isConnecting { - return .ignore - } - - if let lastAttempt = existingState.lastConnectionAttempt, - now.timeIntervalSince(lastAttempt) < 2.0 { - return .ignore - } - } - - if let lastTimeout = recentConnectTimeouts[candidate.peripheralID], - now.timeIntervalSince(lastTimeout) < TransportConfig.bleTimeoutDiscoveryIgnoreSeconds { - return .ignore - } - - if let lastDisconnect = recentDisconnects[candidate.peripheralID], - now.timeIntervalSince(lastDisconnect) < TransportConfig.bleDisconnectDiscoveryIgnoreSeconds { - return .ignore - } - - switch peripheralState { - case .disconnected: - return .connectNow - case .connecting, .connected: - return .cancelStaleConnection - } - } - - func enqueue(_ candidate: BLEConnectionCandidate) { - if let existingIndex = candidates.firstIndex(where: { $0.peripheralID == candidate.peripheralID }) { - candidates[existingIndex] = candidate - } else { - candidates.append(candidate) - } - - candidates.sort { - if $0.rssi != $1.rssi { return $0.rssi > $1.rssi } - return $0.discoveredAt < $1.discoveredAt - } - if candidates.count > candidateCap { - candidates.removeLast(candidates.count - candidateCap) - } - } - - func nextCandidate( - connectedOrConnectingCount: Int, - isAlreadyConnectingOrConnected: (String) -> Bool, - now: Date - ) -> BLEConnectionQueueDecision { - guard connectedOrConnectingCount < maxCentralLinks else { return .none } - - if let retryDelay = rateLimitRetryDelay(now: now) { - return .retryAfter(retryDelay) - } - - while !candidates.isEmpty { - candidates.sort { score($0, now: now) > score($1, now: now) } - let candidate = candidates.removeFirst() - guard candidate.isConnectable else { continue } - - if let delay = weakLinkRetryDelay(for: candidate, now: now) { - enqueue(candidate) - return .retryAfter(delay) - } - - if let delay = disconnectSettleDelay(for: candidate, now: now) { - enqueue(candidate) - return .retryAfter(delay) - } - - if isAlreadyConnectingOrConnected(candidate.peripheralID) { - continue - } - - return .connect(candidate) - } - - return .none - } - - func recordConnectionAttempt(at now: Date) { - lastGlobalConnectAttempt = now - } - - func recordConnectionSuccess(peripheralID: String) { - failureCounts[peripheralID] = 0 - recentConnectTimeouts.removeValue(forKey: peripheralID) - recentDisconnects.removeValue(forKey: peripheralID) - } - - func recordConnectionFailure(peripheralID: String) { - failureCounts[peripheralID, default: 0] += 1 - } - - func recordDisconnectError(peripheralID: String, at now: Date) { - recentDisconnects[peripheralID] = now - } - - func recordConnectionTimeout(peripheralID: String, at now: Date) { - recentConnectTimeouts[peripheralID] = now - recordConnectionFailure(peripheralID: peripheralID) - } - - func pruneConnectionTimeouts(before cutoff: Date) { - recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff } - recentDisconnects = recentDisconnects.filter { $0.value >= cutoff } - } - - func reset() { - lastGlobalConnectAttempt = .distantPast - candidates.removeAll() - failureCounts.removeAll() - recentConnectTimeouts.removeAll() - recentDisconnects.removeAll() - lastIsolatedAt = nil - dynamicRSSIThreshold = initialDynamicRSSIThreshold - } - - @discardableResult - func updateRSSIThreshold( - connectedCount: Int, - connectedOrConnectingLinkCount: Int, - now: Date - ) -> Int { - if connectedCount == 0 { - if lastIsolatedAt == nil { lastIsolatedAt = now } - let isolatedAt = lastIsolatedAt ?? now - let elapsed = now.timeIntervalSince(isolatedAt) - dynamicRSSIThreshold = elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds - ? TransportConfig.bleRSSIIsolatedRelaxed - : TransportConfig.bleRSSIIsolatedBase - return dynamicRSSIThreshold - } - - lastIsolatedAt = nil - // Flaky links are handled per-peripheral (weak-link cooldown, discovery - // ignore window, score bias) — never globally, so one flaky distant peer - // can't blind us to every other edge-of-range peer. - var threshold = TransportConfig.bleDynamicRSSIThresholdDefault - if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap { - threshold = TransportConfig.bleRSSIConnectedThreshold - } - - dynamicRSSIThreshold = threshold - return threshold - } - - private func rateLimitRetryDelay(now: Date) -> TimeInterval? { - let elapsed = now.timeIntervalSince(lastGlobalConnectAttempt) - guard elapsed < connectRateLimitInterval else { return nil } - return connectRateLimitInterval - elapsed + 0.05 - } - - private func weakLinkRetryDelay( - for candidate: BLEConnectionCandidate, - now: Date - ) -> TimeInterval? { - guard let lastTimeout = recentConnectTimeouts[candidate.peripheralID] else { return nil } - let elapsed = now.timeIntervalSince(lastTimeout) - guard elapsed < weakLinkCooldownSeconds && candidate.rssi <= weakLinkRSSICutoff else { return nil } - let remaining = weakLinkCooldownSeconds - elapsed - return min(max(2.0, remaining), 15.0) - } - - // The disconnect settle window must hold on the queue path too: a stale - // candidate enqueued while the peripheral was still connected would - // otherwise reconnect immediately via the post-disconnect queue drain, - // bypassing the window and recreating reconnect/cancel thrash. - private func disconnectSettleDelay( - for candidate: BLEConnectionCandidate, - now: Date - ) -> TimeInterval? { - guard let lastDisconnect = recentDisconnects[candidate.peripheralID] else { return nil } - let remaining = TransportConfig.bleDisconnectDiscoveryIgnoreSeconds - now.timeIntervalSince(lastDisconnect) - guard remaining > 0 else { return nil } - return remaining + 0.05 - } - - private func score(_ candidate: BLEConnectionCandidate, now: Date) -> Int { - let failures = failureCounts[candidate.peripheralID] ?? 0 - let penalty = min(20, 1 << min(4, failures)) - let timeoutBias = recentConnectTimeouts[candidate.peripheralID].map { - now.timeIntervalSince($0) < 60 ? 10 : 0 - } ?? 0 - let base = (candidate.isConnectable ? 1000 : 0) + (candidate.rssi + 100) * 2 - let recency = -Int(now.timeIntervalSince(candidate.discoveredAt) * 10) - return base + recency - penalty - timeoutBias - } -} diff --git a/bitchat/Services/BLE/BLEDirectedRelaySpool.swift b/bitchat/Services/BLE/BLEDirectedRelaySpool.swift deleted file mode 100644 index 9f890eb6..00000000 --- a/bitchat/Services/BLE/BLEDirectedRelaySpool.swift +++ /dev/null @@ -1,71 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEDirectedRelaySpoolEntry { - let recipient: PeerID - let packet: BitchatPacket -} - -struct BLEDirectedRelaySpool { - private struct StoredPacket { - let packet: BitchatPacket - let enqueuedAt: Date - } - - private var packetsByRecipient: [PeerID: [String: StoredPacket]] = [:] - - var isEmpty: Bool { - packetsByRecipient.isEmpty - } - - var count: Int { - packetsByRecipient.values.reduce(0) { $0 + $1.count } - } - - @discardableResult - mutating func enqueue( - packet: BitchatPacket, - recipient: PeerID, - messageID: String, - enqueuedAt: Date - ) -> Bool { - var packets = packetsByRecipient[recipient] ?? [:] - guard packets[messageID] == nil else { - return false - } - - packets[messageID] = StoredPacket(packet: packet, enqueuedAt: enqueuedAt) - packetsByRecipient[recipient] = packets - return true - } - - mutating func drainUnexpired(now: Date, window: TimeInterval) -> [BLEDirectedRelaySpoolEntry] { - var entries: [BLEDirectedRelaySpoolEntry] = [] - - for (recipient, packets) in packetsByRecipient { - for stored in packets.values where now.timeIntervalSince(stored.enqueuedAt) <= window { - entries.append(BLEDirectedRelaySpoolEntry(recipient: recipient, packet: stored.packet)) - } - } - - packetsByRecipient.removeAll() - return entries - } - - mutating func pruneExpired(now: Date, window: TimeInterval) { - guard !packetsByRecipient.isEmpty else { return } - - var pruned: [PeerID: [String: StoredPacket]] = [:] - for (recipient, packets) in packetsByRecipient { - let freshPackets = packets.filter { now.timeIntervalSince($0.value.enqueuedAt) <= window } - if !freshPackets.isEmpty { - pruned[recipient] = freshPackets - } - } - packetsByRecipient = pruned - } - - mutating func removeAll() { - packetsByRecipient.removeAll() - } -} diff --git a/bitchat/Services/BLE/BLEEngineScheduler.swift b/bitchat/Services/BLE/BLEEngineScheduler.swift deleted file mode 100644 index ab9ebbe8..00000000 --- a/bitchat/Services/BLE/BLEEngineScheduler.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -/// Schedules deferred engine work: relay jitter, announce delays, protocol -/// deadlines (ping, capability proof), notification retry backoff, and -/// fragment pacing. -/// -/// This is the transport's only source of engine-side delay. Production -/// wraps the engine queue's `asyncAfter`; tests inject a manually advanced -/// clock so timer-driven behavior is asserted deterministically instead of -/// racing the wall clock — product constants used as deadlines are exactly -/// the hidden-elapsed-deadline flake class the test-timing hygiene rules -/// exist to contain. -protocol BLEEngineScheduling: AnyObject { - /// Called once by the transport with its engine queue. Scheduled work - /// always executes there: deferred bodies touch engine-confined state. - func activate(engineQueue: DispatchQueue) - /// Runs `work` on the engine queue after `delay`, honoring - /// `DispatchWorkItem` cancellation. - func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) -} - -extension BLEEngineScheduling { - func schedule(after delay: TimeInterval, _ body: @escaping () -> Void) { - schedule(after: delay, execute: DispatchWorkItem(block: body)) - } -} - -/// Production scheduler: a thin veneer over the engine queue. -final class BLEEngineDispatchScheduler: BLEEngineScheduling { - private var queue: DispatchQueue? - - func activate(engineQueue: DispatchQueue) { - queue = engineQueue - } - - func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) { - queue?.asyncAfter(deadline: .now() + delay, execute: work) - } -} diff --git a/bitchat/Services/BLE/BLEFanoutSelector.swift b/bitchat/Services/BLE/BLEFanoutSelector.swift deleted file mode 100644 index 8cc8aefe..00000000 --- a/bitchat/Services/BLE/BLEFanoutSelector.swift +++ /dev/null @@ -1,246 +0,0 @@ -import BitFoundation -import CryptoKit -import Foundation - -struct BLEFanoutSelection: Equatable { - let peripheralIDs: Set - let centralIDs: Set -} - -enum BLEFanoutSelector { - static func selectLinks( - peripheralIDs: [String], - centralIDs: [String], - ingressLink: BLEIngressLinkID?, - excludedLinks: Set = [], - peripheralPeerBindings: [String: PeerID] = [:], - centralPeerBindings: [String: PeerID] = [:], - preferredPeripheralPerPeer: [PeerID: String] = [:], - collapseDuplicatePeerLinks: Bool = true, - directedPeerHint: PeerID?, - requireDirectPeerLink: Bool = false, - packetType: UInt8, - messageID: String - ) -> BLEFanoutSelection { - let rawAllowed = allowedLinks( - peripheralIDs: peripheralIDs, - centralIDs: centralIDs, - ingressLink: ingressLink, - excludedLinks: excludedLinks - ) - - if let directedPeerHint, - let directedSelection = directLinks( - to: directedPeerHint, - links: rawAllowed, - peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: centralPeerBindings, - preferredPeripheralPerPeer: preferredPeripheralPerPeer - ) { - return directedSelection - } - if directedPeerHint != nil, requireDirectPeerLink { - return BLEFanoutSelection(peripheralIDs: [], centralIDs: []) - } - if let directedPeerHint, - hasBoundLink( - to: directedPeerHint, - peripheralIDs: peripheralIDs, - centralIDs: centralIDs, - peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: centralPeerBindings - ) { - return BLEFanoutSelection(peripheralIDs: [], centralIDs: []) - } - - // Direct announces are the packet that binds a link to its peer - // (BLEService's raw bind and verified rebind). Collapsing them per - // peer starves duplicate same-peer links of the announce they need to - // become bound — the duplicates then look "pre-announce" forever and - // every broadcast sprays down all of them. Announces are small and - // throttled, so they go on every live link. - let allowed = collapseDuplicatePeerLinks - ? collapseDuplicateLinksPerPeer( - rawAllowed, - peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: centralPeerBindings, - preferredPeripheralPerPeer: preferredPeripheralPerPeer - ) - : rawAllowed - - guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else { - return BLEFanoutSelection( - peripheralIDs: Set(allowed.peripheralIDs), - centralIDs: Set(allowed.centralIDs) - ) - } - - return BLEFanoutSelection( - peripheralIDs: deterministicSubset( - ids: allowed.peripheralIDs, - k: subsetSize(for: allowed.peripheralIDs.count), - seed: messageID - ), - centralIDs: deterministicSubset( - ids: allowed.centralIDs, - k: subsetSize(for: allowed.centralIDs.count), - seed: messageID - ) - ) - } - - private static func allowedLinks( - peripheralIDs: [String], - centralIDs: [String], - ingressLink: BLEIngressLinkID?, - excludedLinks: Set - ) -> (peripheralIDs: [String], centralIDs: [String]) { - var allowedPeripheralIDs = peripheralIDs - var allowedCentralIDs = centralIDs - var blockedLinks = excludedLinks - - if let ingressLink { - blockedLinks.insert(ingressLink) - } - - allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) } - allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) } - - return (allowedPeripheralIDs, allowedCentralIDs) - } - - private static func directLinks( - to peerID: PeerID, - links: (peripheralIDs: [String], centralIDs: [String]), - peripheralPeerBindings: [String: PeerID], - centralPeerBindings: [String: PeerID], - preferredPeripheralPerPeer: [PeerID: String] - ) -> BLEFanoutSelection? { - let directLinks = collapseDuplicateLinksPerPeer( - ( - peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID }, - centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID } - ), - peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: centralPeerBindings, - preferredPeripheralPerPeer: preferredPeripheralPerPeer - ) - - guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else { - return nil - } - - return BLEFanoutSelection( - peripheralIDs: Set(directLinks.peripheralIDs), - centralIDs: Set(directLinks.centralIDs) - ) - } - - private static func hasBoundLink( - to peerID: PeerID, - peripheralIDs: [String], - centralIDs: [String], - peripheralPeerBindings: [String: PeerID], - centralPeerBindings: [String: PeerID] - ) -> Bool { - peripheralIDs.contains { peripheralPeerBindings[$0] == peerID } - || centralIDs.contains { centralPeerBindings[$0] == peerID } - } - - // Dual-role pairs hold two live links (we-as-central writing to their - // peripheral, and they-as-central subscribed to ours). Sending the same - // packet down both doubles airtime for nothing — the receiver's assembler - // and deduplicator just discard the copy. Keep one link per bound peer, - // preferring the peripheral (write) side: it has per-link flow control - // via canSendWriteWithoutResponse, while notifications share the - // peripheral manager's update queue across all centrals. Links with no - // bound peer yet (pre-announce) pass through untouched. - private static func collapseDuplicateLinksPerPeer( - _ links: (peripheralIDs: [String], centralIDs: [String]), - peripheralPeerBindings: [String: PeerID], - centralPeerBindings: [String: PeerID], - preferredPeripheralPerPeer: [PeerID: String] - ) -> (peripheralIDs: [String], centralIDs: [String]) { - guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else { - return links - } - - var seenPeers = Set() - var keptPeripheralIDs: [String] = [] - // When a peer has several bound peripheral links (duplicate - // connections after a restore), collapse onto its preferred one (the - // most recently bound) instead of dictionary order — an arbitrary - // pick could route a peer's single collapsed copy down a stale link. - for id in links.peripheralIDs { - guard let peer = peripheralPeerBindings[id], - preferredPeripheralPerPeer[peer] == id, - seenPeers.insert(peer).inserted else { continue } - keptPeripheralIDs.append(id) - } - for id in links.peripheralIDs { - if let peer = peripheralPeerBindings[id] { - if preferredPeripheralPerPeer[peer] == id { continue } - if !seenPeers.insert(peer).inserted { continue } - } - keptPeripheralIDs.append(id) - } - - // Known limitation: centrals collapse in subscription order (oldest - // first) — there is no recency signal like the peripheral reverse - // map. A central-only peer with duplicate subscriptions rides the - // oldest one until the remote side (which owns those connections) - // consolidates on its next verified announce (bounded by its - // retirement cooldown). - var keptCentralIDs: [String] = [] - for id in links.centralIDs { - if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted { - continue - } - keptCentralIDs.append(id) - } - - return (keptPeripheralIDs, keptCentralIDs) - } - - private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool { - directedPeerHint == nil - && packetType != MessageType.fragment.rawValue - && packetType != MessageType.announce.rawValue - && packetType != MessageType.requestSync.rawValue - } - - private static func subsetSize(for count: Int) -> Int { - guard count > 0 else { return 0 } - if count <= 2 { return count } - - var value = count - 1 - var bits = 0 - while value > 0 { - value >>= 1 - bits += 1 - } - return min(count, max(1, bits + 1)) - } - - private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set { - guard k > 0 && ids.count > k else { return Set(ids) } - - var scored: [(score: [UInt8], id: String)] = [] - for id in ids { - let data = (seed + "::" + id).data(using: .utf8) ?? Data() - let digest = Array(SHA256.hash(data: data)) - scored.append((digest, id)) - } - - scored.sort { lhs, rhs in - for index in 0.. PeerID - /// Local nickname used for sender resolution and collision checks. - let localNickname: () -> String - /// Snapshot of known peers keyed by ID (registry read). - let peersSnapshot: () -> [PeerID: BLEPeerInfo] - /// Verifies a packet's signature against a candidate signing key (registry path). - let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool - /// Local signing key used to authenticate our own gossip-sync replays. - let localSigningPublicKey: () -> Data - /// Resolves a display name from a verified packet signature for peers missing from the registry. - let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? - /// Tracks the broadcast file packet for gossip sync. - let trackPacketSeen: (BitchatPacket) -> Void - /// Enforces the incoming-media storage quota before saving (BCH-01-002). - let enforceStorageQuota: (_ reservingBytes: Int) -> Void - /// Persists the validated file to the incoming-media store; returns the destination URL. - let saveIncomingFile: ( - _ data: Data, - _ preferredName: String?, - _ subdirectory: String, - _ fallbackExtension: String?, - _ defaultPrefix: String - ) -> URL? - /// Resolves the durable receiver decision for a stable private-media ID. - let privateMediaReceiptState: ( - _ messageID: String - ) -> BLEPrivateMediaReceiptState - /// Atomically records a stable private-media ID after the payload save. - let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool - /// Rolls back a saved payload when its durable receipt commit fails. - let removeIncomingFile: (_ storedURL: URL) -> Void - /// Releases the allocator's save-to-UI ownership guard after synchronous - /// conversation insertion has completed. - let finishIncomingFileDelivery: (_ storedURL: URL) -> Void - /// Checks the authenticated sender before any private-media disk work. - let isPrivateMediaSenderBlocked: (PeerID) -> Bool - /// Updates the registry last-seen timestamp for the peer (async barrier write). - let updatePeerLastSeen: (PeerID) -> Void - /// Acknowledges stable private media only after its synchronous - /// conversation delivery has completed. - let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void - /// Delivers `.messageReceived` as one main-actor hop while - /// `shouldDeliver` remains true before and after the synchronous sink. - /// The completion authorizes the stable-media ACK. Finalization runs after - /// every delivery attempt, including rejection, so allocator ownership - /// cannot leak indefinitely. - let deliverMessage: ( - _ message: BitchatMessage, - _ shouldDeliver: @escaping () -> Bool, - _ completion: @escaping () -> Void, - _ finalization: @escaping (TransportEventDeliveryOutcome) -> Void - ) -> Void -} - -/// Process-lifetime reservation cache for stable private-media IDs. -/// -/// The first arrival reserves its ID before quota enforcement. Concurrent -/// arrivals remain coalesced in memory, while accepted state is resolved from -/// the durable ID-to-file ledger so it survives relaunch and becomes retryable -/// if quota cleanup removed the file. -private final class PrivateMediaArrivalDeduplicator { - enum Reservation { - case reserved - case pending - case accepted(URL) - case tombstoned - case unavailable - } - - private let lock = NSLock() - private var pending: Set = [] - - func reserve( - _ messageID: String, - receiptState: () -> BLEPrivateMediaReceiptState - ) -> Reservation { - lock.lock() - defer { lock.unlock() } - if pending.contains(messageID) { - return .pending - } - - switch receiptState() { - case .accepted(let existingURL): - return .accepted(existingURL) - case .tombstoned: - return .tombstoned - case .unavailable: - return .unavailable - case .absent: - break - } - - pending.insert(messageID) - return .reserved - } - - func finish(_ messageID: String) { - lock.lock() - defer { lock.unlock() } - pending.remove(messageID) - } -} - -/// Orchestrates inbound file transfers: self-echo policy, sender display-name -/// resolution, delivery planning, payload validation, quota-checked storage, -/// and UI delivery. -final class BLEFileTransferHandler { - private let environment: BLEFileTransferHandlerEnvironment - private let privateMediaArrivals = PrivateMediaArrivalDeduplicator() - - init(environment: BLEFileTransferHandlerEnvironment) { - self.environment = environment - } - - /// Returns `false` when the raw packet fails sender authentication (or is - /// a live self-echo) and must not be relayed onward. Authentication runs - /// before the routing decision, so a forged directed packet cannot use a - /// node that is not its recipient as an unsigned forwarding hop. - @discardableResult - func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { - let env = environment - let localPeerID = env.localPeerID() - let peersSnapshot = env.peersSnapshot() - - guard let senderNickname = authenticatedRawSenderNickname( - packet: packet, - from: peerID, - peers: peersSnapshot, - env: env - ) else { - SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))…", category: .security) - return false - } - - if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) { - return false - } - - guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else { - return true - } - - if deliveryPlan.shouldTrackForSync { - env.trackPacketSeen(packet) - } - - _ = storeIncomingPayload( - packet.payload, - from: peerID, - senderNickname: senderNickname, - timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000), - isPrivate: deliveryPlan.isPrivateMessage, - usesDurableReceipts: false, - env: env - ) - // Once authenticated, a local decode/quota/save failure is not proof - // that downstream nodes should be denied the valid signed packet. - return true - } - - /// Accepts a file packet only after it has been authenticated and - /// decrypted by the peer's Noise session. The inner packet deliberately - /// has no redundant signature: Noise supplies sender authentication and - /// confidentiality, while this handler retains the same validation, - /// quota, persistence, and UI-delivery behavior as public files. - @discardableResult - func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool { - let env = environment - let peers = env.peersSnapshot() - let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: env.localPeerID(), - localNickname: env.localNickname(), - peers: peers, - allowConnectedUnverified: true - ) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID) - - return storeIncomingPayload( - payload, - from: peerID, - senderNickname: senderNickname, - timestamp: timestamp, - isPrivate: true, - // Every authenticated Noise private-file keeps the stable ID/ACK - // contract introduced with capability bit 8. Bit 9 advertises - // sender-side automatic retry support; it must not downgrade - // prior iOS clients to random IDs or single-check delivery. - usesDurableReceipts: true, - env: env - ) - } - - private func storeIncomingPayload( - _ payload: Data, - from peerID: PeerID, - senderNickname: String, - timestamp: Date, - isPrivate: Bool, - usesDurableReceipts: Bool, - env: BLEFileTransferHandlerEnvironment - ) -> Bool { - - let localPeerID = env.localPeerID() - let filePacket: BitchatFilePacket - let mime: MimeType - switch BLEIncomingFileValidator.validate(payload: payload) { - case .success(let acceptance): - filePacket = acceptance.filePacket - mime = acceptance.mime - case .failure(.malformedPayload): - SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) - return false - case .failure(.payloadTooLarge(let bytes)): - SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) - return false - case .failure(.unsupportedMime(let mimeType, let bytes)): - SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) - return false - case .failure(.magicMismatch(let mime, let bytes, let prefixHex)): - SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) - return false - } - - if isPrivate, env.isPrivateMediaSenderBlocked(peerID) { - SecureLogger.debug( - "🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write", - category: .security - ) - return true - } - - let messageID = usesDurableReceipts - ? PrivateMediaMessageIdentity.stableID( - for: filePacket, - senderPeerID: peerID, - recipientPeerID: localPeerID - ) - : nil - if let messageID { - switch privateMediaArrivals.reserve( - messageID, - receiptState: { env.privateMediaReceiptState(messageID) } - ) { - case .reserved: - break - case .pending: - // The first arrival has not reached durable storage yet. - // Coalesce this retry without ACKing so a failed first save - // remains retryable by the sender. - SecureLogger.debug( - "📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…", - category: .session - ) - return true - case .accepted(let existingFile): - env.updatePeerLastSeen(peerID) - let message = incomingMessage( - messageID: messageID, - senderNickname: senderNickname, - timestamp: timestamp, - isPrivate: true, - peerID: peerID, - destination: existingFile, - category: storedMediaCategory( - for: existingFile, - fallback: mime.category - ), - env: env - ) - SecureLogger.debug( - "📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)", - category: .session - ) - deliverStableMessage( - message, - messageID: messageID, - peerID: peerID, - expectedURL: existingFile, - env: env - ) - return true - case .tombstoned: - // Explicit deletion is a durable terminal receiver decision. - env.updatePeerLastSeen(peerID) - env.acknowledgePrivateMedia(messageID, peerID) - SecureLogger.debug( - "📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…", - category: .session - ) - return true - case .unavailable: - // Never turn an unreadable ledger into an empty ledger. A - // directory-level failure clears on retry; a quarantined - // record keeps exactly this ID fail-closed while every other - // payload still flows. - SecureLogger.warning( - "📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable", - category: .session - ) - return true - } - } - defer { - if let messageID { - privateMediaArrivals.finish(messageID) - } - } - - // BCH-01-002: Enforce storage quota before saving - env.enforceStorageQuota(filePacket.content.count) - - guard let destination = env.saveIncomingFile( - filePacket.content, - filePacket.fileName, - "\(mime.category.mediaDir)/incoming", - mime.defaultExtension, - mime.category.rawValue - ) else { - return false - } - - if let messageID, - !env.commitPrivateMediaFile(messageID, destination) { - // A payload without its durable ID mapping cannot safely suppress - // a retry after relaunch. Roll it back and withhold UI/ACK. - env.removeIncomingFile(destination) - return false - } - - if isPrivate { - env.updatePeerLastSeen(peerID) - } - - let message = incomingMessage( - messageID: messageID, - senderNickname: senderNickname, - timestamp: timestamp, - isPrivate: isPrivate, - peerID: peerID, - destination: destination, - category: mime.category, - env: env - ) - - SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) - - if let messageID { - deliverStableMessage( - message, - messageID: messageID, - peerID: peerID, - expectedURL: destination, - env: env - ) - } else { - env.deliverMessage( - message, - { true }, - {}, - { outcome in - if outcome == .rejected { - // Raw media has no durable receipt that can redeliver - // it later. Do not leave a newly saved, UI-unowned file - // available for a stale fallback path to misidentify. - env.removeIncomingFile(destination) - } else { - // Plain delegates are invoked without synchronous - // insertion confirmation. Preserve the payload for - // that supported delivery path. - env.finishIncomingFileDelivery(destination) - } - } - ) - } - return true - } - - private func deliverStableMessage( - _ message: BitchatMessage, - messageID: String, - peerID: PeerID, - expectedURL: URL, - env: BLEFileTransferHandlerEnvironment - ) { - env.deliverMessage( - message, - { - guard case .accepted(let resolvedURL) = - env.privateMediaReceiptState(messageID) else { - return false - } - return resolvedURL.standardizedFileURL - == expectedURL.standardizedFileURL - }, - { - env.acknowledgePrivateMedia(messageID, peerID) - }, - { _ in - env.finishIncomingFileDelivery(expectedURL) - } - ) - } - - private func incomingMessage( - messageID: String?, - senderNickname: String, - timestamp: Date, - isPrivate: Bool, - peerID: PeerID, - destination: URL, - category: MimeType.Category, - env: BLEFileTransferHandlerEnvironment - ) -> BitchatMessage { - BitchatMessage( - id: messageID, - sender: senderNickname, - content: "\(category.messagePrefix)\(destination.lastPathComponent)", - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: isPrivate, - recipientNickname: nil, - senderPeerID: peerID, - // Received messages need an explicit status: BitchatMessage - // defaults private messages to .sending, which media views render - // as an in-flight send. - deliveryStatus: isPrivate - ? .delivered(to: env.localNickname(), at: timestamp) - : nil - ) - } - - /// The durable URL is authoritative during reconstruction. A sender that - /// reuses a stable filename with a different MIME type must not change how - /// the already-stored payload renders. - private func storedMediaCategory( - for url: URL, - fallback: MimeType.Category - ) -> MimeType.Category { - let mediaDirectory = url - .deletingLastPathComponent() - .deletingLastPathComponent() - .lastPathComponent - switch mediaDirectory { - case MimeType.Category.audio.mediaDir: - return .audio - case MimeType.Category.image.mediaDir: - return .image - case MimeType.Category.file.mediaDir: - return .file - default: - return fallback - } - } - - /// Every remaining raw file transfer is signed, regardless of whether it - /// is broadcast, addressed to us, or merely passing through. Registry - /// signing keys are preferred; persisted identities cover peers that have - /// rotated or are not currently present in the registry. - private func authenticatedRawSenderNickname( - packet: BitchatPacket, - from peerID: PeerID, - peers: [PeerID: BLEPeerInfo], - env: BLEFileTransferHandlerEnvironment - ) -> String? { - guard packet.signature != nil else { return nil } - - let localPeerID = env.localPeerID() - let candidateKey = peerID == localPeerID - ? env.localSigningPublicKey() - : peers[peerID]?.signingPublicKey - let verifiedWithKnownKey = candidateKey.map { - env.verifyPacketSignature(packet, $0) - } ?? false - let signedDisplayName = verifiedWithKnownKey - ? nil - : env.signedSenderDisplayName(packet, peerID) - guard verifiedWithKnownKey || signedDisplayName != nil else { return nil } - - return BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: localPeerID, - localNickname: env.localNickname(), - peers: peers, - // The packet signature authenticates the announced peer; the old - // connected-but-unsigned leniency is not involved. - allowConnectedUnverified: true - ) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID) - } -} diff --git a/bitchat/Services/BLE/BLEFileTransferPolicy.swift b/bitchat/Services/BLE/BLEFileTransferPolicy.swift deleted file mode 100644 index 0eedc429..00000000 --- a/bitchat/Services/BLE/BLEFileTransferPolicy.swift +++ /dev/null @@ -1,71 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEFileTransferDeliveryPlan: Equatable { - let isPrivateMessage: Bool - let shouldTrackForSync: Bool -} - -enum BLEFileTransferPolicy { - static func isSelfEcho(packet: BitchatPacket, from peerID: PeerID, localPeerID: PeerID) -> Bool { - peerID == localPeerID && packet.ttl != 0 - } - - static func deliveryPlan(packet: BitchatPacket, localPeerID: PeerID) -> BLEFileTransferDeliveryPlan? { - guard let recipientID = packet.recipientID else { - return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true) - } - - let isBroadcast = recipientID.allSatisfy { $0 == 0xFF } - if isBroadcast { - return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true) - } - - guard PeerID(hexData: recipientID) == localPeerID else { - return nil - } - - return BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false) - } -} - -struct BLEIncomingFileAcceptance { - let filePacket: BitchatFilePacket - let mime: MimeType -} - -enum BLEIncomingFileRejection: Error, Equatable { - case malformedPayload - case payloadTooLarge(bytes: Int) - case unsupportedMime(mimeType: String?, bytes: Int) - case magicMismatch(mime: MimeType, bytes: Int, prefixHex: String) -} - -enum BLEIncomingFileValidator { - static func validate(payload: Data) -> Result { - guard let filePacket = BitchatFilePacket.decode(payload) else { - return .failure(.malformedPayload) - } - - guard FileTransferLimits.isValidPayload(filePacket.content.count) else { - return .failure(.payloadTooLarge(bytes: filePacket.content.count)) - } - - guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else { - return .failure(.unsupportedMime( - mimeType: filePacket.mimeType, - bytes: filePacket.content.count - )) - } - - guard mime.matches(data: filePacket.content) else { - return .failure(.magicMismatch( - mime: mime, - bytes: filePacket.content.count, - prefixHex: filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ") - )) - } - - return .success(BLEIncomingFileAcceptance(filePacket: filePacket, mime: mime)) - } -} diff --git a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift deleted file mode 100644 index 31656cb4..00000000 --- a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift +++ /dev/null @@ -1,214 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEFragmentKey: Hashable, Equatable { - let sender: UInt64 - let id: UInt64 -} - -struct BLEFragmentHeader: Equatable { - let key: BLEFragmentKey - let index: Int - let total: Int - let originalType: UInt8 - let fragmentData: Data - let isBroadcastFragment: Bool - - var idLogString: String { - String(format: "%016llx", key.id) - } - - init?(packet: BitchatPacket) { - // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type. - guard packet.payload.count >= 13 else { return nil } - - var senderU64: UInt64 = 0 - for byte in packet.senderID.prefix(8) { - senderU64 = (senderU64 << 8) | UInt64(byte) - } - - var fragmentU64: UInt64 = 0 - for byte in packet.payload.prefix(8) { - fragmentU64 = (fragmentU64 << 8) | UInt64(byte) - } - - let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9])) - let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11])) - - guard total > 0 && total <= 10_000 && index >= 0 && index < total else { - return nil - } - - let isBroadcastFragment: Bool = { - guard let recipient = packet.recipientID else { return true } - return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF } - }() - - self.key = BLEFragmentKey(sender: senderU64, id: fragmentU64) - self.index = index - self.total = total - self.originalType = packet.payload[12] - self.fragmentData = Data(packet.payload.suffix(from: 13)) - self.isBroadcastFragment = isBroadcastFragment - } -} - -struct BLEFragmentAssemblyBuffer { - enum AppendResult: Equatable { - case stored(header: BLEFragmentHeader, started: Bool) - case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool) - case oversized(header: BLEFragmentHeader, projectedSize: Int, limit: Int, started: Bool) - } - - private struct Metadata { - let total: Int - let timestamp: Date - let isBroadcast: Bool - var lastFragmentAt: Date - var lastResyncRequestAt: Date? - } - - private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:] - private var metadataByKey: [BLEFragmentKey: Metadata] = [:] - - mutating func removeAll() { - fragmentsByKey.removeAll() - metadataByKey.removeAll() - } - - @discardableResult - mutating func removeExpired(before cutoff: Date) -> Int { - let expiredKeys = metadataByKey - .filter { $0.value.timestamp < cutoff } - .map(\.key) - - for key in expiredKeys { - fragmentsByKey.removeValue(forKey: key) - metadataByKey.removeValue(forKey: key) - } - - return expiredKeys.count - } - - mutating func append( - _ header: BLEFragmentHeader, - maxInFlightAssemblies: Int, - now: Date = Date() - ) -> AppendResult { - let started = startAssemblyIfNeeded(for: header, maxInFlightAssemblies: maxInFlightAssemblies, now: now) - - let currentSize = fragmentsByKey[header.key]?.values.reduce(0) { $0 + $1.count } ?? 0 - let limit = Self.assemblyLimit(for: header.originalType) - let projectedSize = currentSize + header.fragmentData.count - - guard projectedSize <= limit else { - fragmentsByKey.removeValue(forKey: header.key) - metadataByKey.removeValue(forKey: header.key) - return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started) - } - - // Only actual progress resets the stall clock: fragment packets - // bypass the packet deduplicator, so relayed duplicates of an - // already-held index must not keep suppressing the targeted - // REQUEST_SYNC for a stalled stream. - let isNewIndex = fragmentsByKey[header.key]?[header.index] == nil - fragmentsByKey[header.key]?[header.index] = header.fragmentData - if isNewIndex { - metadataByKey[header.key]?.lastFragmentAt = now - } - - guard let fragments = fragmentsByKey[header.key], - fragments.count == header.total else { - return .stored(header: header, started: started) - } - - let reassembled = (0.. Bool { - guard fragmentsByKey[header.key] == nil else { return false } - - if fragmentsByKey.count >= maxInFlightAssemblies, - let oldest = metadataByKey.min(by: { $0.value.timestamp < $1.value.timestamp })?.key { - fragmentsByKey.removeValue(forKey: oldest) - metadataByKey.removeValue(forKey: oldest) - } - - fragmentsByKey[header.key] = [:] - metadataByKey[header.key] = Metadata( - total: header.total, - timestamp: now, - isBroadcast: header.isBroadcastFragment, - lastFragmentAt: now - ) - return true - } - - /// Fragment stream IDs (8-byte, big-endian) of incomplete broadcast - /// reassemblies that have not seen a new fragment for `stalledAfter` - /// seconds — candidates for a targeted REQUEST_SYNC. Each returned - /// stream is marked so it is not re-requested within `retryAfter`. - /// At most `RequestSyncPacket.maxFragmentIdFilterCount` streams are - /// returned per pass — the wire filter cannot carry more — selected - /// oldest-stall first; overflow streams stay unmarked and eligible for - /// the next pass. Directed reassemblies are excluded: peers only archive - /// broadcast fragments for gossip sync, so a targeted request cannot - /// recover them. - mutating func stalledBroadcastFragmentIDs( - stalledAfter: TimeInterval, - retryAfter: TimeInterval, - now: Date = Date() - ) -> [Data] { - var candidates: [(key: BLEFragmentKey, lastFragmentAt: Date)] = [] - for (key, metadata) in metadataByKey { - guard metadata.isBroadcast, - let fragments = fragmentsByKey[key], - fragments.count < metadata.total, - now.timeIntervalSince(metadata.lastFragmentAt) >= stalledAfter else { continue } - if let lastRequest = metadata.lastResyncRequestAt, - now.timeIntervalSince(lastRequest) < retryAfter { continue } - candidates.append((key: key, lastFragmentAt: metadata.lastFragmentAt)) - } - - // Mark only the streams that will actually go on the wire, so the - // overflow is not silently suppressed for `retryAfter`. - let selected = candidates - .sorted { - if $0.lastFragmentAt != $1.lastFragmentAt { - return $0.lastFragmentAt < $1.lastFragmentAt - } - return ($0.key.sender, $0.key.id) < ($1.key.sender, $1.key.id) - } - .prefix(RequestSyncPacket.maxFragmentIdFilterCount) - - return selected.map { candidate in - metadataByKey[candidate.key]?.lastResyncRequestAt = now - return withUnsafeBytes(of: candidate.key.id.bigEndian) { Data($0) } - } - } - - private static func assemblyLimit(for originalType: UInt8) -> Int { - if originalType == MessageType.fileTransfer.rawValue - || originalType == MessageType.noiseEncrypted.rawValue { - // Allow headroom for TLV metadata and binary framing overhead. - // A large noiseEncrypted packet can be an E2E-encrypted private - // file; its authenticated plaintext is validated after decrypt. - return FileTransferLimits.maxFramedFileBytes - } - - return FileTransferLimits.maxPayloadBytes - } -} diff --git a/bitchat/Services/BLE/BLEFragmentHandler.swift b/bitchat/Services/BLE/BLEFragmentHandler.swift deleted file mode 100644 index 9d913151..00000000 --- a/bitchat/Services/BLE/BLEFragmentHandler.swift +++ /dev/null @@ -1,102 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// Narrow environment for `BLEFragmentHandler`. -/// -/// All queue hops (the message-queue entry hop and the collections barrier -/// around the assembly buffer) live on the `BLEService` side — the entry hop -/// in `BLEService.handleFragment`, the barrier inside the supplied closures — -/// keeping the handler queue-agnostic and synchronously testable. -struct BLEFragmentHandlerEnvironment { - /// Local peer identity at the time the fragment is handled. - let localPeerID: () -> PeerID - /// Tracks broadcast fragments for gossip sync. - let trackPacketSeen: (BitchatPacket) -> Void - /// Appends the fragment to the assembly buffer (collections barrier write). - let appendFragment: (BLEFragmentHeader) -> BLEFragmentAssemblyBuffer.AppendResult - /// Ingress acceptance check for the reassembled inner packet. - let isAcceptedIngressPayload: (_ packet: BitchatPacket, _ innerSender: PeerID) -> Bool - /// Re-enters the receive pipeline with the reassembled packet (TTL already zeroed). - let processReassembledPacket: (_ packet: BitchatPacket, _ from: PeerID) -> Void -} - -/// Orchestrates inbound fragments: self-fragment suppression, gossip tracking, -/// assembly-buffer appends, and reassembled-packet validation and re-injection -/// into the receive pipeline. -final class BLEFragmentHandler { - private let environment: BLEFragmentHandlerEnvironment - - init(environment: BLEFragmentHandlerEnvironment) { - self.environment = environment - } - - func handle(_ packet: BitchatPacket, from peerID: PeerID) { - let env = environment - guard let header = BLEFragmentHeader(packet: packet) else { return } - - // Sync replay legitimately hands us our own fragments back (the RSR - // ttl=0 restore path): after a relaunch the fragment store starts - // empty, so our sync filter doesn't cover them and peers re-offer - // them. Record them as seen — the next round's filter then covers - // them and the redelivery stops — but skip assembly: we authored - // the original, there is nothing to reassemble. - if peerID == env.localPeerID() { - if header.isBroadcastFragment { - env.trackPacketSeen(packet) - } - return - } - - if header.isBroadcastFragment { - env.trackPacketSeen(packet) - } - - let assemblyResult = env.appendFragment(header) - - logFragmentAssemblyResult(assemblyResult) - - guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return } - - // Decode the original packet bytes we reassembled, so flags/compression are preserved - if var originalPacket = BinaryProtocol.decode(reassembled) { - - // Reassembled packet validation - let innerSender = PeerID(hexData: originalPacket.senderID) - if !env.isAcceptedIngressPayload(originalPacket, innerSender) { - // Cleanup below - } else { - SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session) - originalPacket.ttl = 0 - env.processReassembledPacket(originalPacket, peerID) - } - } else { - SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session) - } - } - - private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) { - func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) { - if started { - SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session) - } - } - - switch result { - case let .stored(header, started): - logStartedIfNeeded(header: header, started: started) - SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session) - - case let .complete(header, _, started): - logStartedIfNeeded(header: header, started: started) - SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session) - - case let .oversized(header, projectedSize, limit, started): - logStartedIfNeeded(header: header, started: started) - SecureLogger.warning( - "🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)", - category: .security - ) - } - } -} diff --git a/bitchat/Services/BLE/BLEInboundWriteBuffer.swift b/bitchat/Services/BLE/BLEInboundWriteBuffer.swift deleted file mode 100644 index 155a16d6..00000000 --- a/bitchat/Services/BLE/BLEInboundWriteBuffer.swift +++ /dev/null @@ -1,70 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEInboundWriteChunk: Equatable { - let offset: Int - let data: Data -} - -struct BLEInboundWriteAppendMetadata: Equatable { - let accumulatedBytes: Int - let appendedBytes: Int - let offsets: [Int] - let packetType: UInt8? -} - -struct BLEInboundWriteBuffer { - enum AppendResult { - case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata) - case waiting(metadata: BLEInboundWriteAppendMetadata) - case oversized(metadata: BLEInboundWriteAppendMetadata) - } - - private var buffersByCentralID: [String: Data] = [:] - - mutating func removeAll() { - buffersByCentralID.removeAll() - } - - mutating func append( - chunks: [BLEInboundWriteChunk], - for centralID: String, - capBytes: Int - ) -> AppendResult { - var combined = buffersByCentralID[centralID] ?? Data() - var appendedBytes = 0 - var offsets: [Int] = [] - - for chunk in chunks where !chunk.data.isEmpty { - offsets.append(chunk.offset) - let end = chunk.offset + chunk.data.count - - if combined.count < end { - combined.append(Data(repeating: 0, count: end - combined.count)) - } - - combined.replaceSubrange(chunk.offset..= 2 ? combined[1] : nil - ) - - if let packet = BinaryProtocol.decode(combined) { - buffersByCentralID.removeValue(forKey: centralID) - return .decoded(packet: packet, metadata: metadata) - } - - guard combined.count <= capBytes else { - buffersByCentralID.removeValue(forKey: centralID) - return .oversized(metadata: metadata) - } - - buffersByCentralID[centralID] = combined - return .waiting(metadata: metadata) - } -} diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift deleted file mode 100644 index 08ee3649..00000000 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ /dev/null @@ -1,876 +0,0 @@ -import BitLogger -import BitFoundation -import Foundation - -struct PanicRecoveryIntent { - let fileMarkerEstablished: Bool - let externalMarkerEstablished: Bool - - var hasDurableMarker: Bool { - fileMarkerEstablished || externalMarkerEstablished - } -} - -/// Small, dependency-injectable transaction surface used by ChatViewModel. -/// Production persists the same intent in two independent locations before -/// any application state is erased. Tests can inject an ephemeral operation -/// set without touching the developer's Application Support directory. -struct PanicRecoveryOperations { - let isPending: () throws -> Bool - let begin: () -> PanicRecoveryIntent - let wipeMedia: (PanicRecoveryIntent) throws -> Void - let complete: () throws -> Void - - static func ephemeral( - wipeMedia: @escaping () throws -> Void = {} - ) -> PanicRecoveryOperations { - PanicRecoveryOperations( - isPending: { false }, - begin: { - PanicRecoveryIntent( - fileMarkerEstablished: false, - externalMarkerEstablished: false - ) - }, - wipeMedia: { _ in try wipeMedia() }, - complete: {} - ) - } - - static func live( - fileStore: BLEIncomingFileStore = BLEIncomingFileStore(), - defaults: UserDefaults = .standard - ) -> PanicRecoveryOperations { - let defaultsKey = "bitchat.panicResetPending" - return PanicRecoveryOperations( - isPending: { - if defaults.bool(forKey: defaultsKey) { - return true - } - return try fileStore.isPanicRecoveryPending() - }, - begin: { - defaults.set(true, forKey: defaultsKey) - let externalMarkerEstablished = - defaults.synchronize() - && defaults.bool(forKey: defaultsKey) - - let fileMarkerEstablished: Bool - do { - try fileStore.markPanicRecoveryPending() - fileMarkerEstablished = true - } catch { - fileMarkerEstablished = false - SecureLogger.error( - "Failed to persist file panic-recovery marker: \(error)", - category: .security - ) - } - - return PanicRecoveryIntent( - fileMarkerEstablished: fileMarkerEstablished, - externalMarkerEstablished: externalMarkerEstablished - ) - }, - wipeMedia: { intent in - try fileStore.panicWipe( - hasDurablePendingMarker: intent.hasDurableMarker - ) - }, - complete: { - // Keep the independent defaults latch until the file marker - // has definitely cleared. Any failure therefore remains - // visible to the next launch. - try fileStore.completePanicRecovery() - defaults.removeObject(forKey: defaultsKey) - guard defaults.synchronize(), - !defaults.bool(forKey: defaultsKey) else { - throw BLEIncomingFileStore.PanicRecoveryError - .externalMarkerCommitFailed - } - } - ) - } -} - -struct BLEIncomingFileStore: @unchecked Sendable { - enum PanicRecoveryError: Error { - case externalMarkerCommitFailed - case markerWriteFailed(Error) - case markerWriteAndMediaWipeFailed( - markerError: Error, - mediaError: Error - ) - } - - struct PrivateMediaDeletionReservation: Sendable { - fileprivate let id: UUID - } - - private final class PayloadCoordination: @unchecked Sendable { - let lock = NSLock() - var pendingDeliveryPaths: Set = [] - var deletionReservations: [UUID: Set] = [:] - } - - private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024 - /// How long managed media may stay on disk. Bounds by age what the quota - /// only bounds by size; see `expireAgedMedia(retention:)`. - static let defaultMediaRetention: TimeInterval = 7 * 24 * 60 * 60 - /// Kept outside `files/` so deleting the media tree cannot erase the - /// fail-closed startup decision before the full panic has committed. - private static let panicRecoveryPendingMarkerFileName = - ".panic-recovery-pending" - /// Compatibility with a short-lived development build that used the - /// media-specific name for the same full-transaction latch. - private static let legacyPanicRecoveryPendingMarkerFileName = - ".panic-media-wipe-pending" - private static let mediaSubdirectories = [ - "voicenotes/incoming", - "voicenotes/outgoing", - "images/incoming", - "images/outgoing", - "files/incoming", - "files/outgoing" - ] - /// Name prefix of in-flight live voice captures (progressively written by - /// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern — - /// deleting one mid-stream unlinks the inode under an open `FileHandle` - /// and kills playback — and the coordinator's startup sweep deletes any - /// orphans a previous session left behind. - static let liveCapturePrefix = "voice_live_" - - /// Media payloads follow the same at-rest posture as the app's other - /// persistence layers (courier, outbox, receipt index): protected until - /// first unlock, so the launch-time retention sweep can still run after - /// a reboot. Applied to the media directories so recordings that save - /// as they go (live captures, `AVAudioRecorder`) inherit it, and stated - /// explicitly at the payload write site like every other store. - static var mediaProtectionAttributes: [FileAttributeKey: Any]? { - #if os(iOS) - return [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] - #else - return nil - #endif - } - - /// Exposed so callers that write progressively into the store's - /// directories (live voice captures) share the same file manager. - let fileManager: FileManager - private let baseDirectory: URL? - private let dateProvider: () -> Date - private let panicMarkerWriter: (Data, URL) throws -> Void - private let quotaBytes: Int64 - private let privateMediaReceipts: BLEPrivateMediaReceiptStore - private let payloadCoordination: PayloadCoordination - - init( - fileManager: FileManager = .default, - baseDirectory: URL? = nil, - dateProvider: @escaping () -> Date = Date.init, - panicMarkerWriter: @escaping (Data, URL) throws -> Void = { - try $0.write(to: $1, options: .atomic) - }, - quotaBytes: Int64 = Self.defaultQuotaBytes - ) { - self.fileManager = fileManager - self.baseDirectory = baseDirectory - self.dateProvider = dateProvider - self.panicMarkerWriter = panicMarkerWriter - self.quotaBytes = max(0, quotaBytes) - self.privateMediaReceipts = BLEPrivateMediaReceiptStore( - fileManager: fileManager, - baseDirectory: baseDirectory, - now: dateProvider - ) - self.payloadCoordination = PayloadCoordination() - } - - /// Panic-wipe every managed incoming and outgoing media artifact before - /// returning. Recreating the directory tree keeps later capture/receive - /// paths usable without allowing a detached cleanup task to race them. - /// - /// Marker persistence and deletion are deliberately separate error - /// domains: even when both durable marker channels fail, deletion is - /// still attempted before this method reports the marker failure. - func panicWipe( - hasDurablePendingMarker: Bool = false - ) throws { - // The receipt index caches tombstones as well as accepted payloads, - // while payload coordination retains save/delete reservations. Always - // invalidate both on return, including partial-failure paths, so no - // pre-panic receiver decision survives after identity reset. - defer { - privateMediaReceipts.resetForPanic() - payloadCoordination.lock.lock() - payloadCoordination.pendingDeliveryPaths.removeAll( - keepingCapacity: false - ) - payloadCoordination.deletionReservations.removeAll( - keepingCapacity: false - ) - payloadCoordination.lock.unlock() - } - - let markerError: Error? - do { - try markPanicRecoveryPending() - markerError = nil - } catch { - markerError = error - SecureLogger.error( - "Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)", - category: .security - ) - } - - do { - let filesDirectory = try rootDirectory() - .appendingPathComponent("files", isDirectory: true) - if fileManager.fileExists(atPath: filesDirectory.path) { - try fileManager.removeItem(at: filesDirectory) - } - for subdirectory in Self.mediaSubdirectories { - try fileManager.createDirectory( - at: filesDirectory.appendingPathComponent( - subdirectory, - isDirectory: true - ), - withIntermediateDirectories: true, - attributes: Self.mediaProtectionAttributes - ) - } - } catch { - if let markerError { - throw PanicRecoveryError.markerWriteAndMediaWipeFailed( - markerError: markerError, - mediaError: error - ) - } - throw error - } - - if let markerError, !hasDurablePendingMarker { - throw PanicRecoveryError.markerWriteFailed(markerError) - } - } - - func markPanicRecoveryPending() throws { - let markerURL = try panicRecoveryPendingMarkerURL() - try fileManager.createDirectory( - at: markerURL.deletingLastPathComponent(), - withIntermediateDirectories: true, - attributes: nil - ) - try panicMarkerWriter(Data([1]), markerURL) - } - - func isPanicRecoveryPending() throws -> Bool { - try panicRecoveryMarkerURLs().contains { - fileManager.fileExists(atPath: $0.path) - } - } - - func completePanicRecovery() throws { - for markerURL in try panicRecoveryMarkerURLs() - where fileManager.fileExists(atPath: markerURL.path) { - try fileManager.removeItem(at: markerURL) - } - } - - /// Resolves (and creates) an incoming-media directory for callers that - /// write progressively instead of via `save` (live voice captures). - func incomingDirectory(subdirectory: String) throws -> URL { - let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true) - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes) - return directory - } - - func save( - data: Data, - preferredName: String?, - subdirectory: String, - fallbackExtension: String?, - defaultPrefix: String - ) -> URL? { - payloadCoordination.lock.lock() - defer { payloadCoordination.lock.unlock() } - - do { - let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true) - try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes) - let sanitized = sanitizedFileName( - preferredName, - defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))", - fallbackExtension: fallbackExtension - ) - let reservedPaths = privateMediaReceipts.reservedPayloadPaths() - let deletionPaths = payloadCoordination - .deletionReservations.values.reduce(into: Set()) { - $0.formUnion($1) - } - let allocationReservations = deletionPaths.union( - payloadCoordination.pendingDeliveryPaths - ) - let destination = uniqueFileURL( - in: base, - fileName: sanitized, - reservedPaths: (reservedPaths ?? []).union( - allocationReservations - ), - forceRandomizedName: reservedPaths == nil - ) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtectionUntilFirstUserAuthentication) - #endif - try data.write(to: destination, options: options) - payloadCoordination.pendingDeliveryPaths.insert( - destination.standardizedFileURL.path - ) - return destination - } catch { - SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session) - return nil - } - } - - /// Drops THIS instance's in-memory receipt index after a panic wipe. - /// - /// `panicWipe` already resets the receipt store it runs on, but the - /// production wipe runs on the `PanicRecoveryOperations.live()` file - /// store while receipt lookups are served by `BLEService`'s own - /// `incomingFileStore`. The service's panic path must invalidate its own - /// cache explicitly or pre-panic decisions survive in memory. - func resetPrivateMediaReceiptsForPanic() { - privateMediaReceipts.resetForPanic() - } - - func privateMediaReceiptState( - messageID: String - ) -> BLEPrivateMediaReceiptState { - privateMediaReceipts.state(for: messageID) - } - - func commitPrivateMediaFile( - messageID: String, - storedURL: URL - ) -> Bool { - privateMediaReceipts.commitAccepted( - messageID: messageID, - storedURL: storedURL - ) - } - - /// Reserves every receipt/UI path before the asynchronous deletion - /// barrier. Allocation and reservation share one lock, so either an - /// in-flight raw arrival is observed and deletion fails closed, or the - /// arrival is forced onto a different filename. - func reservePrivateMediaDeletion( - messageIDs: [String], - payloadRelativePaths: [String: String] - ) -> PrivateMediaDeletionReservation? { - payloadCoordination.lock.lock() - defer { payloadCoordination.lock.unlock() } - - guard let paths = privateMediaReceipts - .prospectiveDeletionPayloadPaths( - messageIDs: messageIDs, - payloadRelativePaths: payloadRelativePaths - ), - paths.isDisjoint( - with: payloadCoordination.pendingDeliveryPaths - ) else { - return nil - } - - let reservation = PrivateMediaDeletionReservation(id: UUID()) - payloadCoordination.deletionReservations[reservation.id] = paths - return reservation - } - - func commitPrivateMediaDeletion( - reservation: PrivateMediaDeletionReservation, - messageIDs: [String], - payloadRelativePaths: [String: String], - protectedPayloadRelativePaths: Set - ) -> Bool { - payloadCoordination.lock.lock() - defer { - payloadCoordination.deletionReservations.removeValue( - forKey: reservation.id - ) - payloadCoordination.lock.unlock() - } - guard payloadCoordination.deletionReservations[reservation.id] != nil - else { - return false - } - return privateMediaReceipts.recordDeleted( - messageIDs: messageIDs, - payloadRelativePaths: payloadRelativePaths, - protectedPayloadRelativePaths: protectedPayloadRelativePaths - ) - } - - /// Explicit deletion of a LEGACY (non-stable-ID) incoming payload. - /// - /// Legacy media has no durable receipt, so the only safe unlink is one - /// that can prove no other owner may hold the basename: the path must - /// not be pending delivery, must not belong to an in-flight deletion - /// reservation, and must not be owned by a stable receipt or journal - /// entry. When any of those hold — or receipt state cannot be read — - /// the file stays for bounded quota cleanup (the fail-safe fallback). - /// Returns true only when the payload was verifiably unlinked. - @discardableResult - func removeLegacyIncomingFile(relativePath: String) -> Bool { - payloadCoordination.lock.lock() - defer { payloadCoordination.lock.unlock() } - - guard let payload = incomingPayloadURL( - relativePath: relativePath - ) else { - return false - } - let standardizedPath = payload.standardizedFileURL.path - let reservedByDeletion = payloadCoordination.deletionReservations - .values.contains { $0.contains(standardizedPath) } - guard !reservedByDeletion, - !payloadCoordination.pendingDeliveryPaths.contains( - standardizedPath - ), - let receiptOwnedPaths = - privateMediaReceipts.reservedPayloadPaths(), - !receiptOwnedPaths.contains(standardizedPath) else { - return false - } - guard fileManager.fileExists(atPath: payload.path), - (try? payload.resourceValues( - forKeys: [.isRegularFileKey] - ).isRegularFile) == true else { - return false - } - do { - try fileManager.removeItem(at: payload) - return !fileManager.fileExists(atPath: payload.path) - } catch { - SecureLogger.warning( - "⚠️ Failed to remove explicitly deleted legacy media: \(error)", - category: .session - ) - return false - } - } - - /// Resolves a `files/`-relative path iff it lands directly inside one of - /// the incoming media directories. Anything else is not a deletable - /// incoming payload. - private func incomingPayloadURL(relativePath: String) -> URL? { - guard !relativePath.isEmpty, - let base = try? filesDirectory().standardizedFileURL else { - return nil - } - let candidate = base - .appendingPathComponent(relativePath, isDirectory: false) - .standardizedFileURL - let parentPath = candidate.deletingLastPathComponent().path - let incomingDirectories = [ - "voicenotes/incoming", - "images/incoming", - "files/incoming" - ] - guard incomingDirectories.contains(where: { relativeDirectory in - base.appendingPathComponent( - relativeDirectory, - isDirectory: true - ).standardizedFileURL.path == parentPath - }) else { - return nil - } - return candidate - } - - /// Releases the short window between disk save and synchronous - /// conversation insertion. Before this callback, a deletion transaction - /// may not infer ownership from a stale bubble that names the same path. - func finishIncomingFileDelivery(at storedURL: URL) { - payloadCoordination.lock.lock() - defer { payloadCoordination.lock.unlock() } - payloadCoordination.pendingDeliveryPaths.remove( - storedURL.standardizedFileURL.path - ) - } - - /// Best-effort rollback for a payload whose durable receipt commit failed. - func removeIncomingFile(at storedURL: URL) { - payloadCoordination.lock.lock() - defer { payloadCoordination.lock.unlock() } - payloadCoordination.pendingDeliveryPaths.remove( - storedURL.standardizedFileURL.path - ) - guard isURLInsideFilesDirectory(storedURL) else { return } - do { - try fileManager.removeItem(at: storedURL) - } catch { - SecureLogger.warning( - "⚠️ Failed to roll back uncommitted incoming media: \(error)", - category: .session - ) - } - } - - /// Frees least-recently-modified incoming files until `reservingBytes` - /// fits under the quota. Files named `voice_live_*` (in-flight live - /// captures) are never evicted regardless of who triggers enforcement — - /// a finalized transfer can arrive at quota while a burst is still - /// streaming — but they still count toward usage. - func enforceQuota(reservingBytes: Int) { - payloadCoordination.lock.lock() - defer { payloadCoordination.lock.unlock() } - - do { - let base = try filesDirectory() - let incomingDirs = [ - base.appendingPathComponent("voicenotes/incoming", isDirectory: true), - base.appendingPathComponent("images/incoming", isDirectory: true), - base.appendingPathComponent("files/incoming", isDirectory: true) - ] - var allFiles: [(url: URL, size: Int64, modified: Date)] = [] - - for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) { - guard let contents = try? fileManager.contentsOfDirectory( - at: dir, - includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { continue } - - for fileURL in contents { - guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]), - let size = attrs.fileSize, - let modified = attrs.contentModificationDate else { continue } - allFiles.append((url: fileURL, size: Int64(size), modified: modified)) - } - } - - let currentUsage = allFiles.reduce(0) { $0 + $1.size } - let targetUsage = quotaBytes - Int64(reservingBytes) - guard currentUsage > targetUsage else { return } - - let needToFree = currentUsage - targetUsage - let activeDeletionPaths = payloadCoordination - .deletionReservations.values.reduce(into: Set()) { - $0.formUnion($1) - } - let protectedPaths = activeDeletionPaths.union( - payloadCoordination.pendingDeliveryPaths - ) - var freedSpace: Int64 = 0 - for file in allFiles.sorted(by: { $0.modified < $1.modified }) { - guard freedSpace < needToFree else { break } - guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue } - guard !protectedPaths.contains( - file.url.standardizedFileURL.path - ) else { - continue - } - do { - try fileManager.removeItem(at: file.url) - freedSpace += file.size - SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security) - } catch { - SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security) - } - } - - if freedSpace > 0 { - SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security) - } - } catch { - SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security) - } - } - - /// Deletes managed media older than `retention`, across both incoming and - /// outgoing directories, and reports how many files went away. - /// - /// The quota sweep above only bounds *size*, and only for incoming files, - /// so a received photo or a sent voice note could sit on disk unbounded in - /// time — long outliving the conversation it belonged to, which is what a - /// seized device gives up. This bounds media by age instead, on the same - /// principle as the courier envelope and gossip archive lifetimes. - /// - /// Honors the same exclusions as quota eviction: in-flight live captures - /// and files reserved by an in-progress delivery or deletion are left - /// alone regardless of age. - @discardableResult - func expireAgedMedia(retention: TimeInterval = Self.defaultMediaRetention) -> Int { - guard retention > 0 else { return 0 } - - payloadCoordination.lock.lock() - defer { payloadCoordination.lock.unlock() } - - let cutoff = dateProvider().addingTimeInterval(-retention) - let activeDeletionPaths = payloadCoordination - .deletionReservations.values.reduce(into: Set()) { - $0.formUnion($1) - } - let protectedPaths = activeDeletionPaths.union( - payloadCoordination.pendingDeliveryPaths - ) - - var removed = 0 - do { - let base = try filesDirectory() - for subdirectory in Self.mediaSubdirectories { - let dir = base.appendingPathComponent(subdirectory, isDirectory: true) - guard fileManager.fileExists(atPath: dir.path) else { continue } - guard let contents = try? fileManager.contentsOfDirectory( - at: dir, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { continue } - - for fileURL in contents { - guard let modified = try? fileURL.resourceValues( - forKeys: [.contentModificationDateKey] - ).contentModificationDate else { continue } - guard modified < cutoff else { continue } - guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue } - guard !protectedPaths.contains( - fileURL.standardizedFileURL.path - ) else { continue } - - do { - try fileManager.removeItem(at: fileURL) - removed += 1 - } catch { - SecureLogger.warning( - "⚠️ Failed to expire aged media file: \(error)", - category: .security - ) - } - } - } - } catch { - SecureLogger.warning( - "⚠️ Could not expire aged media: \(error)", - category: .security - ) - return removed - } - - if removed > 0 { - SecureLogger.info( - "🗑️ Expired \(removed) media file(s) older than the retention window", - category: .security - ) - } - return removed - } - - /// Stamps the media directories and any resident payloads with the - /// explicit protection class, covering files written by builds that - /// relied on the container default. Runs every launch: re-stamping an - /// equal class is a metadata no-op, and anything carrying a stronger - /// class is left alone, so repetition is cheap and can never downgrade. - /// In-flight live captures are skipped for symmetry with the retention - /// sweep; they receive the class at creation and need no repair. - /// Best-effort like the sweep it runs alongside; a file that cannot be - /// stamped is logged, not fatal, and the migration moves on to the next - /// item. Returns the number of items stamped so the launch path and - /// tests can observe coverage. - @discardableResult - func migrateFileProtectionIfNeeded() -> Int { - #if os(iOS) - guard let attributes = Self.mediaProtectionAttributes else { return 0 } - var stamped = 0 - guard let base = try? filesDirectory() else { return 0 } - for subdirectory in Self.mediaSubdirectories { - let dir = base.appendingPathComponent(subdirectory, isDirectory: true) - guard fileManager.fileExists(atPath: dir.path) else { continue } - let files = (try? fileManager.contentsOfDirectory( - at: dir, - includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey], - options: [.skipsHiddenFiles] - )) ?? [] - stamped += stampProtectionIfWeaker(dir, requireRegularFile: false, attributes: attributes) - for fileURL in files { - guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue } - stamped += stampProtectionIfWeaker(fileURL, requireRegularFile: true, attributes: attributes) - } - } - return stamped - #else - return 0 - #endif - } - - #if os(iOS) - /// Applies the class to one item, but only when the item currently sits - /// at the container default or weaker. The list names the classes that - /// are safe to replace; anything else, including classes added in later - /// iOS versions, is left alone. Only regular files are stamped when - /// `requireRegularFile` is set (and only real directories otherwise), - /// matching the caution the legacy-file removal path applies; symlinks - /// and other non-regular files are left untouched. - private func stampProtectionIfWeaker( - _ itemURL: URL, - requireRegularFile: Bool, - attributes: [FileAttributeKey: Any] - ) -> Int { - let values = try? itemURL.resourceValues( - forKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey] - ) - if requireRegularFile { - guard values?.isRegularFile == true else { return 0 } - } else { - guard values?.isDirectory == true else { return 0 } - } - if let current = values?.fileProtection, - current != .none, - current != .completeUntilFirstUserAuthentication { - return 0 - } - do { - try fileManager.setAttributes(attributes, ofItemAtPath: itemURL.path) - return 1 - } catch let error as CocoaError where error.code == .fileNoSuchFile { - // Quota eviction or a deletion commit on another store instance - // can delete an item out from under this migration; that is not - // a failure. - return 0 - } catch { - SecureLogger.warning( - "⚠️ Failed to migrate media file protection: \(error)", - category: .security - ) - return 0 - } - } - #endif - - private func filesDirectory() throws -> URL { - let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true) - try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes) - return filesDir - } - - private func rootDirectory() throws -> URL { - try baseDirectory ?? fileManager.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - } - - private func panicRecoveryPendingMarkerURL() throws -> URL { - try rootDirectory().appendingPathComponent( - Self.panicRecoveryPendingMarkerFileName, - isDirectory: false - ) - } - - private func panicRecoveryMarkerURLs() throws -> [URL] { - let root = try rootDirectory() - return [ - root.appendingPathComponent( - Self.panicRecoveryPendingMarkerFileName, - isDirectory: false - ), - root.appendingPathComponent( - Self.legacyPanicRecoveryPendingMarkerFileName, - isDirectory: false - ) - ] - } - - private func isURLInsideFilesDirectory(_ url: URL) -> Bool { - guard let filesDirectory = try? filesDirectory().standardizedFileURL else { - return false - } - return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/") - } - - private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String { - var candidate = (name ?? "") - .replacingOccurrences(of: "\0", with: "") - .precomposedStringWithCanonicalMapping - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "\\", with: "_") - - let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters) - candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed - if candidate.isEmpty { candidate = defaultName } - if candidate.hasPrefix(".") { candidate = "_" + candidate } - - if candidate.count > 120 { - let ext = (candidate as NSString).pathExtension - let base = (candidate as NSString).deletingPathExtension - candidate = ext.isEmpty - ? String(candidate.prefix(120)) - : String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext - } - - if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty { - candidate += ".\(fallbackExtension)" - } - - return candidate.isEmpty ? defaultName : candidate - } - - private func uniqueFileURL( - in directory: URL, - fileName: String, - reservedPaths: Set, - forceRandomizedName: Bool - ) -> URL { - let directoryPath = directory.standardizedFileURL.path - func isInsideDirectory(_ url: URL) -> Bool { - url.standardizedFileURL.path.hasPrefix(directoryPath + "/") - } - func isAvailable(_ url: URL) -> Bool { - !reservedPaths.contains(url.standardizedFileURL.path) - && !fileManager.fileExists(atPath: url.path) - } - - var candidate = directory.appendingPathComponent(fileName) - guard isInsideDirectory(candidate) else { - SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security) - return directory.appendingPathComponent("blocked_\(UUID().uuidString)") - } - - let baseName = (fileName as NSString).deletingPathExtension - let ext = (fileName as NSString).pathExtension - if forceRandomizedName { - let suffix = UUID().uuidString - let randomizedName = ext.isEmpty - ? "\(baseName)_\(suffix)" - : "\(baseName)_\(suffix).\(ext)" - return directory.appendingPathComponent(randomizedName) - } - - if isAvailable(candidate) { - return candidate - } - - for counter in 1..<100 { - let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)" - candidate = directory.appendingPathComponent(newName) - guard isInsideDirectory(candidate) else { - return directory.appendingPathComponent("blocked_\(UUID().uuidString)") - } - if isAvailable(candidate) { - return candidate - } - } - - return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)") - } - - private static func timestampString(from date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyyMMdd_HHmmss" - return formatter.string(from: date) - } -} diff --git a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift deleted file mode 100644 index 77a00f56..00000000 --- a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift +++ /dev/null @@ -1,168 +0,0 @@ -import BitFoundation -import Foundation - -enum BLEIngressLinkID: Hashable, Equatable { - case peripheral(String) - case central(String) -} - -struct BLEIngressPacketContext: Equatable { - let receivedFromPeerID: PeerID - let validationPeerID: PeerID -} - -struct BLEIngressLinkRecord: Equatable { - let link: BLEIngressLinkID - let peerID: PeerID - let timestamp: Date -} - -enum BLEIngressRejection: Error, Equatable { - case selfLoopback(packetType: UInt8) - case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID) -} - -struct BLEIngressLinkRegistry { - private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:] - - var isEmpty: Bool { - ingressByMessageID.isEmpty - } - - mutating func removeAll() { - ingressByMessageID.removeAll() - } - - func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? { - ingressByMessageID[Self.messageID(for: packet)] - } - - func link(for packet: BitchatPacket) -> BLEIngressLinkID? { - record(for: packet)?.link - } - - func peerID(for packet: BitchatPacket) -> PeerID? { - record(for: packet)?.peerID - } - - mutating func recordIfNew( - _ packet: BitchatPacket, - link: BLEIngressLinkID, - peerID: PeerID, - now: Date = Date(), - lifetime: TimeInterval - ) -> Bool { - let messageID = Self.messageID(for: packet) - if let existing = ingressByMessageID[messageID], - now.timeIntervalSince(existing.timestamp) <= lifetime { - return false - } - - ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now) - return true - } - - mutating func prune(before cutoff: Date) { - ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff } - } - - static func packetContext( - for packet: BitchatPacket, - claimedSenderID: PeerID, - boundPeerID: PeerID?, - localPeerID: PeerID, - directAnnounceTTL: UInt8 - ) -> Result { - if claimedSenderID == localPeerID, - !isSelfAuthoredSyncResponse(packet) { - return .failure(.selfLoopback(packetType: packet.type)) - } - - if let boundPeerID, boundPeerID != claimedSenderID { - if requiresDirectSenderBinding(packet) { - return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID)) - } - // A direct announce claiming a new sender on a bound link is either - // a spoof or a legitimate peer-ID rotation on a connection that - // outlived the old ID. Attribute it to the claimed sender and let - // it through: announces are self-authenticating, and only a - // signature-verified announce may rebind the link (BLEService). - if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) { - return .success(BLEIngressPacketContext( - receivedFromPeerID: claimedSenderID, - validationPeerID: claimedSenderID - )) - } - } - - let receivedFromPeerID = boundPeerID ?? claimedSenderID - let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID - return .success(BLEIngressPacketContext( - receivedFromPeerID: receivedFromPeerID, - validationPeerID: validationPeerID - )) - } - - static func messageID(for packet: BitchatPacket) -> String { - let senderID = packet.senderID.hexEncodedString() - let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString() - return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)" - } - - private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool { - // REQUEST_SYNC is never relayed, so on a bound link the claimed sender - // must be the link peer — it elicits a full store replay, and the - // response is addressed to whoever the sender claims to be. - packet.type == MessageType.requestSync.rawValue - } - - static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool { - packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL - } - - private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool { - packet.isRSR && packet.ttl == 0 - } -} - -/// Lock-backed shared ownership of the ingress-link registry. Ingress is -/// recorded on bleQueue the moment a frame decodes (the link identity is -/// only known there, and the duplicate-ingress gate must answer before -/// the packet is handed to the engine), while relay and routing decisions -/// read it from the engine. Every registry mutation is a single -/// whole-transition method, so readers never observe a torn state. -final class BLEIngressLinkStore: @unchecked Sendable { - private let lock = NSLock() - private var registry = BLEIngressLinkRegistry() - - var isEmpty: Bool { - lock.withLock { registry.isEmpty } - } - - func removeAll() { - lock.withLock { registry.removeAll() } - } - - func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? { - lock.withLock { registry.record(for: packet) } - } - - func link(for packet: BitchatPacket) -> BLEIngressLinkID? { - lock.withLock { registry.link(for: packet) } - } - - func recordIfNew( - _ packet: BitchatPacket, - link: BLEIngressLinkID, - peerID: PeerID, - lifetime: TimeInterval - ) -> Bool { - lock.withLock { - registry.recordIfNew(packet, link: link, peerID: peerID, lifetime: lifetime) - } - } - - func prune(before cutoff: Date) { - lock.withLock { registry.prune(before: cutoff) } - } -} diff --git a/bitchat/Services/BLE/BLEIngressPacketGuard.swift b/bitchat/Services/BLE/BLEIngressPacketGuard.swift deleted file mode 100644 index 4e4345f2..00000000 --- a/bitchat/Services/BLE/BLEIngressPacketGuard.swift +++ /dev/null @@ -1,76 +0,0 @@ -import BitFoundation -import Foundation - -enum BLEIngressPacketGuard { - enum Rejection: Error, Equatable { - case selfLoopback(packetType: UInt8) - case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID) - case invalidRSR(peerID: PeerID) - case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64) - } - - static func evaluate( - packet: BitchatPacket, - claimedSenderID: PeerID, - boundPeerID: PeerID?, - localPeerID: PeerID, - directAnnounceTTL: UInt8, - nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), - maxTimestampSkewMs: UInt64 = 120_000, - isValidSyncResponse: (PeerID) -> Bool - ) -> Result { - let contextResult = BLEIngressLinkRegistry.packetContext( - for: packet, - claimedSenderID: claimedSenderID, - boundPeerID: boundPeerID, - localPeerID: localPeerID, - directAnnounceTTL: directAnnounceTTL - ) - - let context: BLEIngressPacketContext - switch contextResult { - case .success(let acceptedContext): - context = acceptedContext - case .failure(.selfLoopback(let packetType)): - return .failure(.selfLoopback(packetType: packetType)) - case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)): - return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID)) - } - - switch validatePayload( - packet, - from: context.validationPeerID, - nowMs: nowMs, - maxTimestampSkewMs: maxTimestampSkewMs, - isValidSyncResponse: isValidSyncResponse - ) { - case .success: - return .success(context) - case .failure(let rejection): - return .failure(rejection) - } - } - - static func validatePayload( - _ packet: BitchatPacket, - from peerID: PeerID, - nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), - maxTimestampSkewMs: UInt64 = 120_000, - isValidSyncResponse: (PeerID) -> Bool - ) -> Result { - if packet.isRSR { - guard isValidSyncResponse(peerID) else { - return .failure(.invalidRSR(peerID: peerID)) - } - return .success(()) - } - - let packetTime = packet.timestamp - let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime - guard skew <= maxTimestampSkewMs else { - return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs)) - } - - return .success(()) - } -} diff --git a/bitchat/Services/BLE/BLELinkAuthState.swift b/bitchat/Services/BLE/BLELinkAuthState.swift deleted file mode 100644 index 3989c3c4..00000000 --- a/bitchat/Services/BLE/BLELinkAuthState.swift +++ /dev/null @@ -1,115 +0,0 @@ -import BitFoundation -import Foundation - -/// Per-link Noise authentication and rebind-containment state. -/// -/// A peer ID can retain an established Noise session after its physical -/// link disappears, and link bindings heal on announces whose directness -/// is forgeable (TTL is unsigned). This state pins the stronger facts the -/// containment rules need: which exact ingress link a Noise handshake -/// completed on, each link's revalidation epoch, and the cooldowns that -/// stop a replayed announce from flip-flopping bindings or survivor -/// selection. -/// -/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md), -/// alongside the link bindings it qualifies: BLEService debug-traps any -/// access off the engine queue. -struct BLELinkAuthState { - private var authenticatedOwners: [BLEIngressLinkID: PeerID] = [:] - private var reconnectPolicy = BLENoiseReconnectPolicy() - // Entries older than the cooldown are pruned on each check. - private var lastRebindAt: [String: Date] = [:] - private var lastRedundantRetirementAt: [PeerID: Date] = [:] - - // MARK: - Authentication ownership - - /// Whether `peerID`'s Noise session was established on this exact link. - func isAuthenticated(_ link: BLEIngressLinkID, for peerID: PeerID) -> Bool { - authenticatedOwners[link] == peerID - } - - func links(ownedBy peerID: PeerID) -> [BLEIngressLinkID] { - authenticatedOwners.compactMap { link, owner in - owner == peerID ? link : nil - } - } - - mutating func markAuthenticated(_ link: BLEIngressLinkID, owner peerID: PeerID) { - authenticatedOwners[link] = peerID - } - - /// Retires a link's proof and closes its revalidation epoch — the pair - /// every teardown path (disconnect, unsubscribe, timeout, rebind, - /// redundant retirement) must apply together. - mutating func retireLink(_ link: BLEIngressLinkID) { - authenticatedOwners.removeValue(forKey: link) - reconnectPolicy.endLinkEpoch(link) - } - - /// Retires every link the departing peer's proofs still own; returns - /// the retired links. - mutating func retireLinks(ownedBy peerID: PeerID) -> [BLEIngressLinkID] { - let departed = links(ownedBy: peerID) - for link in departed { - retireLink(link) - } - return departed - } - - /// Drops every link proof and revalidation epoch. The containment - /// cooldowns deliberately SURVIVE this: panic and emergency resets can - /// restart services well inside `bleLinkRebindCooldownSeconds`, and a - /// stable CoreBluetooth UUID must not get a fresh rebind/retirement - /// allowance just because the session state around it was wiped. The - /// maps stay time-pruned on each permit check. - mutating func removeAll() { - authenticatedOwners.removeAll() - reconnectPolicy.removeAll() - } - - // MARK: - Session revalidation - - /// Whether a fresh direct link warrants revalidating a cached - /// peer-level session with a new XX exchange. - mutating func shouldRevalidate( - on link: BLEIngressLinkID, - for peerID: PeerID, - hasEstablishedSession: Bool, - hasAuthenticatedPeerLink: Bool, - now: Date - ) -> Bool { - reconnectPolicy.shouldRevalidate( - on: link, - hasEstablishedSession: hasEstablishedSession, - isNoiseAuthenticatedLink: isAuthenticated(link, for: peerID), - hasAuthenticatedPeerLink: hasAuthenticatedPeerLink, - now: now - ) - } - - // MARK: - Rebind containment cooldowns - - /// At most one rotation rebind per link per cooldown window, so two - /// identities can't fight over a link in a replay flip-flop. Prunes, - /// checks, and records in one transition; true = permitted (recorded). - mutating func permitRebind(linkUUID: String, now: Date, cooldown: TimeInterval) -> Bool { - lastRebindAt = lastRebindAt.filter { - now.timeIntervalSince($0.value) < cooldown - } - guard lastRebindAt[linkUUID] == nil else { return false } - lastRebindAt[linkUUID] = now - return true - } - - /// At most one redundant-link retirement per peer per cooldown window, - /// bounding how often a replayed announce could flip which duplicate - /// link survives. True = permitted (recorded). - mutating func permitRedundantRetirement(peerID: PeerID, now: Date, cooldown: TimeInterval) -> Bool { - lastRedundantRetirementAt = lastRedundantRetirementAt.filter { - now.timeIntervalSince($0.value) < cooldown - } - guard lastRedundantRetirementAt[peerID] == nil else { return false } - lastRedundantRetirementAt[peerID] = now - return true - } -} diff --git a/bitchat/Services/BLE/BLELinkBindings.swift b/bitchat/Services/BLE/BLELinkBindings.swift deleted file mode 100644 index 840acdfa..00000000 --- a/bitchat/Services/BLE/BLELinkBindings.swift +++ /dev/null @@ -1,152 +0,0 @@ -import BitFoundation -import Foundation - -/// Identity↔link bindings: which peer each physical link currently -/// belongs to, in both roles, plus each peer's preferred peripheral link -/// for directed sends and fanout collapse. -/// -/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md), -/// alongside `BLELinkAuthState`: *who owns a link* lives on the engine, -/// *what links exist* stays on bleQueue in the physical store. BLEService -/// debug-traps any access off the engine queue. -/// -/// Lifecycle contract: bindings are only created for live physical links -/// (callers check liveness through `readLinkState`) and are retired -/// through `peripheralRemoved`/`centralRemoved`/`clear*` on an engine hop -/// queued by the physical teardown. A binding can therefore briefly -/// outlive its departed link; queries that need liveness join against the -/// physical store, and everything converges once the queued retirement -/// runs. -struct BLELinkBindings { - private var peripheralPeers: [String: PeerID] = [:] - private var centralPeers: [String: PeerID] = [:] - /// The peer's most recently bound peripheral link, kept so duplicate- - /// link fanout collapse stays deterministic (see BLEFanoutSelector). - private var preferredPeripheral: [PeerID: String] = [:] - - // MARK: - Queries - - func peer(forPeripheralID peripheralID: String) -> PeerID? { - peripheralPeers[peripheralID] - } - - func peer(forCentralUUID centralUUID: String) -> PeerID? { - centralPeers[centralUUID] - } - - func boundPeer(for link: BLEIngressLinkID) -> PeerID? { - switch link { - case .peripheral(let peripheralUUID): - return peripheralPeers[peripheralUUID] - case .central(let centralUUID): - return centralPeers[centralUUID] - } - } - - /// Every link bound to the peer, both roles. After a state restoration - /// the same device can hold several live peripheral links bound to one - /// peer (it reappears under a fresh UUID while the restored connection - /// lives on), so this scans all bindings rather than the 1:1 preferred - /// map. - func links(to peerID: PeerID?) -> Set { - guard let peerID else { return [] } - var links: Set = [] - for (peripheralUUID, boundPeer) in peripheralPeers where boundPeer == peerID { - links.insert(.peripheral(peripheralUUID)) - } - for (centralUUID, boundPeer) in centralPeers where boundPeer == peerID { - links.insert(.central(centralUUID)) - } - return links - } - - func hasCentral(boundTo peerID: PeerID) -> Bool { - centralPeers.values.contains(peerID) - } - - func preferredPeripheralUUID(for peerID: PeerID) -> String? { - preferredPeripheral[peerID] - } - - /// The full preferred-peripheral map, for fanout collapse. - var preferredPeripheralBindings: [PeerID: String] { - preferredPeripheral - } - - /// The full central binding map, for the subscribed-central snapshot. - var centralPeersByUUID: [String: PeerID] { - centralPeers - } - - // MARK: - Binding transitions - - mutating func bindCentral(_ centralUUID: String, to peerID: PeerID) { - centralPeers[centralUUID] = peerID - } - - mutating func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) { - let previousPeerID = peripheralPeers[peripheralUUID] - peripheralPeers[peripheralUUID] = peerID - // Rebinding (peer-ID rotation): drop the retired ID's reverse - // mapping so the old peer no longer claims this link. - if let previousPeerID, previousPeerID != peerID, - preferredPeripheral[previousPeerID] == peripheralUUID { - preferredPeripheral.removeValue(forKey: previousPeerID) - } - preferredPeripheral[peerID] = peripheralUUID - } - - /// Retires a peripheral link's binding. When the removed link was the - /// peer's preferred one, the reverse map is repaired onto a surviving - /// duplicate chosen by the caller from the peer's remaining bound links - /// (the caller knows physical liveness; prefer a writable survivor — - /// repairing onto a link mid-service-rediscovery would strand directed - /// sends until its characteristic comes back). - mutating func peripheralRemoved( - _ peripheralUUID: String, - chooseSurvivor: (_ remainingBoundUUIDs: [String]) -> String? - ) -> PeerID? { - guard let peerID = peripheralPeers.removeValue(forKey: peripheralUUID) else { - return nil - } - // Only clear (or repair) the reverse map when it points at the - // removed link: with duplicate links to one peer, removing a stale - // duplicate must not strand the peer's surviving bound link. - if preferredPeripheral[peerID] == peripheralUUID { - let remaining = peripheralPeers.compactMap { uuid, boundPeer in - boundPeer == peerID ? uuid : nil - } - if let survivorUUID = chooseSurvivor(remaining) { - preferredPeripheral[peerID] = survivorUUID - } else { - preferredPeripheral.removeValue(forKey: peerID) - } - } - return peerID - } - - mutating func centralRemoved(_ centralUUID: String) -> PeerID? { - centralPeers.removeValue(forKey: centralUUID) - } - - /// Drops every peripheral binding; returns the peers that held one. - mutating func clearPeripherals() -> [PeerID] { - let peerIDs = Array(peripheralPeers.values) - peripheralPeers.removeAll() - preferredPeripheral.removeAll() - return peerIDs - } - - /// Drops every central binding; returns the peers that held one. - mutating func clearCentrals() -> [PeerID] { - let peerIDs = Array(centralPeers.values) - centralPeers.removeAll() - return peerIDs - } - - mutating func removeAll() { - peripheralPeers.removeAll() - centralPeers.removeAll() - preferredPeripheral.removeAll() - } -} diff --git a/bitchat/Services/BLE/BLELinkEvent.swift b/bitchat/Services/BLE/BLELinkEvent.swift deleted file mode 100644 index fdc8a6da..00000000 --- a/bitchat/Services/BLE/BLELinkEvent.swift +++ /dev/null @@ -1,40 +0,0 @@ -import BitFoundation -import Foundation - -/// The upward half of the link-layer port: everything the bleQueue link -/// layer tells the engine, as one enumerable surface with one engine -/// entry point (`BLEService.handleLinkEvent`). CoreBluetooth delegates -/// shrink to physical bookkeeping plus event emission, and the simulated -/// mesh drives the engine through exactly the same seam. -/// -/// Naming follows the physical stores: a *peripheral link* is a -/// connection we own as central (keyed by the remote peripheral's UUID); -/// a *central link* is a remote central subscribed to our peripheral role -/// (keyed by its UUID). -enum BLELinkEvent { - /// A decoded frame arrived on a link. Attribution — binding lookup, - /// spoof rejection, raw-announce binding, ingress recording — is - /// engine work. Emission captures the panic lifecycle at the handoff. - case frameDecoded(BitchatPacket, link: BLEIngressLinkID, linkDescription: String) - - /// One peripheral link ended (disconnect, connect failure, or radio - /// policy teardown). The engine retires the link's identity half — - /// proof, epoch, binding with survivor repair — and, when - /// `runPeerBookkeeping` is set (real disconnects), marks the peer - /// disconnected once its last live link is gone and republishes the - /// peer list. - case peripheralLinkEnded(peripheralID: String, runPeerBookkeeping: Bool) - - /// A remote central unsubscribed. The engine retires the central - /// link's identity half and runs last-link peer bookkeeping. - case centralLinkEnded(centralUUID: String) - - /// The central role reset and every peripheral link is gone - /// (power-off retires proofs and notifies peers; an authorization - /// loss only drops the bindings). - case allPeripheralLinksEnded(peripheralIDs: [String], retireProofsAndNotify: Bool) - - /// The peripheral role reset and every central link is gone (same - /// power-off / authorization-loss split). - case allCentralLinksEnded(centralUUIDs: [String], retireProofsAndNotify: Bool) -} diff --git a/bitchat/Services/BLE/BLELinkStateStore.swift b/bitchat/Services/BLE/BLELinkStateStore.swift deleted file mode 100644 index 79440a64..00000000 --- a/bitchat/Services/BLE/BLELinkStateStore.swift +++ /dev/null @@ -1,180 +0,0 @@ -import BitFoundation -import CoreBluetooth -import Foundation - -struct BLEPeripheralLinkState { - let peripheral: CBPeripheral - var characteristic: CBCharacteristic? - var isConnecting: Bool - var isConnected: Bool - var lastConnectionAttempt: Date? - /// When didConnect last fired for this link. Nil for links restored - /// already-connected (their connect predates this process), which is - /// exactly the signal redundant-link consolidation needs: a restored - /// link lives on an old BLE address the peer no longer advertises, - /// so it must never be kept over a freshly connected duplicate. - var lastConnectedAt: Date? = nil - var assembler: NotificationStreamAssembler -} - -struct BLEDirectLinkState: Equatable { - let hasPeripheral: Bool - let hasCentral: Bool -} - -struct BLESubscribedCentralSnapshot { - let centrals: [CBCentral] - let peerIDsByCentralUUID: [String: PeerID] - - func central(for peerID: PeerID) -> CBCentral? { - centrals.first { peerIDsByCentralUUID[$0.identifier.uuidString] == peerID } - } -} - -// BLEDirectLinkState and the identity↔link binding queries live on -// BLELinkBindings; this store owns only physical link state. - -/// Owns the PHYSICAL BLE link state (peripheral connections we hold as -/// central, and central subscriptions we serve as peripheral) — CB object -/// handles, connect lifecycles, characteristics, and stream assemblers. -/// Identity↔link bindings live on `BLELinkBindings`. The store has no -/// internal locking: every access must happen on the single owning queue -/// (the BLE queue). Other queues must go through BLEService's -/// `readLinkState`, which hops to that queue. Call `assumeOwnership(of:)` -/// to have debug builds trap any access from the wrong queue. -final class BLELinkStateStore { - private(set) var peripherals: [String: BLEPeripheralLinkState] = [:] - private(set) var subscribedCentrals: [CBCentral] = [] - - #if DEBUG - private var ownerQueue: DispatchQueue? - #endif - - /// Pin the store to its owning queue. Debug-only enforcement; release - /// builds are unchanged. - func assumeOwnership(of queue: DispatchQueue) { - #if DEBUG - ownerQueue = queue - #endif - } - - @inline(__always) - private func assertOwned() { - #if DEBUG - if let queue = ownerQueue { - dispatchPrecondition(condition: .onQueue(queue)) - } - #endif - } - - var peripheralStates: [BLEPeripheralLinkState] { - assertOwned() - return Array(peripherals.values) - } - - var subscribedCentralCount: Int { - assertOwned() - return subscribedCentrals.count - } - - var connectedOrConnectingPeripheralCount: Int { - assertOwned() - return peripherals.values.filter { $0.isConnected || $0.isConnecting }.count - } - - func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? { - assertOwned() - return peripherals[peripheralID] - } - - func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) { - assertOwned() - peripherals[peripheralID] = state - } - - @discardableResult - func updatePeripheral( - _ peripheralID: String, - _ update: (inout BLEPeripheralLinkState) -> Void - ) -> BLEPeripheralLinkState? { - assertOwned() - guard var state = peripherals[peripheralID] else { return nil } - update(&state) - peripherals[peripheralID] = state - return state - } - - func beginConnecting(to peripheral: CBPeripheral, at date: Date) { - setPeripheralState( - BLEPeripheralLinkState( - peripheral: peripheral, - characteristic: nil, - isConnecting: true, - isConnected: false, - lastConnectionAttempt: date, - assembler: NotificationStreamAssembler() - ), - for: peripheral.identifier.uuidString - ) - } - - func markConnected(_ peripheral: CBPeripheral, at now: Date = Date()) { - let peripheralID = peripheral.identifier.uuidString - if updatePeripheral(peripheralID, { - $0.isConnecting = false - $0.isConnected = true - $0.lastConnectedAt = now - }) == nil { - setPeripheralState( - BLEPeripheralLinkState( - peripheral: peripheral, - characteristic: nil, - isConnecting: false, - isConnected: true, - lastConnectionAttempt: nil, - lastConnectedAt: now, - assembler: NotificationStreamAssembler() - ), - for: peripheralID - ) - } - } - - func updateCharacteristic(_ characteristic: CBCharacteristic, forPeripheralID peripheralID: String) { - updatePeripheral(peripheralID) { - $0.characteristic = characteristic - } - } - - func addSubscribedCentral(_ central: CBCentral) { - assertOwned() - guard !subscribedCentrals.contains(central) else { return } - subscribedCentrals.append(central) - } - - func removeSubscribedCentral(_ central: CBCentral) { - assertOwned() - subscribedCentrals.removeAll { $0.identifier == central.identifier } - } - - func removePeripheral(_ peripheralID: String) { - assertOwned() - peripherals.removeValue(forKey: peripheralID) - } - - func clearPeripherals() { - assertOwned() - peripherals.removeAll() - } - - func clearCentrals() { - assertOwned() - subscribedCentrals.removeAll() - } - - func clearAll() { - assertOwned() - peripherals.removeAll() - subscribedCentrals.removeAll() - } -} diff --git a/bitchat/Services/BLE/BLELocalIdentityStateStore.swift b/bitchat/Services/BLE/BLELocalIdentityStateStore.swift deleted file mode 100644 index 7b5be20b..00000000 --- a/bitchat/Services/BLE/BLELocalIdentityStateStore.swift +++ /dev/null @@ -1,113 +0,0 @@ -import BitFoundation -import Foundation - -struct BLELocalIdentitySnapshot: Equatable, Sendable { - let peerID: PeerID - let peerIDData: Data - let nickname: String - /// Runtime-toggled capability bits (e.g. the internet-gateway toggle) - /// ORed into `PeerCapabilities.localSupported` for every announce. - let runtimeCapabilities: PeerCapabilities - /// Rendezvous cell advertised while bridging; rides announces only - /// while the `.bridge` capability is enabled. - let bridgeGeohash: String? - - var advertisedCapabilities: PeerCapabilities { - PeerCapabilities.localSupported.union(runtimeCapabilities) - } - - var advertisedBridgeGeohash: String? { - runtimeCapabilities.contains(.bridge) ? bridgeGeohash : nil - } -} - -/// Lock-backed local identity state shared by the transport's message, -/// Bluetooth, maintenance, and main-actor entry points. -/// -/// `peerID` and its binary wire representation must change as one unit during -/// panic rotation. A snapshot also gives announce construction one consistent -/// view of the nickname, identity, and advertised capabilities instead of -/// reading independently mutable properties across queues. -final class BLELocalIdentityStateStore: @unchecked Sendable { - private let lock = NSLock() - private var state: BLELocalIdentitySnapshot - - init( - peerID: PeerID = PeerID(str: ""), - nickname: String = "anon" - ) { - state = BLELocalIdentitySnapshot( - peerID: peerID, - peerIDData: Data(hexString: peerID.id) ?? Data(), - nickname: nickname, - runtimeCapabilities: [], - bridgeGeohash: nil - ) - } - - func snapshot() -> BLELocalIdentitySnapshot { - lock.withLock { state } - } - - func setNickname(_ nickname: String) { - lock.withLock { - state = BLELocalIdentitySnapshot( - peerID: state.peerID, - peerIDData: state.peerIDData, - nickname: nickname, - runtimeCapabilities: state.runtimeCapabilities, - bridgeGeohash: state.bridgeGeohash - ) - } - } - - func replacePeerIdentity(with peerID: PeerID) { - lock.withLock { - state = BLELocalIdentitySnapshot( - peerID: peerID, - peerIDData: Data(hexString: peerID.id) ?? Data(), - nickname: state.nickname, - runtimeCapabilities: state.runtimeCapabilities, - bridgeGeohash: state.bridgeGeohash - ) - } - } - - /// Flips a runtime capability bit. Returns whether anything changed. - @discardableResult - func setCapability(_ capability: PeerCapabilities, enabled: Bool) -> Bool { - lock.withLock { - var capabilities = state.runtimeCapabilities - if enabled { - capabilities.insert(capability) - } else { - capabilities.remove(capability) - } - guard capabilities != state.runtimeCapabilities else { return false } - state = BLELocalIdentitySnapshot( - peerID: state.peerID, - peerIDData: state.peerIDData, - nickname: state.nickname, - runtimeCapabilities: capabilities, - bridgeGeohash: state.bridgeGeohash - ) - return true - } - } - - /// Sets the bridged rendezvous cell. Returns whether anything changed. - @discardableResult - func setBridgeGeohash(_ cell: String?) -> Bool { - lock.withLock { - guard cell != state.bridgeGeohash else { return false } - state = BLELocalIdentitySnapshot( - peerID: state.peerID, - peerIDData: state.peerIDData, - nickname: state.nickname, - runtimeCapabilities: state.runtimeCapabilities, - bridgeGeohash: cell - ) - return true - } - } -} diff --git a/bitchat/Services/BLE/BLELogRateLimiter.swift b/bitchat/Services/BLE/BLELogRateLimiter.swift deleted file mode 100644 index e1e246d8..00000000 --- a/bitchat/Services/BLE/BLELogRateLimiter.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -final class BLELogRateLimiter { - private let defaultMinimumInterval: TimeInterval - private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter") - private var lastLogTimeByKey: [String: Date] = [:] - - init(defaultMinimumInterval: TimeInterval) { - self.defaultMinimumInterval = defaultMinimumInterval - } - - func shouldLog( - key: String, - now: Date = Date(), - minimumInterval: TimeInterval? = nil - ) -> Bool { - queue.sync { - let interval = minimumInterval ?? defaultMinimumInterval - if let lastLogTime = lastLogTimeByKey[key], - now.timeIntervalSince(lastLogTime) < interval { - return false - } - lastLogTimeByKey[key] = now - return true - } - } - -} diff --git a/bitchat/Services/BLE/BLEMaintenancePolicy.swift b/bitchat/Services/BLE/BLEMaintenancePolicy.swift deleted file mode 100644 index 2f062b2d..00000000 --- a/bitchat/Services/BLE/BLEMaintenancePolicy.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -struct BLEMaintenancePlan: Equatable { - let shouldSendAnnounce: Bool - let shouldEnsureAdvertising: Bool - let shouldRunCleanup: Bool - let shouldFlushDirectedSpool: Bool - let shouldResetCounter: Bool -} - -enum BLEMaintenancePolicy { - static func plan( - cycle: Int, - connectedCount: Int, - peerRegistryIsEmpty: Bool, - elapsedSinceLastAnnounce: TimeInterval, - hasRecentTraffic: Bool, - connectedAnnounceJitterOffset: TimeInterval? = nil, - highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold - ) -> BLEMaintenancePlan { - BLEMaintenancePlan( - shouldSendAnnounce: shouldSendAnnounce( - connectedCount: connectedCount, - elapsedSinceLastAnnounce: elapsedSinceLastAnnounce, - hasRecentTraffic: hasRecentTraffic, - connectedAnnounceJitterOffset: connectedAnnounceJitterOffset, - highDegreeThreshold: highDegreeThreshold - ), - shouldEnsureAdvertising: peerRegistryIsEmpty, - shouldRunCleanup: cycle.isMultiple(of: 3), - shouldFlushDirectedSpool: !cycle.isMultiple(of: 2), - shouldResetCounter: cycle >= 6 - ) - } - - static func shouldSendAnnounce( - connectedCount: Int, - elapsedSinceLastAnnounce: TimeInterval, - hasRecentTraffic: Bool, - connectedAnnounceJitterOffset: TimeInterval? = nil, - highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold - ) -> Bool { - if hasRecentTraffic && elapsedSinceLastAnnounce >= 10.0 { - return true - } - - guard connectedCount > 0 else { - return elapsedSinceLastAnnounce >= TransportConfig.bleAnnounceIntervalSeconds - } - - let highDegree = connectedCount >= highDegreeThreshold - let base = highDegree ? - TransportConfig.bleConnectedAnnounceBaseSecondsDense : - TransportConfig.bleConnectedAnnounceBaseSecondsSparse - let jitter = highDegree ? - TransportConfig.bleConnectedAnnounceJitterDense : - TransportConfig.bleConnectedAnnounceJitterSparse - let jitterOffset = connectedAnnounceJitterOffset ?? Double.random(in: -jitter...jitter) - - return elapsedSinceLastAnnounce >= base + jitterOffset - } -} diff --git a/bitchat/Services/BLE/BLEMeshPingTracker.swift b/bitchat/Services/BLE/BLEMeshPingTracker.swift deleted file mode 100644 index 3e1e6644..00000000 --- a/bitchat/Services/BLE/BLEMeshPingTracker.swift +++ /dev/null @@ -1,62 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEMeshPingProbe { - let peerID: PeerID - let sentAt: Date - let lifecycleGeneration: UInt64 - let completion: @MainActor (MeshPingResult?) -> Void - let timeout: DispatchWorkItem -} - -/// Engine-confined /ping diagnostics state: outstanding probes keyed by -/// their unguessable nonce, plus the inbound response budget. -/// -/// The budget is keyed by the ingress link (the directly connected peer -/// that delivered the packet), never the packet-claimed sender: pings are -/// unsigned, so the claimed sender is attacker-controlled and rotating it -/// would reset the budget, turning a directed unencrypted probe into an -/// amplification primitive. -/// -/// Pure state — the transport owns packet I/O, timers, and main-actor -/// completion delivery around it. -struct BLEMeshPingTracker { - private var pendingProbes: [Data: BLEMeshPingProbe] = [:] - private var responseLimiter = SyncResponseRateLimiter( - maxResponses: TransportConfig.meshPingInboundMaxPerLink, - window: TransportConfig.meshPingInboundWindowSeconds - ) - - mutating func register(_ probe: BLEMeshPingProbe, nonce: Data) { - pendingProbes[nonce] = probe - } - - /// Resolves a pong against its outstanding probe. The echoed nonce plus - /// the sender check bind the reply to the probed peer. - mutating func resolve(nonce: Data, from peerID: PeerID) -> BLEMeshPingProbe? { - guard pendingProbes[nonce]?.peerID == peerID else { return nil } - return pendingProbes.removeValue(forKey: nonce) - } - - /// Removes a timed-out probe so its completion can fire once with nil. - mutating func expire(nonce: Data) -> BLEMeshPingProbe? { - pendingProbes.removeValue(forKey: nonce) - } - - /// Whether an inbound ping delivered by this link is within budget. - mutating func shouldRespond(toLink linkPeerID: PeerID, now: Date) -> Bool { - responseLimiter.shouldRespond(to: linkPeerID, now: now) - } - - /// Drops all probes and restores a fresh response budget (panic wipe). - /// Returns the orphaned timeout work items for the caller to cancel. - mutating func reset() -> [DispatchWorkItem] { - let timeouts = pendingProbes.values.map(\.timeout) - pendingProbes.removeAll() - responseLimiter = SyncResponseRateLimiter( - maxResponses: TransportConfig.meshPingInboundMaxPerLink, - window: TransportConfig.meshPingInboundWindowSeconds - ) - return timeouts - } -} diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift deleted file mode 100644 index 9314f666..00000000 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ /dev/null @@ -1,444 +0,0 @@ -import BitFoundation -import BitLogger -import CryptoKit -import Foundation - -struct BLENoiseHandshakeHandlingResult { - let processed: Bool - let didEstablishAuthenticatedSession: Bool -} - -struct BLENoiseDecryptionResult { - let plaintext: Data - let sessionGeneration: UUID -} - -/// Narrow environment for `BLENoisePacketHandler`. -/// -/// All queue hops (collections barrier writes, main-actor UI notification) -/// and every `noiseService.*` crypto call live inside the closures supplied by -/// `BLEService`, keeping the handler queue-agnostic and synchronously testable. -struct BLENoisePacketHandlerEnvironment { - /// Local peer identity at the time the packet is handled. - let localPeerID: () -> PeerID - /// Local peer ID bytes used as the sender of handshake responses. - let localPeerIDData: () -> Data - /// TTL value used for direct (non-relayed) packets. - let messageTTL: UInt8 - /// Current time source. - let now: () -> Date - /// Processes an inbound handshake message, returning its optional response - /// and whether that exact candidate authenticated (crypto). - let processHandshakeMessage: - (_ peerID: PeerID, _ message: Data) throws - -> NoiseHandshakeProcessingResult - /// Whether any Noise session (established or pending) exists for the peer (crypto). - let hasNoiseSession: (PeerID) -> Bool - /// Whether an inbound ordinary XX responder is waiting for message 3. - let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool - /// Initiates a fresh Noise handshake with the peer (crypto + send). - let initiateHandshake: (PeerID) -> Void - /// Broadcasts a packet on the mesh (caller is already on the message queue). - let broadcastPacket: (BitchatPacket) -> Void - /// Updates the registry last-seen timestamp for the peer (async barrier write). - let updatePeerLastSeen: (PeerID) -> Void - /// Decrypts an encrypted payload from the peer (crypto). - let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> BLENoiseDecryptionResult - /// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto). - let clearSession: (PeerID) -> Void - /// Consumes session-authenticated protocol state inside the transport. It - /// must never escape to UI or Nostr payload dispatch. - let handleAuthenticatedPeerState: ( - _ peerID: PeerID, - _ payload: Data, - _ sessionGeneration: UUID - ) -> Void - /// Delivers `.noisePayloadReceived` to the UI as one main-actor hop. - let deliverNoisePayload: ( - _ peerID: PeerID, - _ type: NoisePayloadType, - _ payload: Data, - _ timestamp: Date - ) -> Void -} - -/// Orchestrates the Noise session domain for inbound packets: handshake -/// processing (with response), encrypted payload decryption and dispatch, -/// and session recovery on decrypt failure. -final class BLENoisePacketHandler { - private struct DeferredCiphertext { - let packet: BitchatPacket - let receivedAt: Date - } - - /// Early post-handshake packets are normally tiny control messages or - /// queued DMs. Keep the recovery surface deliberately small so an - /// unauthenticated half-handshake cannot create an unbounded memory queue. - private static let maxDeferredPacketsPerPeer = 4 - private static let maxDeferredPacketsGlobal = 32 - /// One legacy sender can immediately follow message 3 with the largest - /// valid private-file ciphertext and has no application-level retry. Keep - /// room for that packet plus a small control-message budget. - private static let maxDeferredBytes = - NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024 - private static let deferredLifetime = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout - - private let environment: BLENoisePacketHandlerEnvironment - private let deferredLock = NSLock() - private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:] - private var deferredCiphertextBytes = 0 - - init(environment: BLENoisePacketHandlerEnvironment) { - self.environment = environment - } - - /// Returns true when the handshake message was processed successfully. - /// Callers use this to distinguish an authenticated reconnect completion - /// from a rejected ordinary responder while rollback state is restored. - @discardableResult - func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { - handleHandshakeWithResult(packet, from: peerID).processed - } - - func handleHandshakeWithResult( - _ packet: BitchatPacket, - from peerID: PeerID - ) -> BLENoiseHandshakeHandlingResult { - let env = environment - // Use NoiseEncryptionService for handshake processing - if PeerID(hexData: packet.recipientID) == env.localPeerID() { - // Handshake is for us - do { - let result = try env.processHandshakeMessage( - peerID, - packet.payload - ) - if let response = result.response { - // Send response - let responsePacket = BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: env.localPeerIDData(), - recipientID: Data(hexString: peerID.id), - timestamp: UInt64(env.now().timeIntervalSince1970 * 1000), - payload: response, - signature: nil, - ttl: env.messageTTL - ) - // We're on messageQueue from delegate callback - env.broadcastPacket(responsePacket) - } - - // The serialized authentication callback installs transport - // state before it drains any bounded early ciphertext. - return BLENoiseHandshakeHandlingResult( - processed: true, - didEstablishAuthenticatedSession: - result.didEstablishAuthenticatedSession - ) - } catch let managedFailure as NoiseManagedHandshakeFailure { - SecureLogger.error( - "Failed to process handshake; manager owns recovery: \(managedFailure.underlying)" - ) - return BLENoiseHandshakeHandlingResult( - processed: false, - didEstablishAuthenticatedSession: false - ) - } catch NoiseSessionError.peerIdentityMismatch { - // The responder was already discarded by the session manager. - // Do not let a spoofed claimed ID trigger a fresh outbound - // handshake or recreate state for the attacker-selected ID. - SecureLogger.warning( - "Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))…", - category: .security - ) - return BLENoiseHandshakeHandlingResult( - processed: false, - didEstablishAuthenticatedSession: false - ) - } catch { - SecureLogger.error("Failed to process handshake: \(error)") - // Try initiating a new handshake - if !env.hasNoiseSession(peerID) { - env.initiateHandshake(peerID) - } - return BLENoiseHandshakeHandlingResult( - processed: false, - didEstablishAuthenticatedSession: false - ) - } - } - return BLENoiseHandshakeHandlingResult( - processed: false, - didEstablishAuthenticatedSession: false - ) - } - - func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { - handleEncrypted(packet, from: peerID, isDeferredRetry: false) - } - - /// Called by the transport's serialized authentication callback after it - /// has installed state for the promoted or restored session generation. - func handleSessionAuthenticated(_ peerID: PeerID) { - drainDeferredCiphertextsIfReady(for: peerID) - } - - /// Synchronously discards ciphertext retained for a pre-panic Noise - /// generation. The handler survives the service's identity replacement, - /// so keeping this queue would replay old bytes after post-panic auth. - func resetForPanic() { - deferredLock.lock() - deferredCiphertexts.removeAll(keepingCapacity: false) - deferredCiphertextBytes = 0 - deferredLock.unlock() - } - - private func handleEncrypted( - _ packet: BitchatPacket, - from peerID: PeerID, - isDeferredRetry: Bool - ) { - let env = environment - guard let recipientID = PeerID(hexData: packet.recipientID) else { - SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session) - return - } - - if recipientID != env.localPeerID() { - SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID.id.prefix(8))…, I am \(env.localPeerID().id.prefix(8))…)", category: .session) - return - } - - // Update lastSeen for the peer we received from (important for private messages) - env.updatePeerLastSeen(peerID) - - do { - let decryption = try env.decrypt(packet.payload, peerID) - let decrypted = decryption.plaintext - guard decrypted.count > 0 else { return } - - // First byte indicates the payload type - let payloadType = decrypted[0] - let payloadData = decrypted.dropFirst() - - guard let noisePayloadType = NoisePayloadType.decoded(rawValue: payloadType) else { - SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)") - return - } - - SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session) - - if noisePayloadType == .authenticatedPeerState { - env.handleAuthenticatedPeerState( - peerID, - Data(payloadData), - decryption.sessionGeneration - ) - return - } - - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts) - } catch NoiseEncryptionError.transportGenerationNotReady { - if isDeferredRetry { - SecureLogger.warning( - "Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again", - category: .session - ) - return - } - // The manager promoted or restored keys before BLE's serialized - // callback installed generation-bound transport state. The - // manager rejected this before decrypting, so replay is safe. - deferCiphertext(packet, from: peerID) - } catch NoiseEncryptionError.sessionNotEstablished { - if isDeferredRetry { - SecureLogger.warning( - "Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable", - category: .session - ) - return - } - // We received an encrypted message before establishing a session with this peer. - // An initiator may already have sent message 3 followed by this - // ciphertext, with BLE delivering the ciphertext first. - if env.isAwaitingResponderHandshakeCompletion(peerID) { - deferCiphertext(packet, from: peerID) - return - } - // Otherwise trigger a handshake so future messages can decrypt. - SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake") - if !env.hasNoiseSession(peerID) { - env.initiateHandshake(peerID) - } - } catch { - if isDeferredRetry { - // An early packet cannot tear down the authenticated session - // merely because its single bounded retry still fails. - SecureLogger.warning( - "Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)", - category: .session - ) - return - } - // A responder may retain an older transport as receive-only - // rollback state while ordinary XX waits for message 3. New-key - // ciphertext can fail against those retained receive keys first. - if env.isAwaitingResponderHandshakeCompletion(peerID) { - if isDeferrableEarlyHandshakeFailure(error) { - deferCiphertext(packet, from: peerID) - } else { - SecureLogger.warning( - "Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)", - category: .session - ) - } - return - } - if isDropOnlyCiphertextFailure(error) { - // The packet is attacker-controlled and did not prove a - // transport-state failure. Never let malformed, replayed, - // forged, oversized, or rate-limited bytes evict working keys. - SecureLogger.warning( - "Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)", - category: .security - ) - return - } - // Decryption failed - clear the corrupted session and re-initiate handshake - // Only local/session lifecycle failures reach this path. - SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake") - env.clearSession(peerID) - env.initiateHandshake(peerID) - } - } - - private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool { - if let noiseError = error as? NoiseError { - switch noiseError { - case .authenticationFailure, .replayDetected: - return true - default: - return false - } - } - if let cryptoError = error as? CryptoKitError, - case .authenticationFailure = cryptoError { - return true - } - return false - } - - private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool { - if let securityError = error as? NoiseSecurityError { - switch securityError { - case .messageTooLarge, .rateLimitExceeded, .invalidPeerID: - return true - case .sessionExpired, .sessionExhausted: - return false - } - } - if let noiseError = error as? NoiseError { - switch noiseError { - case .invalidCiphertext, .authenticationFailure, .replayDetected: - return true - case .uninitializedCipher, .handshakeComplete, - .handshakeNotComplete, .missingLocalStaticKey, - .missingKeys, .invalidMessage, .invalidPublicKey, - .nonceExceeded: - return false - } - } - return error is CryptoKitError - } - - private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) { - guard NoiseSecurityValidator.validatePrivateFileCiphertextSize( - packet.payload - ) else { - SecureLogger.warning( - "Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))…", - category: .security - ) - return - } - - let now = environment.now() - deferredLock.lock() - defer { deferredLock.unlock() } - purgeExpiredCiphertextsLocked(now: now) - - let peerCount = deferredCiphertexts[peerID]?.count ?? 0 - let globalCount = deferredCiphertexts.values.reduce(0) { - $0 + $1.count - } - guard peerCount < Self.maxDeferredPacketsPerPeer, - globalCount < Self.maxDeferredPacketsGlobal, - deferredCiphertextBytes + packet.payload.count - <= Self.maxDeferredBytes else { - SecureLogger.warning( - "Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full", - category: .security - ) - return - } - - deferredCiphertexts[peerID, default: []].append( - DeferredCiphertext(packet: packet, receivedAt: now) - ) - deferredCiphertextBytes += packet.payload.count - SecureLogger.debug( - "Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion", - category: .session - ) - } - - private func drainDeferredCiphertextsIfReady(for peerID: PeerID) { - let env = environment - guard !env.isAwaitingResponderHandshakeCompletion(peerID), - env.hasNoiseSession(peerID) else { - return - } - - let now = env.now() - deferredLock.lock() - purgeExpiredCiphertextsLocked(now: now) - let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? [] - deferredCiphertextBytes -= deferred.reduce(0) { - $0 + $1.packet.payload.count - } - deferredLock.unlock() - - guard !deferred.isEmpty else { return } - SecureLogger.debug( - "Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion", - category: .session - ) - for item in deferred { - handleEncrypted(item.packet, from: peerID, isDeferredRetry: true) - } - } - - private func purgeExpiredCiphertextsLocked(now: Date) { - for peerID in Array(deferredCiphertexts.keys) { - guard let items = deferredCiphertexts[peerID] else { continue } - let retained = items.filter { - now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime - } - guard retained.count != items.count else { continue } - - deferredCiphertextBytes -= items.reduce(0) { - $0 + $1.packet.payload.count - } - deferredCiphertextBytes += retained.reduce(0) { - $0 + $1.packet.payload.count - } - if retained.isEmpty { - deferredCiphertexts.removeValue(forKey: peerID) - } else { - deferredCiphertexts[peerID] = retained - } - } - } -} diff --git a/bitchat/Services/BLE/BLENoisePayloadFactory.swift b/bitchat/Services/BLE/BLENoisePayloadFactory.swift deleted file mode 100644 index a414487b..00000000 --- a/bitchat/Services/BLE/BLENoisePayloadFactory.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -enum BLENoisePayloadFactory { - static func privateMessage(content: String, messageID: String) -> Data? { - guard let payload = PrivateMessagePacket(messageID: messageID, content: content).encode() else { - return nil - } - - return typedPayload(.privateMessage, payload: payload) - } - - static func readReceipt(originalMessageID: String) -> Data { - typedPayload(.readReceipt, payload: Data(originalMessageID.utf8)) - } - - static func delivered(messageID: String) -> Data { - typedPayload(.delivered, payload: Data(messageID.utf8)) - } - - static func privateFile(_ filePacket: BitchatFilePacket) -> Data? { - guard let payload = filePacket.encode() else { return nil } - return typedPayload(.privateFile, payload: payload) - } - - static func authenticatedPeerState(_ state: AuthenticatedPeerStatePacket) -> Data? { - guard let payload = state.encode() else { return nil } - return typedPayload(.authenticatedPeerState, payload: payload) - } - - static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data { - var typed = Data([type.rawValue]) - typed.append(payload) - return typed - } -} diff --git a/bitchat/Services/BLE/BLENoiseReconnectPolicy.swift b/bitchat/Services/BLE/BLENoiseReconnectPolicy.swift deleted file mode 100644 index 99b748fb..00000000 --- a/bitchat/Services/BLE/BLENoiseReconnectPolicy.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch. -/// A live epoch may retry after the cooldown so a lost handshake cannot leave -/// the link permanently unauthenticated. -struct BLENoiseReconnectPolicy { - static let minimumRetryInterval: TimeInterval = 60 - - private var lastAttemptAt: [BLEIngressLinkID: Date] = [:] - - mutating func shouldRevalidate( - on link: BLEIngressLinkID, - hasEstablishedSession: Bool, - isNoiseAuthenticatedLink: Bool, - hasAuthenticatedPeerLink: Bool, - now: Date - ) -> Bool { - guard hasEstablishedSession, - !isNoiseAuthenticatedLink, - !hasAuthenticatedPeerLink else { - return false - } - if let previous = lastAttemptAt[link], - now.timeIntervalSince(previous) < Self.minimumRetryInterval { - return false - } - lastAttemptAt[link] = now - return true - } - - /// Link identifiers can be stable across CoreBluetooth reconnects, so a - /// disconnect explicitly starts a new epoch and permits one fresh attempt. - mutating func endLinkEpoch(_ link: BLEIngressLinkID) { - lastAttemptAt.removeValue(forKey: link) - } - - mutating func removeAll() { - lastAttemptAt.removeAll() - } -} diff --git a/bitchat/Services/BLE/BLENoiseSessionQueues.swift b/bitchat/Services/BLE/BLENoiseSessionQueues.swift deleted file mode 100644 index 301e6bce..00000000 --- a/bitchat/Services/BLE/BLENoiseSessionQueues.swift +++ /dev/null @@ -1,75 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEPendingPrivateMessage: Equatable { - let content: String - let messageID: String -} - -struct BLEPendingTypedPayload: Equatable { - let payload: Data - /// Present for app-initiated media so handshake queuing preserves the - /// fragment scheduler's progress/cancellation identity. - let transferId: String? -} - -struct BLENoiseSessionQueues { - private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:] - private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:] - - var isEmpty: Bool { - privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty - } - - mutating func removeAll() { - privateMessagesByPeerID.removeAll() - typedPayloadsByPeerID.removeAll() - } - - mutating func appendPrivateMessage(content: String, messageID: String, for peerID: PeerID) { - privateMessagesByPeerID[peerID, default: []].append(BLEPendingPrivateMessage(content: content, messageID: messageID)) - } - - mutating func takePrivateMessages(for peerID: PeerID) -> [BLEPendingPrivateMessage] { - let messages = privateMessagesByPeerID[peerID] ?? [] - privateMessagesByPeerID.removeValue(forKey: peerID) - return messages - } - - mutating func prependPrivateMessages(_ messages: [BLEPendingPrivateMessage], for peerID: PeerID) { - guard !messages.isEmpty else { return } - privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0) - } - - mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) { - typedPayloadsByPeerID[peerID, default: []].append( - BLEPendingTypedPayload(payload: payload, transferId: transferId) - ) - } - - mutating func takeTypedPayloads(for peerID: PeerID) -> [BLEPendingTypedPayload] { - let payloads = typedPayloadsByPeerID[peerID] ?? [] - typedPayloadsByPeerID.removeValue(forKey: peerID) - return payloads - } - - func containsTypedPayload(transferId: String) -> Bool { - typedPayloadsByPeerID.values.contains { payloads in - payloads.contains { $0.transferId == transferId } - } - } - - @discardableResult - mutating func removeTypedPayload(transferId: String) -> Bool { - for peerID in Array(typedPayloadsByPeerID.keys) { - guard var payloads = typedPayloadsByPeerID[peerID], - let index = payloads.firstIndex(where: { $0.transferId == transferId }) else { - continue - } - payloads.remove(at: index) - typedPayloadsByPeerID[peerID] = payloads.isEmpty ? nil : payloads - return true - } - return false - } -} diff --git a/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift b/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift deleted file mode 100644 index 09a65616..00000000 --- a/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift +++ /dev/null @@ -1,144 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEOutboundFragmentPlan { - let fragmentPackets: [BitchatPacket] - let fragmentVersion: UInt8 - let chunkSize: Int - let spacingMs: Int - - var totalFragments: Int { - fragmentPackets.count - } - - var shouldPauseScanning: Bool { - totalFragments > 4 - } -} - -enum BLEOutboundFragmentPlanner { - /// Current Android receivers reject fragment sets above 256. Private - /// media v1 treats that deployed ceiling as a cross-platform contract. - static let privateMediaV1MaxFragments = 256 - private static let minimumChunkSize = 64 - private static let fragmentIDLength = 8 - - static func makePlan( - for request: BLEOutboundFragmentTransferRequest, - defaultChunkSize: Int, - bleMaxMTU: Int, - fragmentID: Data = randomFragmentID() - ) -> BLEOutboundFragmentPlan? { - guard fragmentID.count == fragmentIDLength, - let fullData = request.packet.toBinaryData(padding: request.pad) else { - return nil - } - - let sizing = sizingPolicy( - for: request.packet, - requestedMaxChunk: request.maxChunk, - defaultChunkSize: defaultChunkSize, - bleMaxMTU: bleMaxMTU - ) - - let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in - Data(fullData[offset.. Bool { - plan.totalFragments <= privateMediaV1MaxFragments - } - - private static func sizingPolicy( - for packet: BitchatPacket, - requestedMaxChunk: Int?, - defaultChunkSize: Int, - bleMaxMTU: Int - ) -> (fragmentVersion: UInt8, chunkSize: Int) { - var fragmentVersion: UInt8 = 1 - var calculatedChunk = defaultChunkSize - - if let route = packet.route, !route.isEmpty { - fragmentVersion = 2 - let routeSize = 1 + (route.count * 8) - let overhead = 16 + 8 + 8 + routeSize + 13 + 16 - calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead) - } - - return ( - fragmentVersion: fragmentVersion, - chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk) - ) - } - - private static func makeFragmentPacket( - original packet: BitchatPacket, - fragmentID: Data, - index: Int, - total: Int, - fragmentData: Data, - fragmentRecipient: Data?, - fragmentVersion: UInt8 - ) -> BitchatPacket { - var payload = Data() - payload.append(fragmentID) - payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) }) - payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) }) - payload.append(packet.type) - payload.append(fragmentData) - - return BitchatPacket( - type: MessageType.fragment.rawValue, - senderID: packet.senderID, - recipientID: fragmentRecipient, - timestamp: packet.timestamp, - payload: payload, - signature: nil, - ttl: packet.ttl, - version: fragmentVersion, - route: packet.route, - isRSR: packet.isRSR - ) - } - - private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int { - if request.directedPeer != nil || request.packet.recipientID != nil { - return TransportConfig.bleFragmentSpacingDirectedMs - } - - return TransportConfig.bleFragmentSpacingMs - } - - private static func randomFragmentID() -> Data { - Data((0..( - _ fragments: [Fragment], - accepting: (Fragment) -> Bool - ) -> Bool { - for fragment in fragments where !accepting(fragment) { - return false - } - return true - } -} - -struct BLEOutboundFragmentTransferScheduler { - enum QueuePosition { - case front - case back - } - - enum SubmitResult { - case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?) - case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition) - /// Strict direct-link requests are transactional: returning false to - /// their durable owner must mean no process-local copy remains that - /// can transmit later. They are therefore start-or-reject, never - /// admitted to `pendingTransfers`. - case rejectedStrict(request: BLEOutboundFragmentTransferRequest, transferId: String?) - /// The same file is already being (or waiting to be) fragmented out - /// to an audience covering this request; sending it again would just - /// double the airtime (field-verified: one 41KB voice file went out - /// as two complete fragment streams). - case droppedDuplicate(request: BLEOutboundFragmentTransferRequest, activeTransferId: String?) - } - - enum CancelResult { - case active(transferId: String, workItems: [DispatchWorkItem]) - case pending(transferId: String) - case missing - } - - enum SentResult: Equatable { - case progress(sentFragments: Int, totalFragments: Int) - case complete(sentFragments: Int, totalFragments: Int) - case missing - } - - private struct ActiveTransferState { - var totalFragments: Int - var sentFragments: Int - var workItems: [DispatchWorkItem] - var contentKey: String? - var directedPeer: PeerID? - } - - private var activeTransfers: [String: ActiveTransferState] = [:] - private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = [] - - /// A transfer of the same content whose audience covers `directedPeer`: - /// a broadcast covers every peer; a directed transfer covers only its - /// recipient. A directed resend to a peer NOT covered by what's in - /// flight (different recipient of a private file) is never a duplicate. - private func coveringDuplicate(contentKey: String, directedPeer: PeerID?) -> String? { - for (transferId, state) in activeTransfers where state.contentKey == contentKey { - if state.directedPeer == nil || state.directedPeer == directedPeer { - return transferId - } - } - for request in pendingTransfers where request.contentKey == contentKey { - if request.directedPeer == nil || request.directedPeer == directedPeer { - return request.resolvedTransferId - } - } - return nil - } - - var activeCount: Int { - activeTransfers.count - } - - var pendingCount: Int { - pendingTransfers.count - } - - mutating func removeAll() -> [(id: String, workItems: [DispatchWorkItem])] { - let active = activeTransfers.map { ($0.key, $0.value.workItems) } - activeTransfers.removeAll() - pendingTransfers.removeAll() - return active - } - - mutating func submit( - _ request: BLEOutboundFragmentTransferRequest, - maxConcurrentTransfers: Int - ) -> SubmitResult { - guard let transferId = request.resolvedTransferId else { - return .start(request: request, reservedTransferId: nil) - } - - // Only requests without an explicit transferId are dropped as - // duplicates: those are resend paths (gossip-sync replay, directed - // spool) with no UI waiting on them. An app-initiated send carries a - // transferId whose progress events the UI tracks, so it always runs. - if request.transferId == nil, - let contentKey = request.contentKey, - let coveringId = coveringDuplicate(contentKey: contentKey, directedPeer: request.directedPeer) { - return .droppedDuplicate(request: request, activeTransferId: coveringId) - } - - guard activeTransfers.count < maxConcurrentTransfers else { - if request.requireDirectPeerLink { - return .rejectedStrict(request: request, transferId: transferId) - } - pendingTransfers.append(request) - return .queued(request: request, transferId: transferId, position: .back) - } - - guard activeTransfers[transferId] == nil else { - if request.requireDirectPeerLink { - return .rejectedStrict(request: request, transferId: transferId) - } - pendingTransfers.insert(request, at: 0) - return .queued(request: request, transferId: transferId, position: .front) - } - - activeTransfers[transferId] = ActiveTransferState( - totalFragments: 0, - sentFragments: 0, - workItems: [], - contentKey: request.contentKey, - directedPeer: request.directedPeer - ) - return .start(request: request, reservedTransferId: transferId) - } - - mutating func activateReservedTransfer( - id transferId: String, - totalFragments: Int, - workItems: [DispatchWorkItem] - ) -> Bool { - guard var state = activeTransfers[transferId] else { return false } - state.totalFragments = totalFragments - state.sentFragments = 0 - state.workItems = workItems - activeTransfers[transferId] = state - return true - } - - mutating func updateWorkItems(_ workItems: [DispatchWorkItem], for transferId: String) -> Bool { - guard var state = activeTransfers[transferId] else { return false } - state.workItems = workItems - activeTransfers[transferId] = state - return true - } - - mutating func releaseReservation(_ transferId: String) -> [DispatchWorkItem]? { - activeTransfers.removeValue(forKey: transferId)?.workItems - } - - func isActive(_ transferId: String) -> Bool { - activeTransfers[transferId] != nil - } - - mutating func cancelTransfer(_ transferId: String) -> CancelResult { - if let active = activeTransfers.removeValue(forKey: transferId) { - return .active(transferId: transferId, workItems: active.workItems) - } - - if let pendingIndex = pendingTransfers.firstIndex(where: { $0.resolvedTransferId == transferId || $0.transferId == transferId }) { - pendingTransfers.remove(at: pendingIndex) - return .pending(transferId: transferId) - } - - return .missing - } - - mutating func markFragmentSent(transferId: String) -> SentResult { - guard var state = activeTransfers[transferId] else { return .missing } - - state.sentFragments = min(state.sentFragments + 1, state.totalFragments) - let isComplete = state.sentFragments >= state.totalFragments - - if isComplete { - activeTransfers.removeValue(forKey: transferId) - return .complete(sentFragments: state.sentFragments, totalFragments: state.totalFragments) - } - - activeTransfers[transferId] = state - return .progress(sentFragments: state.sentFragments, totalFragments: state.totalFragments) - } - - mutating func reservePendingStarts(maxConcurrentTransfers: Int) -> [SubmitResult] { - var availableSlots = max(0, maxConcurrentTransfers - activeTransfers.count) - guard availableSlots > 0, !pendingTransfers.isEmpty else { return [] } - - var results: [SubmitResult] = [] - var blockedFront: [BLEOutboundFragmentTransferRequest] = [] - - while availableSlots > 0, !pendingTransfers.isEmpty { - let request = pendingTransfers.removeFirst() - - guard let transferId = request.resolvedTransferId else { - availableSlots -= 1 - results.append(.start(request: request, reservedTransferId: nil)) - continue - } - - // A queued duplicate of content that started while it waited - // must not resend the whole file once the slot frees up (same - // explicit-transferId exemption as submit). - if request.transferId == nil, - let contentKey = request.contentKey, - let coveringId = coveringDuplicate(contentKey: contentKey, directedPeer: request.directedPeer) { - results.append(.droppedDuplicate(request: request, activeTransferId: coveringId)) - continue - } - - guard activeTransfers.count < maxConcurrentTransfers else { - pendingTransfers.insert(request, at: 0) - results.append(.queued(request: request, transferId: transferId, position: .front)) - break - } - - guard activeTransfers[transferId] == nil else { - // Blocked on an already-active copy of this content: leave - // the slot budget untouched so a later, unrelated pending - // transfer can still start in this same pass instead of - // being starved until some other transfer happens to - // complete. - blockedFront.append(request) - results.append(.queued(request: request, transferId: transferId, position: .front)) - continue - } - - availableSlots -= 1 - activeTransfers[transferId] = ActiveTransferState( - totalFragments: 0, - sentFragments: 0, - workItems: [], - contentKey: request.contentKey, - directedPeer: request.directedPeer - ) - results.append(.start(request: request, reservedTransferId: transferId)) - } - - if !blockedFront.isEmpty { - pendingTransfers.insert(contentsOf: blockedFront, at: 0) - } - - return results - } -} diff --git a/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift b/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift deleted file mode 100644 index 9f729834..00000000 --- a/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift +++ /dev/null @@ -1,110 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEOutboundLinkPlan: Equatable { - let directedPeerHint: PeerID? - let fragmentChunkSize: Int? - let selectedLinks: BLEFanoutSelection - let shouldSpoolDirectedPacket: Bool -} - -enum BLEOutboundLinkPlanner { - static func plan( - packet: BitchatPacket, - dataCount: Int, - peripheralIDs: [String], - peripheralWriteLimits: [Int], - centralIDs: [String], - centralNotifyLimits: [Int], - ingressRecord: BLEIngressLinkRecord?, - excludedLinks: Set, - peripheralPeerBindings: [String: PeerID] = [:], - centralPeerBindings: [String: PeerID] = [:], - preferredPeripheralPerPeer: [PeerID: String] = [:], - directAnnounceTTL: UInt8 = TransportConfig.messageTTLDefault, - directedOnlyPeer: PeerID?, - requireDirectPeerLink: Bool = false - ) -> BLEOutboundLinkPlan { - let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer) - // Direct announces bypass the per-peer duplicate-link collapse so - // every live link gets bound (see BLEFanoutSelector.selectLinks). - let isDirectAnnounce = packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL - let selectedLinks = BLEFanoutSelector.selectLinks( - peripheralIDs: peripheralIDs, - centralIDs: centralIDs, - ingressLink: ingressRecord?.link, - excludedLinks: excludedLinks, - peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: centralPeerBindings, - preferredPeripheralPerPeer: preferredPeripheralPerPeer, - collapseDuplicatePeerLinks: !isDirectAnnounce, - directedPeerHint: directedPeerHint, - requireDirectPeerLink: requireDirectPeerLink, - packetType: packet.type, - messageID: BLEOutboundPacketPolicy.messageID(for: packet) - ) - - // Fragment only for links that this packet can actually use. Looking - // at every connected link before directed-peer selection lets an - // unrelated peer's MTU make an oversized directed send look routable, - // even though every resulting fragment will select zero target links. - let selectedPeripheralLimits = zip(peripheralIDs, peripheralWriteLimits).compactMap { id, limit in - selectedLinks.peripheralIDs.contains(id) ? limit : nil - } - let selectedCentralLimits = zip(centralIDs, centralNotifyLimits).compactMap { id, limit in - selectedLinks.centralIDs.contains(id) ? limit : nil - } - if let minLimit = minimumLinkLimit( - peripheralWriteLimits: selectedPeripheralLimits, - centralNotifyLimits: selectedCentralLimits - ), packet.type != MessageType.fragment.rawValue, - dataCount > minLimit { - return BLEOutboundLinkPlan( - directedPeerHint: directedPeerHint, - fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit), - selectedLinks: selectedLinks, - shouldSpoolDirectedPacket: false - ) - } - - return BLEOutboundLinkPlan( - directedPeerHint: directedPeerHint, - fragmentChunkSize: nil, - selectedLinks: selectedLinks, - shouldSpoolDirectedPacket: shouldSpoolDirectedPacket( - directedPeerHint: directedPeerHint, - selectedLinks: selectedLinks, - packetType: packet.type - ) - ) - } - - static func directedPeerHint(for packet: BitchatPacket, explicitPeer: PeerID?) -> PeerID? { - if let explicitPeer { return explicitPeer } - if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty { - return recipient - } - return nil - } - - static func minimumLinkLimit(peripheralWriteLimits: [Int], centralNotifyLimits: [Int]) -> Int? { - [peripheralWriteLimits.min(), centralNotifyLimits.min()] - .compactMap { $0 } - .min() - } - - static func shouldSpoolDirectedPacket( - directedPeerHint: PeerID?, - selectedLinks: BLEFanoutSelection, - packetType: UInt8 - ) -> Bool { - guard directedPeerHint != nil, - selectedLinks.peripheralIDs.isEmpty, - selectedLinks.centralIDs.isEmpty else { - return false - } - - return packetType == MessageType.noiseEncrypted.rawValue || - packetType == MessageType.noiseHandshake.rawValue - } -} diff --git a/bitchat/Services/BLE/BLEOutboundNotificationBuffer.swift b/bitchat/Services/BLE/BLEOutboundNotificationBuffer.swift deleted file mode 100644 index 43c94a9a..00000000 --- a/bitchat/Services/BLE/BLEOutboundNotificationBuffer.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -struct BLEPendingNotification { - let data: Data - let targets: [Target]? -} - -struct BLEOutboundNotificationBuffer { - enum EnqueueResult { - case enqueued(count: Int) - case full(count: Int) - } - - private var notifications: [BLEPendingNotification] = [] - - var count: Int { - notifications.count - } - - var isEmpty: Bool { - notifications.isEmpty - } - - mutating func removeAll() { - notifications.removeAll() - } - - mutating func enqueue(data: Data, targets: [Target]?, capCount: Int) -> EnqueueResult { - guard notifications.count < capCount else { - return .full(count: notifications.count) - } - - notifications.append(BLEPendingNotification(data: data, targets: targets)) - return .enqueued(count: notifications.count) - } - - mutating func takeAll() -> [BLEPendingNotification] { - let pending = notifications - notifications.removeAll() - return pending - } - - mutating func prepend(_ pending: [BLEPendingNotification]) { - guard !pending.isEmpty else { return } - notifications.insert(contentsOf: pending, at: 0) - } - - /// Removes a disconnected target from target-specific retries. Broadcast - /// entries (`targets == nil`) remain valid for the surviving subscriber - /// set; an entry with no targets left is discarded entirely. - mutating func removeTarget(where matches: (Target) -> Bool) { - notifications = notifications.compactMap { notification in - guard let targets = notification.targets else { return notification } - let remaining = targets.filter { !matches($0) } - guard !remaining.isEmpty else { return nil } - return BLEPendingNotification(data: notification.data, targets: remaining) - } - } -} diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift deleted file mode 100644 index fa0853d6..00000000 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ /dev/null @@ -1,61 +0,0 @@ -import BitFoundation -import Foundation - -enum BLEOutboundPacketPolicy { - private static let fragmentFrameOverhead = 13 + 8 + 8 + 13 - - static func messageID(for packet: BitchatPacket) -> String { - BLEIngressLinkRegistry.messageID(for: packet) - } - - static func padsBLEFrame(for packetType: UInt8) -> Bool { - switch MessageType(rawValue: packetType) { - case .noiseEncrypted, .noiseHandshake: - return true - // voiceFrame is deliberately unpadded: padding to the 512 block would - // push every ~490-byte signed voice packet over the MTU into the - // fragment path. - // - // announceV2 is unpadded too, but for a different reason and it is worth - // revisiting: it is ~75 bytes, so the smallest bucket would triple the - // airtime of the most frequently sent packet in the protocol. Its length - // is already near-constant by construction (the tag block is fixed - // width); the residual variation is the capability width and whether a - // bridge geohash is present. Making those fixed-width would be cheaper - // than padding. See docs/PEER-ID-ROTATION.md. - case .none, .announce, .announceV2, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame: - return false - } - } - - static func priority(for packet: BitchatPacket, data _: Data) -> BLEOutboundWritePriority { - guard let messageType = MessageType(rawValue: packet.type) else { return .low } - switch messageType { - case .fragment: - return .fragment(totalFragments: fragmentTotalCount(from: packet.payload)) - case .fileTransfer: - return .fileTransfer - case .announceV2: - // Stated rather than inherited from `default`. Presence is small, - // time-bounded to its epoch, and useless once stale, so it belongs - // with the other control traffic at high priority — but that should - // be a decision on the record, not a fall-through, since this type - // is not emitted yet and nobody would notice the choice being made. - return .high - default: - return .high - } - } - - static func fragmentChunkSize(forLinkLimit limit: Int) -> Int { - max(64, limit - fragmentFrameOverhead) - } - - private static func fragmentTotalCount(from payload: Data) -> Int { - guard payload.count >= 12 else { return Int(UInt16.max) } - let totalHigh = Int(payload[10]) - let totalLow = Int(payload[11]) - let total = (totalHigh << 8) | totalLow - return max(total, 1) - } -} diff --git a/bitchat/Services/BLE/BLEOutboundWriteBuffer.swift b/bitchat/Services/BLE/BLEOutboundWriteBuffer.swift deleted file mode 100644 index 44dfcbf1..00000000 --- a/bitchat/Services/BLE/BLEOutboundWriteBuffer.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation - -struct BLEOutboundWritePriority: Comparable { - let level: Int - let suborder: Int - - static let high = BLEOutboundWritePriority(level: 0, suborder: 0) - - static func fragment(totalFragments: Int) -> BLEOutboundWritePriority { - BLEOutboundWritePriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max)))) - } - - static let fileTransfer = BLEOutboundWritePriority(level: 2, suborder: Int.max - 1) - static let low = BLEOutboundWritePriority(level: 2, suborder: Int.max) - - static func < (lhs: BLEOutboundWritePriority, rhs: BLEOutboundWritePriority) -> Bool { - if lhs.level != rhs.level { return lhs.level < rhs.level } - return lhs.suborder < rhs.suborder - } -} - -struct BLEPendingWrite { - let priority: BLEOutboundWritePriority - let data: Data -} - -struct BLEOutboundWriteBuffer { - enum EnqueueResult { - case enqueued(trimmedBytes: Int, remainingBytes: Int) - case oversized(bytes: Int) - } - - private var writesByPeripheralID: [String: [BLEPendingWrite]] = [:] - - var peripheralIDs: [String] { - Array(writesByPeripheralID.keys) - } - - mutating func removeAll() { - writesByPeripheralID.removeAll() - } - - mutating func enqueue( - data: Data, - for peripheralID: String, - priority: BLEOutboundWritePriority, - capBytes: Int - ) -> EnqueueResult { - enqueueReportingAcceptance( - data: data, - for: peripheralID, - priority: priority, - capBytes: capBytes - ).result - } - - /// Enqueues while also reporting whether the newly offered write survived - /// priority trimming. `EnqueueResult.enqueued` alone cannot express that: - /// a full queue may immediately trim the new lowest-priority item. - mutating func enqueueReportingAcceptance( - data: Data, - for peripheralID: String, - priority: BLEOutboundWritePriority, - capBytes: Int - ) -> (result: EnqueueResult, accepted: Bool) { - guard data.count <= capBytes else { - return (.oversized(bytes: data.count), false) - } - - var queue = writesByPeripheralID[peripheralID] ?? [] - let item = BLEPendingWrite(priority: priority, data: data) - let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count - queue.insert(item, at: insertIndex) - - var total = queue.reduce(0) { $0 + $1.data.count } - var trimmedBytes = 0 - - while total > capBytes && !queue.isEmpty { - let removed = queue.removeLast() - trimmedBytes += removed.data.count - total -= removed.data.count - } - - writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue - let accepted = insertIndex < queue.count - return (.enqueued(trimmedBytes: trimmedBytes, remainingBytes: total), accepted) - } - - mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] { - let items = writesByPeripheralID[peripheralID] ?? [] - writesByPeripheralID[peripheralID] = nil - return items - } - - /// Drops link-specific ciphertext when that physical link is gone. Keeping - /// the dictionary entry would let rotating peripheral UUIDs accumulate a - /// fresh per-link byte cap indefinitely. - mutating func discardAll(for peripheralID: String) { - writesByPeripheralID[peripheralID] = nil - } - - mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) { - guard !items.isEmpty else { return } - var existing = writesByPeripheralID[peripheralID] ?? [] - existing.insert(contentsOf: items, at: 0) - writesByPeripheralID[peripheralID] = existing - } -} diff --git a/bitchat/Services/BLE/BLEPacketFreshnessPolicy.swift b/bitchat/Services/BLE/BLEPacketFreshnessPolicy.swift deleted file mode 100644 index 2559d855..00000000 --- a/bitchat/Services/BLE/BLEPacketFreshnessPolicy.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -enum BLEPacketFreshnessPolicy { - static let defaultMaxAgeSeconds: TimeInterval = 900 - - static func isBroadcastRecipient(_ recipientID: Data?) -> Bool { - guard let recipientID else { return true } - return recipientID.count == 8 && recipientID.allSatisfy { $0 == 0xFF } - } - - static func isStale( - timestampMilliseconds: UInt64, - now: Date, - maxAgeSeconds: TimeInterval = defaultMaxAgeSeconds - ) -> Bool { - let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000) - let maxAgeMilliseconds = UInt64(maxAgeSeconds * 1000) - guard nowMilliseconds >= maxAgeMilliseconds else { return false } - return timestampMilliseconds < nowMilliseconds - maxAgeMilliseconds - } - - static func ageSeconds(timestampMilliseconds: UInt64, now: Date) -> Double { - let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000) - guard nowMilliseconds >= timestampMilliseconds else { return 0 } - return Double(nowMilliseconds - timestampMilliseconds) / 1000.0 - } -} diff --git a/bitchat/Services/BLE/BLEPeerEventDebouncer.swift b/bitchat/Services/BLE/BLEPeerEventDebouncer.swift deleted file mode 100644 index 9b3ac4f3..00000000 --- a/bitchat/Services/BLE/BLEPeerEventDebouncer.swift +++ /dev/null @@ -1,25 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEPeerEventDebouncer { - private var lastEmitByPeer: [PeerID: Date] = [:] - - var count: Int { - lastEmitByPeer.count - } - - @discardableResult - mutating func shouldEmit(peerID: PeerID, now: Date, minimumInterval: TimeInterval) -> Bool { - if let lastEmit = lastEmitByPeer[peerID], - now.timeIntervalSince(lastEmit) < minimumInterval { - return false - } - - lastEmitByPeer[peerID] = now - return true - } - - mutating func removeAll() { - lastEmitByPeer.removeAll() - } -} diff --git a/bitchat/Services/BLE/BLEPeerPublishCoalescer.swift b/bitchat/Services/BLE/BLEPeerPublishCoalescer.swift deleted file mode 100644 index b3290baa..00000000 --- a/bitchat/Services/BLE/BLEPeerPublishCoalescer.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -enum BLEPeerPublishDecision: Equatable { - case publishNow - case schedule(delay: TimeInterval) - case skip -} - -struct BLEPeerPublishCoalescer { - private var lastPublishAt: Date - private var publishPending: Bool - private let minimumInterval: TimeInterval - - init( - lastPublishAt: Date = .distantPast, - publishPending: Bool = false, - minimumInterval: TimeInterval = 0.1 - ) { - self.lastPublishAt = lastPublishAt - self.publishPending = publishPending - self.minimumInterval = minimumInterval - } - - mutating func requestPublish(now: Date) -> BLEPeerPublishDecision { - let elapsed = now.timeIntervalSince(lastPublishAt) - if elapsed >= minimumInterval { - lastPublishAt = now - return .publishNow - } - - guard !publishPending else { - return .skip - } - - publishPending = true - return .schedule(delay: minimumInterval - elapsed) - } - - mutating func scheduledPublishFired(now: Date) { - lastPublishAt = now - publishPending = false - } -} diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift deleted file mode 100644 index 62113a59..00000000 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ /dev/null @@ -1,271 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEPeerInfo: Equatable { - let peerID: PeerID - var nickname: String - var isConnected: Bool - var noisePublicKey: Data? - var signingPublicKey: Data? - var isVerifiedNickname: Bool - var lastSeen: Date - var capabilities: PeerCapabilities = [] - /// Distinguishes an old client that omitted the capabilities TLV from a - /// modern client that explicitly advertised a set without a given bit. - var capabilitiesWereExplicitlyAdvertised: Bool = false - /// Rendezvous cell from the peer's announce when it advertises `.bridge`. - var bridgeGeohash: String? -} - -struct BLEPeerAnnounceUpdate: Equatable { - let isNewPeer: Bool - let wasDisconnected: Bool - let previousNickname: String? -} - -struct BLEPeerLinkPresence: Equatable { - var hasPeripheral: Bool - var hasCentral: Bool -} - -struct BLERemovedPeer: Equatable { - let peerID: PeerID - let nickname: String -} - -struct BLEPeerConnectivityChanges: Equatable { - var disconnectedPeerIDs: [PeerID] = [] - var removedPeers: [BLERemovedPeer] = [] -} - -struct BLEPeerRegistry { - private var peers: [PeerID: BLEPeerInfo] = [:] - - var isEmpty: Bool { - peers.isEmpty - } - - var count: Int { - peers.count - } - - var peerIDs: [PeerID] { - Array(peers.keys) - } - - var connectedCount: Int { - peers.values.filter(\.isConnected).count - } - - var connectedPeerIDs: [PeerID] { - peers.values.compactMap { $0.isConnected ? $0.peerID : nil } - } - - var connectedRoutingData: [Data] { - peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData } - } - - var snapshotByID: [PeerID: BLEPeerInfo] { - peers - } - - mutating func removeAll() { - peers.removeAll() - } - - func info(for peerID: PeerID) -> BLEPeerInfo? { - peers[peerID] - } - - mutating func upsert(_ info: BLEPeerInfo) { - peers[info.peerID] = info - } - - @discardableResult - mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? { - peers.removeValue(forKey: peerID) - } - - func isConnected(_ peerID: PeerID) -> Bool { - peers[peerID.toShort()]?.isConnected ?? false - } - - func isReachable(_ peerID: PeerID, now: Date) -> Bool { - let shortID = peerID.toShort() - let meshAttached = connectedCount > 0 - guard let info = peers[shortID] else { return false } - if info.isConnected { return true } - guard meshAttached else { return false } - - let retention: TimeInterval = info.isVerifiedNickname - ? TransportConfig.bleReachabilityRetentionVerifiedSeconds - : TransportConfig.bleReachabilityRetentionUnverifiedSeconds - return now.timeIntervalSince(info.lastSeen) <= retention - } - - func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? { - guard let peer = peers[peerID] else { return nil } - if connectedOnly && !peer.isConnected { return nil } - return peer.nickname - } - - func fingerprint(for peerID: PeerID) -> String? { - peers[peerID]?.noisePublicKey?.sha256Fingerprint() - } - - func capabilities(for peerID: PeerID) -> PeerCapabilities { - peers[peerID.toShort()]?.capabilities ?? [] - } - - func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool { - peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true - } - - /// Peers whose last verified announce advertised the given capability. - func peers(advertising capability: PeerCapabilities) -> [PeerID] { - peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID) - } - - /// A rendezvous cell advertised by any bridge-capable peer, if one is - /// known — lets location-less devices join the island's rendezvous. - func advertisedBridgeGeohash() -> String? { - peers.values - .filter { $0.capabilities.contains(.bridge) } - .compactMap(\.bridgeGeohash) - .first - } - - func displayNicknames(selfNickname: String) -> [PeerID: String] { - let connected = peers.filter { $0.value.isConnected } - let tuples = connected.map { ($0.key, $0.value.nickname, true) } - return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname) - } - - func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] { - let snapshot = Array(peers.values) - let resolvedNames = PeerDisplayNameResolver.resolve( - snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) }, - selfNickname: selfNickname - ) - return snapshot.map { info in - TransportPeerSnapshot( - peerID: info.peerID, - nickname: resolvedNames[info.peerID] ?? info.nickname, - isConnected: info.isConnected, - noisePublicKey: info.noisePublicKey, - lastSeen: info.lastSeen, - isVerified: info.isVerifiedNickname - ) - } - } - - mutating func markDisconnected(_ peerID: PeerID) { - guard var info = peers[peerID] else { return } - info.isConnected = false - peers[peerID] = info - } - - /// Flips an already-known peer to connected. Returns false when the peer - /// is unknown or already connected (nothing changed). - @discardableResult - mutating func markConnected(_ peerID: PeerID) -> Bool { - guard var info = peers[peerID], !info.isConnected else { return false } - info.isConnected = true - peers[peerID] = info - return true - } - - mutating func updateLastSeen(_ peerID: PeerID, at date: Date) { - guard var peer = peers[peerID] else { return } - peer.lastSeen = date - peers[peerID] = peer - } - - /// Replaces the announcement signing key only after the surrounding Noise - /// session proved possession of this peer's static key. - mutating func bindAuthenticatedSigningPublicKey(_ key: Data, for peerID: PeerID) { - guard var peer = peers[peerID.toShort()] else { return } - peer.signingPublicKey = key - peers[peer.peerID] = peer - } - - /// Applies a verified announce to the registry. - /// - /// TOFU signing-key pinning: once a signing key has been bound to this - /// peer entry, an announce carrying a *different* signing key is refused - /// (returns `nil`) and the existing record is left untouched. PeerIDs are - /// derived from the (public) noise key, so without pinning an attacker - /// could replay a victim's noiseKey/peerID with their own signing key and - /// silently take over the victim's mesh identity and nickname. - mutating func upsertVerifiedAnnounce( - peerID: PeerID, - nickname: String, - noisePublicKey: Data, - signingPublicKey: Data?, - isConnected: Bool, - now: Date, - capabilities: PeerCapabilities? = nil, - bridgeGeohash: String? = nil - ) -> BLEPeerAnnounceUpdate? { - let existing = peers[peerID] - - if let pinnedSigningKey = existing?.signingPublicKey, - let announcedSigningKey = signingPublicKey, - pinnedSigningKey != announcedSigningKey { - return nil - } - - let update = BLEPeerAnnounceUpdate( - isNewPeer: existing == nil, - wasDisconnected: existing?.isConnected == false, - previousNickname: existing?.nickname - ) - - peers[peerID] = BLEPeerInfo( - peerID: existing?.peerID ?? peerID, - nickname: nickname.normalizedNickname, - isConnected: isConnected, - noisePublicKey: noisePublicKey, - // Never drop an already-pinned signing key. - signingPublicKey: signingPublicKey ?? existing?.signingPublicKey, - isVerifiedNickname: true, - lastSeen: now, - capabilities: capabilities ?? [], - capabilitiesWereExplicitlyAdvertised: capabilities != nil, - bridgeGeohash: bridgeGeohash - ) - - return update - } - - mutating func reconcileConnectivity( - now: Date, - linkStates: [PeerID: BLEPeerLinkPresence] - ) -> BLEPeerConnectivityChanges { - var changes = BLEPeerConnectivityChanges() - - for (peerID, peer) in Array(peers) { - let age = now.timeIntervalSince(peer.lastSeen) - let retention: TimeInterval = peer.isVerifiedNickname - ? TransportConfig.bleReachabilityRetentionVerifiedSeconds - : TransportConfig.bleReachabilityRetentionUnverifiedSeconds - - if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds { - let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false) - if !state.hasPeripheral && !state.hasCentral { - var updated = peer - updated.isConnected = false - peers[peerID] = updated - changes.disconnectedPeerIDs.append(peerID) - } - } - - if !peer.isConnected && age > retention { - peers.removeValue(forKey: peerID) - changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname)) - } - } - - return changes - } -} diff --git a/bitchat/Services/BLE/BLEPeerRegistryStore.swift b/bitchat/Services/BLE/BLEPeerRegistryStore.swift deleted file mode 100644 index 68110aed..00000000 --- a/bitchat/Services/BLE/BLEPeerRegistryStore.swift +++ /dev/null @@ -1,84 +0,0 @@ -import BitFoundation -import Foundation - -/// Lock-backed shared ownership of the peer registry, readable from any -/// queue or the main actor without hopping onto a transport queue. -/// -/// Mutations come only from the transport's own serial queues — the -/// engine, plus the bleQueue link-drop paths that mark a peer -/// disconnected — and the lock serializes them against each other and -/// against readers, so the main actor answers questions like -/// `isPeerConnected` without blocking behind in-flight transport work. -/// Every `BLEPeerRegistry` mutation is a single whole-transition method, -/// so a reader between two mutations always observes a valid pre- or -/// post-state, never a torn one. -/// -/// Closures passed to `read`/`mutate` run under the (non-recursive) lock -/// and must not call back into the store. -final class BLEPeerRegistryStore: @unchecked Sendable { - private let lock = NSLock() - private var registry = BLEPeerRegistry() - - /// One consistent view across multiple registry reads. - func read(_ body: (BLEPeerRegistry) -> T) -> T { - lock.withLock { body(registry) } - } - - func mutate(_ body: (inout BLEPeerRegistry) -> T) -> T { - lock.withLock { body(®istry) } - } - - // MARK: - Single-question reads - - var isEmpty: Bool { read { $0.isEmpty } } - var peerIDs: [PeerID] { read { $0.peerIDs } } - var connectedCount: Int { read { $0.connectedCount } } - var connectedPeerIDs: [PeerID] { read { $0.connectedPeerIDs } } - var connectedRoutingData: [Data] { read { $0.connectedRoutingData } } - var snapshotByID: [PeerID: BLEPeerInfo] { read { $0.snapshotByID } } - - func info(for peerID: PeerID) -> BLEPeerInfo? { - read { $0.info(for: peerID) } - } - - func isConnected(_ peerID: PeerID) -> Bool { - read { $0.isConnected(peerID) } - } - - func isReachable(_ peerID: PeerID, now: Date) -> Bool { - read { $0.isReachable(peerID, now: now) } - } - - func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? { - read { $0.nickname(for: peerID, connectedOnly: connectedOnly) } - } - - func fingerprint(for peerID: PeerID) -> String? { - read { $0.fingerprint(for: peerID) } - } - - func capabilities(for peerID: PeerID) -> PeerCapabilities { - read { $0.capabilities(for: peerID) } - } - - func advertisedBridgeGeohash() -> String? { - read { $0.advertisedBridgeGeohash() } - } - - func displayNicknames(selfNickname: String) -> [PeerID: String] { - read { $0.displayNicknames(selfNickname: selfNickname) } - } - - func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] { - read { $0.transportSnapshots(selfNickname: selfNickname) } - } - - /// Peers advertising `capability` that are reachable now, in one - /// consistent view. - func reachablePeers(advertising capability: PeerCapabilities, now: Date) -> [PeerID] { - read { registry in - registry.peers(advertising: capability) - .filter { registry.isReachable($0, now: now) } - } - } -} diff --git a/bitchat/Services/BLE/BLEPeerSenderDisplayName.swift b/bitchat/Services/BLE/BLEPeerSenderDisplayName.swift deleted file mode 100644 index c17d8d7e..00000000 --- a/bitchat/Services/BLE/BLEPeerSenderDisplayName.swift +++ /dev/null @@ -1,60 +0,0 @@ -import BitFoundation -import Foundation - -enum BLEPeerSenderDisplayName { - static func resolveKnownPeer( - peerID: PeerID, - localPeerID: PeerID, - localNickname: String, - peers: [PeerID: BLEPeerInfo], - allowConnectedUnverified: Bool - ) -> String? { - if peerID == localPeerID { - return localNickname - } - - guard let info = peers[peerID] else { return nil } - - if info.isVerifiedNickname { - return collisionResolvedName( - displayName: info.nickname, - collisionNickname: info.nickname, - peerID: peerID, - localNickname: localNickname, - peers: peers - ) - } - - if allowConnectedUnverified, info.isConnected { - let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname - return collisionResolvedName( - displayName: displayName, - collisionNickname: info.nickname, - peerID: peerID, - localNickname: localNickname, - peers: peers - ) - } - - return nil - } - - static func anonymousNickname(for peerID: PeerID) -> String { - "anon" + String(peerID.id.prefix(4)) - } - - private static func collisionResolvedName( - displayName: String, - collisionNickname: String, - peerID: PeerID, - localNickname: String, - peers: [PeerID: BLEPeerInfo] - ) -> String { - let hasCollision = peers.values.contains { - $0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID - } || localNickname == collisionNickname - - guard hasCollision else { return displayName } - return displayName + "#" + String(peerID.id.prefix(4)) - } -} diff --git a/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift b/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift deleted file mode 100644 index dc72af4f..00000000 --- a/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift +++ /dev/null @@ -1,1143 +0,0 @@ -import BitLogger -import Foundation - -enum BLEPrivateMediaReceiptState: Equatable { - /// No durable receiver decision exists for this stable message ID. - case absent - /// The payload is durably mapped to a file that still exists. - case accepted(URL) - /// The user explicitly deleted the payload; retries must not resurrect it. - case tombstoned - /// Durable state could not be read safely. Callers must fail closed and - /// must not save, deliver, or acknowledge the payload. - case unavailable -} - -/// Durable, per-message receiver decisions for stable private media. -/// -/// Each ID has its own atomic record so one hot lookup never rewrites or -/// decodes the entire ledger. The process-lifetime index is installed only -/// after a complete directory scan. A structural failure of the directory -/// itself (create/enumerate) — or of the single batch deletion journal — -/// remains globally fail-closed and retryable. An individual record that -/// cannot be read, decoded, or validated is quarantined instead: the file is -/// moved aside with a `.corrupt` suffix, excluded from future scans, and only -/// that ID stays fail-closed — the rest of the ledger keeps working, so one -/// damaged record can never make every inbound private media payload vanish. -final class BLEPrivateMediaReceiptStore: @unchecked Sendable { - typealias DirectoryReader = (_ directory: URL) throws -> [URL] - typealias DataReader = (_ url: URL) throws -> Data - typealias DataWriter = ( - _ data: Data, - _ url: URL, - _ options: Data.WritingOptions - ) throws -> Void - typealias PayloadRemover = (_ url: URL) throws -> Void - private static let receiptDirectoryName = ".private-media-receipts" - private static let quarantinePathExtension = "corrupt" - private static let deletionJournalFileName = ".deletion-journal.json" - private static let maximumDeletionPathsPerMessage = 2 - - private struct ReceiptRecord: Codable, Equatable { - enum Kind: String, Codable { - case accepted - case tombstone - } - - let kind: Kind - /// Path below the app's `files/` root. Absolute application-container - /// prefixes are not stable across updates, restores, or reinstalls. - let relativePath: String? - let recordedAt: Date - } - - /// One atomic write of this journal is the commit point for an entire - /// explicit-deletion batch. Per-ID records and payload unlinks are - /// idempotent materialization performed only after that commit. - private struct DeletionJournalEntry: Codable, Equatable { - let relativePaths: [String] - let recordedAt: Date - } - - private struct DeletionJournal: Codable { - let version: Int - let entries: [String: DeletionJournalEntry] - } - - private final class Runtime: @unchecked Sendable { - let lock = NSLock() - var records: [String: ReceiptRecord]? - /// IDs whose durable record was quarantined as unreadable. Installed - /// together with `records`; these IDs stay fail-closed while every - /// other record keeps serving. - var quarantined: Set = [] - var deletionJournal: [String: DeletionJournalEntry]? - } - - private let fileManager: FileManager - private let baseDirectory: URL? - private let capacity: Int - private let ttl: TimeInterval - private let now: () -> Date - private let directoryReader: DirectoryReader? - private let dataReader: DataReader? - private let dataWriter: DataWriter - private let payloadRemover: PayloadRemover - private let runtime = Runtime() - - init( - fileManager: FileManager = .default, - baseDirectory: URL? = nil, - capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity, - ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds, - now: @escaping () -> Date = Date.init, - directoryReader: DirectoryReader? = nil, - dataReader: DataReader? = nil, - dataWriter: @escaping DataWriter = { - try $0.write(to: $1, options: $2) - }, - payloadRemover: PayloadRemover? = nil - ) { - self.fileManager = fileManager - self.baseDirectory = baseDirectory - self.capacity = max(1, capacity) - self.ttl = max(0, ttl) - self.now = now - self.directoryReader = directoryReader - self.dataReader = dataReader - self.dataWriter = dataWriter - self.payloadRemover = payloadRemover ?? { - try fileManager.removeItem(at: $0) - } - } - - /// Drops process-lifetime decisions after the enclosing media directory - /// has been panic-wiped. A later lookup must rebuild from the durable - /// ledger instead of retaining an accepted receipt or tombstone whose - /// backing files no longer exist. - func resetForPanic() { - runtime.lock.lock() - runtime.records = nil - runtime.quarantined.removeAll(keepingCapacity: false) - runtime.deletionJournal = nil - runtime.lock.unlock() - } - - func state(for messageID: String) -> BLEPrivateMediaReceiptState { - guard PrivateMediaMessageIdentity.isStableID(messageID) else { - return .absent - } - - runtime.lock.lock() - defer { runtime.lock.unlock() } - - let date = now() - guard let directory = resolvedReceiptDirectory(), - loadDurableStateIfNeeded(from: directory, at: date) else { - return .unavailable - } - _ = recoverDeletionJournal(in: directory) - // Committed deletion intent outranks quarantine: a pending journal - // entry proves the payload must stay deleted no matter what state - // the per-ID record file is in. - if runtime.deletionJournal?[messageID] != nil { - return .tombstoned - } - // A quarantined record could have been an acceptance or a tombstone; - // only this ID fails closed, so a retry can neither resurrect deleted - // media nor double-deliver, while every other payload keeps working. - if runtime.quarantined.contains(messageID) { - return .unavailable - } - guard var records = runtime.records else { return .unavailable } - guard var record = records[messageID] else { return .absent } - - if record.kind == .tombstone, record.relativePath != nil { - guard scrubLegacyTombstone( - record, - messageID: messageID, - in: directory, - records: &records - ) else { - return .tombstoned - } - guard let scrubbed = records[messageID] else { - return .unavailable - } - record = scrubbed - } - - let retainsAcceptedPath = - record.kind == .accepted - && record.relativePath.flatMap(existingPayload) != nil - if isExpired(record.recordedAt, at: date), - !retainsAcceptedPath { - guard removeRecord( - messageID: messageID, - from: directory - ) else { - return record.kind == .tombstone - ? .tombstoned - : .unavailable - } - records.removeValue(forKey: messageID) - runtime.records = records - return .absent - } - - switch record.kind { - case .tombstone: - return .tombstoned - - case .accepted: - guard let relativePath = record.relativePath, - let existingURL = existingPayload(relativePath: relativePath) else { - // Quota cleanup is not explicit deletion. Remove the stale - // receipt so a sender retry can restore the payload and bubble. - guard removeRecord( - messageID: messageID, - from: directory - ) else { - return .unavailable - } - records.removeValue(forKey: messageID) - runtime.records = records - return .absent - } - return .accepted(existingURL) - } - } - - /// Records an accepted ID only after the payload is on disk. Callers must - /// roll the payload back and withhold UI delivery/ACK when this returns - /// false. - func commitAccepted(messageID: String, storedURL: URL) -> Bool { - guard PrivateMediaMessageIdentity.isStableID(messageID), - validExistingPayload(storedURL) != nil, - let relativePath = relativePath(for: storedURL) else { - return false - } - - runtime.lock.lock() - defer { runtime.lock.unlock() } - - let date = now() - guard let directory = resolvedReceiptDirectory(), - loadDurableStateIfNeeded(from: directory, at: date) else { - return false - } - _ = recoverDeletionJournal(in: directory) - guard runtime.deletionJournal?[messageID] == nil, - !runtime.quarantined.contains(messageID), - var records = runtime.records else { - return false - } - if runtime.deletionJournal?.values.contains(where: { - $0.relativePaths.contains(relativePath) - }) == true { - return false - } - if records.contains(where: { existingMessageID, record in - existingMessageID != messageID - && record.relativePath == relativePath - }) { - return false - } - if let existing = records[messageID], - existing.kind == .tombstone, - !isExpired(existing.recordedAt, at: date) { - return false - } - - let victim = capacityVictim( - for: .accepted, - replacing: messageID, - in: records - ) - if records[messageID]?.kind != .accepted, - records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity, - victim == nil { - return false - } - - let record = ReceiptRecord( - kind: .accepted, - relativePath: relativePath, - recordedAt: date - ) - guard persist(record, messageID: messageID, to: directory) else { - return false - } - - records[messageID] = record - if let victim, victim != messageID { - records.removeValue(forKey: victim) - removeRecord(messageID: victim, from: directory) - } - runtime.records = records - return true - } - - /// Atomically commits explicit deletion for every stable ID in `messageIDs`. - /// - /// The journal is the single batch commit point: a failed write changes - /// neither durable nor in-memory receiver state, so callers must preserve - /// their bubbles. Once the write succeeds, every entry is tombstoned even - /// if the process exits before per-ID materialization or payload unlink. - /// Recovery retries both operations on the next lookup or launch. - func recordDeleted( - messageIDs: [String], - payloadRelativePaths: [String: String] = [:], - protectedPayloadRelativePaths: Set = [] - ) -> Bool { - let stableIDs = Array( - Set(messageIDs.filter(PrivateMediaMessageIdentity.isStableID)) - ).sorted() - guard !stableIDs.isEmpty else { return true } - guard stableIDs.count <= capacity else { return false } - - runtime.lock.lock() - defer { runtime.lock.unlock() } - - let date = now() - guard let directory = resolvedReceiptDirectory(), - loadDurableStateIfNeeded(from: directory, at: date) else { - return false - } - _ = recoverDeletionJournal(in: directory) - guard let records = runtime.records, - var journal = runtime.deletionJournal else { - return false - } - - // Quarantined IDs need no special case here: their record is never - // indexed, so deleting one requires a caller-supplied payload path - // (the general pathless-deletion refusal below rejects it otherwise), - // and materialization never rewrites a quarantined ID's record. - let pendingIDs = Set(journal.keys) - let newIDs = stableIDs.filter { messageID in - if pendingIDs.contains(messageID) { return false } - if let current = records[messageID], - current.kind == .tombstone, - !isExpired(current.recordedAt, at: date) { - return false - } - return true - } - guard !newIDs.isEmpty else { - return true - } - guard Set(journal.keys).union(newIDs).count <= capacity else { - return false - } - - var newEntries: [String: DeletionJournalEntry] = [:] - for messageID in newIDs { - // Accepted receipts normally supply the exact stored path. The UI - // fallback is required when that receipt aged or was capacity - // evicted while its bubble and payload remain. They may differ - // after a retry selected a suffixed filename, so journal both. - // Never commit a new pathless tombstone: it could retire - // successfully while leaving an untracked payload behind. - let relativePaths = Array(Set([ - records[messageID]?.relativePath, - payloadRelativePaths[messageID] - ].compactMap { $0 })).sorted() - guard !relativePaths.isEmpty, - relativePaths.count <= - Self.maximumDeletionPathsPerMessage, - relativePaths.allSatisfy({ - isSafeDeletionTarget(relativePath: $0) - }), - protectedPayloadRelativePaths.isDisjoint( - with: relativePaths - ), - !records.contains(where: { otherMessageID, record in - otherMessageID != messageID - && !stableIDs.contains(otherMessageID) - && record.relativePath.map( - relativePaths.contains - ) == true - }), - !journal.contains(where: { otherMessageID, entry in - otherMessageID != messageID - && !stableIDs.contains(otherMessageID) - && !Set(entry.relativePaths).isDisjoint( - with: relativePaths - ) - }) else { - return false - } - newEntries[messageID] = DeletionJournalEntry( - // The journal retains every owned path so payload deletion - // remains recoverable across a crash or unlink failure. - relativePaths: relativePaths, - recordedAt: date - ) - } - journal.merge(newEntries) { _, new in new } - - // Do not install any process-local tombstone before this succeeds. - // A failed delete must continue to resolve to its prior accepted - // state, otherwise a retry could be falsely ACKed while UI remains. - guard persistDeletionJournal(journal, in: directory) else { - return false - } - - runtime.deletionJournal = journal - _ = recoverDeletionJournal(in: directory) - return true - } - - func recordDeleted(messageID: String) -> Bool { - guard PrivateMediaMessageIdentity.isStableID(messageID) else { - return false - } - return recordDeleted(messageIDs: [messageID]) - } - - /// Resolves every path a deletion transaction may target. The incoming - /// allocator reserves this set at the journal barrier so a concurrent raw - /// arrival cannot reuse a missing UI fallback. - func prospectiveDeletionPayloadPaths( - messageIDs: [String], - payloadRelativePaths: [String: String] - ) -> Set? { - let stableIDs = Set( - messageIDs.filter(PrivateMediaMessageIdentity.isStableID) - ) - guard !stableIDs.isEmpty else { return [] } - - runtime.lock.lock() - defer { runtime.lock.unlock() } - - let date = now() - guard let directory = resolvedReceiptDirectory(), - loadDurableStateIfNeeded(from: directory, at: date) else { - return nil - } - _ = recoverDeletionJournal(in: directory) - guard let journal = runtime.deletionJournal, - let records = runtime.records else { - return nil - } - - var paths: Set = [] - for messageID in stableIDs { - if let entry = journal[messageID] { - paths.formUnion(entry.relativePaths) - continue - } - if let relativePath = records[messageID]?.relativePath { - paths.insert(relativePath) - } - if let relativePath = payloadRelativePaths[messageID] { - paths.insert(relativePath) - } - } - guard paths.allSatisfy({ - candidatePayload(relativePath: $0) != nil - }) else { - return nil - } - return Set(paths.compactMap { - candidatePayload(relativePath: $0)? - .standardizedFileURL.path - }) - } - - /// Paths owned by accepted receipts, legacy pathful tombstones, or the - /// deletion journal. Incoming allocation must not reuse any of them for a - /// different ID. Quarantined records are unreadable, so any path they may - /// have owned cannot be reserved; their bytes remain preserved in the - /// `.corrupt` file for offline inspection. - func reservedPayloadPaths() -> Set? { - runtime.lock.lock() - defer { runtime.lock.unlock() } - - let date = now() - guard let directory = resolvedReceiptDirectory(), - loadDurableStateIfNeeded(from: directory, at: date) else { - return nil - } - _ = recoverDeletionJournal(in: directory) - guard let journal = runtime.deletionJournal, - let records = runtime.records else { - return nil - } - let recordPaths = records.values.compactMap(\.relativePath) - let journalPaths = journal.values.flatMap(\.relativePaths) - return Set((recordPaths + journalPaths).compactMap { relativePath in - candidatePayload(relativePath: relativePath)? - .standardizedFileURL.path - }) - } - - private func scrubLegacyTombstone( - _ tombstone: ReceiptRecord, - messageID: String, - in directory: URL, - records: inout [String: ReceiptRecord] - ) -> Bool { - guard tombstone.kind == .tombstone, - tombstone.relativePath != nil else { - return true - } - // Pre-journal tombstones cannot prove that the current file is still - // the payload they originally described. Older allocators did not - // reserve these paths, so a raw/public arrival may have reused the - // basename. Shed the ambiguous path without unlinking anything. - let pathless = ReceiptRecord( - kind: .tombstone, - relativePath: nil, - recordedAt: tombstone.recordedAt - ) - guard persist( - pathless, - messageID: messageID, - to: directory - ) else { - return false - } - records[messageID] = pathless - runtime.records = records - return true - } - - private func loadDurableStateIfNeeded( - from directory: URL, - at date: Date - ) -> Bool { - if runtime.records != nil, runtime.deletionJournal != nil { - return true - } - - do { - try fileManager.createDirectory( - at: directory, - withIntermediateDirectories: true, - attributes: nil - ) - } catch { - SecureLogger.error( - "❌ Failed to create private-media receipt directory: \(error)", - category: .session - ) - return false - } - - let urls: [URL] - do { - if let directoryReader { - urls = try directoryReader(directory) - } else { - urls = try fileManager.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: nil, - options: [] - ) - } - } catch { - SecureLogger.error( - "❌ Failed to enumerate private-media receipts: \(error)", - category: .session - ) - return false - } - - var records: [String: ReceiptRecord] = [:] - var scannedRecords: [String: ReceiptRecord] = [:] - var quarantined: Set = [] - var expired: [String] = [] - var tombstones: [(messageID: String, record: ReceiptRecord)] = [] - for url in urls { - // Records quarantined by an earlier scan stay fail-closed on - // every launch without being re-read: only their ID matters. - if url.pathExtension == Self.quarantinePathExtension { - let messageID = url - .deletingPathExtension() - .deletingPathExtension() - .lastPathComponent - if PrivateMediaMessageIdentity.isStableID(messageID) { - quarantined.insert(messageID) - } - continue - } - guard url.pathExtension == "json" else { continue } - let messageID = url.deletingPathExtension().lastPathComponent - guard PrivateMediaMessageIdentity.isStableID(messageID) else { - continue - } - - let record: ReceiptRecord - do { - let data = try dataReader?(url) ?? Data(contentsOf: url) - record = try JSONDecoder().decode(ReceiptRecord.self, from: data) - } catch { - // Never delete or silently skip an unreadable stable-ID - // record: treating it as absent could resurrect accepted or - // deleted media. But never let it poison the whole ledger - // either — quarantine the file and fail only this ID closed. - quarantine(url, messageID: messageID, reason: "\(error)") - quarantined.insert(messageID) - continue - } - - guard isStructurallyValid(record) else { - quarantine( - url, - messageID: messageID, - reason: "structurally invalid" - ) - quarantined.insert(messageID) - continue - } - scannedRecords[messageID] = record - if record.kind == .tombstone { - tombstones.append((messageID, record)) - } - let retainsAcceptedPath = - record.kind == .accepted - && record.relativePath.flatMap(existingPayload) != nil - if isExpired(record.recordedAt, at: date), - !retainsAcceptedPath { - expired.append(messageID) - continue - } - records[messageID] = record - } - - // A readable duplicate of a quarantined ID must not override the - // fail-closed decision, not even through the failed-prune restore - // or legacy-tombstone scrub paths below. - for messageID in quarantined { - records.removeValue(forKey: messageID) - scannedRecords.removeValue(forKey: messageID) - } - tombstones.removeAll { quarantined.contains($0.messageID) } - - let overflow = overflowVictims(in: records) - for messageID in overflow { - records.removeValue(forKey: messageID) - } - - let journal: [String: DeletionJournalEntry] - let journalURL = deletionJournalURL(in: directory) - if fileManager.fileExists(atPath: journalURL.path) { - do { - let data = try dataReader?(journalURL) - ?? Data(contentsOf: journalURL) - let snapshot = try JSONDecoder().decode( - DeletionJournal.self, - from: data - ) - guard snapshot.version == 1, - snapshot.entries.count <= capacity, - snapshot.entries.allSatisfy({ messageID, entry in - PrivateMediaMessageIdentity.isStableID(messageID) - && !entry.relativePaths.isEmpty - && entry.relativePaths.count <= - Self.maximumDeletionPathsPerMessage - && Set(entry.relativePaths).count - == entry.relativePaths.count - && entry.relativePaths.allSatisfy { - candidatePayload(relativePath: $0) != nil - } - }) else { - // The journal is a single batch commit file: like a - // directory-level failure it stays globally fail-closed - // and retryable, never quarantined per-ID. - SecureLogger.error( - "❌ Invalid private-media deletion journal", - category: .session - ) - return false - } - journal = snapshot.entries - } catch { - // The journal is the all-ID commit record. It may never be - // skipped or treated as empty when unreadable. - SecureLogger.error( - "❌ Failed to read private-media deletion journal: \(error)", - category: .session - ) - return false - } - } else { - journal = [:] - } - - var protectedFromPruning: Set = [] - - // Old per-ID tombstones may still carry a payload path from before the - // deletion journal existed. That path is inherently ambiguous because - // old allocators did not reserve it. Convert it to a pathless - // tombstone without unlinking any current file. - for (messageID, tombstone) in tombstones - where journal[messageID] == nil - && tombstone.relativePath != nil { - let pathless = ReceiptRecord( - kind: .tombstone, - relativePath: nil, - recordedAt: tombstone.recordedAt - ) - guard persist( - pathless, - messageID: messageID, - to: directory - ) else { - records[messageID] = tombstone - protectedFromPruning.insert(messageID) - continue - } - scannedRecords[messageID] = pathless - if records[messageID] != nil { - records[messageID] = pathless - } - } - - for messageID in expired + overflow { - guard !protectedFromPruning.contains(messageID) else { continue } - if !removeRecord(messageID: messageID, from: directory), - let record = scannedRecords[messageID] { - records[messageID] = record - } - } - - // Install caches only after every per-ID record and the batch journal - // have been read and validated. Failed legacy cleanup remains indexed - // and path-reserved instead of blocking unrelated media. - runtime.records = records - runtime.quarantined = quarantined - runtime.deletionJournal = journal - return true - } - - /// Idempotently materializes the write-ahead journal. An entry leaves the - /// journal only after its per-ID tombstone is durable and every recorded - /// payload is absent. Any failure keeps the journal authoritative for a - /// later lookup or process restart. - @discardableResult - private func recoverDeletionJournal(in directory: URL) -> Bool { - guard let journal = runtime.deletionJournal, - !journal.isEmpty, - var records = runtime.records else { - return true - } - - let preservedMessageIDs = Set(journal.keys) - var remaining = journal - let orderedEntries = journal.sorted { lhs, rhs in - if lhs.value.recordedAt == rhs.value.recordedAt { - return lhs.key < rhs.key - } - return lhs.value.recordedAt < rhs.value.recordedAt - } - - for (messageID, journalEntry) in orderedEntries { - // Never materialize a per-ID record for a quarantined ID: the - // scanner ignores readable duplicates of a quarantined record, - // and quarantine is already permanently fail-closed - // (state == .unavailable, commitAccepted refuses it), which - // subsumes the tombstone's no-resurrection guarantee. Only the - // payload unlinks below still need to run before the entry can - // retire. - if !runtime.quarantined.contains(messageID) { - let pathlessTombstone = ReceiptRecord( - kind: .tombstone, - relativePath: nil, - recordedAt: journalEntry.recordedAt - ) - if records[messageID] != pathlessTombstone { - let victim = capacityVictim( - for: .tombstone, - replacing: messageID, - in: records, - preserving: preservedMessageIDs - ) - if records[messageID]?.kind != .tombstone, - records.values.lazy.filter({ - $0.kind == .tombstone - }).count >= capacity, - victim == nil { - continue - } - guard persist( - pathlessTombstone, - messageID: messageID, - to: directory - ) else { - continue - } - records[messageID] = pathlessTombstone - if let victim, victim != messageID { - records.removeValue(forKey: victim) - removeRecord(messageID: victim, from: directory) - } - } - } - - // Only the journal retains the paths. Once every unlink succeeds, - // the durable per-ID tombstone is pathless and cannot later - // delete a different payload that reused the basename. - let removedEveryPayload = journalEntry.relativePaths.allSatisfy { - removePayloadRecordedByTombstone(ReceiptRecord( - kind: .tombstone, - relativePath: $0, - recordedAt: journalEntry.recordedAt - )) - } - guard removedEveryPayload else { - continue - } - remaining.removeValue(forKey: messageID) - } - - runtime.records = records - guard remaining != journal else { return false } - - if remaining.isEmpty { - guard removeDeletionJournal(in: directory) else { return false } - } else { - guard persistDeletionJournal(remaining, in: directory) else { - return false - } - } - runtime.deletionJournal = remaining - return remaining.isEmpty - } - - private func persistDeletionJournal( - _ entries: [String: DeletionJournalEntry], - in directory: URL - ) -> Bool { - do { - try fileManager.createDirectory( - at: directory, - withIntermediateDirectories: true, - attributes: nil - ) - let data = try JSONEncoder().encode( - DeletionJournal(version: 1, entries: entries) - ) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert( - .completeFileProtectionUntilFirstUserAuthentication - ) - #endif - try dataWriter(data, deletionJournalURL(in: directory), options) - return true - } catch { - SecureLogger.error( - "❌ Failed to persist private-media deletion journal: \(error)", - category: .session - ) - return false - } - } - - private func removeDeletionJournal(in directory: URL) -> Bool { - let url = deletionJournalURL(in: directory) - guard fileManager.fileExists(atPath: url.path) else { return true } - do { - try fileManager.removeItem(at: url) - return true - } catch { - SecureLogger.warning( - "⚠️ Failed to retire private-media deletion journal: \(error)", - category: .session - ) - return false - } - } - - private func deletionJournalURL(in directory: URL) -> URL { - directory.appendingPathComponent( - Self.deletionJournalFileName, - isDirectory: false - ) - } - - /// Moves an unreadable record aside so future scans skip it while its ID - /// stays fail-closed. The bytes are preserved for offline inspection — - /// quarantine never deletes receiver decisions. - private func quarantine(_ url: URL, messageID: String, reason: String) { - SecureLogger.error( - "❌ Quarantining unreadable private-media receipt \(messageID.prefix(12))…: \(reason)", - category: .session - ) - let destination = url.appendingPathExtension( - Self.quarantinePathExtension - ) - do { - if fileManager.fileExists(atPath: destination.path) { - // Same ID, already fail-closed; keep the earlier evidence. - try fileManager.removeItem(at: url) - } else { - try fileManager.moveItem(at: url, to: destination) - } - } catch { - // The record stays where it is and will be re-quarantined (in - // memory at minimum) by the next scan. Still fail-closed. - SecureLogger.warning( - "⚠️ Failed to move corrupt private-media receipt aside: \(error)", - category: .session - ) - } - } - - private func isStructurallyValid(_ record: ReceiptRecord) -> Bool { - switch record.kind { - case .tombstone: - guard let relativePath = record.relativePath else { return true } - return candidatePayload(relativePath: relativePath) != nil - case .accepted: - guard let relativePath = record.relativePath else { return false } - return candidatePayload(relativePath: relativePath) != nil - } - } - - private func isExpired(_ recordedAt: Date, at date: Date) -> Bool { - date.timeIntervalSince(recordedAt) > ttl - } - - private func overflowVictims( - in records: [String: ReceiptRecord] - ) -> [String] { - var victims: [String] = [] - for kind in [ReceiptRecord.Kind.accepted, .tombstone] { - let allMatching = records.filter { $0.value.kind == kind } - let overflow = allMatching.count - capacity - guard overflow > 0 else { continue } - let eligible = allMatching.filter { _, record in - guard kind == .accepted else { return true } - return record.relativePath.flatMap(existingPayload) == nil - } - victims.append(contentsOf: eligible.sorted { lhs, rhs in - if lhs.value.recordedAt == rhs.value.recordedAt { - return lhs.key < rhs.key - } - return lhs.value.recordedAt < rhs.value.recordedAt - } - .prefix(overflow) - .map(\.key)) - } - return victims - } - - /// Accepted receipts and tombstones have independent capacity. High media - /// volume cannot evict explicit deletion intent, and vice versa. - private func capacityVictim( - for incomingKind: ReceiptRecord.Kind, - replacing messageID: String, - in records: [String: ReceiptRecord], - preserving preservedMessageIDs: Set = [] - ) -> String? { - guard records[messageID]?.kind != incomingKind else { return nil } - // During a live session an accepted receipt is the only durable owner - // of its filename, even after quota removes the payload. Evicting it - // here could let another ID reuse the path while the old bubble still - // exists. The large capacity therefore acts as admission control. - guard incomingKind != .accepted else { return nil } - let matching = records.filter { - $0.key != messageID - && !preservedMessageIDs.contains($0.key) - && $0.value.kind == incomingKind - } - let currentCount = records.values.lazy.filter { - $0.kind == incomingKind - }.count - guard currentCount >= capacity else { return nil } - return matching.min { lhs, rhs in - if lhs.value.recordedAt == rhs.value.recordedAt { - return lhs.key < rhs.key - } - return lhs.value.recordedAt < rhs.value.recordedAt - }?.key - } - - private func persist( - _ record: ReceiptRecord, - messageID: String, - to directory: URL - ) -> Bool { - do { - try fileManager.createDirectory( - at: directory, - withIntermediateDirectories: true, - attributes: nil - ) - let data = try JSONEncoder().encode(record) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtectionUntilFirstUserAuthentication) - #endif - let url = recordURL(messageID: messageID, in: directory) - try dataWriter(data, url, options) - return true - } catch { - SecureLogger.error( - "❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)", - category: .session - ) - return false - } - } - - @discardableResult - private func removeRecord( - messageID: String, - from directory: URL - ) -> Bool { - let url = recordURL(messageID: messageID, in: directory) - guard fileManager.fileExists(atPath: url.path) else { return true } - do { - try fileManager.removeItem(at: url) - return true - } catch { - SecureLogger.warning( - "⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)", - category: .session - ) - return false - } - } - - private func recordURL(messageID: String, in directory: URL) -> URL { - directory - .appendingPathComponent(messageID, isDirectory: false) - .appendingPathExtension("json") - } - - @discardableResult - private func removePayloadRecordedByTombstone( - _ record: ReceiptRecord - ) -> Bool { - guard record.kind == .tombstone, - let relativePath = record.relativePath, - let payload = candidatePayload(relativePath: relativePath), - fileManager.fileExists(atPath: payload.path) else { - return true - } - guard let values = try? payload.resourceValues( - forKeys: [.isRegularFileKey] - ), - values.isRegularFile == true else { - SecureLogger.warning( - "⚠️ Refusing to remove non-file private-media payload", - category: .session - ) - return false - } - do { - try payloadRemover(payload) - return !fileManager.fileExists(atPath: payload.path) - } catch { - SecureLogger.warning( - "⚠️ Failed to remove explicitly deleted private media: \(error)", - category: .session - ) - return false - } - } - - private func isSafeDeletionTarget(relativePath: String) -> Bool { - guard let payload = candidatePayload(relativePath: relativePath) else { - return false - } - guard fileManager.fileExists(atPath: payload.path) else { - return true - } - return (try? payload.resourceValues( - forKeys: [.isRegularFileKey] - ).isRegularFile) == true - } - - private func validExistingPayload(_ url: URL) -> URL? { - let standardized = url.standardizedFileURL - guard isInsideIncomingMediaDirectory(standardized) else { - return nil - } - var isDirectory: ObjCBool = false - guard fileManager.fileExists( - atPath: standardized.path, - isDirectory: &isDirectory - ), !isDirectory.boolValue else { - return nil - } - return standardized - } - - private func relativePath(for url: URL) -> String? { - guard let filesRoot = try? filesDirectory().standardizedFileURL else { - return nil - } - let prefix = filesRoot.path + "/" - let standardized = url.standardizedFileURL - guard standardized.path.hasPrefix(prefix) else { return nil } - let relativePath = String(standardized.path.dropFirst(prefix.count)) - return relativePath.isEmpty ? nil : relativePath - } - - private func existingPayload(relativePath: String) -> URL? { - guard let candidate = candidatePayload(relativePath: relativePath) else { - return nil - } - return validExistingPayload(candidate) - } - - private func candidatePayload(relativePath: String) -> URL? { - guard !relativePath.isEmpty, - let filesRoot = try? filesDirectory().standardizedFileURL else { - return nil - } - let candidate = filesRoot - .appendingPathComponent(relativePath, isDirectory: false) - .standardizedFileURL - guard isInsideIncomingMediaDirectory(candidate) else { return nil } - return candidate - } - - private func isInsideIncomingMediaDirectory(_ url: URL) -> Bool { - guard let filesRoot = try? filesDirectory().standardizedFileURL else { - return false - } - let parentPath = url.standardizedFileURL - .deletingLastPathComponent().path - return [ - "voicenotes/incoming", - "images/incoming", - "files/incoming" - ].contains { relativeDirectory in - filesRoot.appendingPathComponent( - relativeDirectory, - isDirectory: true - ) - .standardizedFileURL.path == parentPath - } - } - - private func resolvedReceiptDirectory() -> URL? { - return try? filesDirectory().appendingPathComponent( - Self.receiptDirectoryName, - isDirectory: true - ) - } - - private func filesDirectory() throws -> URL { - let root = try baseDirectory ?? fileManager.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let files = root.appendingPathComponent("files", isDirectory: true) - try fileManager.createDirectory( - at: files, - withIntermediateDirectories: true, - attributes: nil - ) - return files - } -} diff --git a/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift b/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift deleted file mode 100644 index 461d934c..00000000 --- a/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift +++ /dev/null @@ -1,363 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEAuthenticatedPeerStateObservation { - let fingerprint: String - let sessionGeneration: UUID - let capabilities: PeerCapabilities -} - -struct BLEPrivateMediaProofTimeoutMarker { - let fingerprint: String - let sessionGeneration: UUID? -} - -struct BLEPrivateMediaProofWatchdog { - let fingerprint: String - let sessionGeneration: UUID - let timeoutNonce: UUID -} - -struct BLEPendingPrivateMediaPolicyResolution { - let fingerprint: String - var sessionGeneration: UUID? - var timeoutNonce: UUID - var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void] -} - -struct BLEAuthenticatedPeerStateSendProgress { - let sessionGeneration: UUID - var sentInitial = false - var sentEcho = false -} - -/// Lock-backed private-media session state: which Noise generation each -/// peer's capability proof, peer-state exchange, and policy waiters are -/// bound to. A fresh Noise authentication rotates the generation UUID, so -/// stale proof timers and proof packets cannot classify a replacement -/// session. -/// -/// Lock-backed rather than engine-confined for two reasons: the send -/// policy is answered synchronously on the main actor, and several -/// transitions run inside noise-manager critical sections that the engine -/// is sync-waiting on (where re-entering the engine would self-deadlock, -/// but taking a leaf lock is safe). Every method is one whole transition -/// under the lock, so no caller can observe a torn intermediate state. -final class BLEPrivateMediaSessionStore: @unchecked Sendable { - private let lock = NSLock() - private var sessionGenerations: [PeerID: UUID] = [:] - private var authenticatedStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:] - private var proofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:] - private var proofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:] - private var pendingPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:] - private var stateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:] - /// Peers whose parked outbound queues must stay parked until the - /// convergence retry re-authenticates: a timeout-restore brings back - /// keys the counterpart may have already discarded, so nothing — not - /// even the capability-proof watchdog — may drain the queues under - /// them. Set on the deferred restore transition, cleared by any - /// transition that is allowed to drain. - private var outboundConvergenceDeferred: Set = [] - - // MARK: Reads - - func currentGeneration(for peerID: PeerID) -> UUID? { - lock.withLock { sessionGenerations[peerID] } - } - - /// The exact current generation iff it authenticated both encrypted - /// private media (bit 8) and durable receipts/retry (bit 9). - func receiptSessionGeneration(for peerID: PeerID, currentNoiseGeneration: UUID?) -> UUID? { - lock.withLock { - guard let generation = sessionGenerations[peerID], - generation == currentNoiseGeneration, - let authenticated = authenticatedStates[peerID], - authenticated.sessionGeneration == generation, - authenticated.capabilities.contains(.privateMedia), - authenticated.capabilities.contains(.privateMediaReceipts) else { - return nil - } - return generation - } - } - - /// One consistent view of the state the send-policy calculus needs. - func policyInputs(for peerID: PeerID) -> ( - sessionGeneration: UUID?, - authenticatedState: BLEAuthenticatedPeerStateObservation?, - timedOut: BLEPrivateMediaProofTimeoutMarker? - ) { - lock.withLock { - ( - sessionGenerations[peerID], - authenticatedStates[peerID], - proofTimeoutMarkers[peerID] - ) - } - } - - func hasPendingPolicyResolution(for peerID: PeerID) -> Bool { - lock.withLock { pendingPolicyResolutions[peerID] != nil } - } - - /// The live proof-timeout identity for a peer (watchdog first, then a - /// registered waiter) — what a forced/expired timeout must present. - func proofTimeoutTarget(for peerID: PeerID) -> (fingerprint: String, generation: UUID?, nonce: UUID)? { - lock.withLock { - if let watchdog = proofWatchdogs[peerID] { - return (watchdog.fingerprint, watchdog.sessionGeneration, watchdog.timeoutNonce) - } - if let pending = pendingPolicyResolutions[peerID] { - return (pending.fingerprint, pending.sessionGeneration, pending.timeoutNonce) - } - return nil - } - } - - // MARK: Generation transitions - - /// Installs a freshly authenticated generation: rotates the proof - /// watchdog, resets peer-state send progress, and re-binds any pending - /// policy waiters whose fingerprint still matches (mismatched waiters - /// are rejected and returned for completion). Returns nil when the - /// generation is already current — the same-generation reconciliation - /// path, which must not re-arm proof machinery. - func beginAuthenticatedGeneration( - for peerID: PeerID, - fingerprint: String, - generation: UUID - ) -> (watchdogNonce: UUID, rejected: [@MainActor (PrivateMediaSendPolicy) -> Void])? { - lock.withLock { - guard sessionGenerations[peerID] != generation else { return nil } - let watchdogNonce = UUID() - sessionGenerations[peerID] = generation - authenticatedStates.removeValue(forKey: peerID) - proofTimeoutMarkers.removeValue(forKey: peerID) - proofWatchdogs[peerID] = BLEPrivateMediaProofWatchdog( - fingerprint: fingerprint, - sessionGeneration: generation, - timeoutNonce: watchdogNonce - ) - stateSendProgress[peerID] = - BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation) - - guard var pending = pendingPolicyResolutions[peerID] else { - return (watchdogNonce, []) - } - guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else { - pendingPolicyResolutions.removeValue(forKey: peerID) - return (watchdogNonce, Array(pending.completions.values)) - } - pending.sessionGeneration = generation - pending.timeoutNonce = watchdogNonce - pendingPolicyResolutions[peerID] = pending - return (watchdogNonce, []) - } - } - - /// Records a verified authenticated-peer-state packet for the current - /// generation: pins the observation, retires proof timers, and releases - /// matching policy waiters. Returns nil when the generation is no longer - /// current (the caller's lease raced a replacement). - func applyAuthenticatedPeerState( - for peerID: PeerID, - fingerprint: String, - generation: UUID, - capabilities: PeerCapabilities - ) -> [@MainActor (PrivateMediaSendPolicy) -> Void]? { - lock.withLock { - guard sessionGenerations[peerID] == generation else { return nil } - authenticatedStates[peerID] = BLEAuthenticatedPeerStateObservation( - fingerprint: fingerprint, - sessionGeneration: generation, - capabilities: capabilities - ) - proofTimeoutMarkers.removeValue(forKey: peerID) - proofWatchdogs.removeValue(forKey: peerID) - guard let pending = pendingPolicyResolutions.removeValue(forKey: peerID), - pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, - pending.sessionGeneration == generation else { - return [] - } - return Array(pending.completions.values) - } - } - - /// Consumes one peer-state send slot (initial or echo) for the current - /// generation. Returns whether the packet should actually go out. - func markPeerStateSend(for peerID: PeerID, echo: Bool) -> Bool { - lock.withLock { - guard let generation = sessionGenerations[peerID], - var progress = stateSendProgress[peerID], - progress.sessionGeneration == generation else { return false } - if echo { - guard !progress.sentEcho else { return false } - progress.sentEcho = true - } else { - guard !progress.sentInitial else { return false } - progress.sentInitial = true - } - stateSendProgress[peerID] = progress - return true - } - } - - // MARK: Outbound convergence deferral - - func setOutboundDeferredUntilConvergence(_ peerID: PeerID) { - lock.withLock { _ = outboundConvergenceDeferred.insert(peerID) } - } - - func clearOutboundDeferredUntilConvergence(_ peerID: PeerID) { - lock.withLock { _ = outboundConvergenceDeferred.remove(peerID) } - } - - // MARK: Proof timeout - - /// Expires a proof deadline if its nonce/generation/fingerprint still - /// identify the live watchdog or waiter set. On expiry the timeout - /// marker is pinned and any waiters are returned for completion. - /// `deferredOutbound` reports whether the peer's parked queues must - /// stay parked (timeout-restore pending its convergence retry). - func expireProofDeadline( - for peerID: PeerID, - fingerprint: String, - sessionGeneration: UUID?, - nonce: UUID - ) -> (expired: Bool, deferredOutbound: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) { - lock.withLock { - let pending = pendingPolicyResolutions[peerID] - let pendingMatches = pending?.timeoutNonce == nonce - && pending?.sessionGeneration == sessionGeneration - && pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame - let watchdog = proofWatchdogs[peerID] - let watchdogMatches = sessionGeneration != nil - && watchdog?.timeoutNonce == nonce - && watchdog?.sessionGeneration == sessionGeneration - && watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame - guard pendingMatches || watchdogMatches else { - return (false, false, []) - } - var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = [] - if pendingMatches, let pending { - completions = Array(pending.completions.values) - } - if pendingMatches { - pendingPolicyResolutions.removeValue(forKey: peerID) - } - if watchdogMatches { - proofWatchdogs.removeValue(forKey: peerID) - } - proofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker( - fingerprint: fingerprint, - sessionGeneration: sessionGeneration - ) - return (true, outboundConvergenceDeferred.contains(peerID), completions) - } - } - - /// Registers a policy-resolution waiter for a peer still awaiting its - /// capability proof. Joins the existing waiter set when fingerprints - /// match (bounded), otherwise starts one, reusing the live watchdog's - /// deadline identity when it covers the same fingerprint/generation so - /// only one timeout is ever in flight. `shouldSchedule` tells the - /// caller to arm a fresh deadline. - func registerPolicyResolution( - for peerID: PeerID, - fingerprint: String, - requestID: UUID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) { - lock.withLock { - let generation = sessionGenerations[peerID] - if var pending = pendingPolicyResolutions[peerID] { - guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, - pending.completions.count - < TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else { - return (false, false, UUID(), generation) - } - pending.completions[requestID] = completion - pendingPolicyResolutions[peerID] = pending - return (true, false, pending.timeoutNonce, pending.sessionGeneration) - } - - guard pendingPolicyResolutions.count - < TransportConfig.privateMediaCapabilityProofPendingPeerCap else { - return (false, false, UUID(), generation) - } - let currentWatchdog = proofWatchdogs[peerID] - let reusesWatchdog = currentWatchdog?.fingerprint - .caseInsensitiveCompare(fingerprint) == .orderedSame - && currentWatchdog?.sessionGeneration == generation - let nonce: UUID - if reusesWatchdog, let currentWatchdog { - nonce = currentWatchdog.timeoutNonce - } else { - nonce = UUID() - } - pendingPolicyResolutions[peerID] = - BLEPendingPrivateMediaPolicyResolution( - fingerprint: fingerprint, - sessionGeneration: generation, - timeoutNonce: nonce, - completions: [requestID: completion] - ) - return (true, !reusesWatchdog, nonce, generation) - } - } - - // MARK: Teardown - - /// A session clear retires every generation-bound record. Waiters are - /// kept but rebased onto a nil generation with a fresh deadline nonce, - /// returned so the caller re-arms their timeout. - func clearSession(for peerID: PeerID) -> (fingerprint: String, nonce: UUID)? { - lock.withLock { - sessionGenerations.removeValue(forKey: peerID) - authenticatedStates.removeValue(forKey: peerID) - proofTimeoutMarkers.removeValue(forKey: peerID) - proofWatchdogs.removeValue(forKey: peerID) - stateSendProgress.removeValue(forKey: peerID) - outboundConvergenceDeferred.remove(peerID) - guard var pending = pendingPolicyResolutions[peerID] else { - return nil - } - let nonce = UUID() - pending.sessionGeneration = nil - pending.timeoutNonce = nonce - pendingPolicyResolutions[peerID] = pending - return (pending.fingerprint, nonce) - } - } - - /// Panic wipe: these records belong to pre-panic transfer state, and - /// invoking their callbacks would let queued UI work recreate or resend - /// wiped media — drop everything. - func panicReset() { - lock.withLock { - sessionGenerations.removeAll() - authenticatedStates.removeAll() - proofTimeoutMarkers.removeAll() - proofWatchdogs.removeAll() - pendingPolicyResolutions.removeAll() - stateSendProgress.removeAll() - outboundConvergenceDeferred.removeAll() - } - } -} - -extension BLEPrivateMediaSessionStore { - /// The current generation iff its authenticated peer state proved the - /// private-media capability (and, when required, durable receipts). - func provenGeneration(for peerID: PeerID, requireReceipts: Bool) -> UUID? { - let inputs = policyInputs(for: peerID) - guard let generation = inputs.sessionGeneration, - let authenticated = inputs.authenticatedState, - authenticated.sessionGeneration == generation, - authenticated.capabilities.contains(.privateMedia) else { return nil } - if requireReceipts { - guard authenticated.capabilities.contains(.privateMediaReceipts) else { return nil } - } - return generation - } -} diff --git a/bitchat/Services/BLE/BLEPublicMessageHandler.swift b/bitchat/Services/BLE/BLEPublicMessageHandler.swift deleted file mode 100644 index 7ff786be..00000000 --- a/bitchat/Services/BLE/BLEPublicMessageHandler.swift +++ /dev/null @@ -1,139 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// Narrow environment for `BLEPublicMessageHandler`. -/// -/// All queue hops (collections registry reads, BLE-queue link-state reads, -/// main-actor UI notification) live inside the closures supplied by -/// `BLEService`, keeping the handler queue-agnostic and synchronously testable. -struct BLEPublicMessageHandlerEnvironment { - /// Local peer identity at the time the message is handled. - let localPeerID: () -> PeerID - /// Local nickname used for sender resolution and collision checks. - let localNickname: () -> String - /// Current time source. - let now: () -> Date - /// Snapshot of known peers keyed by ID (registry read). - let peersSnapshot: () -> [PeerID: BLEPeerInfo] - /// Verifies a packet's signature against a known signing public key. - let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool - /// Resolves a display name from a verified packet signature for peers missing from the registry. - let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? - /// Tracks the broadcast message packet for gossip sync. - let trackPacketSeen: (BitchatPacket) -> Void - /// Direct link state for the peer (BLE-queue read). - let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) - /// Resolves and consumes the original message ID for our own re-broadcast. - let takeSelfBroadcastMessageID: (BitchatPacket) -> String? - /// Delivers `.publicMessageReceived` to the UI as one main-actor hop. - let deliverPublicMessage: ( - _ peerID: PeerID, - _ nickname: String, - _ content: String, - _ timestamp: Date, - _ messageID: String? - ) -> Void -} - -/// Orchestrates inbound public (broadcast) messages: freshness/self-echo -/// policy, sender display-name resolution, gossip tracking, payload decoding, -/// and UI delivery. -final class BLEPublicMessageHandler { - private let environment: BLEPublicMessageHandlerEnvironment - - init(environment: BLEPublicMessageHandlerEnvironment) { - self.environment = environment - } - - func handle(_ packet: BitchatPacket, from peerID: PeerID) { - let env = environment - let now = env.now() - let messageDecision = BLEPublicMessagePolicy.evaluate( - packet: packet, - from: peerID, - localPeerID: env.localPeerID(), - now: now - ) - - let messagePolicy: BLEPublicMessageAcceptance - switch messageDecision { - case .accept(let acceptance): - messagePolicy = acceptance - case .reject(.selfEcho): - return - case .reject(.staleBroadcast(let ageSeconds)): - SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) - return - } - - // Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks. - let peersSnapshot = env.peersSnapshot() - - // Public messages are always signed by their sender. `senderID` is - // attacker-controlled, so registry membership alone is NOT proof of - // identity — a peer in the registry as "verified" could be impersonated - // by anyone spoofing their senderID. Require a valid packet signature - // from the claimed sender (our own echoes are exempt; they are matched - // by self-broadcast tracking below). - // - // Verify against the signing key already in the (synchronously-updated) - // peer registry first: identity-cache persistence is asynchronous, so a - // message arriving right after a verified announce would otherwise be - // dropped because `signedSenderDisplayName` only searches the persisted - // cache. Fall back to that persisted-identity lookup for peers not (yet) - // in the registry. - let isSelf = peerID == env.localPeerID() - let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey - let verifiedViaRegistry = !isSelf - && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false) - let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID) - guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { - SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security) - return - } - - // Authenticity is established; prefer the registry's collision-resolved - // display name, then the signature-derived name. - guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: env.localPeerID(), - localNickname: env.localNickname(), - peers: peersSnapshot, - allowConnectedUnverified: false - ) ?? signedDisplayName else { - SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))…", category: .security) - return - } - - if messagePolicy.shouldTrackForSync { - env.trackPacketSeen(packet) - } - - guard let content = String(data: packet.payload, encoding: .utf8) else { - SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session) - return - } - // Determine if we have a direct link to the sender - let directLink = env.linkState(peerID) - let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral - - let pathTag = hasDirectLink ? "direct" : "mesh" - SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session) - - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - let messageID: String? - if peerID == env.localPeerID() { - messageID = env.takeSelfBroadcastMessageID(packet) - } else { - // The wire carries no message ID; derive the stable one every - // device agrees on so bridged copies dedup against the radio copy. - messageID = MeshMessageIdentity.stableID( - senderIDHex: peerID.id, - timestampMs: packet.timestamp, - content: content - ) - } - env.deliverPublicMessage(peerID, senderNickname, content, ts, messageID) - } -} diff --git a/bitchat/Services/BLE/BLEPublicMessagePolicy.swift b/bitchat/Services/BLE/BLEPublicMessagePolicy.swift deleted file mode 100644 index 9a0bc965..00000000 --- a/bitchat/Services/BLE/BLEPublicMessagePolicy.swift +++ /dev/null @@ -1,49 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEPublicMessageAcceptance: Equatable { - let shouldTrackForSync: Bool -} - -enum BLEPublicMessageRejection: Equatable { - case selfEcho - case staleBroadcast(ageSeconds: Double) -} - -enum BLEPublicMessageDecision: Equatable { - case accept(BLEPublicMessageAcceptance) - case reject(BLEPublicMessageRejection) -} - -enum BLEPublicMessagePolicy { - static func evaluate( - packet: BitchatPacket, - from peerID: PeerID, - localPeerID: PeerID, - now: Date - ) -> BLEPublicMessageDecision { - if peerID == localPeerID && packet.ttl != 0 { - return .reject(.selfEcho) - } - - let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) - // Acceptance window matches the gossip-sync serving window: a peer - // walking between partitions carries hours of public history, so the - // receive side must not drop what sync legitimately serves. - if isBroadcast, - BLEPacketFreshnessPolicy.isStale( - timestampMilliseconds: packet.timestamp, - now: now, - maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds - ) { - return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds( - timestampMilliseconds: packet.timestamp, - now: now - ))) - } - - return .accept(BLEPublicMessageAcceptance( - shouldTrackForSync: isBroadcast && packet.type == MessageType.message.rawValue - )) - } -} diff --git a/bitchat/Services/BLE/BLERadioController.swift b/bitchat/Services/BLE/BLERadioController.swift deleted file mode 100644 index cf9d6c2d..00000000 --- a/bitchat/Services/BLE/BLERadioController.swift +++ /dev/null @@ -1,411 +0,0 @@ -import BitLogger -import CoreBluetooth -import Foundation - -/// The radio's contact points back into the transport. All calls arrive on -/// bleQueue. -protocol BLERadioControllerDelegate: AnyObject { - /// Whether a panic wipe has quiesced the radio. - func radioIsPanicSuspended() -> Bool - /// iOS app-active snapshot (drives allow-duplicates scanning and - /// background connect deferral); always true on macOS. - func radioIsAppActive() -> Bool - /// A connect attempt died (timeout or foreground stale-reclaim): retire - /// the link's transport bookkeeping — write buffers, link-auth proof, - /// reconnect epoch, and the link-state entry itself. - func radioTearDownPeripheralLink(_ peripheralID: String) -} - -/// bleQueue-confined owner of the central-role radio policy: discovery -/// admission, the connection budget and queue, connect timeouts, -/// wake-on-proximity background connects, scan duty-cycling, RSSI -/// adaptation, and the advertising payload. -/// -/// First slice of the link layer (docs/BLE-ARCHITECTURE-V3.md): this type -/// makes no peer decisions and owns no bindings or security state — it -/// shares the bleQueue-confined link-state store for admission reads and -/// asks its delegate to tear down transport bookkeeping when an attempt -/// dies. -final class BLERadioController { - weak var delegate: BLERadioControllerDelegate? - /// The transport is every peripheral's CBPeripheralDelegate; connects - /// initiated here must point new peripherals at it. - weak var peripheralDelegate: CBPeripheralDelegate? - /// Attached when the transport creates (or restores) its managers. - weak var central: CBCentralManager? - - private let queue: DispatchQueue - private let linkStateStore: BLELinkStateStore - private let recentTraffic: BLERecentTrafficMonitor - - // Connection budget & scheduling (central role) - private var scheduler = BLEConnectionScheduler() - // Recently seen peripherals retained for background wake-on-proximity - // connects - private let recentPeripheralCache = BLERecentPeripheralCache() - - // Adaptive scanning duty-cycle - private var scanDutyTimer: DispatchSourceTimer? - private var dutyEnabled: Bool = true - private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration - private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration - private var dutyActive: Bool = false - - init( - queue: DispatchQueue, - linkStateStore: BLELinkStateStore, - recentTraffic: BLERecentTrafficMonitor - ) { - self.queue = queue - self.linkStateStore = linkStateStore - self.recentTraffic = recentTraffic - } - - // MARK: - Advertising - - static func advertisementData() -> [String: Any] { - // No Local Name for privacy. - [CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]] - } - - // MARK: - Scanning - - func startScanning() { - guard delegate?.radioIsPanicSuspended() == false, - let central, - central.state == .poweredOn, - !central.isScanning else { return } - - // Allow duplicates while active for faster discovery: immediate - // discovery events instead of coalesced ones. - let allowDuplicates = delegate?.radioIsAppActive() ?? true - central.scanForPeripherals( - withServices: [BLEService.serviceUUID], - options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates] - ) - } - - func updateScanningDutyCycle(connectedCount: Int) { - guard let central, central.state == .poweredOn else { return } - // Duty cycle only when the app is active and at least one peer is - // connected; force full-time scanning with few neighbors or very - // recent traffic. - let hasRecentTraffic = recentTraffic.hasTraffic( - within: TransportConfig.bleRecentTrafficForceScanSeconds, - now: Date() - ) - let scanPlan = BLEScanDutyPolicy.plan( - dutyEnabled: dutyEnabled, - appIsActive: delegate?.radioIsAppActive() ?? true, - connectedCount: connectedCount, - hasRecentTraffic: hasRecentTraffic - ) - - switch scanPlan { - case .dutyCycle(let onDuration, let offDuration): - let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration - dutyOnDuration = onDuration - dutyOffDuration = offDuration - - if scanDutyTimer == nil { - // Start with scanning ON; turn OFF after onDuration. - let t = DispatchSource.makeTimerSource(queue: queue) - if !central.isScanning { startScanning() } - dutyActive = true - t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) - t.setEventHandler { [weak self] in - guard let self, let c = self.central else { return } - if self.dutyActive { - if c.isScanning { c.stopScan() } - self.dutyActive = false - self.queue.asyncAfter(deadline: .now() + self.dutyOffDuration) { - if self.central?.state == .poweredOn { self.startScanning() } - self.dutyActive = true - } - } - } - t.resume() - scanDutyTimer = t - } else if durationsChanged { - scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) - if !central.isScanning { startScanning() } - dutyActive = true - } - case .continuous: - // Cancel duty cycle and ensure scanning is ON for discovery. - scanDutyTimer?.cancel() - scanDutyTimer = nil - if !central.isScanning { startScanning() } - } - } - - func stopDutyCycle() { - scanDutyTimer?.cancel() - scanDutyTimer = nil - } - - func updateRSSIThreshold(connectedCount: Int) { - scheduler.updateRSSIThreshold( - connectedCount: connectedCount, - connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount, - now: Date() - ) - } - - // MARK: - Discovery & connection budget - - func handleDiscovery( - _ peripheral: CBPeripheral, - advertisementData: [String: Any], - rssi: NSNumber - ) { - guard delegate?.radioIsPanicSuspended() == false, let central else { return } - let peripheralID = peripheral.identifier.uuidString - let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…") - let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true - - let candidate = BLEConnectionCandidate( - peripheral: peripheral, - peripheralID: peripheralID, - rssi: rssi.intValue, - name: String(advertisedName), - isConnectable: isConnectable, - discoveredAt: Date() - ) - if isConnectable { - recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt) - } - let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init) - - switch scheduler.handleDiscovery( - candidate, - connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, - existingState: existingState, - peripheralState: peripheral.state.connectionSchedulerState, - now: candidate.discoveredAt - ) { - case .ignore, .queued: - return - case .scheduleRetry(let delay): - queue.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.tryConnectFromQueue() - } - return - case .cancelStaleConnection: - central.cancelPeripheralConnection(peripheral) - return - case .connectNow: - beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect") - } - } - - func tryConnectFromQueue() { - guard delegate?.radioIsPanicSuspended() == false, - let central, - central.state == .poweredOn else { return } - - let decision = scheduler.nextCandidate( - connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, - isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in - let state = linkStateStore.state(forPeripheralID: peripheralID) - return state?.isConnected == true || state?.isConnecting == true - }, - now: Date() - ) - - switch decision { - case .none: - return - case .retryAfter(let delay): - queue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } - case .connect(let candidate): - beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect") - } - } - - private func beginCentralConnection( - _ candidate: BLEConnectionCandidate, - using central: CBCentralManager, - logPrefix: String - ) { - guard delegate?.radioIsPanicSuspended() == false else { return } - let peripheral = candidate.peripheral - let peripheralID = candidate.peripheralID - linkStateStore.beginConnecting(to: peripheral, at: Date()) - peripheral.delegate = peripheralDelegate - let options: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnConnectionKey: true, - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true - ] - central.connect(peripheral, options: options) - scheduler.recordConnectionAttempt(at: Date()) - SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session) - - queue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in - guard let self, - let state = self.linkStateStore.state(forPeripheralID: peripheralID), - state.isConnecting && !state.isConnected else { return } - - guard peripheral.state != .connected else { - SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session) - return - } - - if self.delegate?.radioIsAppActive() == false { - // Backgrounded: leave the connect pending. iOS never expires - // it — the controller completes it whenever the peer comes - // back into range, waking the app (state restoration - // relaunches us if we were terminated). Foreground return - // cancels stale pendings via cancelStalePendingConnects(). - SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session) - return - } - - SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session) - central.cancelPeripheralConnection(peripheral) - self.delegate?.radioTearDownPeripheralLink(peripheralID) - self.scheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date()) - self.tryConnectFromQueue() - } - } - - // MARK: - Scheduler bookkeeping (called from the transport's delegates) - - var candidateCount: Int { scheduler.candidateCount } - - func recordConnectionSuccess(peripheralID: String) { - scheduler.recordConnectionSuccess(peripheralID: peripheralID) - } - - func recordConnectionFailure(peripheralID: String) { - scheduler.recordConnectionFailure(peripheralID: peripheralID) - } - - func recordDisconnectError(peripheralID: String, at date: Date) { - scheduler.recordDisconnectError(peripheralID: peripheralID, at: date) - } - - func recordRecentPeripheral(_ peripheral: CBPeripheral, peripheralID: String, at date: Date) { - recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: date) - } - - func pruneConnectionTimeouts(before cutoff: Date) { - scheduler.pruneConnectionTimeouts(before: cutoff) - } - - /// Panic wipe: drop the candidate queue, backoff state, and RSSI - /// adaptation with the identity they served. - func reset() { - scheduler.reset() - } - - #if os(iOS) - // MARK: - Background wake-on-proximity - - /// Backgrounding hands the freed connection budget to iOS as pending - /// connects against recently seen peers: the controller completes one - /// whenever its peer comes into range, waking (or relaunching) the app. - /// A couple of central slots stay reserved for connects driven by live - /// background discovery — except on the disconnect re-arm path, which - /// may consume the slot the disconnect itself just freed (a dense mesh - /// with 4+ remaining links would otherwise compute a zero budget and - /// never re-arm the lost peer). - func armPendingBackgroundConnects( - slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve - ) { - queue.async { [weak self] in - guard let self, - self.delegate?.radioIsPanicSuspended() == false, - let central = self.central, - central.state == .poweredOn else { return } - let budget = TransportConfig.bleMaxCentralLinks - - slotReserve - - self.linkStateStore.connectedOrConnectingPeripheralCount - let now = Date() - let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in - let state = self.linkStateStore.state(forPeripheralID: peripheralID) - return state?.isConnected == true || state?.isConnecting == true - } - guard !targets.isEmpty else { return } - for target in targets { - // lastConnectionAttempt stays nil: an indefinite pending - // connect has no attempt clock, and nil marks it always-stale - // so cancelStalePendingConnects() reclaims it on foreground - // even after a quick background→foreground bounce. - self.linkStateStore.setPeripheralState( - BLEPeripheralLinkState( - peripheral: target.peripheral, - characteristic: nil, - isConnecting: true, - isConnected: false, - lastConnectionAttempt: nil, - assembler: NotificationStreamAssembler() - ), - for: target.peripheralID - ) - target.peripheral.delegate = self.peripheralDelegate - central.connect(target.peripheral, options: [ - CBConnectPeripheralOptionNotifyOnConnectionKey: true, - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true - ]) - } - SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session) - } - } - - /// Foreground restores normal connection management: pending connects - /// older than the connect timeout (including ones rebuilt by state - /// restoration after a relaunch) are cancelled so live scanning and the - /// scheduler take over. Anything still nearby is rediscovered within - /// seconds by the allow-duplicates foreground scan. - func cancelStalePendingConnects() { - queue.async { [weak self] in - guard let self, let central = self.central else { return } - let now = Date() - var cancelled = 0 - for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected { - let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity - guard age > TransportConfig.bleConnectTimeoutSeconds else { continue } - let peripheralID = state.peripheral.identifier.uuidString - central.cancelPeripheralConnection(state.peripheral) - self.delegate?.radioTearDownPeripheralLink(peripheralID) - cancelled += 1 - } - if cancelled > 0 { - SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session) - self.tryConnectFromQueue() - } - } - } - #endif -} - - -// MARK: - Connection scheduling helpers - -private extension BLEExistingConnectionState { - init(_ state: BLEPeripheralLinkState) { - self.init( - isConnecting: state.isConnecting, - isConnected: state.isConnected, - lastConnectionAttempt: state.lastConnectionAttempt - ) - } -} - -private extension CBPeripheralState { - var connectionSchedulerState: BLEPeripheralConnectionState { - switch self { - case .connected: - return .connected - case .connecting: - return .connecting - case .disconnected, .disconnecting: - return .disconnected - @unknown default: - return .disconnected - } - } -} diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift deleted file mode 100644 index 17af256b..00000000 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ /dev/null @@ -1,128 +0,0 @@ -import BitFoundation -import Foundation - -struct BLEReceivedPacketContext: Equatable { - let senderID: PeerID - let messageID: String - let messageType: MessageType? - let shouldDeduplicate: Bool - let logsHandlingDetails: Bool -} - -struct BLEReceivePipeline { - static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext { - let senderID = PeerID(hexData: packet.senderID) - // Include a payload digest so that distinct packets sharing the same - // sender/timestamp(ms)/type are not collapsed as duplicates. The - // post-handshake flush sends queued messages, delivery and read receipts - // back-to-back within a single millisecond; without the digest every - // packet after the first would be silently dropped. - let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString() - let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)" - let messageType = MessageType(rawValue: packet.type) - let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID - let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay - - return BLEReceivedPacketContext( - senderID: senderID, - messageID: messageID, - messageType: messageType, - shouldDeduplicate: shouldDeduplicate, - logsHandlingDetails: messageType != .announce - ) - } - - static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool { - connectedPeerCount > 2 - } - - static func relayDecision( - for packet: BitchatPacket, - senderID: PeerID, - localPeerID: PeerID, - degree: Int, - highDegreeThreshold: Int - ) -> RelayDecision { - RelayController.decide( - ttl: packet.ttl, - senderIsSelf: senderID == localPeerID, - recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID, - isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, - // Courier envelopes are directed opaque ciphertext like DMs; a - // remote handover toward a relayed announce rides this same - // deterministic relay treatment instead of the broadcast clamp. - // Ping/pong diagnostics ride it too: probes need the same - // deterministic multi-hop relay as DMs (always relay, jitter, - // no TTL cap) so RTT and hop counts reflect the real path. - // Directed nostrCarrier uplinks (mesh-only peer -> gateway) need - // the same multi-hop treatment to reach a non-adjacent gateway. - isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue - || packet.type == MessageType.courierEnvelope.rawValue - || packet.type == MessageType.ping.rawValue - || packet.type == MessageType.pong.rawValue - || packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil, - isFragment: packet.type == MessageType.fragment.rawValue, - isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, - isHandshake: packet.type == MessageType.noiseHandshake.rawValue, - isAnnounce: packet.type == MessageType.announce.rawValue, - isRequestSync: packet.type == MessageType.requestSync.rawValue, - // Board posts relay like broadcast messages; urgent ones get the - // announce-class TTL headroom so alerts travel the extra hop. - isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue - && BoardWire.urgentFlag(in: packet.payload), - isVoiceFrame: packet.type == MessageType.voiceFrame.rawValue, - degree: degree, - highDegreeThreshold: highDegreeThreshold - ) - } -} - -/// Lock-backed traffic-level signal: the receive pipeline records packets, -/// and the radio layer (maintenance and scan-duty adaptation on bleQueue) -/// reads the level without crossing onto a transport queue. -final class BLERecentTrafficMonitor: @unchecked Sendable { - private let lock = NSLock() - private var tracker = BLERecentTrafficTracker() - - func recordPacket(at now: Date) { - lock.withLock { tracker.recordPacket(at: now) } - } - - func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool { - lock.withLock { tracker.hasTraffic(within: seconds, now: now) } - } - - func removeAll() { - lock.withLock { tracker.removeAll() } - } -} - -struct BLERecentTrafficTracker: Equatable { - private var packetTimestamps: [Date] = [] - - var count: Int { - packetTimestamps.count - } - - mutating func removeAll() { - packetTimestamps.removeAll() - } - - mutating func recordPacket(at now: Date) { - packetTimestamps.append(now) - prune(at: now) - } - - func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool { - let cutoff = now.addingTimeInterval(-seconds) - return packetTimestamps.contains { $0 >= cutoff } - } - - private mutating func prune(at now: Date) { - let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds) - if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount { - packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount) - } - packetTimestamps.removeAll { $0 < cutoff } - } -} diff --git a/bitchat/Services/BLE/BLERecentPeripheralCache.swift b/bitchat/Services/BLE/BLERecentPeripheralCache.swift deleted file mode 100644 index 1f7819fd..00000000 --- a/bitchat/Services/BLE/BLERecentPeripheralCache.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped -/// links) so the service can arm pending background connections against them -/// when the app leaves the foreground. Generic over the peripheral type so -/// the eviction/expiry logic is testable without CoreBluetooth. -final class BLERecentPeripheralCache { - private struct Entry { - let peripheral: Peripheral - var lastSeen: Date - } - - private var entries: [String: Entry] = [:] - private let capacity: Int - private let maxAge: TimeInterval - - init( - capacity: Int = TransportConfig.bleRecentPeripheralCacheCap, - maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds - ) { - self.capacity = capacity - self.maxAge = maxAge - } - - var count: Int { entries.count } - - func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) { - entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now) - guard entries.count > capacity else { return } - // Inserts overshoot capacity by at most one; evict the stalest entry - if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) { - entries.removeValue(forKey: stalest.key) - } - } - - /// Most-recently-seen peripherals eligible for a pending background - /// connect, freshest first, capped at `limit`. Expired entries are - /// pruned as a side effect. - func reconnectTargets( - now: Date, - limit: Int, - excluding: (String) -> Bool - ) -> [(peripheralID: String, peripheral: Peripheral)] { - let cutoff = now.addingTimeInterval(-maxAge) - entries = entries.filter { $0.value.lastSeen >= cutoff } - guard limit > 0 else { return [] } - return entries - .filter { !excluding($0.key) } - .sorted { $0.value.lastSeen > $1.value.lastSeen } - .prefix(limit) - .map { (peripheralID: $0.key, peripheral: $0.value.peripheral) } - } -} diff --git a/bitchat/Services/BLE/BLERedundantLinkPolicy.swift b/bitchat/Services/BLE/BLERedundantLinkPolicy.swift deleted file mode 100644 index 7bf5c3f4..00000000 --- a/bitchat/Services/BLE/BLERedundantLinkPolicy.swift +++ /dev/null @@ -1,138 +0,0 @@ -import BitFoundation -import Foundation - -/// Decides which central-role connections (peripheral links we own) are -/// redundant duplicates of a peer's live link. -/// -/// One connection per role per peer is the normal dual-role topology (each -/// device is both central and peripheral). After a BLE state-restoration -/// relaunch, though, the same phone can reappear under a fresh peripheral -/// UUID while the restored connection lives on — leaving several live -/// central-role connections to one peer, each carrying every packet -/// (field-verified: 2-3x airtime on all traffic). Only same-role duplicates -/// are retired; the peer's central-role subscription on our peripheral -/// manager is its own connection to manage, and it runs the same policy. -enum BLERedundantLinkPolicy { - struct PeripheralLink: Equatable { - let uuid: String - let peerID: PeerID? - let isConnected: Bool - /// Whether the link has a discovered characteristic (is writable). - /// A link mid-service-rediscovery (didModifyServices cleared it) - /// must never be kept over a writable duplicate. - let hasCharacteristic: Bool - /// When didConnect last fired for this link in this process. Nil - /// for restored links, whose connect predates the relaunch. - let lastConnectedAt: Date? - - init( - uuid: String, - peerID: PeerID?, - isConnected: Bool, - hasCharacteristic: Bool, - lastConnectedAt: Date? = nil - ) { - self.uuid = uuid - self.peerID = peerID - self.isConnected = isConnected - self.hasCharacteristic = hasCharacteristic - self.lastConnectedAt = lastConnectedAt - } - } - - /// The link to keep when a peer has several connected bound peripheral - /// links, or nil when there is nothing to consolidate. - /// - /// Prefers the most recently CONNECTED candidate. Duplicates arise when - /// the peer reappears under a fresh BLE address (privacy address - /// rotation) while an older connection — typically state-restored — - /// lives on: only the newest connection sits on the address the peer - /// still advertises. Cancelling that one instead just gets it - /// rediscovered and reconnected, a retire↔reconnect oscillation at the - /// retirement cooldown (field-observed July 31); the older-address link - /// cannot return once cancelled, so consolidation converges immediately. - /// Physical connect recency is also a signal an announce replay cannot - /// nominate, unlike the previous ingress-link preference — announce - /// anchors (ingress, then most recently bound) now only break ties and - /// serve links with no connect timestamp at all. Link "health" signals - /// like RSSI are deliberately not inputs: they are transient and the - /// stale-address link often reads stronger; connect recency is the only - /// signal that tracks address currency. - /// - /// The survivor must be writable while any writable candidate exists: - /// keeping a characteristic-less link and cancelling the writable - /// duplicate would strand outbound traffic on the central link until - /// rediscovery finishes. But when the physically NEWEST connection is - /// the one that is not writable yet (service discovery still running), - /// consolidation defers entirely — selecting an older writable link - /// would cancel the freshly advertised connection and recreate the - /// oscillation. When no candidate is identifiable, consolidation waits - /// for a later announce rather than guessing. - static func keptPeripheralUUID( - ingressPeripheralUUID: String?, - mostRecentlyBoundUUID: String?, - links: [PeripheralLink], - peerID: PeerID - ) -> String? { - let bound = links.filter { $0.peerID == peerID && $0.isConnected } - guard bound.count > 1 else { return nil } - - let writable = bound.filter(\.hasCharacteristic) - let candidates = writable.isEmpty ? bound : writable - - // The newest connection is still mid-service-discovery while a - // writable (typically restored, stale-address) duplicate exists: - // defer to a later announce instead of keeping the older link and - // cancelling the one connection on the currently advertised address. - if !writable.isEmpty, - let newestBoundDate = bound.compactMap(\.lastConnectedAt).max(), - !writable.contains(where: { $0.lastConnectedAt == newestBoundDate }) { - return nil - } - - if let newestDate = candidates.compactMap(\.lastConnectedAt).max() { - let newest = candidates.filter { $0.lastConnectedAt == newestDate } - if newest.count == 1 { - return newest[0].uuid - } - return anchoredChoice( - among: newest, - ingressPeripheralUUID: ingressPeripheralUUID, - mostRecentlyBoundUUID: mostRecentlyBoundUUID - ) ?? newest.map(\.uuid).min() - } - - return anchoredChoice( - among: candidates, - ingressPeripheralUUID: ingressPeripheralUUID, - mostRecentlyBoundUUID: mostRecentlyBoundUUID - ) - } - - /// The pre-timestamp anchors: the verified announce's ingress link, - /// then the peer's most recently bound link. - private static func anchoredChoice( - among candidates: [PeripheralLink], - ingressPeripheralUUID: String?, - mostRecentlyBoundUUID: String? - ) -> String? { - if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) { - return ingressPeripheralUUID - } - if let mostRecentlyBoundUUID, candidates.contains(where: { $0.uuid == mostRecentlyBoundUUID }) { - return mostRecentlyBoundUUID - } - return nil - } - - /// Connected peripheral links bound to the peer other than the kept one. - static func peripheralUUIDsToRetire( - links: [PeripheralLink], - peerID: PeerID, - keeping keptUUID: String - ) -> [String] { - links - .filter { $0.peerID == peerID && $0.isConnected && $0.uuid != keptUUID } - .map(\.uuid) - } -} diff --git a/bitchat/Services/BLE/BLERouteForwardingPolicy.swift b/bitchat/Services/BLE/BLERouteForwardingPolicy.swift deleted file mode 100644 index aa12f7e0..00000000 --- a/bitchat/Services/BLE/BLERouteForwardingPolicy.swift +++ /dev/null @@ -1,101 +0,0 @@ -import BitFoundation -import Foundation - -struct BLERouteForwardingPlan { - let shouldSuppressFloodRelay: Bool - let forwardPacket: BitchatPacket? - let nextHop: PeerID? - - static let allowFloodRelay = BLERouteForwardingPlan( - shouldSuppressFloodRelay: false, - forwardPacket: nil, - nextHop: nil - ) - - static let suppressFloodRelay = BLERouteForwardingPlan( - shouldSuppressFloodRelay: true, - forwardPacket: nil, - nextHop: nil - ) - - static func forward(_ packet: BitchatPacket, to nextHop: PeerID) -> BLERouteForwardingPlan { - BLERouteForwardingPlan( - shouldSuppressFloodRelay: true, - forwardPacket: packet, - nextHop: nextHop - ) - } -} - -struct BLERouteForwardingPolicy { - static func plan( - for packet: BitchatPacket, - localPeerID: PeerID, - localRoutingData: Data?, - routingPeer: (Data) -> PeerID?, - isPeerConnected: (PeerID) -> Bool - ) -> BLERouteForwardingPlan { - // REQUEST_SYNC is link-local: never forward it, on the flood path or - // the source-routed path. A crafted request with a route and TTL - // headroom must not be able to fan a full-store replay out to the next - // hop. Suppressing here also short-circuits the flood relay. - if packet.type == MessageType.requestSync.rawValue { - return .suppressFloodRelay - } - - if PeerID(hexData: packet.recipientID) == localPeerID { - return .suppressFloodRelay - } - - guard let route = packet.route, !route.isEmpty else { - return .allowFloodRelay - } - - guard packet.ttl > 1 else { - return .suppressFloodRelay - } - - guard let localRoutingData else { - return .allowFloodRelay - } - - guard let localIndex = route.firstIndex(of: localRoutingData) else { - return forward(packet, toRouteData: route[0], routingPeer: routingPeer, isPeerConnected: isPeerConnected) - } - - if localIndex == route.count - 1 { - guard let destinationPeer = PeerID(hexData: packet.recipientID), - isPeerConnected(destinationPeer) else { - return .allowFloodRelay - } - return .forward(relayed(packet), to: destinationPeer) - } - - return forward( - packet, - toRouteData: route[localIndex + 1], - routingPeer: routingPeer, - isPeerConnected: isPeerConnected - ) - } - - private static func forward( - _ packet: BitchatPacket, - toRouteData routeData: Data, - routingPeer: (Data) -> PeerID?, - isPeerConnected: (PeerID) -> Bool - ) -> BLERouteForwardingPlan { - guard let nextPeer = routingPeer(routeData), - isPeerConnected(nextPeer) else { - return .allowFloodRelay - } - - return .forward(relayed(packet), to: nextPeer) - } - - private static func relayed(_ packet: BitchatPacket) -> BitchatPacket { - var relayPacket = packet - relayPacket.ttl = packet.ttl - 1 - return relayPacket - } -} diff --git a/bitchat/Services/BLE/BLEScanDutyPolicy.swift b/bitchat/Services/BLE/BLEScanDutyPolicy.swift deleted file mode 100644 index 5d85ae59..00000000 --- a/bitchat/Services/BLE/BLEScanDutyPolicy.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -enum BLEScanDutyPlan: Equatable { - case continuous - case dutyCycle(onDuration: TimeInterval, offDuration: TimeInterval) -} - -enum BLEScanDutyPolicy { - static func plan( - dutyEnabled: Bool, - appIsActive: Bool, - connectedCount: Int, - hasRecentTraffic: Bool, - highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold - ) -> BLEScanDutyPlan { - let forceContinuousScan = connectedCount <= 2 || hasRecentTraffic - let shouldDutyCycle = dutyEnabled && appIsActive && connectedCount > 0 && !forceContinuousScan - - guard shouldDutyCycle else { - return .continuous - } - - if connectedCount >= highDegreeThreshold { - return .dutyCycle( - onDuration: TransportConfig.bleDutyOnDurationDense, - offDuration: TransportConfig.bleDutyOffDurationDense - ) - } - - return .dutyCycle( - onDuration: TransportConfig.bleDutyOnDuration, - offDuration: TransportConfig.bleDutyOffDuration - ) - } -} diff --git a/bitchat/Services/BLE/BLEScheduledRelayStore.swift b/bitchat/Services/BLE/BLEScheduledRelayStore.swift deleted file mode 100644 index 1aa66882..00000000 --- a/bitchat/Services/BLE/BLEScheduledRelayStore.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -struct BLEScheduledRelayStore { - private var relays: [String: DispatchWorkItem] = [:] - - var count: Int { - relays.count - } - - var isEmpty: Bool { - relays.isEmpty - } - - mutating func schedule(_ workItem: DispatchWorkItem, messageID: String) { - relays[messageID] = workItem - } - - @discardableResult - mutating func remove(messageID: String) -> DispatchWorkItem? { - relays.removeValue(forKey: messageID) - } - - @discardableResult - mutating func cancel(messageID: String) -> Bool { - guard let workItem = relays.removeValue(forKey: messageID) else { - return false - } - - workItem.cancel() - return true - } - - mutating func cancelAll() { - relays.values.forEach { $0.cancel() } - relays.removeAll() - } - - mutating func removeAllIfOverCapacity(_ maxCount: Int) { - if relays.count > maxCount { - relays.removeAll() - } - } -} diff --git a/bitchat/Services/BLE/BLESelfBroadcastTracker.swift b/bitchat/Services/BLE/BLESelfBroadcastTracker.swift deleted file mode 100644 index 83756cfe..00000000 --- a/bitchat/Services/BLE/BLESelfBroadcastTracker.swift +++ /dev/null @@ -1,40 +0,0 @@ -import BitFoundation -import Foundation - -struct BLESelfBroadcastTracker { - private struct Entry { - let messageID: String - let sentAt: Date - } - - private var entriesByDedupID: [String: Entry] = [:] - - var isEmpty: Bool { - entriesByDedupID.isEmpty - } - - var count: Int { - entriesByDedupID.count - } - - mutating func record(messageID: String, packet: BitchatPacket, sentAt: Date) { - entriesByDedupID[Self.dedupID(for: packet)] = Entry(messageID: messageID, sentAt: sentAt) - } - - mutating func takeMessageID(for packet: BitchatPacket) -> String? { - entriesByDedupID.removeValue(forKey: Self.dedupID(for: packet))?.messageID - } - - mutating func prune(before cutoff: Date) { - guard !entriesByDedupID.isEmpty else { return } - entriesByDedupID = entriesByDedupID.filter { cutoff <= $0.value.sentAt } - } - - mutating func removeAll() { - entriesByDedupID.removeAll() - } - - static func dedupID(for packet: BitchatPacket) -> String { - "\(packet.senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)" - } -} diff --git a/bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift b/bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift deleted file mode 100644 index bf45a5dc..00000000 --- a/bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift +++ /dev/null @@ -1,433 +0,0 @@ -// -// BLEService+LinkLayerCentralRole.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import CoreBluetooth -import Foundation - -// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do -// physical bookkeeping (link-state store, buffers, radio policy) and report -// everything else to the engine through the link-event port -// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md. - -// MARK: - CBCentralManagerDelegate - -extension BLEService: CBCentralManagerDelegate { - #if os(iOS) - func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { - let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] - guard !isPanicSuspended else { - central.stopScan() - restoredPeripherals.forEach { - central.cancelPeripheralConnection($0) - } - return - } - let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] - let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] - let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool - - SecureLogger.info( - "♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))", - category: .session - ) - - for peripheral in restoredPeripherals { - let identifier = peripheral.identifier.uuidString - peripheral.delegate = self - let existing = linkStateStore.state(forPeripheralID: identifier) - let assembler = existing?.assembler ?? NotificationStreamAssembler() - let characteristic = existing?.characteristic - let wasConnecting = existing?.isConnecting ?? false - let wasConnected = existing?.isConnected ?? false - - let restoredState = BLEPeripheralLinkState( - peripheral: peripheral, - characteristic: characteristic, - isConnecting: wasConnecting || peripheral.state == .connecting, - isConnected: wasConnected || peripheral.state == .connected, - lastConnectionAttempt: existing?.lastConnectionAttempt, - assembler: assembler - ) - linkStateStore.setPeripheralState(restoredState, for: identifier) - - // Restored peripherals are the freshest wake-on-proximity - // candidates we have after a relaunch — without this the cache - // starts empty and backgrounding right after a restore arms - // nothing. Service rediscovery for restored-connected links waits - // for poweredOn: CoreBluetooth drops commands issued during - // restoration (API MISUSE warnings). - radio.recordRecentPeripheral(peripheral, peripheralID: identifier, at: Date()) - } - - // Via the sampler (not a direct capture): it refreshes the cached - // background budget on main first, so the restore log shows the real - // wake window instead of the init sentinel. - logBluetoothStatus("central-restore") - - if central.state == .poweredOn { - radio.startScanning() - } - } - #endif - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - emitTransportEvent(.bluetoothStateUpdated(central.state)) - - switch central.state { - case .poweredOn: - guard !isPanicSuspended else { - central.stopScan() - return - } - // Links restored as connected have no characteristic in the new - // process; without rediscovery they sit connected-but-unusable - // until the peer disconnects. Runs here (not willRestoreState) - // because commands issued before poweredOn are dropped. - for state in linkStateStore.peripheralStates where state.isConnected - && state.characteristic == nil - && state.peripheral.state == .connected { - SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))…", category: .session) - state.peripheral.discoverServices([BLEService.serviceUUID]) - } - - // Start scanning - use allow duplicates for faster discovery when active - radio.startScanning() - - case .poweredOff: - // CoreBluetooth has already transitioned out of poweredOn. Do - // not issue stop/cancel commands now; they are rejected as API - // misuse. Retire our link state locally instead. - SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session) - let peripheralIDs = linkStateStore.peripheralStates.map { $0.peripheral.identifier.uuidString } - for peripheralID in peripheralIDs { - pendingPeripheralWrites.discardAll(for: peripheralID) - } - linkStateStore.clearPeripherals() - emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: peripheralIDs, retireProofsAndNotify: true)) - - case .unauthorized: - // User denied Bluetooth permission - SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) - linkStateStore.clearPeripherals() - emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: [], retireProofsAndNotify: false)) - - case .unsupported: - // Device doesn't support BLE - SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session) - - case .resetting: - // Bluetooth stack is resetting - will get another state update when done - SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session) - - case .unknown: - // Initial state before we know the actual state - SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session) - - @unknown default: - SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session) - } - } - - - func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { - radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI) - } - - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - guard !isPanicSuspended else { - central.cancelPeripheralConnection(peripheral) - return - } - let peripheralID = peripheral.identifier.uuidString - - #if os(iOS) - // A connect completing while backgrounded is the wake-on-proximity - // path doing its job — worth an info line for field verification. - if !isAppActive { - SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session) - } - #endif - - // Update state to connected - linkStateStore.markConnected(peripheral) - - // Reset backoff state on success - radio.recordConnectionSuccess(peripheralID: peripheralID) - - SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session) - - // Discover services - peripheral.discoverServices([BLEService.serviceUUID]) - } - - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - let peripheralID = peripheral.identifier.uuidString - - SecureLogger.debug("📱 Disconnect: \(peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session) - - // If disconnect carried an error (often timeout), apply short backoff to avoid thrash - if error != nil { - radio.recordDisconnectError(peripheralID: peripheralID, at: Date()) - } - - // Retain the handle: a dropped link is the best wake-on-proximity - // candidate if the app backgrounds before the peer returns. - radio.recordRecentPeripheral(peripheral, peripheralID: peripheralID, at: Date()) - - #if os(iOS) - // Link lost while backgrounded (peer walked away): re-arm a pending - // connect during this wake window so the peer's return wakes us again. - // Delayed past the disconnect-settle window to avoid reconnect thrash - // at range edge. - if !isAppActive { - bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in - guard let self, !self.isAppActive else { return } - // Reserve 0: use the slot this disconnect freed even in a - // dense mesh, so the lost peer can wake us when it returns. - self.radio.armPendingBackgroundConnects(slotReserve: 0) - } - } - #endif - - // Physical teardown now; identity retirement and peer-disconnect - // bookkeeping ride the link-event port. The scan restart and - // connect-slot refill below stay on bleQueue — they respond to - // the physical drop regardless of remaining logical links. - discardPeripheralLinkPhysical(peripheralID) - emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: true)) - - // Restart scanning with allow duplicates for faster rediscovery - if centralManager?.state == .poweredOn { - // Stop and restart scanning to ensure we get fresh discovery events - centralManager?.stopScan() - bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in - self?.radio.startScanning() - } - } - // Attempt to fill freed slot from queue - bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() } - } - - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - let peripheralID = peripheral.identifier.uuidString - - // Clean up the references: physical now, identity via the port. - discardPeripheralLinkPhysical(peripheralID) - emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: false)) - - SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) - radio.recordConnectionFailure(peripheralID: peripheralID) - // Try next candidate - bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() } - } -} - -// MARK: - CBPeripheralDelegate - -extension BLEService: CBPeripheralDelegate { - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - guard !isPanicSuspended else { return } - if let error = error { - SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) - // Retry service discovery after a delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard peripheral.state == .connected else { return } - peripheral.discoverServices([BLEService.serviceUUID]) - } - return - } - - guard let services = peripheral.services else { - SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session) - return - } - - guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else { - // Not a BitChat peer - disconnect - centralManager?.cancelPeripheralConnection(peripheral) - return - } - - // Discovering BLE characteristics - peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service) - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - guard !isPanicSuspended else { return } - if let error = error { - SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) - return - } - - guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else { - SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session) - return - } - - // Found characteristic - - // Log characteristic properties for debugging - var properties: [String] = [] - if characteristic.properties.contains(.read) { properties.append("read") } - if characteristic.properties.contains(.write) { properties.append("write") } - if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") } - if characteristic.properties.contains(.notify) { properties.append("notify") } - if characteristic.properties.contains(.indicate) { properties.append("indicate") } - // Characteristic properties: \(properties.joined(separator: ", ")) - - // Verify characteristic supports reliable writes - if !characteristic.properties.contains(.write) { - SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session) - } - - // Store characteristic in our consolidated structure - let peripheralID = peripheral.identifier.uuidString - linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID) - - // Subscribe for notifications - if characteristic.properties.contains(.notify) { - peripheral.setNotifyValue(true, for: characteristic) - SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session) - - // Send announce after subscription is confirmed (force send for new connection) - engineScheduler.schedule(after: TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in - self?.sendAnnounce(forceSend: true) - // Try flushing any spooled directed packets now that we have a link - self?.flushDirectedSpool() - } - } else { - SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session) - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - guard !isPanicSuspended else { return } - if let error = error { - SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session) - return - } - - guard let data = characteristic.value, !data.isEmpty else { - SecureLogger.warning("⚠️ No data in notification", category: .session) - return - } - - bufferNotificationChunk(data, from: peripheral) - } - - private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) { - let peripheralUUID = peripheral.identifier.uuidString - - var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState( - peripheral: peripheral, - characteristic: nil, - isConnecting: false, - isConnected: peripheral.state == .connected, - lastConnectionAttempt: nil, - assembler: NotificationStreamAssembler() - ) - - var assembler = state.assembler - let result = assembler.append(chunk) - state.assembler = assembler - linkStateStore.setPeripheralState(state, for: peripheralUUID) - - for byte in result.droppedPrefixes { - SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session) - } - - if result.reset { - SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session) - } - - // Attribution — spoof rejection, announce binding, ingress - // recording — is engine work now (the engine owns the bindings). - // Frames hop up in decode order; the engine's serial slot ordering - // gives the same same-batch spoof protection the old bleQueue-side - // batch-local binding enforced: an announce that binds this link is - // attributed before every frame that rode behind it. - for frame in result.frames { - guard let packet = BinaryProtocol.decode(frame) else { - let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") - SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session) - continue - } - emitLinkEvent(.frameDecoded( - packet, - link: .peripheral(peripheralUUID), - linkDescription: "Peripheral \(peripheralUUID.prefix(8))…" - )) - } - } - - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - if let error = error { - SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session) - // Don't retry - just log the error - } else { - SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) - } - } - - func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { - guard !isPanicSuspended else { return } - // Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again - if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") { - SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session) - } - drainPendingWrites(for: peripheral) - } - - func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { - guard !isPanicSuspended else { return } - SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) - - let shouldRediscover = BLEService.shouldRediscoverBitChatService( - invalidatedServiceUUIDs: invalidatedServices.map(\.uuid), - cachedServiceUUIDs: peripheral.services?.map(\.uuid) - ) - - guard shouldRediscover else { return } - - let peripheralID = peripheral.identifier.uuidString - linkStateStore.updatePeripheral(peripheralID) { - $0.characteristic = nil - $0.assembler = NotificationStreamAssembler() - } - - SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session) - peripheral.discoverServices([BLEService.serviceUUID]) - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - guard !isPanicSuspended else { return } - if let error = error { - SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session) - } else { - SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session) - - // If notifications are now on, send an announce to ensure this peer knows about us - if characteristic.isNotifying { - // Sending announce after subscription - self.sendAnnounce(forceSend: true) - } - } - } - -} - -extension BLEService { - static func shouldRediscoverBitChatService( - invalidatedServiceUUIDs: [CBUUID], - cachedServiceUUIDs: [CBUUID]? - ) -> Bool { - invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true - } -} diff --git a/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift b/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift deleted file mode 100644 index c55c30e4..00000000 --- a/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift +++ /dev/null @@ -1,320 +0,0 @@ -// -// BLEService+LinkLayerPeripheralRole.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import CoreBluetooth -import Foundation - -// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do -// physical bookkeeping (link-state store, buffers, radio policy) and report -// everything else to the engine through the link-event port -// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md. - -// MARK: - CBPeripheralManagerDelegate - -extension BLEService: CBPeripheralManagerDelegate { - func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { - SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session) - - switch peripheral.state { - case .poweredOn: - guard !isPanicSuspended else { - peripheral.stopAdvertising() - peripheral.removeAllServices() - characteristic = nil - return - } - // Remove all services first to ensure clean state - peripheral.removeAllServices() - - // Create characteristic - characteristic = CBMutableCharacteristic( - type: BLEService.characteristicUUID, - properties: [.notify, .write, .writeWithoutResponse, .read], - value: nil, - permissions: [.readable, .writeable] - ) - - // Create service - let service = CBMutableService(type: BLEService.serviceUUID, primary: true) - service.characteristics = [characteristic!] - - // Add service (advertising will start in didAdd delegate) - SecureLogger.debug("🔧 Adding BLE service...", category: .session) - peripheral.add(service) - - case .poweredOff: - // Bluetooth was turned off - clean up peripheral state - SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session) - // Clear subscribed centrals (they are now invalid) - let centralIDs = linkStateStore.subscribedCentrals.map { $0.identifier.uuidString } - pendingNotifications.removeAll() - pendingWriteBuffers.removeAll() - linkStateStore.clearCentrals() - subscriptionAnnounceLimiter.removeAll() - characteristic = nil - emitLinkEvent(.allCentralLinksEnded(centralUUIDs: centralIDs, retireProofsAndNotify: true)) - - case .unauthorized: - // User denied Bluetooth permission - SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) - linkStateStore.clearCentrals() - subscriptionAnnounceLimiter.removeAll() - characteristic = nil - emitLinkEvent(.allCentralLinksEnded(centralUUIDs: [], retireProofsAndNotify: false)) - - case .unsupported: - // Device doesn't support BLE peripheral role - SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session) - - case .resetting: - // Bluetooth stack is resetting - SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session) - - case .unknown: - SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session) - - @unknown default: - SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session) - } - } - - #if os(iOS) - func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) { - guard !isPanicSuspended else { - peripheral.stopAdvertising() - peripheral.removeAllServices() - characteristic = nil - return - } - let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] - let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] - - SecureLogger.info( - "♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))", - category: .session - ) - - // Attempt to recover characteristic from restored services - if characteristic == nil { - if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }), - let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic { - characteristic = restoredCharacteristic - } - } - - // Via the sampler for a fresh background budget (see central-restore). - logBluetoothStatus("peripheral-restore") - - if peripheral.state == .poweredOn && !peripheral.isAdvertising { - peripheral.startAdvertising(BLERadioController.advertisementData()) - } - } - #endif - - func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { - guard !isPanicSuspended else { - peripheral.stopAdvertising() - return - } - if let error = error { - SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session) - return - } - - SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session) - - // Start advertising after service is confirmed added - let adData = BLERadioController.advertisementData() - peripheral.startAdvertising(adData) - - SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session) - } - - func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { - guard !isPanicSuspended else { return } - let centralUUID = central.identifier.uuidString - SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session) - linkStateStore.addSubscribedCentral(central) - - // BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks - let now = Date() - switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) { - case .allowed: - break - case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce): - SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security) - if suppressAnnounce { - SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security) - return - } - - // Still flush directed packets for legitimate mesh operation - engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in - self?.flushDirectedSpool() - } - return - } - - // Send announce to the newly subscribed central after a small delay - engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in - self?.sendAnnounce(forceSend: true) - // Flush any spooled directed packets now that we have a central subscribed - self?.flushDirectedSpool() - } - } - - func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { - let centralID = central.identifier.uuidString - SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session) - // bleQueue: physical retirement now. - pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } - linkStateStore.removeSubscribedCentral(central) - - // Ensure we're still advertising for other devices to find us - if !isPanicSuspended, peripheral.isAdvertising == false { - SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session) - peripheral.startAdvertising(BLERadioController.advertisementData()) - } - - // Identity retirement and peer-disconnect bookkeeping ride the - // link-event port. - emitLinkEvent(.centralLinkEnded(centralUUID: centralID)) - } - - func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { - guard !isPanicSuspended else { return } - drainPendingNotifications(logPrefix: "✅ Sent") - } - - func logBackpressureSampled(_ message: @autoclosure () -> String) { - notificationBackpressureLogCount += 1 - if notificationBackpressureLogCount == 1 || - notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) { - SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session) - } - } - - func drainPendingNotifications(logPrefix: String) { - bleQueue.async { [weak self] in - guard let self = self, - let characteristic = self.characteristic, - !self.pendingNotifications.isEmpty else { return } - - let pending = self.pendingNotifications.takeAll() - let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic) - - if sentCount > 0 { - self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)") - } - } - } - - private func sendPendingNotifications(_ pending: [BLEPendingNotification], characteristic: CBMutableCharacteristic) -> Int { - var sentCount = 0 - - for (index, notification) in pending.enumerated() { - let success = peripheralManager?.updateValue( - notification.data, - for: characteristic, - onSubscribedCentrals: notification.targets - ) ?? false - - guard success else { - let remaining = Array(pending.dropFirst(index)) - pendingNotifications.prepend(remaining) - logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items") - break - } - - sentCount += 1 - } - - return sentCount - } - - func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { - // Suppress logs for single write requests to reduce noise - if requests.count > 1 { - SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session) - } - - // IMPORTANT: Respond immediately to prevent timeouts! - // We must respond within a few milliseconds or the central will timeout - for request in requests { - peripheral.respond(to: request, withResult: .success) - } - guard !isPanicSuspended else { return } - - // Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets. - // Combine per-central request values by offset before decoding. - // Process directly on our message queue to match transport context - let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString }) - for (centralUUID, group) in grouped { - // Sort by offset ascending - let sorted = group.sorted { $0.offset < $1.offset } - let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0 - let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in - guard let data = request.value, !data.isEmpty else { return nil } - return BLEInboundWriteChunk(offset: request.offset, data: data) - } - - let result = pendingWriteBuffers.append( - chunks: chunks, - for: centralUUID, - capBytes: TransportConfig.blePendingWriteBufferCapBytes - ) - - switch result { - case let .decoded(packet, metadata): - logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) - processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central) - - case let .waiting(metadata): - logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) - logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted) - - case let .oversized(metadata): - logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) - SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))…", category: .session) - logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted) - } - } - } - - private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) { - guard let packetType = metadata.packetType, - packetType != MessageType.announce.rawValue else { return } - - SecureLogger.debug( - "📥 Accumulated write from central \(centralUUID.prefix(8))…: size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)", - category: .session - ) - } - - private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) { - guard !hasMultiple, let raw = sortedRequests.first?.value else { return } - - let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") - SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session) - } - - private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) { - // bleQueue: physical bookkeeping only. A writer is a live central - // whether or not it subscribed; track it so directed replies and - // the fanout planner can reach it. - linkStateStore.addSubscribedCentral(central) - // Attribution is engine work (the engine owns the bindings). - emitLinkEvent(.frameDecoded( - packet, - link: .central(centralUUID), - linkDescription: "Central \(centralUUID.prefix(8))…" - )) - } -} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 5f9b97af..055c254f 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -3,182 +3,15 @@ import BitFoundation import Foundation import CoreBluetooth import Combine +import CryptoKit #if os(iOS) import UIKit #endif -/// Linearizes app-private-media admission against cancellation before work is -/// handed to the fragment scheduler. A transfer starts here synchronously, -/// before its `messageQueue` work item is enqueued; cancel/delete can therefore -/// leave a tombstone that the deferred work must observe. -/// -/// Active admissions and cancellation tombstones have independent count -/// bounds. Tombstones may age out or evict older tombstones; active entries -/// are never evicted under pressure. A one-hour active timeout is reported as -/// an explicit transfer failure and removes any handshake-queued payload. -private final class BLEPrivateMediaTransferAdmissionRegistry { - enum BeginResult: Equatable { - case admitted - case alreadyKnown - case capacityExhausted - } - - private enum State: Equatable { - case active - case cancelled - } - - private struct Entry { - var state: State - var updatedAt: Date - } - - private let lock = NSLock() - private let maxActiveEntries = 512 - private let maxCancelledTombstones = 512 - private let lifetime: TimeInterval = 60 * 60 - private let onActiveExpired: (String) -> Void - private var entries: [String: Entry] = [:] - - init(onActiveExpired: @escaping (String) -> Void) { - self.onActiveExpired = onActiveExpired - } - - func begin(_ transferId: String, now: Date = Date()) -> BeginResult { - guard !transferId.isEmpty else { return .alreadyKnown } - lock.lock() - let expiredActive = pruneLocked(now: now) - // Transfer IDs are invocation-unique. Never revive a cancellation or - // admit a duplicate invocation that reused an in-flight identifier. - let result: BeginResult - if entries[transferId] != nil { - result = .alreadyKnown - } else if activeCountLocked >= maxActiveEntries { - // Never evict an admitted transfer: doing so strands its UI - // placeholder with no completion event. Reject the newcomer and - // let the caller surface the bounded-pressure failure instead. - result = .capacityExhausted - } else { - entries[transferId] = Entry(state: .active, updatedAt: now) - result = .admitted - } - lock.unlock() - notifyExpired(expiredActive) - return result - } - - func cancel(_ transferId: String, now: Date = Date()) { - guard !transferId.isEmpty else { return } - lock.lock() - // Cancel the requested active entry before expiry pruning so a user - // cancellation wins over a simultaneous timeout notification. - entries[transferId] = Entry(state: .cancelled, updatedAt: now) - let expiredActive = pruneLocked(now: now) - trimCancelledTombstonesLocked() - lock.unlock() - notifyExpired(expiredActive) - } - - func isActive(_ transferId: String, now: Date = Date()) -> Bool { - lock.lock() - let expiredActive = pruneLocked(now: now) - let active = entries[transferId]?.state == .active - if active { - entries[transferId]?.updatedAt = now - } - lock.unlock() - notifyExpired(expiredActive) - return active - } - - /// Runs `body` while holding the admission lock. Callers use this at the - /// collections-queue append/submit boundary so cancellation and admission - /// have one deterministic order: whichever acquires this lock first wins. - func withActive( - _ transferId: String, - now: Date = Date(), - _ body: () -> Result - ) -> Result? { - lock.lock() - let expiredActive = pruneLocked(now: now) - guard entries[transferId]?.state == .active else { - lock.unlock() - notifyExpired(expiredActive) - return nil - } - entries[transferId]?.updatedAt = now - let result = body() - lock.unlock() - notifyExpired(expiredActive) - return result - } - - func finish(_ transferId: String) { - lock.lock() - entries.removeValue(forKey: transferId) - lock.unlock() - } - - var count: Int { - lock.lock() - let expiredActive = pruneLocked(now: Date()) - let result = entries.count - lock.unlock() - notifyExpired(expiredActive) - return result - } - - func prune(now: Date = Date()) { - lock.lock() - let expiredActive = pruneLocked(now: now) - lock.unlock() - notifyExpired(expiredActive) - } - - private var activeCountLocked: Int { - entries.values.reduce(into: 0) { count, entry in - if entry.state == .active { count += 1 } - } - } - - /// Removes stale tombstones silently and stale active admissions with a - /// caller-visible timeout notification. Must be called with `lock` held; - /// notifications are delivered only after the lock is released. - private func pruneLocked(now: Date) -> [String] { - var expiredActive: [String] = [] - let expiredEntries = entries.filter { - now.timeIntervalSince($0.value.updatedAt) > lifetime - } - for (transferId, entry) in expiredEntries { - if entry.state == .active { - expiredActive.append(transferId) - } - entries.removeValue(forKey: transferId) - } - trimCancelledTombstonesLocked() - return expiredActive - } - - private func trimCancelledTombstonesLocked() { - let cancelled = entries - .filter { $0.value.state == .cancelled } - .sorted { $0.value.updatedAt < $1.value.updatedAt } - let overflow = max(0, cancelled.count - maxCancelledTombstones) - for victim in cancelled.prefix(overflow) { - entries.removeValue(forKey: victim.key) - } - } - - private func notifyExpired(_ transferIds: [String]) { - for transferId in transferIds { - onActiveExpired(transferId) - } - } -} - /// BLEService — Bluetooth Mesh Transport /// - Emits events exclusively via `BitchatDelegate` for UI. /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). +/// - A lightweight `peerSnapshotPublisher` is provided for non-UI services. final class BLEService: NSObject { // MARK: - Constants @@ -202,259 +35,163 @@ final class BLEService: NSObject { private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays // MARK: - Core State (5 Essential Collections) - - // 1. Consolidated BLE link tracking for both central and peripheral roles. - var linkStateStore = BLELinkStateStore() - - // The engine-owned identity domain: per-link Noise authentication + - // rebind containment (courier handover needs the stronger fact that a - // session was established *on this current ingress link*, not merely - // that some session exists for the claimed ID), and the identity↔link - // bindings that qualify every attribution decision. - // - // Owned by the engine queue since the option-B flip: bleQueue hands - // decoded packets up as (packet, linkID) and the engine attributes - // them; bleQueue never touches these. A binding can therefore briefly - // outlive its physical link (the delegate's retirement hop is async) — - // every query that needs liveness joins against the physical store, - // which the engine may sync-read via `readLinkState`. - private var _linkAuth = BLELinkAuthState() - private var _linkBindings = BLELinkBindings() - private var linkAuth: BLELinkAuthState { - get { assertLinkIdentityEngineOwned(); return _linkAuth } - set { assertLinkIdentityEngineOwned(); _linkAuth = newValue } - } - private var linkBindings: BLELinkBindings { - get { assertLinkIdentityEngineOwned(); return _linkBindings } - set { assertLinkIdentityEngineOwned(); _linkBindings = newValue } - } - /// Debug-traps any identity-domain access off the engine queue — the - /// mechanical form of the option-B ownership contract. - private func assertLinkIdentityEngineOwned() { - #if DEBUG - dispatchPrecondition(condition: .onQueue(messageQueue)) - #endif - } - - // BCH-01-004: Rate-limiting for subscription-triggered announces. - var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter() - // 3. Peer Information (single source of truth). Lock-backed so the main - // actor reads it directly instead of blocking on the engine queue. - // Mutations come only from the transport's own serial queues — the - // engine, plus the two bleQueue link-drop paths (didDisconnectPeripheral - // / didUnsubscribeFrom) that mark a peer disconnected the moment its - // last physical link goes; the store's lock serializes them. - private let peerRegistry = BLEPeerRegistryStore() + // 1. Consolidated Peripheral Tracking + private struct PeripheralState { + let peripheral: CBPeripheral + var characteristic: CBCharacteristic? + var peerID: PeerID? + var isConnecting: Bool = false + var isConnected: Bool = false + var lastConnectionAttempt: Date? = nil + var assembler = NotificationStreamAssembler() + } + private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState + private var peerToPeripheralUUID: [PeerID: String] = [:] // PeerID -> Peripheral UUID + + // 2. BLE Centrals (when acting as peripheral) + private var subscribedCentrals: [CBCentral] = [] + private var centralToPeerID: [String: PeerID] = [:] // Central UUID -> Peer ID mapping + + // BCH-01-004: Rate-limiting for subscription-triggered announces + // Tracks subscription attempts per central to prevent enumeration attacks + private struct SubscriptionRateLimitState { + var lastAnnounceTime: Date + var attemptCount: Int + var currentBackoffSeconds: TimeInterval + } + private var centralSubscriptionRateLimits: [String: SubscriptionRateLimitState] = [:] // Central UUID -> rate limit state + + // 3. Peer Information (single source of truth) + private struct PeerInfo { + let peerID: PeerID + var nickname: String + var isConnected: Bool + var noisePublicKey: Data? + var signingPublicKey: Data? + var isVerifiedNickname: Bool + var lastSeen: Date + } + private var peers: [PeerID: PeerInfo] = [:] + private var currentPeerIDs: [PeerID] { + Array(peers.keys) + } // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() - - // Courier store-and-forward: envelopes this device carries for offline - // third parties, and the trust gate for accepting deposits. The policy - // maps (depositor key, announce-verified?) to a quota tier, or nil to - // reject. Injectable for tests; main-actor policy because favorites live - // on the main actor. - var courierStore: CourierStore = .shared - // Bulletin-board posts this device carries; injectable for tests. - var boardStore: BoardStore = .shared - var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in - if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite } - return isVerifiedPeer ? .verified : nil - } - // Local-only store-and-forward counters; nil in unit tests. - var sfMetrics: StoreAndForwardMetrics? - - // Verified one-time prekey bundles gossiped by other peers, used to seal - // courier mail forward-secretly. Injectable for tests. - var prekeyBundleStore: PrekeyBundleStore = .shared - // Throttle for re-broadcasting our own (unchanged) bundle - // (engine-confined). - private var lastPrekeyBundleSentAt: Date? - // Prekey bundles that arrived before their owner's verified announce bound - // a signing key. Over the air a bundle can still arrive before the - // announce it depends on; we retain the latest such bundle per owner - // (bounded) and re-attempt attribution when the announce lands. - // Engine-confined. - private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:] - private static let pendingPrekeyBundleCap = 64 - // Gateway mode: sink for received nostrCarrier packets (set by app - // wiring, called on the main actor after transport-level checks) and the - // runtime-toggled capability bits ORed into `PeerCapabilities.localSupported` - // for every announce. `directedToUs` distinguishes an uplink deposit - // addressed to this device from a downlink broadcast. - var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? - /// Fired (off-main) when a signature-verified announce is processed — - /// the bridge courier watch refreshes its tag set on new arrivals. - var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? - - #if DEBUG - // Test-only tap on the outbound pipeline so multi-node tests can ferry - // packets between in-process service instances. - var _test_onOutboundPacket: ((BitchatPacket) -> Void)? - /// May block a synthetic CoreBluetooth receive callback immediately - /// before it hands a packet to `messageQueue`. - var _test_beforeReceivePacketHandoff: (() -> Void)? - var _test_onReceivePacketHandoff: (() -> Void)? - var _test_onPrivateMediaSessionReconciled: ((PeerID) -> Void)? - /// May block in tests to hold the serial message queue immediately before - /// the deferred private-media admission check. - var _test_beforePrivateMediaDeferredSend: ((String) -> Void)? - /// May block the convergence-recovery callback on its global-queue thread - /// before it enqueues onto `messageQueue`. Tests use this boundary to - /// force the quarantine-restore handler to win the dispatch race. - var _test_beforeHandshakeRecoveryEnqueued: ((PeerID) -> Void)? - #endif - private var selfBroadcastTracker = BLESelfBroadcastTracker() + private var selfBroadcastMessageIDs: [String: (id: String, timestamp: Date)] = [:] + private lazy var mediaDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + return formatter + }() private let meshTopology = MeshTopologyTracker() - // Route health for originated source routes (engine-confined). - private var sourceRouteFailures = BLESourceRouteFailureCache() - - // Mesh diagnostics (/ping): engine-confined probe and budget state. - private var meshPings = BLEMeshPingTracker() - + // 5. Fragment Reassembly (necessary for messages > MTU) - private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() - private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() - private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in - self?.handlePrivateMediaAdmissionExpiry(transferId) + private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 } + private var incomingFragments: [FragmentKey: [Int: Data]] = [:] + private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:] + private struct ActiveTransferState { + let totalFragments: Int + var sentFragments: Int + var workItems: [DispatchWorkItem] } - // Generation-bound private-media session state (lock-backed store: the - // main actor answers the send policy from it synchronously, and noise - // critical sections mutate it without re-entering the engine). - private let privateMediaSessions = BLEPrivateMediaSessionStore() - private let incomingFileStore: BLEIncomingFileStore + private var activeTransfers: [String: ActiveTransferState] = [:] + // Backoff for peripherals that recently timed out connecting + private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout // Simple announce throttling - private let announceThrottle = BLEAnnounceThrottle() + private var lastAnnounceSent = Date.distantPast + private let announceMinInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval // Application state tracking (thread-safe) #if os(iOS) - var isAppActive: Bool = true // Assume active initially - /// Last `UIApplication.shared.backgroundTimeRemaining` sampled on the - /// main thread, cached so bleQueue status logs can read it without ever - /// dispatching to main (see `captureBluetoothStatus` for the invariant). - private let backgroundTimeLock = NSLock() - private var _cachedBackgroundTimeRemaining: TimeInterval = .greatestFiniteMagnitude - private var cachedBackgroundTimeRemaining: TimeInterval { - backgroundTimeLock.lock(); defer { backgroundTimeLock.unlock() } - return _cachedBackgroundTimeRemaining - } + private var isAppActive: Bool = true // Assume active initially #endif // MARK: - Core BLE Objects - var centralManager: CBCentralManager? - var peripheralManager: CBPeripheralManager? - var characteristic: CBMutableCharacteristic? - private let shouldInitializeBluetoothManagers: Bool - private let panicLifecycleLock = NSLock() - private var _isPanicSuspended: Bool - private var panicLifecycleGeneration: UInt64 = 0 + private var centralManager: CBCentralManager? + private var peripheralManager: CBPeripheralManager? + private var characteristic: CBMutableCharacteristic? // MARK: - Identity private var noiseService: NoiseEncryptionService - /// Injected so tests can compress the quarantine/rollback window; - /// production always passes the security-constant default. - private let noiseResponderHandshakeTimeout: TimeInterval private let identityManager: SecureIdentityStateManagerProtocol private let keychain: KeychainManagerProtocol private let idBridge: NostrIdentityBridge - private let localIdentityState = BLELocalIdentityStateStore() + private var myPeerIDData: Data = Data() // MARK: - Advertising Privacy // No Local Name by default for maximum privacy. No rotating alias. // MARK: - Queues - /// The engine queue: one serial domain that owns every piece of mesh - /// protocol state (the former concurrent message queue and the separate - /// collections queue it guarded state with). BLE throughput is far below - /// what one queue serializes comfortably, and a single writer makes the - /// old per-field ownership comments and barrier discipline structural. - private let messageQueue = DispatchQueue(label: "mesh.message") + private let messageQueue = DispatchQueue(label: "mesh.message", attributes: .concurrent) + private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent) private let messageQueueKey = DispatchSpecificKey() - /// The only source of deferred engine work (see BLEEngineScheduling); - /// injectable so tests drive protocol deadlines with a manual clock. - let engineScheduler: BLEEngineScheduling - let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated) + private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated) private let bleQueueKey = DispatchSpecificKey() - - /// Runs `body` exclusively with respect to all engine-owned state. - /// Executes inline when already on the engine queue; otherwise blocks - /// until the engine drains the work ahead of it. - /// - /// Sync-edge order (deadlock freedom): main, test threads, and the - /// gossip manager's mesh.sync queue may sync-wait on the engine; the - /// engine sync-waits on bleQueue (`readLinkState`) and on the - /// crypto/identity services' internal queues. None of those may ever - /// sync-wait back on the engine — bleQueue callers hop with - /// `messageQueue.async` instead, and debug builds trap any violation - /// here. (The engine only ever async-dispatches into mesh.sync; its - /// queue.sync helpers are DEBUG test entry points on test threads.) - private func onEngine(_ body: () -> T) -> T { - #if DEBUG - dispatchPrecondition(condition: .notOnQueue(bleQueue)) - #endif - if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - return body() - } - // queue-contract-ok: this is the single sanctioned sync entry — the - // trap above is exactly what BLEQueueContractTests exists to protect. - return messageQueue.sync(execute: body) - } - // Noise messages and typed payloads pending handshake completion. - private var pendingNoiseSessionQueues = BLENoiseSessionQueues() - // Queue for notifications that failed due to full queue (bleQueue-owned, - // like the link state store: every producer and drain runs there). - var pendingNotifications = BLEOutboundNotificationBuffer() - // Backpressure logging fires per fragment during media transfers - // (hundreds of lines per image); sampled via this counter, which is - // only touched on bleQueue (no sync needed). - var notificationBackpressureLogCount = 0 + // Queue for messages pending handshake completion + private var pendingMessagesAfterHandshake: [PeerID: [(content: String, messageID: String)]] = [:] + // Noise typed payloads (ACKs, read receipts, etc.) pending handshake + private var pendingNoisePayloadsAfterHandshake: [PeerID: [Data]] = [:] + // Queue for notifications that failed due to full queue + private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = [] // Accumulate long write chunks per central until a full frame decodes - // (bleQueue-owned) - var pendingWriteBuffers = BLEInboundWriteBuffer() + private var pendingWriteBuffers: [String: Data] = [:] // Relay jitter scheduling to reduce redundant floods - private var scheduledRelays = BLEScheduledRelayStore() + private var scheduledRelays: [String: DispatchWorkItem] = [:] // Track short-lived traffic bursts to adapt announces/scanning under load - // (lock-backed: written by the receive pipeline, read on bleQueue) - private let recentTrafficTracker = BLERecentTrafficMonitor() + private var recentPacketTimestamps: [Date] = [] - // Ingress link tracking for duplicate and last-hop suppression - // (lock-backed: recorded on bleQueue the moment a frame decodes, read - // by engine relay/routing decisions) - private let ingressLinks = BLEIngressLinkStore() - // Inner message IDs of recently opened courier envelopes. Redundant - // copies of one message ride different envelopes (each seal uses a fresh - // ephemeral key, and bridge drops multiply across relays/couriers), so - // envelope-level dedup can't catch them; dedup on the inner ID before - // delivery so a duplicate costs one decrypt instead of a delivery + ack - // + handshake each. Engine-confined. - private var openedCourierMessageIDs = BoundedIDSet(capacity: TransportConfig.courierOpenedMessageIDCap) - let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5) + // Ingress link tracking for last-hop suppression + private enum LinkID: Hashable { + case peripheral(String) + case central(String) + } + private var ingressByMessageID: [String: (link: LinkID, timestamp: Date)] = [:] - // Per-peripheral write backpressure (bleQueue-owned) - var pendingPeripheralWrites = BLEOutboundWriteBuffer() + // Backpressure-aware write queue per peripheral + private struct OutboundPriority: Comparable { + let level: Int + let suborder: Int + + static let high = OutboundPriority(level: 0, suborder: 0) + static func fragment(totalFragments: Int) -> OutboundPriority { + OutboundPriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max)))) + } + static let fileTransfer = OutboundPriority(level: 2, suborder: Int.max - 1) + static let low = OutboundPriority(level: 2, suborder: Int.max) + + static func < (lhs: OutboundPriority, rhs: OutboundPriority) -> Bool { + if lhs.level != rhs.level { return lhs.level < rhs.level } + return lhs.suborder < rhs.suborder + } + } + private struct PendingWrite { + let priority: OutboundPriority + let data: Data + } + private struct PendingFragmentTransfer { + let packet: BitchatPacket + let pad: Bool + let maxChunk: Int? + let directedPeer: PeerID? + let transferId: String? + } + private var pendingPeripheralWrites: [String: [PendingWrite]] = [:] + private var pendingFragmentTransfers: [PendingFragmentTransfer] = [] // Debounce duplicate disconnect notifies - private var disconnectNotifyDebouncer = BLEPeerEventDebouncer() + private var recentDisconnectNotifies: [PeerID: Date] = [:] // Store-and-forward for directed messages when we have no links - private var pendingDirectedRelays = BLEDirectedRelaySpool() + // Keyed by recipient short peerID -> messageID -> (packet, enqueuedAt) + private var pendingDirectedRelays: [PeerID: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:] // Debounce for 'reconnected' logs - private var reconnectLogDebouncer = BLEPeerEventDebouncer() - // Announce-packet orchestration (queue hops stay in the environment closures) - private lazy var announceHandler = BLEAnnounceHandler(environment: makeAnnounceHandlerEnvironment()) - // Public-message orchestration (queue hops stay in the environment closures) - private lazy var publicMessageHandler = BLEPublicMessageHandler(environment: makePublicMessageHandlerEnvironment()) - // Noise handshake/encrypted orchestration (queue hops and crypto stay in the environment closures) - private lazy var noisePacketHandler = BLENoisePacketHandler(environment: makeNoisePacketHandlerEnvironment()) - // Fragment-assembly orchestration (queue hops stay in the environment closures) - private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment()) - // File-transfer orchestration (queue hops stay in the environment closures) - private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment()) + private var lastReconnectLogAt: [PeerID: Date] = [:] // MARK: - Gossip Sync private var gossipSyncManager: GossipSyncManager? @@ -464,37 +201,49 @@ final class BLEService: NSObject { private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceCounter = 0 // Track maintenance cycles - private var lastMaintenanceAt = Date.distantPast // bleQueue-confined; drives background-wake catch-up passes - /// Whether real CoreBluetooth managers were initialized. When false (unit - /// tests), periodic mesh background work is not started — the maintenance - /// timer and the gossip-sync timers only drain BLE writes/notifications, - /// re-announce, and sign/broadcast sync packets, all meaningless without - /// Bluetooth. Leaving them running in the test process is pure background - /// churn that aggravates flaky exit hangs. - private var meshBackgroundEnabled = false - // MARK: - Radio (central-role policy: discovery admission, connection - // budget, connect timeouts, background connects, scan duty, advertising) - lazy var radio = BLERadioController( - queue: bleQueue, - linkStateStore: linkStateStore, - recentTraffic: recentTrafficTracker - ) + // MARK: - Connection budget & scheduling (central role) + private let maxCentralLinks = TransportConfig.bleMaxCentralLinks + private let connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval + private var lastGlobalConnectAttempt: Date = .distantPast + private struct ConnectionCandidate { + let peripheral: CBPeripheral + let rssi: Int + let name: String + let isConnectable: Bool + let discoveredAt: Date + } + private var connectionCandidates: [ConnectionCandidate] = [] + private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures + private var lastIsolatedAt: Date? = nil + private var dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault + + // MARK: - Adaptive scanning duty-cycle + private var scanDutyTimer: DispatchSourceTimer? + private var dutyEnabled: Bool = true + private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration + private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration + private var dutyActive: Bool = false // Debounced publish to coalesce rapid changes - private var peerPublishCoalescer = BLEPeerPublishCoalescer() + private var lastPeerPublishAt: Date = .distantPast + private var peerPublishPending: Bool = false + private let peerPublishMinInterval: TimeInterval = 0.1 private func requestPeerDataPublish() { - switch peerPublishCoalescer.requestPublish(now: Date()) { - case .publishNow: + let now = Date() + let elapsed = now.timeIntervalSince(lastPeerPublishAt) + if elapsed >= peerPublishMinInterval { + lastPeerPublishAt = now publishFullPeerData() - case .schedule(let delay): - engineScheduler.schedule(after: delay) { [weak self] in + } else if !peerPublishPending { + peerPublishPending = true + let delay = peerPublishMinInterval - elapsed + messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self else { return } - self.peerPublishCoalescer.scheduledPublishFired(now: Date()) + self.lastPeerPublishAt = Date() + self.peerPublishPending = false self.publishFullPeerData() } - case .skip: - break } } @@ -504,24 +253,11 @@ final class BLEService: NSObject { keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - initializeBluetoothManagers: Bool = true, - incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(), - startSuspendedForPanicRecovery: Bool = false, - noiseResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, - engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler() + initializeBluetoothManagers: Bool = true ) { - self.engineScheduler = engineScheduler self.keychain = keychain self.idBridge = idBridge - self.incomingFileStore = incomingFileStore - self.shouldInitializeBluetoothManagers = initializeBluetoothManagers - self._isPanicSuspended = startSuspendedForPanicRecovery - self.noiseResponderHandshakeTimeout = noiseResponderHandshakeTimeout - noiseService = NoiseEncryptionService( - keychain: keychain, - ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout - ) + noiseService = NoiseEncryptionService(keychain: keychain) self.identityManager = identityManager super.init() @@ -530,28 +266,15 @@ final class BLEService: NSObject { // Set queue key for identification messageQueue.setSpecific(key: messageQueueKey, value: ()) - engineScheduler.activate(engineQueue: messageQueue) - radio.delegate = self - radio.peripheralDelegate = self // Set up application state tracking (iOS only) #if os(iOS) - // Check initial state on main thread. The background-budget cache is - // seeded here too: a background-restore launch captures Bluetooth - // status before any lifecycle notification fires, and the init-time - // sentinel would log a meaningless bgRemaining=∞ for exactly the - // wake window that matters. + // Check initial state on main thread if Thread.isMainThread { isAppActive = UIApplication.shared.applicationState == .active - refreshCachedBackgroundTimeRemaining() } else { - // queue-contract-ok: init-time only — no engine or bleQueue work - // exists yet that main could be sync-waiting on, so this cannot - // pair into a cycle. Everything after init caches main-actor - // state instead (see scheduleBluetoothStatusSample). DispatchQueue.main.sync { isAppActive = UIApplication.shared.applicationState == .active - refreshCachedBackgroundTimeRemaining() } } @@ -572,95 +295,44 @@ final class BLEService: NSObject { // Tag BLE queue for re-entrancy detection bleQueue.setSpecific(key: bleQueueKey, value: ()) - // Link state is owned exclusively by bleQueue; debug builds trap - // any access from another queue (cross-queue reads use readLinkState). - linkStateStore.assumeOwnership(of: bleQueue) - if !startSuspendedForPanicRecovery { - initializeBluetoothManagersIfNeeded() + if initializeBluetoothManagers { + // Initialize BLE on background queue to prevent main thread blocking. + #if os(iOS) + let centralOptions: [String: Any] = [ + CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID + ] + centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions) + + let peripheralOptions: [String: Any] = [ + CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID + ] + peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions) + #else + centralManager = CBCentralManager(delegate: self, queue: bleQueue) + peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) + #endif } - // Single maintenance timer for all periodic tasks (dispatch-based for - // determinism). Only run it when real Bluetooth managers exist. - meshBackgroundEnabled = initializeBluetoothManagers - if !startSuspendedForPanicRecovery { - startMaintenanceTimer() + // Single maintenance timer for all periodic tasks (dispatch-based for determinism) + let timer = DispatchSource.makeTimerSource(queue: bleQueue) + timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, + repeating: TransportConfig.bleMaintenanceInterval, + leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds)) + timer.setEventHandler { [weak self] in + self?.performMaintenance() } + timer.resume() + maintenanceTimer = timer // Publish initial empty state requestPeerDataPublish() // Initialize gossip sync manager - if !startSuspendedForPanicRecovery { - restartGossipManager() - } - } - - var isPanicSuspended: Bool { - panicLifecycleLock.lock() - defer { panicLifecycleLock.unlock() } - return _isPanicSuspended - } - - private func setPanicSuspended(_ suspended: Bool) { - panicLifecycleLock.lock() - if suspended { - panicLifecycleGeneration &+= 1 - } - _isPanicSuspended = suspended - panicLifecycleLock.unlock() - } - - private func capturePanicLifecycleGeneration() -> UInt64? { - panicLifecycleLock.lock() - defer { panicLifecycleLock.unlock() } - return _isPanicSuspended ? nil : panicLifecycleGeneration - } - - private func isCurrentPanicLifecycleGeneration(_ generation: UInt64) -> Bool { - panicLifecycleLock.lock() - defer { panicLifecycleLock.unlock() } - return !_isPanicSuspended && panicLifecycleGeneration == generation - } - - private func initializeBluetoothManagersIfNeeded() { - guard shouldInitializeBluetoothManagers, - centralManager == nil, - peripheralManager == nil, - !isPanicSuspended else { return } - - // Initialize BLE on its dedicated delegate queue. On iOS, retain the - // restoration identifiers even when construction was deferred by a - // pending panic-recovery latch. - #if os(iOS) - let centralOptions: [String: Any] = [ - CBCentralManagerOptionRestoreIdentifierKey: - BLEService.centralRestorationID - ] - centralManager = CBCentralManager( - delegate: self, - queue: bleQueue, - options: centralOptions - ) - - let peripheralOptions: [String: Any] = [ - CBPeripheralManagerOptionRestoreIdentifierKey: - BLEService.peripheralRestorationID - ] - peripheralManager = CBPeripheralManager( - delegate: self, - queue: bleQueue, - options: peripheralOptions - ) - #else - centralManager = CBCentralManager(delegate: self, queue: bleQueue) - peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) - #endif - radio.central = centralManager + restartGossipManager() } private func restartGossipManager() { - guard !isPanicSuspended else { return } // Stop existing gossipSyncManager?.stop() @@ -669,7 +341,6 @@ final class BLEService: NSObject { gcsMaxBytes: TransportConfig.syncGCSMaxBytes, gcsTargetFpr: TransportConfig.syncGCSTargetFpr, maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds, - publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds, maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds, stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds, stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds, @@ -677,32 +348,12 @@ final class BLEService: NSObject { fileTransferCapacity: TransportConfig.syncFileTransferCapacity, fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds, fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds, - messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds, - responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses, - responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds, - prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity, - prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds, - prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds + messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds ) - - // Only real Bluetooth sessions archive to disk; unit tests stay hermetic. - let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive) + + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) manager.delegate = self - // Board posts sync from the board store (their retention owner) so - // deleted/expired posts drop out of rounds immediately. Real sessions - // only, matching the archive: unit tests stay hermetic. - if meshBackgroundEnabled { - manager.boardPacketsProvider = { [weak self] in - self?.boardStore.syncCandidates() ?? [] - } - } - // Only start the periodic sync timers when real Bluetooth exists. In unit - // tests there is no mesh to sync with, and the periodic sign/broadcast - // churn just keeps the process busy and aggravates flaky exit hangs. - if meshBackgroundEnabled { - manager.start() - } + manager.start() gossipSyncManager = manager } @@ -710,7 +361,8 @@ final class BLEService: NSObject { deinit { maintenanceTimer?.cancel() - radio.stopDutyCycle() + scanDutyTimer?.cancel() + scanDutyTimer = nil centralManager?.stopScan() peripheralManager?.stopAdvertising() #if os(iOS) @@ -718,128 +370,46 @@ final class BLEService: NSObject { #endif } - /// Close radio admission before application state starts disappearing. - /// CoreBluetooth callbacks consult the same gate and cannot restart scan - /// or advertising while the full panic transaction is incomplete. - func suspendForPanicReset() { - setPanicSuspended(true) - noisePacketHandler.resetForPanic() - gossipSyncManager?.stop() - gossipSyncManager = nil - // Stop the radio and drain CoreBluetooth's delegate queue first. A - // callback may already have passed its initial suspension check; the - // bleQueue drain forces its final messageQueue handoff to happen - // before the receive barrier below. - stopServicesImmediatelyForPanic() - // Drain every receive/send submitted by callbacks that finished ahead - // of the radio stop. Later callbacks observe the closed lifecycle, and - // generation-bound handoffs that raced this barrier reject themselves. - // Clear the old identity's bounded early-ciphertext queue again after - // those callbacks drain so none can repopulate it after the first wipe. - onEngine { - noisePacketHandler.resetForPanic() - } - clearEmergencySessionState() - } - - /// Reopen the radio only after media deletion and recovery-marker commit. - func completePanicReset(restartServices: Bool) { - // The media wipe ran on the recovery operations' own file store; this - // service's store still caches pre-panic receipt decisions (and a - // callback drained during suspension may have re-read the pre-wipe - // ledger). Drop the cache before admission reopens so the next lookup - // rebuilds from the wiped directory. - incomingFileStore.resetPrivateMediaReceiptsForPanic() - setPanicSuspended(false) - guard restartServices else { return } - startServices() - sendAnnounce(forceSend: true) - } - - func resetIdentityForPanic( - currentNickname: String, - restartServices: Bool = true - ) { - gossipSyncManager?.stop() - gossipSyncManager = nil - // Discard deferred pre-panic ciphertext behind any in-flight receive - // handlers so none can repopulate the handler's bounded queue. - onEngine { - noisePacketHandler.resetForPanic() - } - onEngine { - pendingNoiseSessionQueues.removeAll() + func resetIdentityForPanic(currentNickname: String) { + messageQueue.sync(flags: .barrier) { + pendingMessagesAfterHandshake.removeAll() + pendingNoisePayloadsAfterHandshake.removeAll() } - let panicReset = onEngine { - let transfers = outboundFragmentTransfers.removeAll() - fragmentAssemblyBuffer.removeAll() + collectionsQueue.sync(flags: .barrier) { + pendingPeripheralWrites.removeAll() + pendingFragmentTransfers.removeAll() + pendingNotifications.removeAll() pendingDirectedRelays.removeAll() - ingressLinks.removeAll() - recentTrafficTracker.removeAll() - scheduledRelays.cancelAll() - // Proofs and revalidation epochs die with the identity; the - // rebind/retirement cooldowns deliberately survive (see - // BLELinkAuthState.removeAll). - linkAuth.removeAll() - // The new identity owes no announce-throttle debt: without this, - // a panic within the forced minimum interval of the last - // announce swallows the rotation announce and the new identity - // stays invisible until the next maintenance cycle. - announceThrottle.reset() - // These callbacks belong to pre-panic transfer state. Invoking - // them would let queued UI work recreate or resend wiped media. - privateMediaSessions.panicReset() - // Let the post-panic identity publish its fresh bundle promptly. - lastPrekeyBundleSentAt = nil - return transfers - } - - for entry in panicReset { - entry.workItems.forEach { $0.cancel() } - TransferProgressManager.shared.cancel(id: entry.id) + ingressByMessageID.removeAll() + recentPacketTimestamps.removeAll() + scheduledRelays.values.forEach { $0.cancel() } + scheduledRelays.removeAll() } bleQueue.sync { - pendingPeripheralWrites.removeAll() - pendingNotifications.removeAll() pendingWriteBuffers.removeAll() - radio.reset() + recentConnectTimeouts.removeAll() } - disconnectNotifyDebouncer.removeAll() + recentDisconnectNotifies.removeAll() - // The crypto-service replacement and the derived identity swap must be - // one atomic unit with respect to messageQueue senders: a queued send - // must never observe the new Noise service alongside the old peer ID - // (it would sign with the new identity while carrying the old sender). - // refreshPeerIdentity() executes inline here via its re-entrancy check. - onEngine { - noiseService.clearEphemeralStateForPanic() - noiseService.clearPersistentIdentity() + noiseService.clearEphemeralStateForPanic() + noiseService.clearPersistentIdentity() + + let newNoise = NoiseEncryptionService(keychain: keychain) + noiseService = newNoise + configureNoiseServiceCallbacks(for: newNoise) + refreshPeerIdentity() + restartGossipManager() + + setNickname(currentNickname) - let newNoise = NoiseEncryptionService( - keychain: keychain, - ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout - ) - noiseService = newNoise - configureNoiseServiceCallbacks(for: newNoise) - refreshPeerIdentity() - } - // Keep the transport silent until the application-level transaction - // has also removed its media and committed both recovery markers. - // Set through the identity store directly (not setNickname(_:), which - // would force-send an announce and break that silence). - localIdentityState.setNickname(currentNickname) messageDeduplicator.reset() - messageQueue.async { [weak self] in - self?.selfBroadcastTracker.removeAll() + messageQueue.async(flags: .barrier) { [weak self] in + self?.selfBroadcastMessageIDs.removeAll() } requestPeerDataPublish() - if restartServices { - restartGossipManager() - startServices() - sendAnnounce(forceSend: true) - } + startServices() } // Ensure this runs on message queue to avoid main thread blocking @@ -851,7 +421,6 @@ final class BLEService: NSObject { } return } - guard !isPanicSuspended else { return } guard content.count <= maxMessageLength else { SecureLogger.error("Message too long: \(content.count) chars", category: .session) @@ -881,10 +450,11 @@ final class BLEService: NSObject { return } // Pre-mark our own broadcast as processed to avoid handling relayed self copy - let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket) + let senderHex = signedPacket.senderID.hexEncodedString() + let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)" messageDeduplicator.markProcessed(dedupID) if let messageID { - selfBroadcastTracker.record(messageID: messageID, packet: signedPacket, sentAt: sendDate) + selfBroadcastMessageIDs[dedupID] = (id: messageID, timestamp: sendDate) } // Call synchronously since we're already on background queue broadcastPacket(signedPacket) @@ -895,65 +465,50 @@ final class BLEService: NSObject { // MARK: - Transport Protocol Conformance // MARK: Delegates - + weak var delegate: BitchatDelegate? - weak var eventDelegate: TransportEventDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? + + // MARK: Peer snapshots publisher (non-UI convenience) + + private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>() + var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { + peerSnapshotSubject.eraseToAnyPublisher() + } func currentPeerSnapshots() -> [TransportPeerSnapshot] { - peerRegistry.transportSnapshots(selfNickname: myNickname) + collectionsQueue.sync { + let snapshot = Array(peers.values) + let resolvedNames = PeerDisplayNameResolver.resolve( + snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) }, + selfNickname: myNickname + ) + return snapshot.map { info in + TransportPeerSnapshot( + peerID: info.peerID, + nickname: resolvedNames[info.peerID] ?? info.nickname, + isConnected: info.isConnected, + noisePublicKey: info.noisePublicKey, + lastSeen: info.lastSeen + ) + } + } } // MARK: Identity - - /// Derived from the Noise identity fingerprint. Reads can originate from - /// the main actor, message queue, Bluetooth queue, and maintenance timer, - /// so all three local identity fields live in one lock-backed snapshot. - var myPeerID: PeerID { localIdentityState.snapshot().peerID } - var myNickname: String { localIdentityState.snapshot().nickname } - private var myPeerIDData: Data { localIdentityState.snapshot().peerIDData } - - /// Sole mutator for `myNickname`: updates the stored value and force-sends - /// an announce so peers learn the new name. + + var myPeerID = PeerID(str: "") + var myNickname: String = "anon" + func setNickname(_ nickname: String) { - localIdentityState.setNickname(nickname) + self.myNickname = nickname // Send announce to notify peers of nickname change (force send) sendAnnounce(forceSend: true) } // MARK: Lifecycle - /// Creates and starts the periodic maintenance timer if it is not already - /// running. Idempotent so it can be called from both `init` and - /// `startServices()` — the latter matters after a panic reset, where - /// `stopServices()` cancels and nils the timer. - private func startMaintenanceTimer() { - guard !isPanicSuspended, - meshBackgroundEnabled, - maintenanceTimer == nil else { return } - let timer = DispatchSource.makeTimerSource(queue: bleQueue) - timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, - repeating: TransportConfig.bleMaintenanceInterval, - leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds)) - timer.setEventHandler { [weak self] in - self?.performMaintenance() - } - timer.resume() - maintenanceTimer = timer - } - func startServices() { - guard let lifecycleGeneration = - capturePanicLifecycleGeneration() else { return } - initializeBluetoothManagersIfNeeded() - if gossipSyncManager == nil { - restartGossipManager() - } - // Restart the maintenance timer if a prior stopServices() cancelled it - // (e.g. the panic flow), otherwise periodic announces, peer reconciliation - // and cache cleanup would never resume until app restart. - startMaintenanceTimer() - // Start BLE services if not already running if centralManager?.state == .poweredOn { centralManager?.scanForPeripherals( @@ -964,21 +519,16 @@ final class BLEService: NSObject { // Send initial announce after services are ready // Use longer delay to avoid conflicts with other announces - engineScheduler.schedule(after: TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in - guard let self, - self.isCurrentPanicLifecycleGeneration( - lifecycleGeneration - ) else { return } - self.sendAnnounce(forceSend: true) + messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in + self?.sendAnnounce(forceSend: true) } } func stopServices() { - let localIdentity = localIdentityState.snapshot() // Send leave message synchronously to ensure delivery var leavePacket = BitchatPacket( type: MessageType.leave.rawValue, - senderID: localIdentity.peerIDData, + senderID: myPeerIDData, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: Data(), @@ -992,11 +542,11 @@ final class BLEService: NSObject { // Send immediately to all connected peers (synchronized access to BLE state) if let data = leavePacket.toBinaryData(padding: false) { - let leavePriority = BLEOutboundPacketPolicy.priority(for: leavePacket, data: data) + let leavePriority = priority(for: leavePacket, data: data) // Snapshot BLE state under bleQueue to avoid races with delegate callbacks let (peripheralStates, centralsCount, char) = bleQueue.sync { - (linkStateStore.peripheralStates, linkStateStore.subscribedCentralCount, characteristic) + (Array(peripherals.values), subscribedCentrals.count, characteristic) } // Send to peripherals we're connected to as central @@ -1019,43 +569,21 @@ final class BLEService: NSObject { } // Clear pending notifications - bleQueue.sync { + collectionsQueue.sync(flags: .barrier) { pendingNotifications.removeAll() } // Stop timer maintenanceTimer?.cancel() maintenanceTimer = nil - radio.stopDutyCycle() + scanDutyTimer?.cancel() + scanDutyTimer = nil centralManager?.stopScan() peripheralManager?.stopAdvertising() // Disconnect all peripherals (synchronized access) - let peripheralsToDisconnect = bleQueue.sync { linkStateStore.peripheralStates } - for state in peripheralsToDisconnect { - centralManager?.cancelPeripheralConnection(state.peripheral) - } - } - - /// Panic cannot spend its security boundary sending a signed LEAVE or - /// pumping the main run loop. Close the radio and timers immediately; - /// the identity/session cleanup follows synchronously. - private func stopServicesImmediatelyForPanic() { - bleQueue.sync { - pendingNotifications.removeAll() - } - - maintenanceTimer?.cancel() - maintenanceTimer = nil - radio.stopDutyCycle() - - centralManager?.stopScan() - peripheralManager?.stopAdvertising() - - let peripheralsToDisconnect = bleQueue.sync { - linkStateStore.peripheralStates - } + let peripheralsToDisconnect = bleQueue.sync { Array(peripherals.values) } for state in peripheralsToDisconnect { centralManager?.cancelPeripheralConnection(state.peripheral) } @@ -1063,43 +591,36 @@ final class BLEService: NSObject { func emergencyDisconnectAll() { stopServices() - clearEmergencySessionState() - } - private func clearEmergencySessionState() { // Clear all sessions and peers - let cancelled = onEngine { - let entries = outboundFragmentTransfers.removeAll().map { - (id: $0.id, items: $0.workItems) - } - let pingTimeouts = meshPings.reset() - peerRegistry.mutate { $0.removeAll() } - fragmentAssemblyBuffer.removeAll() - sourceRouteFailures = BLESourceRouteFailureCache() + let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) { + let entries = activeTransfers.map { ($0.key, $0.value.workItems) } + peers.removeAll() + incomingFragments.removeAll() + fragmentMetadata.removeAll() + activeTransfers.removeAll() // Also clear pending message queues to avoid stale state across sessions - pendingNoiseSessionQueues.removeAll() + pendingMessagesAfterHandshake.removeAll() + pendingNoisePayloadsAfterHandshake.removeAll() pendingDirectedRelays.removeAll() - // Identity domain is engine-owned: bindings and link proofs - // clear here, physical link state clears on bleQueue below. - linkBindings.removeAll() - linkAuth.removeAll() - return (transfers: entries, pingTimeouts: pingTimeouts) + return entries } - for entry in cancelled.transfers { + for entry in cancelledTransfers { entry.items.forEach { $0.cancel() } TransferProgressManager.shared.cancel(id: entry.id) } - cancelled.pingTimeouts.forEach { $0.cancel() } // Clear processed messages messageDeduplicator.reset() // Clear peripheral references (synchronized access to avoid races with BLE callbacks) bleQueue.sync { - linkStateStore.clearAll() - radio.reset() - subscriptionAnnounceLimiter.removeAll() + peripherals.removeAll() + peerToPeripheralUUID.removeAll() + subscribedCentrals.removeAll() + centralToPeerID.removeAll() + centralSubscriptionRateLimits.removeAll() } meshTopology.reset() } @@ -1108,276 +629,47 @@ final class BLEService: NSObject { func isPeerConnected(_ peerID: PeerID) -> Bool { // Accept both 16-hex short IDs and 64-hex Noise keys - return peerRegistry.isConnected(peerID) + let shortID = peerID.toShort() + return collectionsQueue.sync { peers[shortID]?.isConnected ?? false } } func isPeerReachable(_ peerID: PeerID) -> Bool { // Accept both 16-hex short IDs and 64-hex Noise keys - peerRegistry.isReachable(peerID, now: Date()) - } - - func canDeliverSecurely(to peerID: PeerID) -> Bool { - // A live link binding alone is forgeable: the rotation heal rebinds a - // link on a signature-verified "direct" announce, but directness rides - // on the unsigned TTL, so a replayed announce can bind an absent - // peer's ID to the replayer's link. An established Noise session - // proves the other end of the link holds the peer's private key. - // - // Sessions are keyed by the short wire ID, so normalize like - // isPeerConnected does — a send keyed by the full 64-hex Noise key - // must not misread an established session as insecure. - noiseService.hasEstablishedSession(with: peerID.toShort()) + let shortID = peerID.toShort() + return collectionsQueue.sync { + // Must be mesh-attached: at least one live direct link to the mesh + let meshAttached = peers.values.contains { $0.isConnected } + guard let info = peers[shortID] else { return false } + if info.isConnected { return true } + guard meshAttached else { return false } + // Apply reachability retention window + let isVerified = info.isVerifiedNickname + let retention: TimeInterval = isVerified ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds + return Date().timeIntervalSince(info.lastSeen) <= retention + } } func peerNickname(peerID: PeerID) -> String? { - peerRegistry.nickname(for: peerID, connectedOnly: true) - } - - /// Capabilities the peer advertised in its last verified announce. - /// Empty for peers that predate the capabilities TLV. - func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { - peerRegistry.capabilities(for: peerID) - } - - func authenticatedPrivateMediaReceiptSessionGeneration( - to peerID: PeerID - ) -> UUID? { - let normalizedPeerID = peerID.toShort() - let currentNoiseGeneration = - noiseService.sessionGeneration(for: normalizedPeerID) - return privateMediaSessions.receiptSessionGeneration( - for: normalizedPeerID, - currentNoiseGeneration: currentNoiseGeneration - ) - } - - private func privateMediaPolicyFingerprint( - for peerID: PeerID, - expectedSessionGeneration: UUID? - ) -> String? { - let normalizedPeerID = peerID.toShort() - if let expectedSessionGeneration, - noiseService.sessionGeneration(for: normalizedPeerID) - == expectedSessionGeneration, - let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID), - noiseService.sessionGeneration(for: normalizedPeerID) - == expectedSessionGeneration { - // The exact authenticated Noise static key is stronger than a - // registry entry populated by a public announce. - return fingerprint + collectionsQueue.sync { + guard let peer = peers[peerID], peer.isConnected else { return nil } + return peer.nickname } - return peerRegistry.info(for: normalizedPeerID)? - .noisePublicKey? - .sha256Fingerprint() - } - - func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { - let normalizedPeerID = peerID.toShort() - let state: ( - capabilities: PeerCapabilities, - fingerprint: String?, - sessionGeneration: UUID?, - authenticatedState: BLEAuthenticatedPeerStateObservation?, - timedOut: BLEPrivateMediaProofTimeoutMarker? - ) = { - let info = peerRegistry.info(for: normalizedPeerID) - let session = privateMediaSessions.policyInputs(for: normalizedPeerID) - return ( - info?.capabilities ?? [], - info?.noisePublicKey?.sha256Fingerprint(), - session.sessionGeneration, - session.authenticatedState, - session.timedOut - ) - }() - let currentNoiseGeneration = noiseService.sessionGeneration(for: normalizedPeerID) - - // A session replacement can happen before its authentication callback - // reaches messageQueue. Never reuse an observation from the previous - // transport generation during that window. - if state.sessionGeneration != currentNoiseGeneration { - return .awaitingCapabilityProof - } - - guard let fingerprint = privateMediaPolicyFingerprint( - for: normalizedPeerID, - expectedSessionGeneration: state.sessionGeneration - ) ?? state.fingerprint else { - // A raw fallback must be bound to the stable Noise key from a - // verified registry entry; a routing ID alone can rotate or be - // spoofed. Without that key neither proof nor safe migration state - // can be attributed. - return .blockedDowngrade - } - - let wasPreviouslyCapable = identityManager.hasObservedPrivateMediaCapability( - fingerprint: fingerprint - ) - - if let authenticated = state.authenticatedState, - authenticated.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, - authenticated.sessionGeneration == state.sessionGeneration { - if authenticated.capabilities.contains(.privateMedia) { - return .encrypted - } - return wasPreviouslyCapable ? .blockedDowngrade : .legacyRequiresConsent - } - - if let timedOut = state.timedOut, - timedOut.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, - timedOut.sessionGeneration == state.sessionGeneration { - return wasPreviouslyCapable ? .blockedDowngrade : .legacyRequiresConsent - } - - // The announce bit is a discovery hint only. It can trigger a Noise - // handshake, but it cannot select encrypted media or create a durable - // pin because anyone can copy a public Noise key into a self-signed - // announce. A prior pin also re-confirms on each replacement session - // so an authenticated no-bit response becomes a visible downgrade. - if state.capabilities.contains(.privateMedia) || wasPreviouslyCapable { - return .awaitingCapabilityProof - } - - // Old clients that never advertised the bit remain eligible only for - // the explicit, invocation-scoped legacy consent path. - return .legacyRequiresConsent - } - - func resolvePrivateMediaSendPolicy( - to peerID: PeerID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) { - let normalizedPeerID = peerID.toShort() - messageQueue.async { [weak self] in - guard let self else { return } - let immediate = self.privateMediaSendPolicy(to: normalizedPeerID) - guard immediate == .awaitingCapabilityProof else { - self.completePrivateMediaPolicyResolution([completion], with: immediate) - return - } - - let generation = self.privateMediaSessions.currentGeneration(for: normalizedPeerID) - let fingerprint = self.privateMediaPolicyFingerprint( - for: normalizedPeerID, - expectedSessionGeneration: generation - ) - guard let fingerprint else { - self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade) - return - } - - let requestID = UUID() - let registration = self.privateMediaSessions.registerPolicyResolution( - for: normalizedPeerID, - fingerprint: fingerprint, - requestID: requestID, - completion: completion - ) - - guard registration.registered else { - self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade) - return - } - if registration.shouldSchedule { - self.schedulePrivateMediaProofTimeout( - for: normalizedPeerID, - fingerprint: fingerprint, - sessionGeneration: registration.generation, - nonce: registration.nonce - ) - } - - if !self.noiseService.hasEstablishedSession(with: normalizedPeerID) { - self.initiateNoiseHandshake(with: normalizedPeerID) - } - } - } - - private func completePrivateMediaPolicyResolution( - _ completions: [@MainActor (PrivateMediaSendPolicy) -> Void], - with policy: PrivateMediaSendPolicy - ) { - guard !completions.isEmpty else { return } - notifyUI { - completions.forEach { $0(policy) } - } - } - - private func schedulePrivateMediaProofTimeout( - for peerID: PeerID, - fingerprint: String, - sessionGeneration: UUID?, - nonce: UUID - ) { - engineScheduler.schedule(after: TransportConfig.privateMediaCapabilityProofTimeoutSeconds) { [weak self] in - self?.handlePrivateMediaProofTimeout( - for: peerID, - fingerprint: fingerprint, - sessionGeneration: sessionGeneration, - nonce: nonce - ) - } - } - - private func handlePrivateMediaProofTimeout( - for peerID: PeerID, - fingerprint: String, - sessionGeneration: UUID?, - nonce: UUID - ) { - let expiration = privateMediaSessions.expireProofDeadline( - for: peerID, - fingerprint: fingerprint, - sessionGeneration: sessionGeneration, - nonce: nonce - ) - guard expiration.expired else { return } - let policy = privateMediaSendPolicy(to: peerID) - if !expiration.deferredOutbound { - sendPendingNoisePayloadsAfterHandshake(for: peerID) - } - completePrivateMediaPolicyResolution(expiration.completions, with: policy) - } - - /// Enables or disables a runtime-advertised capability bit (e.g. the - /// internet-gateway toggle) and re-announces so peers learn promptly. - /// Build-time bits stay in `PeerCapabilities.localSupported`. - func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) { - guard localIdentityState.setCapability(capability, enabled: enabled) else { return } - sendAnnounce(forceSend: true) - } - - /// Reachable peers currently advertising the `.gateway` capability. - func reachableGatewayPeers() -> [PeerID] { - peerRegistry.reachablePeers(advertising: .gateway, now: Date()) - } - - /// Reachable peers currently advertising the `.bridge` capability. - func reachableBridgePeers() -> [PeerID] { - peerRegistry.reachablePeers(advertising: .bridge, now: Date()) - } - - /// A rendezvous cell advertised by a bridge-capable peer's announce. - func advertisedBridgeGeohash() -> String? { - peerRegistry.advertisedBridgeGeohash() - } - - /// The rendezvous cell this device advertises in its own announces while - /// bridging with the gateway toggle on. Set from the main actor; the - /// value rides the next (forced) announce. - func setLocalBridgeGeohash(_ cell: String?) { - guard localIdentityState.setBridgeGeohash(cell) else { return } - sendAnnounce(forceSend: true) } func getPeerNicknames() -> [PeerID: String] { - peerRegistry.displayNicknames(selfNickname: myNickname) + return collectionsQueue.sync { + let connected = peers.filter { $0.value.isConnected } + let tuples = connected.map { ($0.key, $0.value.nickname, true) } + return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname) + } } // MARK: Protocol utilities func getFingerprint(for peerID: PeerID) -> String? { - peerRegistry.fingerprint(for: peerID) + return collectionsQueue.sync { + return peers[peerID]?.noisePublicKey?.sha256Fingerprint() + } } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { @@ -1391,47 +683,11 @@ final class BLEService: NSObject { } func triggerHandshake(with peerID: PeerID) { - // Callers are on the main actor; the handshake broadcast sync-waits - // on bleQueue for link state, so hop off main first. - messageQueue.async { [weak self] in - self?.initiateNoiseHandshake(with: peerID) - } + initiateNoiseHandshake(with: peerID) } - // MARK: Noise identity/session access (narrow Transport wrappers) - - func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { - noiseService.getPeerPublicKeyData(peerID) - } - - func noiseIdentityFingerprint() -> String { - noiseService.getIdentityFingerprint() - } - - func noiseStaticPublicKeyData() -> Data { - noiseService.getStaticPublicKeyData() - } - - func noiseSigningPublicKeyData() -> Data { - noiseService.getSigningPublicKeyData() - } - - func noiseSignData(_ data: Data) -> Data? { - noiseService.signData(data) - } - - func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { - noiseService.verifySignature(signature, for: data, publicKey: publicKey) - } - - func installNoiseSessionCallbacks( - onPeerAuthenticated: @escaping (PeerID, String) -> Void, - onHandshakeRequired: @escaping (PeerID) -> Void - ) { - // `onPeerAuthenticated` is additive (the encryption service keeps an - // array of handlers); `onHandshakeRequired` is a single slot. - noiseService.onPeerAuthenticated = onPeerAuthenticated - noiseService.onHandshakeRequired = onHandshakeRequired + func getNoiseService() -> NoiseEncryptionService { + return noiseService } func getCurrentBluetoothState() -> CBManagerState { @@ -1440,48 +696,20 @@ final class BLEService: NSObject { // MARK: Messaging - private func handlePrivateMediaAdmissionExpiry(_ transferId: String) { - // Expiry can be discovered from the BLE maintenance queue or from an - // engine slot. Cleanup is therefore fire-and-forget; never - // synchronously re-enter the engine. - messageQueue.async { [weak self] in - _ = self?.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) - } - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String( - localized: "content.delivery.reason.private_media_admission_expired", - defaultValue: "Media transfer timed out before it could start", - comment: "Failure reason when private-media admission expires before fragment scheduling" - ) - ) - } - func cancelTransfer(_ transferId: String) { - // Cancellation must become visible synchronously. Scheduler/pending- - // Noise cleanup remains asynchronous, but deferred private-media work - // cannot pass another admission boundary after this returns. - privateMediaTransferAdmissions.cancel(transferId) - messageQueue.async { [weak self] in + collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - - switch self.outboundFragmentTransfers.cancelTransfer(transferId) { - case let .active(id, workItems): - workItems.forEach { $0.cancel() } + if let state = self.activeTransfers.removeValue(forKey: transferId) { + state.workItems.forEach { $0.cancel() } TransferProgressManager.shared.cancel(id: transferId) - SecureLogger.debug("🛑 Cancelled transfer \(id.prefix(8))…", category: .session) + SecureLogger.debug("🛑 Cancelled transfer \(transferId.prefix(8))…", category: .session) self.messageQueue.async { [weak self] in self?.startNextPendingTransferIfNeeded() } - - case let .pending(id): + } else if let pendingIndex = self.pendingFragmentTransfers.firstIndex(where: { $0.transferId == transferId }) { + self.pendingFragmentTransfers.remove(at: pendingIndex) TransferProgressManager.shared.cancel(id: transferId) - SecureLogger.debug("🛑 Removed pending transfer \(id.prefix(8))… before start", category: .session) - - case .missing: - if self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) { - SecureLogger.debug("🛑 Removed handshake-queued transfer \(transferId.prefix(8))…", category: .session) - } + SecureLogger.debug("🛑 Removed pending transfer \(transferId.prefix(8))… before start", category: .session) } } } @@ -1503,7 +731,6 @@ final class BLEService: NSObject { func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) { messageQueue.async { [weak self] in guard let self = self else { return } - guard !self.isPanicSuspended else { return } guard let payload = filePacket.encode() else { SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session) return @@ -1537,454 +764,114 @@ final class BLEService: NSObject { } } - func sendFilePrivate( - _ filePacket: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool - ) { - sendFilePrivate( - filePacket, - to: peerID, - transferId: transferId, - allowLegacyFallback: allowLegacyFallback, - requiresAuthenticatedPrivateMediaReceipts: false - ) - } - - func sendFilePrivateReceiptRetry( - _ filePacket: BitchatFilePacket, - to peerID: PeerID, - transferId: String - ) { - sendFilePrivate( - filePacket, - to: peerID, - transferId: transferId, - allowLegacyFallback: false, - requiresAuthenticatedPrivateMediaReceipts: true - ) - } - - private func sendFilePrivate( - _ filePacket: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool, - requiresAuthenticatedPrivateMediaReceipts: Bool - ) { - // Register before enqueueing onto messageQueue. This closes the window - // where cancel/delete could run first, observe no scheduler state, and - // then be followed by a deferred clear-media send. - switch privateMediaTransferAdmissions.begin(transferId) { - case .admitted: - break - - case .alreadyKnown: - SecureLogger.debug( - "Private media admission already cancelled or duplicated for \(transferId.prefix(8))…", - category: .security - ) - return - - case .capacityExhausted: - SecureLogger.warning( - "Private media admission capacity exhausted for \(transferId.prefix(8))…", - category: .security - ) - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String( - localized: "content.delivery.reason.private_media_admission_full", - defaultValue: "Too many media transfers are waiting; try again shortly", - comment: "Failure reason when too many private-media transfers are awaiting admission" - ) - ) - return - } + func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { messageQueue.async { [weak self] in guard let self = self else { return } - #if DEBUG - self._test_beforePrivateMediaDeferredSend?(transferId) - #endif - guard !self.isPanicSuspended else { - self.privateMediaTransferAdmissions.finish(transferId) - return - } - guard self.privateMediaTransferAdmissions.isActive(transferId) else { - self.privateMediaTransferAdmissions.finish(transferId) - return - } - let targetID = peerID.toShort() - switch self.privateMediaSendPolicy(to: targetID) { - case .encrypted: - break - - case .awaitingCapabilityProof: - // The UI coordinator resolves this state before calling the - // transport. Keep the transport guard fail-closed for direct - // callers and for a session replacement that races the call. - SecureLogger.warning( - "Private media held pending authenticated capability proof for \(targetID.id.prefix(8))…", - category: .security - ) - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String( - localized: "content.delivery.reason.private_media_capability_unresolved", - defaultValue: "Could not confirm encrypted media support", - comment: "Failure reason when private-media capability negotiation did not resolve" - ) - ) - self.privateMediaTransferAdmissions.finish(transferId) - return - - case .legacyRequiresConsent: - guard allowLegacyFallback else { - SecureLogger.warning( - "Private media blocked pending explicit legacy-clear consent for \(targetID.id.prefix(8))…", - category: .security - ) - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String( - localized: "content.delivery.reason.legacy_media_consent_required", - defaultValue: "Confirmation required before sending without end-to-end encryption", - comment: "Failure reason when a legacy private-media send lacks per-send consent" - ) - ) - self.privateMediaTransferAdmissions.finish(transferId) - return - } - // Migration path accepted by current Android and used by older - // iOS releases: preserve the directed raw file-transfer wire - // shape, but require the signature the receive path verifies. - // The allow flag belongs to this invocation only and is - // consumed here; a retry must obtain fresh user consent. - self.sendSignedLegacyPrivateFile( - filePacket, - to: targetID, - transferId: transferId - ) - return - - case .blockedDowngrade: - SecureLogger.warning( - "Private media downgrade blocked for \(targetID.id.prefix(8))…", - category: .security - ) - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String( - localized: "content.delivery.reason.private_media_downgrade_blocked", - defaultValue: "Encrypted media required; ask this contact to upgrade", - comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade" - ) - ) - self.privateMediaTransferAdmissions.finish(transferId) - return - } - if requiresAuthenticatedPrivateMediaReceipts, - self.authenticatedPrivateMediaReceiptSessionGeneration( - to: targetID - ) == nil { - SecureLogger.warning( - "Private media retry blocked without current authenticated receipt support for \(targetID.id.prefix(8))…", - category: .security - ) - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String( - localized: "content.delivery.reason.private_media_capability_unresolved", - defaultValue: "Could not confirm encrypted media support", - comment: "Failure reason when private-media capability negotiation did not resolve" - ) - ) - self.privateMediaTransferAdmissions.finish(transferId) - return - } - guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else { + guard let payload = filePacket.encode() else { SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String(localized: "content.delivery.reason.media_encoding_failed", defaultValue: "Failed to prepare media", comment: "Failure reason when private media cannot be encoded") - ) - self.privateMediaTransferAdmissions.finish(transferId) return } - guard self.noiseService.hasEstablishedSession(with: targetID) else { - if requiresAuthenticatedPrivateMediaReceipts { - // A retry belongs to one exact authenticated generation. - // Never let it enter the ordinary pending queue where a - // bit-8-only replacement session could later flush it. - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String( - localized: "content.delivery.reason.private_media_capability_unresolved", - defaultValue: "Could not confirm encrypted media support", - comment: "Failure reason when private-media capability negotiation did not resolve" - ) - ) - self.privateMediaTransferAdmissions.finish(transferId) - return - } - let queued = onEngine { - self.privateMediaTransferAdmissions.withActive(transferId) { - self.pendingNoiseSessionQueues.appendTypedPayload( - typedPayload, - transferId: transferId, - for: targetID - ) - return true - } ?? false - } - guard queued else { - self.privateMediaTransferAdmissions.finish(transferId) - return - } - SecureLogger.debug("📥 Queued private file for \(targetID.id.prefix(8))… pending handshake", category: .session) - guard self.privateMediaTransferAdmissions.isActive(transferId) else { - onEngine { - _ = self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) - } - self.privateMediaTransferAdmissions.finish(transferId) - return - } - self.initiateNoiseHandshake(with: targetID) + // Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility + // This ensures 64-hex Noise keys are converted to the canonical routing format + let targetID = peerID.toShort() + guard let recipientData = Data(hexString: targetID.id) else { + SecureLogger.error("❌ Invalid recipient peer ID for file transfer: \(peerID)", category: .session) return } - do { - guard self.privateMediaTransferAdmissions.isActive(transferId) else { - self.privateMediaTransferAdmissions.finish(transferId) - return - } - let packet = try self.makeEncryptedNoisePacket( - typedPayload, - to: targetID, - requiresAuthenticatedPrivateMediaReceipts: - requiresAuthenticatedPrivateMediaReceipts - ) - guard self.privateMediaTransferAdmissions.isActive(transferId) else { - self.privateMediaTransferAdmissions.finish(transferId) - return - } - SecureLogger.debug("📁 Sending encrypted private file to \(targetID.id.prefix(8))… plaintextBytes=\(typedPayload.count)", category: .session) - self.broadcastPacket( - packet, - transferId: transferId, - requiresPrivateMediaAdmission: true - ) - } catch { - SecureLogger.error("❌ Failed to encrypt private file for \(targetID.id.prefix(8))…: \(error)", category: .security) - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer") - ) - self.privateMediaTransferAdmissions.finish(transferId) + var packet = BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: self.myPeerIDData, + recipientID: recipientData, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: self.messageTTL, + version: 2 + ) + + if let signed = self.noiseService.signPacket(packet) { + packet = signed } - } - } - /// Compatibility-only fallback for peers that have not advertised - /// encrypted private media. The payload is authenticated but visible to - /// relays, matching the pre-migration behavior until those clients upgrade. - private func sendSignedLegacyPrivateFile( - _ filePacket: BitchatFilePacket, - to targetID: PeerID, - transferId: String - ) { - guard privateMediaTransferAdmissions.isActive(transferId) else { - privateMediaTransferAdmissions.finish(transferId) - return + SecureLogger.debug("📁 Sending private file transfer to \(peerID.id.prefix(8))… bytes=\(payload.count)", category: .session) + self.broadcastPacket(packet, transferId: transferId) } - guard let payload = filePacket.encode(), - let recipientData = Data(hexString: targetID.id) else { - SecureLogger.error("❌ Failed to encode legacy private file transfer", category: .session) - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String(localized: "content.delivery.reason.media_encoding_failed", defaultValue: "Failed to prepare media", comment: "Failure reason when private media cannot be encoded") - ) - privateMediaTransferAdmissions.finish(transferId) - return - } - - let unsigned = BitchatPacket( - type: MessageType.fileTransfer.rawValue, - senderID: myPeerIDData, - recipientID: recipientData, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: messageTTL, - version: 2 - ) - guard let signed = noiseService.signPacket(unsigned) else { - SecureLogger.error("❌ Failed to sign legacy private file transfer", category: .security) - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String(localized: "content.delivery.reason.media_signing_failed", defaultValue: "Failed to authenticate media", comment: "Failure reason when a legacy private-media packet cannot be signed") - ) - privateMediaTransferAdmissions.finish(transferId) - return - } - - // Signing can be non-trivial; cancellation that won while it ran must - // still prevent the clear payload from reaching the broadcast path. - guard privateMediaTransferAdmissions.isActive(transferId) else { - privateMediaTransferAdmissions.finish(transferId) - return - } - - SecureLogger.warning( - "📁 Sending signed legacy private file to \(targetID.id.prefix(8))…; peer has not advertised E2E media", - category: .security - ) - broadcastPacket( - signed, - transferId: transferId, - requiresPrivateMediaAdmission: true - ) } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { - // Hop like sendMessage: callers are often on the main actor, and the - // send path sync-waits on bleQueue for link state — the main thread - // must never block on bleQueue (see captureBluetoothStatus). - if DispatchQueue.getSpecific(key: messageQueueKey) == nil { - messageQueue.async { [weak self] in - self?.sendReadReceipt(receipt, to: peerID) - } - return - } - let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID) + // Create typed payload: [type byte] + [message ID] + var payload = Data([NoisePayloadType.readReceipt.rawValue]) + payload.append(contentsOf: receipt.originalMessageID.utf8) if noiseService.hasEstablishedSession(with: peerID) { - SecureLogger.debug("📤 Sending READ receipt id=\(receipt.originalMessageID.prefix(8))… to \(peerID.id.prefix(8))…", category: .session) + SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session) do { - broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID)) + let encrypted = try noiseService.encrypt(payload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) } catch { SecureLogger.error("Failed to send read receipt: \(error)") } } else { - // Queue for after handshake; initiate only while the peer is - // around to answer (see sendDeliveryAck — absent senders must - // not turn queued acks into handshake floods). - onEngine { - pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID) + // Queue for after handshake and initiate if needed + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) } - if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) { - initiateNoiseHandshake(with: peerID) - } - SecureLogger.debug("🕒 Queued READ receipt for \(peerID.id.prefix(8))… until handshake completes", category: .session) + if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } + SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session) } } - private func acceptedIngressContext( - for packet: BitchatPacket, - claimedSenderID: PeerID, - boundPeerID: PeerID?, - linkDescription: String - ) -> BLEIngressPacketContext? { - switch BLEIngressPacketGuard.evaluate( - packet: packet, - claimedSenderID: claimedSenderID, - boundPeerID: boundPeerID, - localPeerID: myPeerID, - directAnnounceTTL: messageTTL, - isValidSyncResponse: { [requestSyncManager] peerID in - requestSyncManager.isValidResponse(from: peerID, isRSR: true) + private enum ConnectionSource { + case peripheral(String) + case central(String) + case unknown + } + + private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool { + let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) + + let isRSR = packet.isRSR + var skipTimestampCheck = false + + if isRSR { + if requestSyncManager.isValidResponse(from: peerID, isRSR: true) { + SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security) + skipTimestampCheck = true + } else { + SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security) + return false } - ) { - case .success(let context): - if packet.isRSR { - logValidRSR(from: context.validationPeerID) - } - return context - case .failure(.selfLoopback): - logSelfLoopback(packetType: packet.type, linkDescription: linkDescription) - return nil - case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)): - SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! \(linkDescription) claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(boundPeerID.id.prefix(8))…", category: .security) - return nil - case .failure(.invalidRSR(let peerID)): - SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security) - return nil - case .failure(.timestampSkew(let peerID, let skewMs, let maxSkewMs)): - SecureLogger.warning("Packet timestamp skewed by \(skewMs)ms (max \(maxSkewMs)ms) from \(peerID.id.prefix(8))…", category: .security) - return nil } - } - private func isAcceptedIngressPayload(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { - switch BLEIngressPacketGuard.validatePayload( - packet, - from: peerID, - isValidSyncResponse: { [requestSyncManager] peerID in - requestSyncManager.isValidResponse(from: peerID, isRSR: true) + if !skipTimestampCheck { + let maxSkew: UInt64 = 120_000 + let packetTime = packet.timestamp + let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime) + + if skew > maxSkew { + SecureLogger.warning("Packet timestamp skewed by \(skew)ms (max \(maxSkew)ms) from \(peerID.id.prefix(8))…", category: .security) + return false } - ) { - case .success: - if packet.isRSR { - logValidRSR(from: peerID) - } - return true - case .failure(.invalidRSR(let peerID)): - SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security) - return false - case .failure(.timestampSkew(let peerID, let skewMs, let maxSkewMs)): - SecureLogger.warning("Packet timestamp skewed by \(skewMs)ms (max \(maxSkewMs)ms) from \(peerID.id.prefix(8))…", category: .security) - return false - case .failure(.selfLoopback), .failure(.directSenderMismatch): - return false } - } - private func logValidRSR(from peerID: PeerID) { - guard logRateLimiter.shouldLog(key: "valid-rsr:\(peerID.id)") else { return } - SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security) - } - - private func logSelfLoopback(packetType: UInt8, linkDescription: String) { - guard logRateLimiter.shouldLog( - key: "self-loopback:\(packetType)", - minimumInterval: 30 - ) else { return } - SecureLogger.debug("↩️ Dropping BLE self-loopback packet type \(packetType) from \(linkDescription)", category: .session) - } - - private func recordIngressIfNew(_ packet: BitchatPacket, link: BLEIngressLinkID, peerID: PeerID) -> Bool { - ingressLinks.recordIfNew( - packet, - link: link, - peerID: peerID, - lifetime: TransportConfig.bleIngressRecordLifetimeSeconds - ) + return true } // MARK: - Packet Broadcasting - private func broadcastPacket( - _ packet: BitchatPacket, - transferId: String? = nil, - requiresPrivateMediaAdmission: Bool = false - ) { - guard !isPanicSuspended else { - if requiresPrivateMediaAdmission, let transferId { - privateMediaTransferAdmissions.finish(transferId) - } - return - } - if requiresPrivateMediaAdmission { - guard let transferId, - privateMediaTransferAdmissions.isActive(transferId) else { - if let transferId { - privateMediaTransferAdmissions.finish(transferId) - } - return - } - } + private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) { // Apply route if recipient exists (centralized route application) let packetToSend: BitchatPacket if let recipientPeerID = PeerID(hexData: packet.recipientID) { @@ -1992,113 +879,11 @@ final class BLEService: NSObject { } else { packetToSend = packet } - + // Encode once using a small per-type padding policy, then delegate by type - let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) - - // The 256-fragment ceiling exists to protect *current Android* - // receivers, which only ever receive private media over the directed - // raw-file migration fallback (they do not implement the encrypted - // 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to - // peers that advertised the `.privateMedia` capability — modern clients - // that assemble up to the full receiver ceiling (see - // `BLEFragmentAssemblyBuffer`'s 10,000-fragment guard) — so forcing them - // down to Android's 256 cap would needlessly reject iOS→iOS photos in - // the ~120–512 KiB range that work today. Restrict the low cap to the - // migration fallback (directed `fileTransfer`); public media is - // unaffected. Run the same planner the scheduler will use, after route - // application, and reject before reserving a transfer slot or writing - // any fragment. - // TODO(#1434): negotiate an explicit per-peer fragment limit so a future - // Android client that adopts the encrypted 0x20 path but still caps its - // reassembler can advertise its own ceiling instead of relying on the - // capability/type proxy above. - if let transferId, - let recipientPeerID = PeerID(hexData: packetToSend.recipientID), - packetToSend.type == MessageType.fileTransfer.rawValue { - let compatibilityRequest = BLEOutboundFragmentTransferRequest( - packet: packetToSend, - pad: padForBLE, - maxChunk: nil, - directedPeer: recipientPeerID, - transferId: transferId - ) - guard let plan = BLEOutboundFragmentPlanner.makePlan( - for: compatibilityRequest, - defaultChunkSize: defaultFragmentSize, - bleMaxMTU: bleMaxMTU - ), BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(plan) else { - SecureLogger.warning( - "Private media rejected: exceeds cross-platform 256-fragment limit", - category: .security - ) - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String( - localized: "content.delivery.reason.private_media_too_many_fragments", - defaultValue: "File is too large for this contact's client (more than 256 mesh fragments)", - comment: "Failure reason when private media exceeds the Android-compatible fragment limit" - ) - ) - if requiresPrivateMediaAdmission { - privateMediaTransferAdmissions.finish(transferId) - } - return - } - } - - // Route planning and fragment preflight can take enough time for a - // user cancellation to win. Recheck before exposing even the test tap, - // then check atomically with scheduler admission below. - if requiresPrivateMediaAdmission { - guard let transferId, - privateMediaTransferAdmissions.isActive(transferId) else { - if let transferId { - privateMediaTransferAdmissions.finish(transferId) - } - return - } - } - - #if DEBUG - _test_onOutboundPacket?(packetToSend) - #endif - + let padForBLE = padPolicy(for: packetToSend.type) if packetToSend.type == MessageType.fileTransfer.rawValue { - sendFragmentedPacket( - packetToSend, - pad: padForBLE, - maxChunk: nil, - directedOnlyPeer: nil, - transferId: transferId, - requiresPrivateMediaAdmission: requiresPrivateMediaAdmission - ) - return - } - // App-initiated private media is already one opaque Noise ciphertext. - // Always fragment that outer packet so the existing transfer scheduler - // retains progress/cancel behavior without exposing the file TLVs. - if packetToSend.type == MessageType.noiseEncrypted.rawValue, - let transferId, - let recipientPeerID = PeerID(hexData: packetToSend.recipientID) { - sendFragmentedPacket( - packetToSend, - pad: padForBLE, - maxChunk: nil, - directedOnlyPeer: recipientPeerID, - transferId: transferId, - requiresPrivateMediaAdmission: requiresPrivateMediaAdmission - ) - return - } - if requiresPrivateMediaAdmission { - if let transferId { - privateMediaTransferAdmissions.finish(transferId) - } - SecureLogger.error( - "Private media admission reached an unsupported non-directed packet shape", - category: .security - ) + sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId) return } guard let data = packetToSend.toBinaryData(padding: padForBLE) else { @@ -2112,31 +897,52 @@ final class BLEService: NSObject { sendGenericBroadcast(packetToSend, data: data, pad: padForBLE) } + // MARK: - Broadcast helpers (single responsibility) + private func padPolicy(for type: UInt8) -> Bool { + switch MessageType(rawValue: type) { + case .noiseEncrypted, .noiseHandshake: + return true + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer: + return false + } + } + private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) { guard let recipientPeerID = PeerID(hexData: packet.recipientID) else { return } var sentEncrypted = false - let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data) + let outboundPriority = priority(for: packet, data: data) // Per-link limits for the specific peer - let directPeripheralState = snapshotDirectPeripheralState(for: recipientPeerID) - let recipientCentral = snapshotSubscribedCentrals().central(for: recipientPeerID) - - if let peripheralMaxLen = directPeripheralState?.peripheral.maximumWriteValueLength(for: .withoutResponse), - data.count > peripheralMaxLen { - let chunk = BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: peripheralMaxLen) + var peripheralMaxLen: Int? + if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }) { + if let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[perUUID] : bleQueue.sync(execute: { peripherals[perUUID] }) { + peripheralMaxLen = state.peripheral.maximumWriteValueLength(for: .withoutResponse) + } + } + var centralMaxLen: Int? + do { + let (centrals, mapping) = snapshotSubscribedCentrals() + if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) { + centralMaxLen = central.maximumUpdateValueLength + } + } + if let pm = peripheralMaxLen, data.count > pm { + let overhead = 13 + 8 + 8 + 13 + let chunk = max(64, pm - overhead) sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID) return } - if let centralMaxLen = recipientCentral?.maximumUpdateValueLength, - data.count > centralMaxLen { - let chunk = BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: centralMaxLen) + if let cm = centralMaxLen, data.count > cm { + let overhead = 13 + 8 + 8 + 13 + let chunk = max(64, cm - overhead) sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID) return } // Direct write via peripheral link - if let state = directPeripheralState, + if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }), + let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }), state.isConnected, let characteristic = state.characteristic { writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: outboundPriority) @@ -2144,12 +950,12 @@ final class BLEService: NSObject { } // Notify via central link (dual-role) - if let characteristic = characteristic, !sentEncrypted, let recipientCentral { - let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [recipientCentral]) ?? false - if success { - sentEncrypted = true - } else { - enqueuePendingNotification(data: data, centrals: [recipientCentral], context: "encrypted") + if let characteristic = characteristic, !sentEncrypted { + let (centrals, mapping) = snapshotSubscribedCentrals() + for central in centrals where mapping[central.identifier.uuidString] == recipientPeerID { + let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false + if success { sentEncrypted = true; break } + enqueuePendingNotification(data: data, centrals: [central], context: "encrypted") } } @@ -2164,18 +970,11 @@ final class BLEService: NSObject { } private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) { - guard !isPanicSuspended else { return } - bleQueue.async { [weak self] in + collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - guard !self.isPanicSuspended else { return } - let result = self.pendingNotifications.enqueue( - data: data, - targets: centrals, - capCount: TransportConfig.blePendingNotificationsCapCount - ) - - if case let .enqueued(count) = result { - self.logBackpressureSampled("📋 Queued \(context) packet for retry (pending=\(count))") + if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount { + self.pendingNotifications.append((data: data, centrals: centrals)) + SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(self.pendingNotifications.count))", category: .session) return } @@ -2185,473 +984,293 @@ final class BLEService: NSObject { } let backoff = TransportConfig.bleNotificationRetryDelayMs * max(1, attempt + 1) - self.engineScheduler.schedule(after: Double(backoff) / 1_000) { [weak self] in + let deadline = DispatchTime.now() + .milliseconds(backoff) + self.messageQueue.asyncAfter(deadline: deadline) { [weak self] in self?.enqueuePendingNotification(data: data, centrals: centrals, context: context, attempt: attempt + 1) } } } - /// Synchronously admits a notification to the link-specific retry queue. - /// Destructive courier handoff uses this result as its commit point, so a - /// full process-local queue must be reported as rejection, not success. - private func enqueuePendingNotificationIfAccepted( - data: Data, - centrals: [CBCentral], - context: String - ) -> Bool { - dispatchPrecondition(condition: .onQueue(bleQueue)) - let result = pendingNotifications.enqueue( - data: data, - targets: centrals, - capCount: TransportConfig.blePendingNotificationsCapCount - ) - switch result { - case let .enqueued(count): - SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session) - return true - case let .full(count): - SecureLogger.warning("⚠️ Rejecting \(context) packet: notification queue full (pending=\(count))", category: .session) - return false - } - } - - /// The authenticated-link eligibility check runs here on the engine — - /// the queue that owns bindings and rebinds — so fanout planning and - /// the final check are serialized against identity changes by - /// construction. Only the physical admission (updateValue and the - /// backpressure queue) hops to `bleQueue`; a central that physically - /// departs in between is a harmless no-op delivery. - private func notifyOrEnqueueIfAccepted( - data: Data, - centrals: [CBCentral], - characteristic: CBMutableCharacteristic, - context: String, - requiredAuthenticatedPeer: PeerID? - ) -> Bool { - let eligible: [CBCentral] - if let peerID = requiredAuthenticatedPeer { - eligible = centrals.filter { central in - let link = BLEIngressLinkID.central(central.identifier.uuidString) - return linkAuth.isAuthenticated(link, for: peerID) - && linkBindings.peer(forCentralUUID: central.identifier.uuidString) == peerID + private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) { + // Determine last-hop link for this message to avoid echoing back + let messageID = makeMessageID(for: packet) + let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link } + let directedPeerHint: PeerID? = { + if let explicit = directedOnlyPeer { return explicit } + if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty { + return recipient } - } else { - eligible = centrals - } - guard !eligible.isEmpty else { return false } - - let accept = { [self] in - if peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: eligible) == true { - return true - } - return enqueuePendingNotificationIfAccepted( - data: data, - centrals: eligible, - context: context - ) - } - // queue-contract-ok: engine → bleQueue is the sanctioned sync direction. - return bleQueue.sync(execute: accept) - } - - /// Returns true only when the packet was accepted by at least one current - /// physical link (including its link-specific backpressure queue). A - /// process-local directed spool is deliberately not success: callers - /// that own a durable upstream copy must keep it retryable. - @discardableResult - private func sendOnAllLinks( - packet: BitchatPacket, - data: Data, - pad: Bool, - directedOnlyPeer: PeerID?, - requireDirectPeerLink: Bool = false, - requireNoiseAuthenticatedPeerLink: Bool = false - ) -> Bool { - guard !isPanicSuspended else { return false } - let ingressRecord = ingressLinks.record(for: packet) - var excludedPeerLinks = links(to: ingressRecord?.peerID) - if requireNoiseAuthenticatedPeerLink { - guard let directedOnlyPeer else { return false } - let boundLinks = links(to: directedOnlyPeer) - let authenticatedLinks = currentNoiseAuthenticatedLinks(to: directedOnlyPeer) - guard !authenticatedLinks.isEmpty else { return false } - excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks)) - } - let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data) + return nil + }() + let outboundPriority = priority(for: packet, data: data) let states = snapshotPeripheralStates() - // A link without a discovered characteristic cannot be written to - // (the write loop below skips it); offering it to the planner only - // wastes fanout slots — and a peer's single collapsed copy would be - // silently dropped if its bound link is still mid-rediscovery. - let connectedStates = states.filter { $0.isConnected && $0.characteristic != nil } - let centralSnapshot = snapshotSubscribedCentrals() - let subscribedCentrals = characteristic == nil ? [] : centralSnapshot.centrals - let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString } - let centralIDs = subscribedCentrals.map { $0.identifier.uuidString } - let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state -> (String, PeerID)? in - let uuid = state.peripheral.identifier.uuidString - return linkBindings.peer(forPeripheralID: uuid).map { (uuid, $0) } - }) - let plan = BLEOutboundLinkPlanner.plan( - packet: packet, - dataCount: data.count, - peripheralIDs: connectedPeripheralIDs, - peripheralWriteLimits: connectedStates.map { $0.peripheral.maximumWriteValueLength(for: .withoutResponse) }, - centralIDs: centralIDs, - centralNotifyLimits: subscribedCentrals.map { $0.maximumUpdateValueLength }, - ingressRecord: ingressRecord, - excludedLinks: excludedPeerLinks, - peripheralPeerBindings: peripheralPeerBindings, - centralPeerBindings: centralSnapshot.peerIDsByCentralUUID, - preferredPeripheralPerPeer: linkBindings.preferredPeripheralBindings, - directAnnounceTTL: messageTTL, - directedOnlyPeer: directedOnlyPeer, - requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink - ) + var minCentralWriteLen: Int? + for s in states where s.isConnected { + let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse) + minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m + } + var snapshotCentrals: [CBCentral] = [] + if let _ = characteristic { + let (centrals, _) = snapshotSubscribedCentrals() + snapshotCentrals = centrals + } + var minNotifyLen: Int? + if !snapshotCentrals.isEmpty { + minNotifyLen = snapshotCentrals.map { $0.maximumUpdateValueLength }.min() + } + // Avoid re-fragmenting fragment packets + if packet.type != MessageType.fragment.rawValue, + let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min(), + data.count > minLen { + let overhead = 13 + 8 + 8 + 13 + let chunk = max(64, minLen - overhead) + sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer) + return + } + // Build link lists and apply K-of-N fanout for broadcasts; always exclude ingress link + let connectedPeripheralIDs: [String] = states.filter { $0.isConnected }.map { $0.peripheral.identifier.uuidString } + let subscribedCentrals: [CBCentral] + var centralIDs: [String] = [] + if let _ = characteristic { + let (centrals, _) = snapshotSubscribedCentrals() + subscribedCentrals = centrals + centralIDs = centrals.map { $0.identifier.uuidString } + } else { + subscribedCentrals = [] + } - if let chunk = plan.fragmentChunkSize { - guard !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty else { - return false + // Exclude ingress link + var allowedPeripheralIDs = connectedPeripheralIDs + var allowedCentralIDs = centralIDs + if let ingress = ingressLink { + switch ingress { + case .peripheral(let id): + allowedPeripheralIDs.removeAll { $0 == id } + case .central(let id): + allowedCentralIDs.removeAll { $0 == id } } - return sendFragmentedPacket( - packet, - pad: pad, - maxChunk: chunk, - directedOnlyPeer: directedOnlyPeer, - requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink, - requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink - ) + } + + // For broadcast (no directed peer) and non-fragment, choose a subset deterministically + // Special-case control/presence messages: do NOT subset to maximize immediate coverage + var selectedPeripheralIDs = Set(allowedPeripheralIDs) + var selectedCentralIDs = Set(allowedCentralIDs) + if directedPeerHint == nil + && packet.type != MessageType.fragment.rawValue + && packet.type != MessageType.announce.rawValue + && packet.type != MessageType.requestSync.rawValue { + let kp = subsetSizeForFanout(allowedPeripheralIDs.count) + let kc = subsetSizeForFanout(allowedCentralIDs.count) + selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID) + selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID) } // If directed and we currently have no links to forward on, spool for a short window - if let only = plan.directedPeerHint, - plan.shouldSpoolDirectedPacket { + if let only = directedPeerHint, + selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty, + (packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) { spoolDirectedPacket(packet, recipientPeerID: only) } - var acceptedByPhysicalLink = false - // Writes to selected connected peripherals - for s in connectedStates { + for s in states where s.isConnected { let pid = s.peripheral.identifier.uuidString - guard plan.selectedLinks.peripheralIDs.contains(pid) else { continue } + guard selectedPeripheralIDs.contains(pid) else { continue } if let ch = s.characteristic { - if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { - acceptedByPhysicalLink = writeOrEnqueueIfAccepted( - data, - to: s.peripheral, - characteristic: ch, - priority: outboundPriority, - requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil - ) || acceptedByPhysicalLink - } else { - writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority) - } + writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority) } } // Notify selected subscribed centrals if let ch = characteristic { - let targets = subscribedCentrals.filter { plan.selectedLinks.centralIDs.contains($0.identifier.uuidString) } + let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) } if !targets.isEmpty { - if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { - acceptedByPhysicalLink = notifyOrEnqueueIfAccepted( - data: data, - centrals: targets, - characteristic: ch, - context: "directed", - requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil - ) || acceptedByPhysicalLink - } else { - let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false - if !success { - // Notification queue full - queue for retry to prevent silent packet loss - // This is critical for fragment delivery reliability - let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast" - enqueuePendingNotification(data: data, centrals: targets, context: context) - } + let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false + if !success { + // Notification queue full - queue for retry to prevent silent packet loss + // This is critical for fragment delivery reliability + let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast" + enqueuePendingNotification(data: data, centrals: targets, context: context) } } } - if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { return acceptedByPhysicalLink } - return !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty } // Directed send helper (unicast to a specific peerID) without altering packet contents - @discardableResult - private func sendPacketDirected( - _ packet: BitchatPacket, - to peerID: PeerID, - requireDirectPeerLink: Bool = false, - requireNoiseAuthenticatedPeerLink: Bool = false - ) -> Bool { - #if DEBUG - _test_onOutboundPacket?(packet) - #endif - guard let data = packet.toBinaryData(padding: false) else { return false } - return sendOnAllLinks( - packet: packet, - data: data, - pad: false, - directedOnlyPeer: peerID, - requireDirectPeerLink: requireDirectPeerLink, - requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink - ) + private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) { + guard let data = packet.toBinaryData(padding: false) else { return } + sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) } // MARK: - Directed store-and-forward private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: PeerID) { - let msgID = BLEOutboundPacketPolicy.messageID(for: packet) - messageQueue.async { [weak self] in + let msgID = makeMessageID(for: packet) + collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - if self.pendingDirectedRelays.enqueue( - packet: packet, - recipient: recipientPeerID, - messageID: msgID, - enqueuedAt: Date() - ) { + var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:] + if byMsg[msgID] == nil { + byMsg[msgID] = (packet: packet, enqueuedAt: Date()) + self.pendingDirectedRelays[recipientPeerID] = byMsg SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session) } } } - func flushDirectedSpool() { - guard !isPanicSuspended else { return } - // Runs from bleQueue maintenance: hop to the engine asynchronously - // (bleQueue must never sync-wait on the engine). Move items out and - // attempt broadcast; if still no links, they'll be re-spooled. - messageQueue.async { [weak self] in - guard let self, !self.isPanicSuspended else { return } - let toSend = self.pendingDirectedRelays.drainUnexpired( - now: Date(), - window: TransportConfig.bleDirectedSpoolWindowSeconds - ) - for entry in toSend { - self.broadcastPacket(entry.packet) - } - } - } - - private func signedSenderDisplayName(for packet: BitchatPacket, from peerID: PeerID) -> String? { - guard let signature = packet.signature, - let packetData = packet.toBinaryDataForSigning() else { - return nil - } - - let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) - for candidate in candidates { - guard let signingKey = candidate.signingPublicKey, - noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) else { - continue - } - - if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { - return social.localPetname ?? social.claimedNickname - } - - return BLEPeerSenderDisplayName.anonymousNickname(for: peerID) - } - - return nil - } - - // MARK: - Archived public messages ("heard here earlier") - - func purgeArchivedPublicMessages(from peerID: PeerID) { - gossipSyncManager?.removePublicMessages(from: peerID) - } - - /// Clearing the mesh timeline erases the archive behind it, so the cleared - /// history is gone from disk rather than merely hidden from the timeline. - func purgeAllArchivedPublicMessages() { - gossipSyncManager?.removeAllPublicMessages() - } - - func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { - guard let generation = capturePanicLifecycleGeneration() else { - return - } - guard let sync = gossipSyncManager else { - notifyUI { [weak self] in - guard let self, - self.isCurrentPanicLifecycleGeneration(generation) else { - return + private func flushDirectedSpool() { + // Move items out and attempt broadcast; if still no links, they'll be re-spooled + let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) { + var out: [(String, BitchatPacket)] = [] + let now = Date() + for (recipient, dict) in pendingDirectedRelays { + for (_, entry) in dict { + if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds { + out.append((recipient.id, entry.packet)) + } } - completion([]) + // Clear recipient bucket; items will be re-spooled if still no links + pendingDirectedRelays.removeValue(forKey: recipient) } + return out + } + guard !toSend.isEmpty else { return } + for (_, packet) in toSend { + messageQueue.async { [weak self] in self?.broadcastPacket(packet) } + } + } + + private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) { + if peerID == myPeerID && packet.ttl != 0 { return } + + var accepted = false + var senderNickname = "" + + let peersSnapshot = collectionsQueue.sync { peers } + + if peerID == myPeerID { + accepted = true + senderNickname = myNickname + } else if let info = peersSnapshot[peerID], info.isVerifiedNickname { + accepted = true + senderNickname = info.nickname + let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) + if hasCollision { + senderNickname += "#" + String(peerID.id.prefix(4)) + } + } else if let info = peersSnapshot[peerID], info.isConnected { + accepted = true + senderNickname = info.nickname.isEmpty ? "anon" + String(peerID.id.prefix(4)) : info.nickname + let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) + if hasCollision { + senderNickname += "#" + String(peerID.id.prefix(4)) + } + } else if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { + let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + for candidate in candidates { + if let signingKey = candidate.signingPublicKey, + noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { + accepted = true + if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { + senderNickname = social.localPetname ?? social.claimedNickname + } else { + senderNickname = "anon" + String(peerID.id.prefix(4)) + } + break + } + } + } + + guard accepted else { + SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) return } - sync.collectPublicMessagePackets { [weak self] packets in - guard let self, - self.isCurrentPanicLifecycleGeneration(generation) else { + + // Skip directed packets that are not intended for us + if let recipient = packet.recipientID { + if PeerID(hexData: recipient) != myPeerID && !recipient.allSatisfy({ $0 == 0xFF }) { return } - // Signature verification and registry lookups run on messageQueue - // like the live receive path. - self.messageQueue.async { - guard self.isCurrentPanicLifecycleGeneration(generation) else { - return - } - let decoded = packets - .compactMap { self.decodeArchivedPublicMessage($0) } - .sorted { $0.timestamp < $1.timestamp } - self.notifyUI { [weak self] in - guard let self, - self.isCurrentPanicLifecycleGeneration(generation) else { - return - } - completion(decoded) - } - } } - } - private func decodeArchivedPublicMessage(_ packet: BitchatPacket) -> ArchivedPublicMessage? { - guard packet.type == MessageType.message.rawValue, - let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty - else { return nil } - let senderPeerID = PeerID(hexData: packet.senderID) - let peers = peerRegistry.snapshotByID - // Archived senders are usually long gone, so the signature-derived - // identity is the best shot at a name; a live registry entry is - // next; anonymous fallback matches the live path. - let nickname = signedSenderDisplayName(for: packet, from: senderPeerID) - ?? BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: senderPeerID, - localPeerID: myPeerID, - localNickname: myNickname, - peers: peers, - allowConnectedUnverified: false - ) - ?? BLEPeerSenderDisplayName.anonymousNickname(for: senderPeerID) - return ArchivedPublicMessage( - packetIdHex: PacketIdUtil.computeId(packet).hexEncodedString(), - senderPeerID: senderPeerID, - senderNickname: nickname, - content: content, - timestamp: Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000) + if let recipient = packet.recipientID, + recipient.allSatisfy({ $0 == 0xFF }) { + gossipSyncManager?.onPublicPacketSeen(packet) + } else if packet.recipientID == nil { + gossipSyncManager?.onPublicPacketSeen(packet) + } + + guard let filePacket = BitchatFilePacket.decode(packet.payload) else { + SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) + return + } + + guard FileTransferLimits.isValidPayload(filePacket.content.count) else { + SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(filePacket.content.count) bytes)", category: .security) + return + } + + guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else { + SecureLogger.warning("🚫 MIME REJECT: '\(filePacket.mimeType ?? "")' not supported. Size=\(filePacket.content.count)b from \(peerID.id.prefix(8))...", category: .security) + return + } + + // Validate content matches declared MIME type (magic byte check) + guard mime.matches(data: filePacket.content) else { + let prefix = filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ") + SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(filePacket.content.count)b prefix=[\(prefix)] from \(peerID.id.prefix(8))...", category: .security) + return + } + + // BCH-01-002: Enforce storage quota before saving + enforceIncomingFilesQuota(reservingBytes: filePacket.content.count) + + guard let destination = saveIncomingFile( + data: filePacket.content, + preferredName: filePacket.fileName, + subdirectory: "\(mime.category.mediaDir)/incoming", + fallbackExtension: mime.defaultExtension, + defaultPrefix: mime.category.rawValue + ) else { + return + } + + let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID + + if isPrivateMessage { + updatePeerLastSeen(peerID) + } + + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + let message = BitchatMessage( + sender: senderNickname, + content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", + timestamp: ts, + isRelay: false, + originalSender: nil, + isPrivate: isPrivateMessage, + recipientNickname: nil, + senderPeerID: peerID ) - } - private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { - fileTransferHandler.handle(packet, from: peerID) - } + SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) - /// Builds the file-transfer handler environment. All queue hops stay here - /// so `BLEFileTransferHandler` remains queue-agnostic and synchronously - /// testable. - private func makeFileTransferHandlerEnvironment() -> BLEFileTransferHandlerEnvironment { - BLEFileTransferHandlerEnvironment( - localPeerID: { [weak self] in - self?.myPeerID ?? PeerID(str: "") - }, - localNickname: { [weak self] in - self?.myNickname ?? "" - }, - peersSnapshot: { [weak self] in - guard let self = self else { return [:] } - return self.peerRegistry.snapshotByID - }, - verifyPacketSignature: { [weak self] packet, signingPublicKey in - self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false - }, - localSigningPublicKey: { [weak self] in - self?.noiseService.getSigningPublicKeyData() ?? Data() - }, - signedSenderDisplayName: { [weak self] packet, peerID in - self?.signedSenderDisplayName(for: packet, from: peerID) - }, - trackPacketSeen: { [weak self] packet in - self?.gossipSyncManager?.onPublicPacketSeen(packet) - }, - enforceStorageQuota: { [weak self] reservingBytes in - self?.incomingFileStore.enforceQuota(reservingBytes: reservingBytes) - }, - saveIncomingFile: { [weak self] data, preferredName, subdirectory, fallbackExtension, defaultPrefix in - self?.incomingFileStore.save( - data: data, - preferredName: preferredName, - subdirectory: subdirectory, - fallbackExtension: fallbackExtension, - defaultPrefix: defaultPrefix - ) - }, - privateMediaReceiptState: { [weak self] messageID in - self?.incomingFileStore.privateMediaReceiptState( - messageID: messageID - ) ?? .unavailable - }, - commitPrivateMediaFile: { [weak self] messageID, storedURL in - self?.incomingFileStore.commitPrivateMediaFile( - messageID: messageID, - storedURL: storedURL - ) ?? false - }, - removeIncomingFile: { [weak self] storedURL in - self?.incomingFileStore.removeIncomingFile(at: storedURL) - }, - finishIncomingFileDelivery: { [weak self] storedURL in - // Serialize pending-owner release behind deletion barriers. - // If /clear snapshots before this UI insertion, its already - // queued barrier must still observe the path as pending. If - // insertion wins first, the next MainActor snapshot sees the - // new bubble and protects the path explicitly. - self?.messageQueue.async { - self?.incomingFileStore.finishIncomingFileDelivery( - at: storedURL - ) - } - }, - isPrivateMediaSenderBlocked: { [weak self] peerID in - guard let self else { return false } - let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID) - ?? onEngine { - self.peerRegistry.info(for: peerID)?.noisePublicKey - } - guard let senderStaticKey else { return false } - return self.identityManager.isBlocked( - fingerprint: senderStaticKey.sha256Fingerprint() - ) - }, - updatePeerLastSeen: { [weak self] peerID in - self?.updatePeerLastSeen(peerID) - }, - acknowledgePrivateMedia: { [weak self] messageID, peerID in - guard let self, - let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID), - !self.identityManager.isBlocked( - fingerprint: senderStaticKey.sha256Fingerprint() - ) else { - return - } - self.sendDeliveryAck(for: messageID, to: peerID) - }, - deliverMessage: { [weak self] message, shouldDeliver, completion, finalization in - self?.emitTransportEvent( - .messageReceived(message), - shouldDeliver: shouldDeliver, - completion: completion, - finalization: finalization - ) - } - ) + notifyUI { [weak self] in + self?.delegate?.didReceiveMessage(message) + } } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { - SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session) + SecureLogger.debug("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)", category: .session) // Include Nostr public key in the notification var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]" - var includesNostrIdentity = false // Add our Nostr public key if available if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() { content += ":" + myNostrIdentity.npub - includesNostrIdentity = true - SecureLogger.debug("📝 Favorite notification includes Nostr npub=\(myNostrIdentity.npub.prefix(16))…", category: .session) + SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session) } - SecureLogger.debug("📤 Sending favorite notification to \(peerID.id.prefix(8))… isFavorite=\(isFavorite) includesNostrIdentity=\(includesNostrIdentity)", category: .session) + SecureLogger.debug("📤 Sending favorite notification to \(peerID): \(content)", category: .session) sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString) } @@ -2660,73 +1279,42 @@ final class BLEService: NSObject { } func sendDeliveryAck(for messageID: String, to peerID: PeerID) { - // Hop like sendMessage: callers are often on the main actor, and the - // send path sync-waits on bleQueue for link state — the main thread - // must never block on bleQueue (see captureBluetoothStatus). - if DispatchQueue.getSpecific(key: messageQueueKey) == nil { - messageQueue.async { [weak self] in - self?.sendDeliveryAck(for: messageID, to: peerID) - } - return - } - let payload = BLENoisePayloadFactory.delivered(messageID: messageID) + // Create typed payload: [type byte] + [message ID] + var payload = Data([NoisePayloadType.delivered.rawValue]) + payload.append(contentsOf: messageID.utf8) if noiseService.hasEstablishedSession(with: peerID) { do { - broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID)) + let encrypted = try noiseService.encrypt(payload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) } catch { SecureLogger.error("Failed to send delivery ACK: \(error)") } } else { - // Queue for after handshake; initiate only while the peer is - // around to answer — couriered/bridged mail routinely arrives - // from absent (or rotated) identities, and every duplicate copy - // initiating a handshake broadcast turns one undeliverable ack - // into a mesh-wide flood. The queued ack flushes whenever a - // session eventually establishes. - onEngine { - pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID) + // Queue for after handshake and initiate if needed + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) } - if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) { - initiateNoiseHandshake(with: peerID) - } - SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID.id.prefix(8))… until handshake completes", category: .session) + if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } + SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session) } } - /// Accept a leave only when the claimed sender proves possession of the - /// signing key bound by a verified announce. The persisted identity cache - /// keeps delayed/relayed leaves verifiable after the live registry entry - /// has aged out. - private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { - let registrySigningKey = onEngine { - peerRegistry.info(for: peerID)?.signingPublicKey + private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) { + _ = collectionsQueue.sync(flags: .barrier) { + // Remove the peer when they leave + peers.removeValue(forKey: peerID) } - let verifiedViaRegistry = registrySigningKey.map { - noiseService.verifyPacketSignature(packet, publicKey: $0) - } ?? false - let verifiedViaPersistedIdentity = !verifiedViaRegistry - && identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).contains { identity in - PeerID(publicKey: identity.publicKey) == peerID - && identity.signingPublicKey.map { - noiseService.verifyPacketSignature(packet, publicKey: $0) - } == true - } - - guard verifiedViaRegistry || verifiedViaPersistedIdentity else { - SecureLogger.warning( - "🚫 Dropping leave with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", - category: .security - ) - return false - } - - // A valid departure retires transport state too; otherwise - // canDeliverSecurely could remain true for a peer we just removed. - clearNoiseSession(for: peerID) - _ = linkAuth.retireLinks(ownedBy: peerID) - // Remove the peer when they leave - peerRegistry.mutate { _ = $0.remove(peerID) } // Remove any stored announcement for sync purposes gossipSyncManager?.removeAnnouncementForPeer(peerID) // Send on main thread @@ -2734,50 +1322,215 @@ final class BLEService: NSObject { guard let self = self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.peerRegistry.peerIDs + let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } - self.deliverTransportEvent(.peerDisconnected(peerID)) - self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) + self.delegate?.didDisconnectFromPeer(peerID) + self.delegate?.didUpdatePeerList(currentPeerIDs) } - return true } - func sendAnnounce(forceSend: Bool = false) { - guard !isPanicSuspended else { return } - // Announce construction reads the replaceable Noise service and several - // related state snapshots. Serialize the whole operation with identity - // rotation instead of letting CoreBluetooth and maintenance callbacks - // execute it directly on their own queues. - messageQueue.async { [weak self] in - self?.sendAnnounceNow(forceSend: forceSend) + + // MARK: - Helper Functions + + private func applicationFilesDirectory() throws -> URL { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let filesDir = base.appendingPathComponent("files", isDirectory: true) + try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) + return filesDir + } + + private func sanitizeFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String { + var candidate = name ?? "" + + // Security: Remove null bytes (path traversal vector) + candidate = candidate.replacingOccurrences(of: "\0", with: "") + + // Security: Unicode normalization prevents fullwidth character bypass + candidate = candidate.precomposedStringWithCanonicalMapping + + // Security: Remove ALL path separators (not just strip last component) + candidate = candidate.replacingOccurrences(of: "/", with: "_") + candidate = candidate.replacingOccurrences(of: "\\", with: "_") + + // Security: Remove control characters and dangerous filesystem chars + let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters) + candidate = candidate.components(separatedBy: invalid).joined(separator: "_") + + candidate = candidate.trimmed + if candidate.isEmpty { candidate = defaultName } + + // Security: Reject dotfiles (hidden file attacks) + if candidate.hasPrefix(".") { + candidate = "_" + candidate + } + + // Truncate while preserving extension + if candidate.count > 120 { + let ext = (candidate as NSString).pathExtension + let base = (candidate as NSString).deletingPathExtension + if ext.isEmpty { + candidate = String(candidate.prefix(120)) + } else { + let maxBase = max(10, 120 - ext.count - 1) + candidate = String(base.prefix(maxBase)) + "." + ext + } + } + + if let fallbackExtension = fallbackExtension, (candidate as NSString).pathExtension.isEmpty { + candidate += ".\(fallbackExtension)" + } + + if candidate.isEmpty { candidate = defaultName } + return candidate + } + + private func uniqueFileURL(in directory: URL, fileName: String) -> URL { + var candidate = directory.appendingPathComponent(fileName) + + // Security: Validate path doesn't escape directory + if !candidate.path.hasPrefix(directory.path) { + SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security) + return directory.appendingPathComponent("blocked_\(UUID().uuidString)") + } + + if !FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + + let baseName = (fileName as NSString).deletingPathExtension + let ext = (fileName as NSString).pathExtension + var counter = 1 + + // Limit iterations to prevent DoS + while counter < 100 { + let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)" + candidate = directory.appendingPathComponent(newName) + + // Validate each iteration + guard candidate.path.hasPrefix(directory.path) else { + return directory.appendingPathComponent("blocked_\(UUID().uuidString)") + } + + if !FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + counter += 1 + } + + // Fallback: UUID to guarantee uniqueness + return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)") + } + + private func saveIncomingFile(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String) -> URL? { + do { + let base = try applicationFilesDirectory().appendingPathComponent(subdirectory, isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil) + let timestamp = mediaDateFormatter.string(from: Date()) + let defaultName = "\(defaultPrefix)_\(timestamp)" + let sanitized = sanitizeFileName(preferredName, defaultName: defaultName, fallbackExtension: fallbackExtension) + let destination = uniqueFileURL(in: base, fileName: sanitized) + try data.write(to: destination, options: .atomic) + return destination + } catch { + SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session) + return nil } } - private func sendAnnounceNow(forceSend: Bool) { - // Re-check on the serialized queue: a panic suspend may have started - // after this announce was scheduled but before it runs. - guard !isPanicSuspended else { return } + // MARK: - Storage Quota Management (BCH-01-002) + + /// Maximum total storage for incoming files (100 MB) + private static let incomingFilesQuota: Int64 = 100 * 1024 * 1024 + + /// Enforces storage quota for incoming files by deleting oldest files when quota is exceeded. + /// Call before saving a new incoming file. + private func enforceIncomingFilesQuota(reservingBytes: Int) { + do { + let base = try applicationFilesDirectory() + let incomingDirs = [ + base.appendingPathComponent("voicenotes/incoming", isDirectory: true), + base.appendingPathComponent("images/incoming", isDirectory: true), + base.appendingPathComponent("files/incoming", isDirectory: true) + ] + + // Gather all incoming files with their sizes and modification dates + var allFiles: [(url: URL, size: Int64, modified: Date)] = [] + let fileManager = FileManager.default + + for dir in incomingDirs { + guard fileManager.fileExists(atPath: dir.path) else { continue } + guard let contents = try? fileManager.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { continue } + + for fileURL in contents { + guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]), + let size = attrs.fileSize, + let modified = attrs.contentModificationDate else { continue } + allFiles.append((url: fileURL, size: Int64(size), modified: modified)) + } + } + + // Calculate current usage + let currentUsage = allFiles.reduce(0) { $0 + $1.size } + let targetUsage = Self.incomingFilesQuota - Int64(reservingBytes) + + guard currentUsage > targetUsage else { return } + + // Sort by modification date (oldest first) and delete until under quota + let sortedFiles = allFiles.sorted { $0.modified < $1.modified } + var freedSpace: Int64 = 0 + let needToFree = currentUsage - targetUsage + + for file in sortedFiles { + guard freedSpace < needToFree else { break } + do { + try fileManager.removeItem(at: file.url) + freedSpace += file.size + SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security) + } catch { + SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security) + } + } + + if freedSpace > 0 { + SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security) + } + } catch { + SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security) + } + } + + private func sendAnnounce(forceSend: Bool = false) { // Throttle announces to prevent flooding - if !announceThrottle.shouldSend(force: forceSend, now: Date()) { + let now = Date() + let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent) + + // Even forced sends should respect a minimum interval to avoid overwhelming BLE + let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval + + if timeSinceLastAnnounce < minInterval { + // Skipping announce (rate limited) return } - + lastAnnounceSent = now + // Reduced logging - only log errors, not every announce // Create announce payload with both noise and signing public keys let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - let connectedPeerIDs = peerRegistry.connectedRoutingData - let localIdentity = localIdentityState.snapshot() - let advertisedCapabilities = localIdentity.advertisedCapabilities - let advertisedBridgeCell = localIdentity.advertisedBridgeGeohash + let connectedPeerIDs: [Data] = collectionsQueue.sync { + peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData } + } + let announcement = AnnouncementPacket( - nickname: localIdentity.nickname, + nickname: myNickname, noisePublicKey: noisePub, signingPublicKey: signingPub, - directNeighbors: connectedPeerIDs, - capabilities: advertisedCapabilities, - bridgeGeohash: advertisedBridgeCell + directNeighbors: connectedPeerIDs ) guard let payload = announcement.encode() else { @@ -2788,7 +1541,7 @@ final class BLEService: NSObject { // Create packet with signature using the noise private key let packet = BitchatPacket( type: MessageType.announce.rawValue, - senderID: localIdentity.peerIDData, + senderID: myPeerIDData, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, @@ -2802,59 +1555,20 @@ final class BLEService: NSObject { return } - broadcastPacket(signedPacket) + // Call directly if on messageQueue, otherwise dispatch + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(signedPacket) + } else { + messageQueue.async { [weak self] in + self?.broadcastPacket(signedPacket) + } + } // Ensure our own announce is included in sync state gossipSyncManager?.onPublicPacketSeen(signedPacket) - - // Keep our prekey bundle riding alongside presence (throttled; the - // send is a no-op when the bundle was refreshed recently). - sendPrekeyBundle() } // MARK: QR Verification over Noise - // MARK: Private Groups - - /// Sends creator-signed group state (invite) 1:1 over the Noise session, - /// queueing behind a handshake when none is established yet. - func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) { - sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID) - } - - /// Sends creator-signed group state (key rotation / roster update) 1:1 - /// over the Noise session. - func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) { - sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID) - } - - /// Broadcasts a sealed group message (MessageType 0x25) like a public - /// message: fire-and-flood with gossip-sync backfill. The outer packet is - /// intentionally unsigned — receivers authenticate the sender's Ed25519 - /// signature inside the ciphertext, which still verifies for backfilled - /// copies long after the sender's announce has expired. - func broadcastGroupMessage(_ envelope: Data) { - guard !envelope.isEmpty else { return } - messageQueue.async { [weak self] in - guard let self else { return } - let packet = BitchatPacket( - type: MessageType.groupMessage.rawValue, - senderID: Data(hexString: self.myPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: envelope, - signature: nil, - ttl: self.messageTTL - ) - // Pre-mark our own broadcast as processed to avoid handling a - // relayed self copy. - let dedupID = BLESelfBroadcastTracker.dedupID(for: packet) - self.messageDeduplicator.markProcessed(dedupID) - self.broadcastPacket(packet) - // Track our own broadcast for gossip sync - self.gossipSyncManager?.onPublicPacketSeen(packet) - } - } - func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA) sendNoisePayload(payload, to: peerID) @@ -2864,87 +1578,16 @@ final class BLEService: NSObject { guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return } sendNoisePayload(payload, to: peerID) } - - // MARK: Vouching over Noise - - func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { - sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID) - } - - // MARK: Live Voice (PTT) - - /// Sends one live voice-burst packet inside the Noise session. Unlike - /// `sendNoisePayload` this never queues behind a handshake: live audio is - /// only useful now, so without an established session frames are dropped. - func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) { - messageQueue.async { [weak self] in - guard let self else { return } - guard self.noiseService.hasEstablishedSession(with: peerID) else { - SecureLogger.debug("PTT: dropping voice frame — no established session with \(peerID.id.prefix(8))…", category: .session) - return - } - do { - let typedPayload = NoisePayload(type: .voiceFrame, data: burstContent).encode() - self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID)) - } catch { - SecureLogger.error("Failed to send voice frame: \(error)", category: .session) - } - } - } - - /// Broadcasts one live voice-burst packet to the public mesh, signed like - /// a public message so receivers can authenticate the talker. Ephemeral: - /// never tracked for gossip sync (stale audio is worthless to replay). - func sendVoiceFrameBroadcast(_ burstContent: Data) { - guard !burstContent.isEmpty else { return } - messageQueue.async { [weak self] in - guard let self else { return } - let packet = BitchatPacket( - type: MessageType.voiceFrame.rawValue, - senderID: self.myPeerIDData, - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: burstContent, - signature: nil, - ttl: self.messageTTL - ) - guard let signedPacket = self.noiseService.signPacket(packet) else { - SecureLogger.error("❌ Failed to sign voice frame", category: .security) - return - } - // Pre-mark our own broadcast as processed to avoid handling a - // relayed self copy. - let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket) - self.messageDeduplicator.markProcessed(dedupID) - self.broadcastPacket(signedPacket) - } - } - - func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { - // Appends to the encryption service's handler array, so this never - // displaces the callbacks installed by installNoiseSessionCallbacks. - noiseService.addOnPeerAuthenticatedHandler(handler) - } } // MARK: - GossipSyncManager Delegate extension BLEService: GossipSyncManager.Delegate { - // Gossip calls arrive on the manager's own serial queue; sends read - // the engine-owned bindings, so they enter an engine slot. The sync - // hop is safe: mesh.sync sits above the engine in the sync order — - // production engine code only ever queue.async's into the manager - // (the queue.sync helpers are DEBUG test entry points that run on - // test threads), so no reverse edge exists. func sendPacket(_ packet: BitchatPacket) { - onEngine { - broadcastPacket(packet) - } + broadcastPacket(packet) } func sendPacket(to peerID: PeerID, packet: BitchatPacket) { - onEngine { - _ = sendPacketDirected(packet, to: peerID) - } + sendPacketDirected(packet, to: peerID) } func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { @@ -2952,699 +1595,1141 @@ extension BLEService: GossipSyncManager.Delegate { } func getConnectedPeers() -> [PeerID] { - return onEngine { - peerRegistry.connectedPeerIDs + return collectionsQueue.sync { + peers.values.compactMap { $0.isConnected ? $0.peerID : nil } } } } -// MARK: - Radio controller integration +// MARK: - CBCentralManagerDelegate -extension BLEService: BLERadioControllerDelegate { - func radioIsPanicSuspended() -> Bool { - isPanicSuspended +extension BLEService: CBCentralManagerDelegate { + #if os(iOS) + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { + let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] + let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] + let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] + let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool + + SecureLogger.info( + "♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))", + category: .session + ) + + for peripheral in restoredPeripherals { + let identifier = peripheral.identifier.uuidString + peripheral.delegate = self + let existing = peripherals[identifier] + let assembler = existing?.assembler ?? NotificationStreamAssembler() + let characteristic = existing?.characteristic + let peerID = existing?.peerID + let wasConnecting = existing?.isConnecting ?? false + let wasConnected = existing?.isConnected ?? false + + let restoredState = PeripheralState( + peripheral: peripheral, + characteristic: characteristic, + peerID: peerID, + isConnecting: wasConnecting || peripheral.state == .connecting, + isConnected: wasConnected || peripheral.state == .connected, + lastConnectionAttempt: existing?.lastConnectionAttempt, + assembler: assembler + ) + peripherals[identifier] = restoredState + } + + captureBluetoothStatus(context: "central-restore") + + if central.state == .poweredOn { + startScanning() + } } + #endif - func radioIsAppActive() -> Bool { - #if os(iOS) - return isAppActive - #else - return true - #endif - } + func centralManagerDidUpdateState(_ central: CBCentralManager) { + // Notify delegate about state change on main thread + Task { @MainActor in + self.delegate?.didUpdateBluetoothState(central.state) + } - func radioTearDownPeripheralLink(_ peripheralID: String) { - // bleQueue (the controller's queue): physical discard now, identity - // retirement via the port. - discardPeripheralLinkPhysical(peripheralID) - emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: false)) - } + switch central.state { + case .poweredOn: + // Start scanning - use allow duplicates for faster discovery when active + startScanning() - /// bleQueue half of a peripheral-link teardown: the link's write - /// backpressure and its physical link-state entry. Identity retirement - /// (proof, epoch, binding repair) rides a separate engine hop — - /// `retirePeripheralLinkIdentity`. bleQueue-confined. - func discardPeripheralLinkPhysical(_ peripheralID: String) { - pendingPeripheralWrites.discardAll(for: peripheralID) - linkStateStore.removePeripheral(peripheralID) - } - - /// Engine half of a peripheral-link teardown: retires the link's Noise - /// proof and revalidation epoch, and its binding — repairing the peer's - /// preferred link onto a connected survivor, preferring a writable one - /// (a link mid-service-rediscovery would strand directed sends until - /// its characteristic comes back). Returns the peer that owned the - /// binding. Engine-confined. - @discardableResult - func retirePeripheralLinkIdentity(_ peripheralID: String) -> PeerID? { - linkAuth.retireLink(.peripheral(peripheralID)) - return linkBindings.peripheralRemoved(peripheralID) { remaining in - let alive = readLinkState { store in - remaining.compactMap { uuid -> (uuid: String, writable: Bool)? in - guard let state = store.state(forPeripheralID: uuid), - state.isConnected else { return nil } - return (uuid, state.characteristic != nil) + case .poweredOff: + // Bluetooth was turned off - stop scanning and clean up connection state + SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session) + central.stopScan() + // Mark all peripheral connections as disconnected (they are now invalid) + let peerIDs: [PeerID] = peripherals.compactMap { $0.value.peerID } + for state in peripherals.values { + central.cancelPeripheralConnection(state.peripheral) + } + peripherals.removeAll() + peerToPeripheralUUID.removeAll() + // Notify UI of disconnections + for peerID in peerIDs { + notifyUI { [weak self] in + self?.notifyPeerDisconnectedDebounced(peerID) } } - return (alive.first(where: \.writable) ?? alive.first)?.uuid + + case .unauthorized: + // User denied Bluetooth permission + SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) + central.stopScan() + peripherals.removeAll() + peerToPeripheralUUID.removeAll() + + case .unsupported: + // Device doesn't support BLE + SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session) + + case .resetting: + // Bluetooth stack is resetting - will get another state update when done + SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session) + + case .unknown: + // Initial state before we know the actual state + SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session) + + @unknown default: + SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session) } } - - /// Binds only live physical links, preserving the store-era guard that - /// a binding can never precede its link (a lost race against a - /// concurrent physical removal is healed by that removal's queued - /// identity retirement). Engine-confined. - func bindPeripheralLink(_ peripheralUUID: String, to peerID: PeerID) { - guard readLinkState({ $0.state(forPeripheralID: peripheralUUID) }) != nil else { return } - linkBindings.bindPeripheral(peripheralUUID, to: peerID) - } - - /// Whether the peer holds a live direct link in either role: bindings - /// (engine) joined against physical liveness (readLinkState). - /// Engine-confined. - func directLinkState(for peerID: PeerID) -> BLEDirectLinkState { - let hasPeripheral = linkBindings.preferredPeripheralUUID(for: peerID) - .flatMap { uuid in readLinkState { $0.state(forPeripheralID: uuid)?.isConnected } } ?? false - return BLEDirectLinkState( - hasPeripheral: hasPeripheral, - hasCentral: linkBindings.hasCentral(boundTo: peerID) + + private func startScanning() { + guard let central = centralManager, + central.state == .poweredOn, + !central.isScanning else { return } + + // Use allow duplicates = true for faster discovery in foreground + // This gives us discovery events immediately instead of coalesced + #if os(iOS) + let allowDuplicates = isAppActive // Use our tracked state (thread-safe) + #else + let allowDuplicates = true // macOS doesn't have background restrictions + #endif + + central.scanForPeripherals( + withServices: [BLEService.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates] ) + + // Started BLE scanning } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + let peripheralID = peripheral.identifier.uuidString + let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…") + let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true + let rssiValue = RSSI.intValue + + // Skip if peripheral is not connectable (per advertisement data) + guard isConnectable else { return } - /// The peer's preferred peripheral link state, when physically present. - /// Engine-confined. - func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { - linkBindings.preferredPeripheralUUID(for: peerID) - .flatMap { uuid in readLinkState { $0.state(forPeripheralID: uuid) } } - } + // Skip immediate connect if signal too weak for current conditions; enqueue instead + if rssiValue <= dynamicRSSIThreshold { + connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date())) + // Keep list tidy + connectionCandidates.sort { (a, b) in + if a.rssi != b.rssi { return a.rssi > b.rssi } + return a.discoveredAt < b.discoveredAt + } + if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { + connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax) + } + return + } + + // Budget: limit simultaneous central links (connected + connecting) + let currentCentralLinks = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count + if currentCentralLinks >= maxCentralLinks { + // Enqueue as candidate; we'll attempt later as slots open + connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date())) + // Keep candidate list tidy: prefer stronger RSSI, then recency; cap list + connectionCandidates.sort { (a, b) in + if a.rssi != b.rssi { return a.rssi > b.rssi } + return a.discoveredAt < b.discoveredAt + } + if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { + connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax) + } + return + } - /// Subscribed centrals with their bindings, one view. Engine-confined. - func subscribedCentralSnapshot() -> BLESubscribedCentralSnapshot { - BLESubscribedCentralSnapshot( - centrals: readLinkState(\.subscribedCentrals), - peerIDsByCentralUUID: linkBindings.centralPeersByUUID + // Rate limit global connect attempts + let sinceLast = Date().timeIntervalSince(lastGlobalConnectAttempt) + if sinceLast < connectRateLimitInterval { + connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date())) + connectionCandidates.sort { (a, b) in + if a.rssi != b.rssi { return a.rssi > b.rssi } + return a.discoveredAt < b.discoveredAt + } + // Schedule a deferred attempt after rate-limit interval + let delay = connectRateLimitInterval - sinceLast + 0.05 + bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.tryConnectFromQueue() + } + return + } + + // Check if we already have this peripheral + if let state = peripherals[peripheralID] { + if state.isConnected || state.isConnecting { + return // Already connected or connecting + } + + // Add backoff for reconnection attempts + if let lastAttempt = state.lastConnectionAttempt { + let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt) + if timeSinceLastAttempt < 2.0 { + return // Wait at least 2 seconds between connection attempts + } + } + } + + // Backoff if this peripheral recently timed out connection within the last 15 seconds + if let lastTimeout = recentConnectTimeouts[peripheralID], Date().timeIntervalSince(lastTimeout) < 15 { + return + } + + // Check peripheral state - but cancel if stale + if peripheral.state == .connecting || peripheral.state == .connected { + // iOS might have stale state - force disconnect and retry + central.cancelPeripheralConnection(peripheral) + // Will retry on next discovery + return + } + + // Only log when we're actually attempting connection + // Discovered BLE peripheral + + // Store the peripheral and mark as connecting + peripherals[peripheralID] = PeripheralState( + peripheral: peripheral, + characteristic: nil, + peerID: nil, + isConnecting: true, + isConnected: false, + lastConnectionAttempt: Date(), + assembler: NotificationStreamAssembler() ) + peripheral.delegate = self + + // Connect to the peripheral with options for faster connection + SecureLogger.debug("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]", category: .session) + + // Use connection options for faster reconnection + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true + ] + central.connect(peripheral, options: options) + lastGlobalConnectAttempt = Date() + + // Set a timeout for the connection attempt (slightly longer for reliability) + // Use BLE queue to mutate BLE-related state consistently + bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in + guard let self = self, + let state = self.peripherals[peripheralID], + state.isConnecting && !state.isConnected else { return } + + // Double-check actual CBPeripheral state to avoid canceling a just-connected peripheral + // This prevents a race where connection completes just as timeout fires + guard peripheral.state != .connected else { + SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(advertisedName)", category: .session) + return + } + + // Connection timed out - cancel it + SecureLogger.debug("⏱️ Timeout: \(advertisedName)", category: .session) + central.cancelPeripheralConnection(peripheral) + self.peripherals[peripheralID] = nil + self.recentConnectTimeouts[peripheralID] = Date() + self.failureCounts[peripheralID, default: 0] += 1 + // Try next candidate if any + self.tryConnectFromQueue() + } + } + +func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + let peripheralID = peripheral.identifier.uuidString + + // Update state to connected + if var state = peripherals[peripheralID] { + state.isConnecting = false + state.isConnected = true + peripherals[peripheralID] = state + } else { + // Create new state if not found + peripherals[peripheralID] = PeripheralState( + peripheral: peripheral, + characteristic: nil, + peerID: nil, + isConnecting: false, + isConnected: true, + lastConnectionAttempt: nil, + assembler: NotificationStreamAssembler() + ) + } + + // Reset backoff state on success + failureCounts[peripheralID] = 0 + recentConnectTimeouts.removeValue(forKey: peripheralID) + + SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session) + + // Discover services + peripheral.discoverServices([BLEService.serviceUUID]) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + let peripheralID = peripheral.identifier.uuidString + + // Find the peer ID if we have it + let peerID = peripherals[peripheralID]?.peerID + + SecureLogger.debug("📱 Disconnect: \(peerID?.id ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session) + + // If disconnect carried an error (often timeout), apply short backoff to avoid thrash + if error != nil { + recentConnectTimeouts[peripheralID] = Date() + } + + // Clean up references + peripherals.removeValue(forKey: peripheralID) + + // Clean up peer mappings + if let peerID { + peerToPeripheralUUID.removeValue(forKey: peerID) + + // Do not remove peer; mark as not connected but retain for reachability + collectionsQueue.sync(flags: .barrier) { + if var info = peers[peerID] { + info.isConnected = false + peers[peerID] = info + } + } + refreshLocalTopology() + } + + + // Restart scanning with allow duplicates for faster rediscovery + if centralManager?.state == .poweredOn { + // Stop and restart scanning to ensure we get fresh discovery events + centralManager?.stopScan() + bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in + self?.startScanning() + } + } + // Attempt to fill freed slot from queue + bleQueue.async { [weak self] in self?.tryConnectFromQueue() } + + // Notify delegate about disconnection on main thread (direct link dropped) + notifyUI { [weak self] in + guard let self = self else { return } + + // Get current peer list (after removal) + let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } + + if let peerID { + self.notifyPeerDisconnectedDebounced(peerID) + } + self.requestPeerDataPublish() + self.delegate?.didUpdatePeerList(currentPeerIDs) + } + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + let peripheralID = peripheral.identifier.uuidString + + // Clean up the references + peripherals.removeValue(forKey: peripheralID) + + SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) + failureCounts[peripheralID, default: 0] += 1 + // Try next candidate + bleQueue.async { [weak self] in self?.tryConnectFromQueue() } } } +// MARK: - Connection scheduling helpers +extension BLEService { + private func tryConnectFromQueue() { + guard let central = centralManager, central.state == .poweredOn else { return } + // Check budget and rate limit + let current = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count + guard current < maxCentralLinks else { return } + let delta = Date().timeIntervalSince(lastGlobalConnectAttempt) + guard delta >= connectRateLimitInterval else { + let delay = connectRateLimitInterval - delta + 0.05 + bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } + return + } + // Pull best candidate by composite score + guard !connectionCandidates.isEmpty else { return } + // compute score: connectable> RSSI > recency, with backoff penalty + func score(_ c: ConnectionCandidate) -> Int { + let uuid = c.peripheral.identifier.uuidString + // Penalty if recently timed out (exponential) + let fails = failureCounts[uuid] ?? 0 + let penalty = min(20, (1 << min(4, fails))) // 1,2,4,8,16 cap 16-20 + let timeoutRecent = recentConnectTimeouts[uuid] + let timeoutBias = (timeoutRecent != nil && Date().timeIntervalSince(timeoutRecent!) < 60) ? 10 : 0 + let base = (c.isConnectable ? 1000 : 0) + (c.rssi + 100) * 2 + let rec = -Int(Date().timeIntervalSince(c.discoveredAt) * 10) + return base + rec - penalty - timeoutBias + } + connectionCandidates.sort { score($0) > score($1) } + let candidate = connectionCandidates.removeFirst() + guard candidate.isConnectable else { return } + let peripheral = candidate.peripheral + let peripheralID = peripheral.identifier.uuidString + // Weak-link cooldown: if we recently timed out and RSSI is very weak, delay retries + if let lastTO = recentConnectTimeouts[peripheralID] { + let elapsed = Date().timeIntervalSince(lastTO) + if elapsed < TransportConfig.bleWeakLinkCooldownSeconds && candidate.rssi <= TransportConfig.bleWeakLinkRSSICutoff { + // Requeue the candidate and try again later + connectionCandidates.append(candidate) + let remaining = TransportConfig.bleWeakLinkCooldownSeconds - elapsed + let delay = min(max(2.0, remaining), 15.0) + bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } + return + } + } + if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true { + // Already in progress; skip + bleQueue.async { [weak self] in self?.tryConnectFromQueue() } + return + } + // Initiate connection + peripherals[peripheralID] = PeripheralState( + peripheral: peripheral, + characteristic: nil, + peerID: nil, + isConnecting: true, + isConnected: false, + lastConnectionAttempt: Date(), + assembler: NotificationStreamAssembler() + ) + peripheral.delegate = self + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true + ] + central.connect(peripheral, options: options) + lastGlobalConnectAttempt = Date() + SecureLogger.debug("⏩ Queue connect: \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session) + } +} #if DEBUG // Test-only helper to inject packets into the receive pipeline extension BLEService { - /// Queues an event through the same MainActor hop as production receive - /// handlers so panic-boundary tests can deterministically invalidate it. - func _test_emitTransportEvent(_ event: TransportEvent) { - emitTransportEvent(event) - } - - var _test_isPanicIngressOpen: Bool { - capturePanicLifecycleGeneration() != nil - } - - /// Queries the receipt store of the service's OWN incoming-file store — - /// the instance production lookups run against — so panic tests exercise - /// the real wiring instead of a same-instance shortcut. - func _test_privateMediaReceiptState( - messageID: String - ) -> BLEPrivateMediaReceiptState { - incomingFileStore.privateMediaReceiptState(messageID: messageID) - } - - /// Models a CoreBluetooth delegate callback without requiring a physical - /// peripheral. The callback itself runs on `bleQueue`, exactly where the - /// panic radio-stop barrier must linearize it. - func _test_handlePacketFromBLEQueue( - _ packet: BitchatPacket, - fromPeerID: PeerID - ) { - bleQueue.async { [weak self] in - self?.handleReceivedPacket(packet, from: fromPeerID) - } - } - - /// Simulated-link ingress: the full production attribution path — - /// binding lookup, spoof rejection, raw-announce binding, ingress - /// recording — for a frame arriving on a synthetic link. The - /// SimulatedMesh harness feeds every node through this, so multi-node - /// tests exercise the same engine code as CoreBluetooth ingress. - func _test_ingestFrame(_ packet: BitchatPacket, link: BLEIngressLinkID) { - emitLinkEvent(.frameDecoded(packet, link: link, linkDescription: "Simulated \(link)")) - } - - /// Sends an unthrottled announce, exactly like the maintenance forced - /// path. SimulatedMesh uses this as the deterministic discovery step. - func _test_forceAnnounce() { - onEngine { sendAnnounceNow(forceSend: true) } - } - - /// Clears the announce throttle's wall-clock debt — the simulator's - /// stand-in for "enough real time has passed", since scheduler time - /// cannot move the throttle's Date-based window. Deliberately NOT - /// part of `_test_forceAnnounce`: the panic-rotation mesh test relies - /// on the production panic path performing its own reset, and a - /// blanket reset here would mask that regression. - func _test_resetAnnounceThrottle() { - announceThrottle.reset() - } - - /// Blocks until every engine slot enqueued so far has run — the - /// deterministic settling fence for simulated-mesh pumping. - func _test_fenceEngine() { - onEngine {} - } - - func _test_emitTransportEvent( - _ event: TransportEvent, - completion: @escaping () -> Void, - finalization: @escaping (TransportEventDeliveryOutcome) -> Void - ) { - emitTransportEvent( - event, - completion: completion, - finalization: finalization - ) - } - - func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) { + func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) { if preseedPeer { // Ensure the synthetic peer is known and marked verified for public-message tests let normalizedID = PeerID(hexData: packet.senderID) - peerRegistry.mutate { registry in - if var existing = registry.info(for: normalizedID) { - existing.isConnected = true - existing.isVerifiedNickname = true - if let signingPublicKey { existing.signingPublicKey = signingPublicKey } - existing.lastSeen = Date() - registry.upsert(existing) - } else { - registry.upsert(BLEPeerInfo( + collectionsQueue.sync(flags: .barrier) { + if peers[normalizedID] == nil { + peers[normalizedID] = PeerInfo( peerID: normalizedID, nickname: "TestPeer_\(fromPeerID.id.prefix(4))", isConnected: true, noisePublicKey: packet.senderID, - signingPublicKey: signingPublicKey, + signingPublicKey: nil, isVerifiedNickname: true, lastSeen: Date() - )) + ) + } else { + var p = peers[normalizedID]! + p.isConnected = true + p.isVerifiedNickname = true + p.lastSeen = Date() + peers[normalizedID] = p } } } handleReceivedPacket(packet, from: fromPeerID) } - - /// Waits until fragment ingress already submitted by a test has finished - /// reassembly/reinjection and any resulting transport event has crossed - /// the MainActor delivery hop. This is a deterministic pipeline fence, - /// avoiding wall-clock sleeps that become flaky under a parallel suite. - func _test_drainFragmentPipeline() async { - await withCheckedContinuation { continuation in - messageQueue.async { - // Reassembled packets are reinjected synchronously on - // `messageQueue`; their UI delivery task is therefore already - // enqueued before this later MainActor marker. - Task { @MainActor in - continuation.resume() - } - } - } - } - - func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool { - gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false - } - - func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool { - let claimedSenderID = PeerID(hexData: packet.senderID) - guard case .success = BLEIngressLinkRegistry.packetContext( - for: packet, - claimedSenderID: claimedSenderID, - boundPeerID: boundPeerID, - localPeerID: myPeerID, - directAnnounceTTL: messageTTL - ) else { - return false - } - return true - } - - func _test_recordIngressIfNew(packet: BitchatPacket, linkID: String) -> Bool { - recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID)) - } - - func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) { - onEngine { linkBindings.bindCentral(centralUUID, to: peerID) } - } - - func _test_centralBinding(_ centralUUID: String) -> PeerID? { - onEngine { linkBindings.peer(forCentralUUID: centralUUID) } - } - - func _test_linkBinding(_ link: BLEIngressLinkID) -> PeerID? { - onEngine { linkBindings.boundPeer(for: link) } - } - - func _test_knownPeerIDs() -> [PeerID] { - peerRegistry.peerIDs - } - - func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) { - onEngine { - guard linkBindings.peer(forCentralUUID: centralUUID) == peerID else { return } - linkAuth.markAuthenticated(.central(centralUUID), owner: peerID) - } - } - - func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool { - onEngine { - linkAuth.isAuthenticated(.central(centralUUID), for: peerID) - } - } - - func _test_seedConnectedPeer( - _ peerID: PeerID, - nickname: String, - capabilities: PeerCapabilities? = nil, - noisePublicKey: Data? = nil - ) { - peerRegistry.mutate { - $0.upsert(BLEPeerInfo( - peerID: peerID, - nickname: nickname, - isConnected: true, - noisePublicKey: noisePublicKey, - signingPublicKey: nil, - isVerifiedNickname: true, - lastSeen: Date(), - capabilities: capabilities ?? [], - capabilitiesWereExplicitlyAdvertised: capabilities != nil - )) - } - } - - /// Handshake plumbing for tests that need a real established Noise - /// session (e.g. canDeliverSecurely) without Bluetooth in the loop. - func _test_noiseInitiateHandshake(with peerID: PeerID) throws -> Data { - try noiseService.initiateHandshake(with: peerID) - } - - func _test_noiseProcessHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { - try noiseService.processHandshakeMessage(from: peerID, message: message) - } - - func _test_enqueuePendingPrivateMessage( - content: String, - messageID: String, - for peerID: PeerID - ) { - onEngine { - pendingNoiseSessionQueues.appendPrivateMessage( - content: content, - messageID: messageID, - for: peerID - ) - } - } - - func _test_enqueuePendingNoisePayload( - _ payload: Data, - transferId: String, - for peerID: PeerID - ) { - guard privateMediaTransferAdmissions.begin(transferId) == .admitted else { return } - onEngine { - pendingNoiseSessionQueues.appendTypedPayload( - payload, - transferId: transferId, - for: peerID - ) - } - } - - func _test_sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) { - sendPendingNoisePayloadsAfterHandshake(for: peerID) - } - - func _test_hasPendingPrivateMediaPolicyResolution(for peerID: PeerID) -> Bool { - privateMediaSessions.hasPendingPolicyResolution(for: peerID.toShort()) - } - - func _test_forcePrivateMediaProofTimeout(for peerID: PeerID) { - let normalizedPeerID = peerID.toShort() - let target = privateMediaSessions.proofTimeoutTarget(for: normalizedPeerID) - guard let target else { return } - handlePrivateMediaProofTimeout( - for: normalizedPeerID, - fingerprint: target.fingerprint, - sessionGeneration: target.generation, - nonce: target.nonce - ) - } - - func _test_privateMediaTransferState( - transferId: String - ) -> (admissionActive: Bool, pendingNoise: Bool, activeScheduler: Int, pendingScheduler: Int) { - let scheduler = onEngine { - ( - pendingNoiseSessionQueues.containsTypedPayload(transferId: transferId), - outboundFragmentTransfers.activeCount, - outboundFragmentTransfers.pendingCount - ) - } - return ( - privateMediaTransferAdmissions.isActive(transferId), - scheduler.0, - scheduler.1, - scheduler.2 - ) - } - - func _test_privateMediaAdmissionEntryCount() -> Int { - privateMediaTransferAdmissions.count - } - - @discardableResult - func _test_beginPrivateMediaAdmission(_ transferId: String, now: Date) -> Bool { - privateMediaTransferAdmissions.begin(transferId, now: now) == .admitted - } - - func _test_isPrivateMediaAdmissionActive(_ transferId: String, now: Date) -> Bool { - privateMediaTransferAdmissions.isActive(transferId, now: now) - } - - func _test_finishPrivateMediaAdmission(_ transferId: String) { - privateMediaTransferAdmissions.finish(transferId) - } - - func _test_drainPrivateMediaSendPipeline() async { - // Capture only the (Sendable) queue, not self, so the @Sendable - // dispatch closures carry no non-Sendable state. - let queue = messageQueue - await withCheckedContinuation { continuation in - queue.async { - queue.async { - continuation.resume() - } - } - } - } - - func _test_broadcastPrivateMediaPacket( - _ packet: BitchatPacket, - transferId: String - ) { - broadcastPacket( - packet, - transferId: transferId, - requiresPrivateMediaAdmission: true - ) - } - - func _test_drainNoiseMessagePipeline() async { - let queue = messageQueue - await withCheckedContinuation { continuation in - queue.async { - queue.async { - continuation.resume() - } - } - } - } - - /// Replays the current generation's ready callback. Restore tests use - /// this to prove same-generation reconciliation is idempotent. - func _test_reconcileCurrentNoiseSession(for peerID: PeerID) { - let normalizedPeerID = peerID.toShort() - messageQueue.async { [weak self] in - guard let self, - let generation = self.noiseService.sessionGeneration( - for: normalizedPeerID - ), - let fingerprint = self.noiseService.getPeerFingerprint( - normalizedPeerID - ) else { - return - } - self.handleNoisePeerAuthenticated( - peerID: normalizedPeerID, - fingerprint: fingerprint, - sessionGeneration: generation - ) - } - } - - /// Builds an authenticated-session packet from an exact typed plaintext. - /// Compatibility tests use this to model Android's deployed 0x20 file - /// payload and the short-lived 0x09 prerelease payload without exposing a - /// production API that can emit the old value. - func _test_makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket { - try makeEncryptedNoisePacket(typedPayload, to: peerID) - } - - static func _test_shouldRediscoverBitChatService( - invalidatedServiceUUIDs: [CBUUID], - cachedServiceUUIDs: [CBUUID]? - ) -> Bool { - shouldRediscoverBitChatService( - invalidatedServiceUUIDs: invalidatedServiceUUIDs, - cachedServiceUUIDs: cachedServiceUUIDs - ) - } } #endif +// MARK: - CBPeripheralDelegate + +extension BLEService: CBPeripheralDelegate { + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error = error { + SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) + // Retry service discovery after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + guard peripheral.state == .connected else { return } + peripheral.discoverServices([BLEService.serviceUUID]) + } + return + } + + guard let services = peripheral.services else { + SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session) + return + } + + guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else { + // Not a BitChat peer - disconnect + centralManager?.cancelPeripheralConnection(peripheral) + return + } + + // Discovering BLE characteristics + peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error = error { + SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) + return + } + + guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else { + SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session) + return + } + + // Found characteristic + + // Log characteristic properties for debugging + var properties: [String] = [] + if characteristic.properties.contains(.read) { properties.append("read") } + if characteristic.properties.contains(.write) { properties.append("write") } + if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") } + if characteristic.properties.contains(.notify) { properties.append("notify") } + if characteristic.properties.contains(.indicate) { properties.append("indicate") } + // Characteristic properties: \(properties.joined(separator: ", ")) + + // Verify characteristic supports reliable writes + if !characteristic.properties.contains(.write) { + SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session) + } + + // Store characteristic in our consolidated structure + let peripheralID = peripheral.identifier.uuidString + if var state = peripherals[peripheralID] { + state.characteristic = characteristic + peripherals[peripheralID] = state + } + + // Subscribe for notifications + if characteristic.properties.contains(.notify) { + peripheral.setNotifyValue(true, for: characteristic) + SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session) + + // Send announce after subscription is confirmed (force send for new connection) + messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in + self?.sendAnnounce(forceSend: true) + // Try flushing any spooled directed packets now that we have a link + self?.flushDirectedSpool() + } + } else { + SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session) + return + } + + guard let data = characteristic.value, !data.isEmpty else { + SecureLogger.warning("⚠️ No data in notification", category: .session) + return + } + + bufferNotificationChunk(data, from: peripheral) + } + + private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) { + let peripheralUUID = peripheral.identifier.uuidString + + var state = peripherals[peripheralUUID] ?? PeripheralState( + peripheral: peripheral, + characteristic: nil, + peerID: nil, + isConnecting: false, + isConnected: peripheral.state == .connected, + lastConnectionAttempt: nil, + assembler: NotificationStreamAssembler() + ) + + var assembler = state.assembler + let result = assembler.append(chunk) + state.assembler = assembler + peripherals[peripheralUUID] = state + + for byte in result.droppedPrefixes { + SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session) + } + + if result.reset { + SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session) + } + + // Codex review identified TOCTOU in this patch. + // Enforce per-link sender binding immediately within the same notification batch. + // NOTE: `processNotificationPacket` may bind `peripherals[peripheralUUID].peerID` when an announce + // is processed, but `state` above is a snapshot. Track a local binding that we update as soon as + // we see a binding-eligible announce so subsequent frames can't spoof a different sender. + var boundPeerID: PeerID? = state.peerID + + for frame in result.frames { + guard let packet = BinaryProtocol.decode(frame) else { + let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") + SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session) + continue + } + + let claimedSenderID = PeerID(hexData: packet.senderID) + + let trustedSenderID: PeerID? + if let knownPeerID = boundPeerID { + if knownPeerID != claimedSenderID { + SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Peripheral \(peripheralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))…", category: .security) + continue + } + trustedSenderID = knownPeerID + } else { + trustedSenderID = nil + } + + if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .peripheral(peripheralUUID)) { + continue + } + + // If this is a direct-link announce, bind immediately for the remainder of this batch. + if boundPeerID == nil, + packet.type == MessageType.announce.rawValue, + packet.ttl == messageTTL { + boundPeerID = claimedSenderID + state.peerID = claimedSenderID + peripherals[peripheralUUID] = state + } + processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID) + } + } + + private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String) { + let senderID = PeerID(hexData: packet.senderID) + + if packet.type != MessageType.announce.rawValue { + SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session) + } + + if packet.type == MessageType.announce.rawValue { + if packet.ttl == messageTTL { + if var state = peripherals[peripheralUUID] { + state.peerID = senderID + peripherals[peripheralUUID] = state + } + peerToPeripheralUUID[senderID] = peripheralUUID + refreshLocalTopology() + } + + let msgID = makeMessageID(for: packet) + collectionsQueue.async(flags: .barrier) { [weak self] in + self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date()) + } + handleReceivedPacket(packet, from: senderID) + } else { + let msgID = makeMessageID(for: packet) + collectionsQueue.async(flags: .barrier) { [weak self] in + self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date()) + } + handleReceivedPacket(packet, from: senderID) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session) + // Don't retry - just log the error + } else { + SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) + } + } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + // Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again + SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session) + drainPendingWrites(for: peripheral) + } + + func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) + + // Check if our service was invalidated (peer app quit) + let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false + + if !hasOurService { + // Service is gone - disconnect + SecureLogger.warning("❌ BitChat service removed - disconnecting from \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) + centralManager?.cancelPeripheralConnection(peripheral) + } else { + // Try to rediscover + peripheral.discoverServices([BLEService.serviceUUID]) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session) + } else { + SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session) + + // If notifications are now on, send an announce to ensure this peer knows about us + if characteristic.isNotifying { + // Sending announce after subscription + self.sendAnnounce(forceSend: true) + } + } + } + +} + +// MARK: - CBPeripheralManagerDelegate + +extension BLEService: CBPeripheralManagerDelegate { + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session) + + switch peripheral.state { + case .poweredOn: + // Remove all services first to ensure clean state + peripheral.removeAllServices() + + // Create characteristic + characteristic = CBMutableCharacteristic( + type: BLEService.characteristicUUID, + properties: [.notify, .write, .writeWithoutResponse, .read], + value: nil, + permissions: [.readable, .writeable] + ) + + // Create service + let service = CBMutableService(type: BLEService.serviceUUID, primary: true) + service.characteristics = [characteristic!] + + // Add service (advertising will start in didAdd delegate) + SecureLogger.debug("🔧 Adding BLE service...", category: .session) + peripheral.add(service) + + case .poweredOff: + // Bluetooth was turned off - clean up peripheral state + SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session) + peripheral.stopAdvertising() + // Clear subscribed centrals (they are now invalid) + let centralPeerIDs = centralToPeerID.values.map { $0 } + subscribedCentrals.removeAll() + centralToPeerID.removeAll() + centralSubscriptionRateLimits.removeAll() + characteristic = nil + // Notify UI of disconnections + for peerID in centralPeerIDs { + notifyUI { [weak self] in + self?.notifyPeerDisconnectedDebounced(peerID) + } + } + + case .unauthorized: + // User denied Bluetooth permission + SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) + peripheral.stopAdvertising() + subscribedCentrals.removeAll() + centralToPeerID.removeAll() + centralSubscriptionRateLimits.removeAll() + characteristic = nil + + case .unsupported: + // Device doesn't support BLE peripheral role + SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session) + + case .resetting: + // Bluetooth stack is resetting + SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session) + + case .unknown: + SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session) + + @unknown default: + SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session) + } + } + + #if os(iOS) + func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) { + let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] + let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] + + SecureLogger.info( + "♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))", + category: .session + ) + + // Attempt to recover characteristic from restored services + if characteristic == nil { + if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }), + let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic { + characteristic = restoredCharacteristic + } + } + + captureBluetoothStatus(context: "peripheral-restore") + + if peripheral.state == .poweredOn && !peripheral.isAdvertising { + peripheral.startAdvertising(buildAdvertisementData()) + } + } + #endif + + func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + if let error = error { + SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session) + return + } + + SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session) + + // Start advertising after service is confirmed added + let adData = buildAdvertisementData() + peripheral.startAdvertising(adData) + + SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + let centralUUID = central.identifier.uuidString + SecureLogger.debug("📥 Central subscribed: \(centralUUID)", category: .session) + subscribedCentrals.append(central) + + // BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks + let now = Date() + var state = centralSubscriptionRateLimits[centralUUID] + + // Clean up stale entries periodically + cleanupStaleSubscriptionRateLimits() + + // Check if this central is rate-limited + if let existingState = state { + let timeSinceLastAnnounce = now.timeIntervalSince(existingState.lastAnnounceTime) + + // If within backoff period, skip the announce + if timeSinceLastAnnounce < existingState.currentBackoffSeconds { + SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(existingState.currentBackoffSeconds))s, attempts: \(existingState.attemptCount))", category: .security) + + // Increment attempt count and increase backoff + // Update lastAnnounceTime to 'now' so each blocked attempt extends the suppression window + // This prevents attackers from waiting out the backoff while spamming attempts + let newAttemptCount = existingState.attemptCount + 1 + let newBackoff = min( + existingState.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor, + TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds + ) + centralSubscriptionRateLimits[centralUUID] = SubscriptionRateLimitState( + lastAnnounceTime: now, // Reset timer on each blocked attempt + attemptCount: newAttemptCount, + currentBackoffSeconds: newBackoff + ) + + // If too many rapid attempts, this is likely an enumeration attack - don't respond + if newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts { + SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security) + return + } + + // Still flush directed packets for legitimate mesh operation + messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in + self?.flushDirectedSpool() + } + return + } + + // Outside backoff period - allow announce but track it + state = SubscriptionRateLimitState( + lastAnnounceTime: now, + attemptCount: 1, + currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds + ) + } else { + // First subscription from this central - track it + state = SubscriptionRateLimitState( + lastAnnounceTime: now, + attemptCount: 1, + currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds + ) + } + centralSubscriptionRateLimits[centralUUID] = state + + // Send announce to the newly subscribed central after a small delay + messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in + self?.sendAnnounce(forceSend: true) + // Flush any spooled directed packets now that we have a central subscribed + self?.flushDirectedSpool() + } + } + + /// BCH-01-004: Clean up stale rate-limit entries to prevent memory growth + private func cleanupStaleSubscriptionRateLimits() { + let now = Date() + let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds + centralSubscriptionRateLimits = centralSubscriptionRateLimits.filter { _, state in + now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { + SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString)", category: .session) + subscribedCentrals.removeAll { $0.identifier == central.identifier } + + // Ensure we're still advertising for other devices to find us + if peripheral.isAdvertising == false { + SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session) + peripheral.startAdvertising(buildAdvertisementData()) + } + + // Find and disconnect the peer associated with this central + let centralUUID = central.identifier.uuidString + if let peerID = centralToPeerID[centralUUID] { + // Mark peer as not connected; retain for reachability + collectionsQueue.sync(flags: .barrier) { + if var info = peers[peerID] { + info.isConnected = false + peers[peerID] = info + } + } + + // Clean up mappings + centralToPeerID.removeValue(forKey: centralUUID) + refreshLocalTopology() + + // Update UI immediately + notifyUI { [weak self] in + guard let self = self else { return } + + // Get current peer list (after removal) + let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } + + self.notifyPeerDisconnectedDebounced(peerID) + // Publish snapshots so UnifiedPeerService can refresh icons promptly + self.requestPeerDataPublish() + self.delegate?.didUpdatePeerList(currentPeerIDs) + } + } + } + + func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { + SecureLogger.debug("📤 Peripheral manager ready to send more notifications", category: .session) + + // Retry pending notifications now that queue has space + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self, + let characteristic = self.characteristic, + !self.pendingNotifications.isEmpty else { return } + + let pending = self.pendingNotifications + self.pendingNotifications.removeAll() + + // Try to send pending notifications + var sentCount = 0 + for (index, (data, centrals)) in pending.enumerated() { + if let centrals = centrals { + // Send to specific centrals + let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false + if !success { + // Still full, re-queue this and all remaining items + let remaining = pending.dropFirst(index) + self.pendingNotifications.append(contentsOf: remaining) + SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session) + break // Stop trying, wait for next ready callback + } else { + sentCount += 1 + } + } else { + // Broadcast to all + let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false + if !success { + // Still full, re-queue this and all remaining items + let remaining = pending.dropFirst(index) + self.pendingNotifications.append(contentsOf: remaining) + SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session) + break + } else { + sentCount += 1 + } + } + } + + if sentCount > 0 { + SecureLogger.debug("✅ Sent \(sentCount) pending notifications from retry queue", category: .session) + } + + if !self.pendingNotifications.isEmpty { + SecureLogger.debug("📋 Still have \(self.pendingNotifications.count) pending notifications", category: .session) + } + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { + // Suppress logs for single write requests to reduce noise + if requests.count > 1 { + SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session) + } + + // IMPORTANT: Respond immediately to prevent timeouts! + // We must respond within a few milliseconds or the central will timeout + for request in requests { + peripheral.respond(to: request, withResult: .success) + } + + // Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets. + // Combine per-central request values by offset before decoding. + // Process directly on our message queue to match transport context + let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString }) + for (centralUUID, group) in grouped { + // Sort by offset ascending + let sorted = group.sorted { $0.offset < $1.offset } + let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0 + + // Always merge into a persistent per-central buffer to handle multi-callback long writes + var combined = pendingWriteBuffers[centralUUID] ?? Data() + var appendedBytes = 0 + var offsets: [Int] = [] + for r in sorted { + guard let chunk = r.value, !chunk.isEmpty else { continue } + offsets.append(r.offset) + let end = r.offset + chunk.count + if combined.count < end { + combined.append(Data(repeating: 0, count: end - combined.count)) + } + // Write chunk into the correct position (supports out-of-order and overlapping writes) + combined.replaceSubrange(r.offset..= 2 { + let peekType = combined[1] + if peekType != MessageType.announce.rawValue { + SecureLogger.debug("📥 Accumulated write from central \(centralUUID): size=\(combined.count) (+\(appendedBytes)) bytes (type=\(peekType)), offsets=\(offsets)", category: .session) + } + } + + // Try decode the accumulated buffer + if let packet = BinaryProtocol.decode(combined) { + // Clear buffer on success + pendingWriteBuffers.removeValue(forKey: centralUUID) + + let claimedSenderID = PeerID(hexData: packet.senderID) + + let trustedSenderID: PeerID? + if let knownPeerID = centralToPeerID[centralUUID] { + if knownPeerID != claimedSenderID { + SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Central \(centralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))…", category: .security) + continue + } + trustedSenderID = knownPeerID + } else { + trustedSenderID = nil + } + + if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .central(centralUUID)) { + continue + } + + if packet.type != MessageType.announce.rawValue { + SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID)", category: .session) + } + if !subscribedCentrals.contains(sorted[0].central) { + subscribedCentrals.append(sorted[0].central) + } + if packet.type == MessageType.announce.rawValue { + if packet.ttl == messageTTL { + centralToPeerID[centralUUID] = claimedSenderID + refreshLocalTopology() + } + // Record ingress link for last-hop suppression then process + let msgID = makeMessageID(for: packet) + collectionsQueue.async(flags: .barrier) { [weak self] in + self?.ingressByMessageID[msgID] = (.central(centralUUID), Date()) + } + handleReceivedPacket(packet, from: claimedSenderID) + } else { + // Record ingress link for last-hop suppression then process + let msgID = makeMessageID(for: packet) + collectionsQueue.async(flags: .barrier) { [weak self] in + self?.ingressByMessageID[msgID] = (.central(centralUUID), Date()) + } + handleReceivedPacket(packet, from: claimedSenderID) + } + } else { + // If buffer grows suspiciously large, reset to avoid memory leak + if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety + pendingWriteBuffers.removeValue(forKey: centralUUID) + SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: .session) + } + // If this was a single short write and still failed, log the raw chunk for debugging + if !hasMultiple, let only = sorted.first, let raw = only.value { + let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") + SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session) + } + } + } + } +} // MARK: - Advertising Builders & Alias Rotation extension BLEService { - // Advertising payload and alias policy live on BLERadioController. -} - -// MARK: - Private Media Deletion - -extension BLEService: PrivateMediaDeletionPersisting { - @MainActor - func persistDeletedPrivateMedia( - messageIDs: [String], - payloadRelativePaths: [String: String], - protectedPayloadRelativePaths: Set, - completion: @escaping @MainActor (Bool) -> Void - ) { - let fileStore = incomingFileStore - messageQueue.async { - guard let reservation = fileStore - .reservePrivateMediaDeletion( - messageIDs: messageIDs, - payloadRelativePaths: payloadRelativePaths - ) else { - Task { @MainActor in - completion(false) - } - return - } - let persisted = fileStore - .commitPrivateMediaDeletion( - reservation: reservation, - messageIDs: messageIDs, - payloadRelativePaths: payloadRelativePaths, - protectedPayloadRelativePaths: - protectedPayloadRelativePaths - ) - Task { @MainActor in - completion(persisted) - } - } - } - - @MainActor - func removeLegacyPrivateMediaPayload(relativePath: String) { - let fileStore = incomingFileStore - messageQueue.async { - fileStore.removeLegacyIncomingFile(relativePath: relativePath) - } + private func buildAdvertisementData() -> [String: Any] { + let data: [String: Any] = [ + CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID] + ] + // No Local Name for privacy + return data } + + // No alias rotation or advertising restarts required. } // MARK: - Private Helpers -enum TransportEventDeliveryOutcome: Equatable { - /// A synchronous sink inserted the message and revalidation succeeded. - case accepted - /// A supported plain delegate was invoked, but insertion cannot be - /// confirmed synchronously. - case invokedUnconfirmed - /// No sink accepted the event, or receipt revalidation rejected it. - case rejected -} - -enum TransportEventDeliveryGate { - /// Runs finalization exactly once for every attempted main-actor delivery, - /// including pre-insertion rejection, a missing/rejecting sink, and - /// post-insertion revalidation failure. Only a fully accepted delivery - /// runs `completion` (for example, a stable-media ACK). - @MainActor - static func attempt( - shouldDeliver: () -> Bool, - deliver: () -> TransportEventDeliveryOutcome, - completion: () -> Void, - finalization: (TransportEventDeliveryOutcome) -> Void - ) { - var outcome = TransportEventDeliveryOutcome.rejected - defer { finalization(outcome) } - guard shouldDeliver() else { - return - } - switch deliver() { - case .rejected: - return - case .invokedUnconfirmed: - outcome = .invokedUnconfirmed - return - case .accepted: - break - } - guard shouldDeliver() else { return } - outcome = .accepted - completion() - } -} - extension BLEService { /// Notify UI on the MainActor to satisfy Swift concurrency isolation - private func notifyUI(_ block: @escaping @MainActor () -> Void) { - // Capture the panic lifecycle before queueing the MainActor hop. A - // receive callback can enqueue UI delivery immediately before panic - // clears application state; rechecking here prevents that stale work - // from repopulating the wiped conversation store afterward. - guard let generation = capturePanicLifecycleGeneration() else { - return - } - Task { @MainActor [weak self] in - guard let self, - self.isCurrentPanicLifecycleGeneration(generation) else { - return - } + private func notifyUI(_ block: @escaping () -> Void) { + // Always hop onto the MainActor so calls to @MainActor delegates are safe + Task { @MainActor in block() } } - func emitTransportEvent( - _ event: TransportEvent, - shouldDeliver: (() -> Bool)? = nil, - completion: (() -> Void)? = nil, - finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil - ) { - guard let generation = capturePanicLifecycleGeneration() else { - Task { @MainActor in - finalization?(.rejected) - } - return + private func logBluetoothStatus(_ context: String) { + bleQueue.async { [weak self] in + guard let self = self else { return } + self.captureBluetoothStatus(context: context) } - Task { @MainActor [weak self] in - guard let self, - self.isCurrentPanicLifecycleGeneration(generation) else { - finalization?(.rejected) - return - } - TransportEventDeliveryGate.attempt( - shouldDeliver: { - self.isCurrentPanicLifecycleGeneration(generation) - && (shouldDeliver?() ?? true) - }, - deliver: { - return self.deliverTransportEvent(event) - }, - completion: { completion?() }, - finalization: { finalization?($0) } - ) - } - } - - /// Delivers a transport event to the installed delegates and reports - /// whether acceptance was confirmed. - /// - /// For `.messageReceived`, returns `true` only when a - /// `SynchronousMessageTransportEventDelegate` synchronously confirmed - /// acceptance of the message (duplicates count as accepted). Returns - /// `false` when acceptance cannot be confirmed: the sink blocked the - /// message, the content was empty, or only a non-synchronous delegate is - /// installed so delivery happens without confirmation. Downstream logic - /// MUST NOT treat `false` as safe to acknowledge — a `false` return - /// means do not ACK. - /// - /// For all other events, returns `true` when any delegate received the - /// event and `false` when no delegate is installed. - @MainActor - @discardableResult - private func deliverTransportEvent( - _ event: TransportEvent - ) -> TransportEventDeliveryOutcome { - if case .messageReceived(let message) = event { - if let synchronousDelegate = - eventDelegate as? SynchronousMessageTransportEventDelegate { - return synchronousDelegate - .didReceiveTransportMessageSynchronously(message) - ? .accepted - : .rejected - } - if let eventDelegate { - eventDelegate.didReceiveTransportEvent(event) - return .invokedUnconfirmed - } - if let synchronousDelegate = - delegate as? SynchronousMessageTransportEventDelegate { - return synchronousDelegate - .didReceiveTransportMessageSynchronously(message) - ? .accepted - : .rejected - } - } - - if let eventDelegate { - eventDelegate.didReceiveTransportEvent(event) - return .accepted - } else { - guard let delegate else { return .rejected } - delegate.receiveTransportEvent(event) - if case .messageReceived = event { - return .invokedUnconfirmed - } - return .accepted - } - } - - func logBluetoothStatus(_ context: String) { - scheduleBluetoothStatusSample(after: 0, context: context) } private func scheduleBluetoothStatusSample(after delay: TimeInterval, context: String) { - #if os(iOS) - // Sample the main-actor background budget first (async hop, never a - // sync wait), then log from bleQueue off the cache — bleQueue must - // never block on main (see captureBluetoothStatus). - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self else { return } - self.refreshCachedBackgroundTimeRemaining() - self.bleQueue.async { self.captureBluetoothStatus(context: context) } - } - #else bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.captureBluetoothStatus(context: context) + guard let self = self else { return } + self.captureBluetoothStatus(context: context) } - #endif } - #if os(iOS) - /// Main thread only (reads main-actor UIApplication state). - private func refreshCachedBackgroundTimeRemaining() { - dispatchPrecondition(condition: .onQueue(.main)) - let seconds = UIApplication.shared.backgroundTimeRemaining - backgroundTimeLock.lock() - _cachedBackgroundTimeRemaining = seconds - backgroundTimeLock.unlock() - } - #endif - private func captureBluetoothStatus(context: String) { assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "captureBluetoothStatus must run on bleQueue") @@ -3653,25 +2738,20 @@ extension BLEService { let peripheralState = peripheralManager?.state ?? .unknown let isAdvertising = peripheralManager?.isAdvertising ?? false - let candidateCount = radio.candidateCount - let peerSummary = peerRegistry.read { + let peerSummary = collectionsQueue.sync { ( - connected: $0.connectedCount, - known: $0.count, - candidates: candidateCount + connected: peers.values.filter { $0.isConnected }.count, + known: peers.count, + candidates: connectionCandidates.count ) } #if os(iOS) - // INVARIANT: bleQueue must NEVER sync-dispatch to the main thread. - // The main actor sync-waits on bleQueue along the send paths - // (readLinkState), so a main.sync here completes an ABBA deadlock — - // field-verified as a permanent freeze when a courier-drop storm put - // an ack send (main → bleQueue.sync) up against a status capture - // (bleQueue → main.sync). backgroundTimeRemaining is main-actor - // state, so it is sampled on main and cached. - let backgroundSeconds = cachedBackgroundTimeRemaining - let backgroundDescriptor: String + var backgroundDescriptor = "" + var backgroundSeconds: TimeInterval = 0 + DispatchQueue.main.sync { + backgroundSeconds = UIApplication.shared.backgroundTimeRemaining + } if backgroundSeconds == .greatestFiniteMagnitude { backgroundDescriptor = " bgRemaining=∞" } else { @@ -3694,35 +2774,20 @@ extension BLEService { } private func refreshLocalTopology() { - meshTopology.updateNeighbors(for: myPeerIDData, neighbors: peerRegistry.connectedRoutingData) + let neighbors: [Data] = collectionsQueue.sync { + peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData } + } + meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors) } private func computeRoute(to peerID: PeerID) -> [Data]? { - // Version-gated: every hop and the recipient must have been observed - // speaking v2, since a v1-only node drops v2 frames on decode. - meshTopology.computeRoute( - from: myPeerIDData, - to: routingData(for: peerID), - maxHops: TransportConfig.bleSourceRouteMaxIntermediateHops, - requiringVersion: 2 - ) + meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID)) } private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket { - let now = Date() - let route = BLESourceRouteOriginationPolicy.route( - for: packet, - to: recipient, - localPeerIDData: myPeerIDData, - isRecipientConnected: { self.isPeerConnected($0) }, - shouldAttemptRoute: { peer in - onEngine { - self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now) - } - }, - computeRoute: { self.computeRoute(to: $0) } - ) - guard let route else { return packet } + guard let route = computeRoute(to: recipient), route.count >= 1 else { + return packet + } // Create new packet with route applied and version upgraded to 2 let routedPacket = BitchatPacket( type: packet.type, @@ -3740,9 +2805,6 @@ extension BLEService { SecureLogger.error("❌ Failed to re-sign packet with route", category: .security) return packet // Return original packet if signing fails } - onEngine { - sourceRouteFailures.noteRoutedSend(to: recipient, now: now) - } return signedPacket } @@ -3750,1321 +2812,199 @@ extension BLEService { PeerID(routingData: data) } - // MARK: - Mesh Diagnostics (/ping, /trace, topology map) - - /// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL). - /// The completion fires exactly once on the main actor: with RTT/hops - /// when the matching pong returns, or nil after the timeout window. - func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { - guard let generation = capturePanicLifecycleGeneration() else { - return - } - messageQueue.async { [weak self] in - guard let self, - self.isCurrentPanicLifecycleGeneration(generation), - let recipientData = peerID.toShort().routingData, - let payload = MeshPingPayload( - nonce: Data((0.. [PeerID]? { - refreshLocalTopology() - if let route = computeRoute(to: peerID) { - return route.compactMap { PeerID(routingData: $0) } - } - // Confirmed claims can lag a brand-new link (the peer's next announce - // hasn't arrived yet); a live direct connection is still a known path. - return isPeerConnected(peerID) ? [] : nil - } - - /// Mesh graph for the topology map. Edges are advisory: announces cap - /// neighbor lists at 10, so an edge claimed by either endpoint counts. - func currentMeshTopology() -> MeshTopologySnapshot? { - refreshLocalTopology() - let claims = meshTopology.adjacencySnapshot() - var nodes = Set() - var edges = Set() - for (source, neighbors) in claims { - guard let sourcePeer = PeerID(routingData: source) else { continue } - nodes.insert(sourcePeer) - for neighborData in neighbors { - guard let neighborPeer = PeerID(routingData: neighborData), - neighborPeer != sourcePeer else { continue } - nodes.insert(neighborPeer) - edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer)) - } - } - nodes.insert(myPeerID) - return MeshTopologySnapshot( - localPeerID: myPeerID, - nodes: nodes.sorted(), - edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) } - ) - } - private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool { + guard let route = packet.route, !route.isEmpty else { return false } let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData) - let plan = BLERouteForwardingPolicy.plan( - for: packet, - localPeerID: myPeerID, - localRoutingData: myRoutingData, - routingPeer: routingPeer(from:), - isPeerConnected: isPeerConnected(_:) - ) - - if let forwardPacket = plan.forwardPacket, let nextHop = plan.nextHop { - sendPacketDirected(forwardPacket, to: nextHop) + guard let selfData = myRoutingData else { return false } + + // Route contains only intermediate hops (start and end excluded) + // If we're not in the route, we're the sender - forward to first hop + guard let index = route.firstIndex(of: selfData) else { + // We're the sender, forward to first intermediate hop + guard packet.ttl > 1 else { return true } + let firstHopData = route[0] + guard let nextPeer = routingPeer(from: firstHopData), + isPeerConnected(nextPeer) else { + return false + } + var relayPacket = packet + relayPacket.ttl = packet.ttl - 1 + sendPacketDirected(relayPacket, to: nextPeer) + return true } - return plan.shouldSuppressFloodRelay + // We're an intermediate node in the route + // If we're the last intermediate hop, forward to destination + if index == route.count - 1 { + guard packet.ttl > 1 else { return true } + guard let destinationPeer = PeerID(hexData: packet.recipientID), + isPeerConnected(destinationPeer) else { + return false + } + var relayPacket = packet + relayPacket.ttl = packet.ttl - 1 + sendPacketDirected(relayPacket, to: destinationPeer) + return true + } + + // Forward to next intermediate hop + guard packet.ttl > 1 else { return true } + let nextHopData = route[index + 1] + guard let nextPeer = routingPeer(from: nextHopData), + isPeerConnected(nextPeer) else { + return false + } + + var relayPacket = packet + relayPacket.ttl = packet.ttl - 1 + sendPacketDirected(relayPacket, to: nextPeer) + return true } - /// The current direct-link state for a peer. Engine-confined (bindings - /// joined against physical liveness inside directLinkState). + /// Safely fetch the current direct-link state for a peer using the BLE queue. private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) { - let state = directLinkState(for: peerID) - return (state.hasPeripheral, state.hasCentral) - } - - private func links(to peerID: PeerID?) -> Set { - linkBindings.links(to: peerID) - } - - - - /// Marks the exact physical ingress link that completed a fresh Noise - /// handshake. An old session keyed only by peer ID is insufficient: a - /// replayed announce can rebind an attacker's link to that ID. - /// Engine-confined. - private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) { - guard let link = ingressLinks.link(for: packet) else { return } - guard linkBindings.boundPeer(for: link) == peerID else { return } - linkAuth.markAuthenticated(link, owner: peerID) - } - - private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool { - guard let link = ingressLinks.link(for: packet) else { return false } - return linkAuth.isAuthenticated(link, for: peerID) && linkBindings.boundPeer(for: link) == peerID - } - - private func hasCurrentNoiseAuthenticatedLink(to peerID: PeerID) -> Bool { - !currentNoiseAuthenticatedLinks(to: peerID).isEmpty - } - - private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set { - Set(linkAuth.links(ownedBy: peerID).filter { link in - linkBindings.boundPeer(for: link) == peerID - }) - } - - /// A peer-level session can outlive the physical link that established it. - /// Revalidate a fresh direct link with an ordinary XX exchange, retiring - /// cached sending keys atomically before message 1 can leave. - /// - /// Takes the already-resolved ingress link. Engine-confined: it runs - /// inside the rebind's engine slot, so no observer can see the new - /// binding while a cached peer-level sender is still considered - /// established. - private func refreshNoiseSessionForVerifiedDirectLink( - link: BLEIngressLinkID, - peerID: PeerID - ) { - let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID) - let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID) - let shouldRevalidate: Bool - if linkBindings.boundPeer(for: link) == peerID { - shouldRevalidate = linkAuth.shouldRevalidate( - on: link, - for: peerID, - hasEstablishedSession: hasEstablishedSession, - hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty, - now: Date() - ) - } else { - shouldRevalidate = false + let computeState = { () -> (Bool, Bool) in + let peripheralUUID = self.peerToPeripheralUUID[peerID] + let hasPeripheral = peripheralUUID.flatMap { self.peripherals[$0]?.isConnected } ?? false + let hasCentral = self.centralToPeerID.values.contains(peerID) + return (hasPeripheral, hasCentral) } - guard shouldRevalidate else { return } - SecureLogger.info( - "🔄 Revalidating cached Noise session on fresh direct link to \(peerID.id.prefix(8))…", - category: .session - ) - initiateNoiseReconnectHandshake(with: peerID) + if DispatchQueue.getSpecific(key: bleQueueKey) != nil { + return computeState() + } else { + return bleQueue.sync { computeState() } + } } private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { - service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in - SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…") - // Authentication can be reported while an initiator is still - // returning XX message 3. Serialize generation-bound state and - // every post-handshake drain behind the handshake packet handler. + service.onPeerAuthenticated = { [weak self] peerID, fingerprint in + SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...") self?.messageQueue.async { [weak self] in - self?.handleNoisePeerAuthenticated( - peerID: peerID, - fingerprint: fingerprint, - sessionGeneration: generation - ) + self?.sendPendingMessagesAfterHandshake(for: peerID) + self?.sendPendingNoisePayloadsAfterHandshake(for: peerID) } - } - service.onRekeyHandshakeReady = { [weak self, weak service] peerID, initiation in - self?.messageQueue.async { [weak self, weak service] in - guard let self, - let service, - self.noiseService === service else { - return - } - self.noteNoiseSessionCleared(for: peerID) - guard let message = service.claimHandshakeInitiation( - initiation, - for: peerID - ) else { - return - } - self.broadcastNoiseHandshake(message, to: peerID) - } - } - service.onHandshakeRecoveryRequired = { [weak self, weak service] request in - guard let self, let service else { return } - #if DEBUG - self._test_beforeHandshakeRecoveryEnqueued?(request.peerID) - #endif - self.messageQueue.async { [weak self, weak service] in - guard let self, - let service, - self.noiseService === service else { - return - } - let peerID = request.peerID - guard self.isPeerReachable(peerID) else { - service.cancelHandshakeRecovery(request) - return - } - - do { - guard let preparation = - try service.prepareHandshakeRecovery(request) else { - return - } - switch preparation { - case .ordinary(let initiation): - self.noteNoiseSessionCleared(for: peerID) - guard let handshakeData = - service.claimHandshakeInitiation( - initiation, - for: peerID - ) else { - return - } - self.broadcastNoiseHandshake( - handshakeData, - to: peerID - ) - case .transferred: - return - } - } catch { - SecureLogger.error( - "Failed to prepare handshake recovery with \(peerID.id.prefix(8))…: \(error)", - category: .session - ) - } - } - } - service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation, reason in - guard let self, let service else { return } - // The manager makes restored keys visible atomically. Reconcile - // transport state and queued sends as the next serialized phase. - self.messageQueue.async { [weak self, weak service] in - guard let self, - let service, - self.noiseService === service, - let fingerprint = service.getPeerFingerprint(peerID) else { - return - } - SecureLogger.debug( - "🔐 Restored quarantined Noise session with \(peerID.id.prefix(8))…", - category: .session - ) - // Re-enter the same generation-bound transition used after a - // successful handshake to restore authenticated protocol - // state. Only a terminal restore may also drain the PM and - // typed-payload queues: after a responder timeout the - // counterpart may have completed the replacement handshake - // and discarded the restored keys, so encrypting the queues - // under them would lose every message silently. The mandatory - // convergence retry that accompanies the restore drains them - // under the new session instead (any establishment does). - self.handleNoisePeerAuthenticated( - peerID: peerID, - fingerprint: fingerprint, - sessionGeneration: generation, - deferOutboundUntilConvergence: reason == .pendingConvergence - ) + self?.messageQueue.async { [weak self] in + self?.sendAnnounce(forceSend: true) } } } - private func handleNoisePeerAuthenticated( - peerID: PeerID, - fingerprint: String, - sessionGeneration generation: UUID, - deferOutboundUntilConvergence: Bool = false - ) { - // Engine-only: the store transition below runs inside the noise - // manager's critical section while this engine slot stays blocked. - // The store is a leaf lock, so that nesting is safe — but nothing in - // that closure may sync-re-enter the engine (self-deadlock). - #if DEBUG - dispatchPrecondition(condition: .onQueue(messageQueue)) - #endif - let normalizedPeerID = peerID.toShort() - // The generation lease serializes this transition against session - // replacement; nil-inside-nil distinguishes a lost lease (outer) - // from the same-generation reconciliation path (inner). - guard let leased = noiseService.withCurrentSessionGeneration( - for: normalizedPeerID, - expected: generation, - { - privateMediaSessions.beginAuthenticatedGeneration( - for: normalizedPeerID, - fingerprint: fingerprint, - generation: generation - ) - } - ) else { return } - - guard let fresh = leased else { - // A quarantined transport restored the same cryptographic - // generation. Its capability proof and announce state never - // became stale; only work queued while outbound keys were paused - // needs one idempotent ready transition. Retrying the bounded - // early-ciphertext queue is receive-side and therefore always - // safe under the restored keys. - noisePacketHandler.handleSessionAuthenticated(normalizedPeerID) - #if DEBUG - _test_onPrivateMediaSessionReconciled?(normalizedPeerID) - #endif - if deferOutboundUntilConvergence { - // Timeout-restore: the counterpart may have completed the - // replacement handshake and discarded these keys, so - // encrypting the parked queues here would lose them silently. - // The restore's mandatory convergence retry — or any later - // handshake the reconnect policy initiates — re-enters this - // transition with a fresh generation and drains them under - // keys both sides hold. The flag also holds the proof - // watchdog's drain to the same rule. - privateMediaSessions.setOutboundDeferredUntilConvergence(normalizedPeerID) - return - } - privateMediaSessions.clearOutboundDeferredUntilConvergence(normalizedPeerID) - sendPendingMessagesAfterHandshake(for: normalizedPeerID) - sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) - return - } - - completePrivateMediaPolicyResolution(fresh.rejected, with: .blockedDowngrade) - schedulePrivateMediaProofTimeout( - for: normalizedPeerID, - fingerprint: fingerprint, - sessionGeneration: generation, - nonce: fresh.watchdogNonce - ) - // Cross-link delivery can put ciphertext sent immediately after - // message 3 ahead of message 3 itself. Retry the bounded queue only - // after this generation's transport state has been fully installed. - noisePacketHandler.handleSessionAuthenticated(normalizedPeerID) - - if deferOutboundUntilConvergence { - // Timeout-restore: the session is back for receive purposes and - // the generation-bound protocol state above is rebuilt, but the - // counterpart may already hold replacement keys that discarded - // this generation's. Encrypting the pending queues here would - // lose them silently, so leave them parked: the restore's - // mandatory convergence retry — or any later handshake the - // reconnect policy initiates — re-enters this transition with a - // fresh generation and drains them under keys both sides hold. - // The flag also holds the proof watchdog's drain to the same - // rule — its timeout can fire while this restore is current. - privateMediaSessions.setOutboundDeferredUntilConvergence(normalizedPeerID) - #if DEBUG - _test_onPrivateMediaSessionReconciled?(normalizedPeerID) - #endif - return - } - privateMediaSessions.clearOutboundDeferredUntilConvergence(normalizedPeerID) - - // `onPeerAuthenticated` can fire while the initiator is returning XX - // message 3. This callback is queued behind the handshake handler, so - // message 3 is broadcast first. Both peers also send one idempotent - // echo after receiving the other's state to recover cross-link races. - sendAuthenticatedPeerState(to: normalizedPeerID, echo: false) - #if DEBUG - _test_onPrivateMediaSessionReconciled?(normalizedPeerID) - #endif - sendPendingMessagesAfterHandshake(for: normalizedPeerID) - sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) - sendAnnounce(forceSend: true) - } - - private func sendAuthenticatedPeerState(to peerID: PeerID, echo: Bool) { - let normalizedPeerID = peerID.toShort() - guard privateMediaSessions.markPeerStateSend(for: normalizedPeerID, echo: echo) else { return } - - let capabilities = localIdentityState.snapshot().advertisedCapabilities - let state = AuthenticatedPeerStatePacket( - capabilities: capabilities, - signingPublicKey: noiseService.getSigningPublicKeyData() - ) - guard let payload = BLENoisePayloadFactory.authenticatedPeerState(state) else { - SecureLogger.error("Failed to encode authenticated peer state", category: .security) - return - } - sendNoisePayload(payload, to: normalizedPeerID) - } - - private func handleAuthenticatedPeerState( - _ payload: Data, - from peerID: PeerID, - sessionGeneration generation: UUID - ) { - // Engine-only, like handleNoisePeerAuthenticated: the closure below - // runs on the noise manager's queue while this engine slot stays - // blocked, so it accesses engine-owned state directly instead of - // sync-re-entering the engine (self-deadlock). - #if DEBUG - dispatchPrecondition(condition: .onQueue(messageQueue)) - #endif - let normalizedPeerID = peerID.toShort() - guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else { - SecureLogger.warning( - "Ignoring malformed authenticated peer state from \(normalizedPeerID.id.prefix(8))…", - category: .security - ) - return - } - guard let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID), - let publicKey = noiseService.getPeerPublicKeyData(normalizedPeerID), - publicKey.sha256Fingerprint().caseInsensitiveCompare(fingerprint) == .orderedSame else { - SecureLogger.warning( - "Ignoring peer state without a matching authenticated Noise identity", - category: .security - ) - return - } - guard let application = noiseService.withCurrentSessionGeneration( - for: normalizedPeerID, - expected: generation, - { - () -> (accepted: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in - guard privateMediaSessions.currentGeneration(for: normalizedPeerID) == generation else { - return (false, []) - } - - // The generation lease (plus the engine slot this section - // holds) prevents rekey/session promotion from interleaving - // between validation and these durable mutations. - identityManager.bindAuthenticatedSigningPublicKey( - state.signingPublicKey, - fingerprint: fingerprint - ) - identityManager.upsertCryptographicIdentity( - fingerprint: fingerprint, - noisePublicKey: publicKey, - signingPublicKey: state.signingPublicKey, - claimedNickname: nil - ) - if state.capabilities.contains(.privateMedia) { - identityManager.markPrivateMediaCapable(fingerprint: fingerprint) - } - - peerRegistry.mutate { - $0.bindAuthenticatedSigningPublicKey( - state.signingPublicKey, - for: normalizedPeerID - ) - } - guard let completions = privateMediaSessions.applyAuthenticatedPeerState( - for: normalizedPeerID, - fingerprint: fingerprint, - generation: generation, - capabilities: state.capabilities - ) else { - return (false, []) - } - return (true, completions) - } - ), application.accepted else { return } - - // One bounded echo makes initiator/responder proof ordering converge - // even when message 3 and the first proof take different mesh links. - sendAuthenticatedPeerState(to: normalizedPeerID, echo: true) - let policy = privateMediaSendPolicy(to: normalizedPeerID) - sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) - completePrivateMediaPolicyResolution(application.completions, with: policy) - } - - private func noteNoiseSessionCleared(for peerID: PeerID) { - let normalizedPeerID = peerID.toShort() - let reset = privateMediaSessions.clearSession(for: normalizedPeerID) - if let reset { - schedulePrivateMediaProofTimeout( - for: normalizedPeerID, - fingerprint: reset.fingerprint, - sessionGeneration: nil, - nonce: reset.nonce - ) - } - } - - private func clearNoiseSession(for peerID: PeerID) { - noiseService.clearSession(for: peerID) - noteNoiseSessionCleared(for: peerID) - } - - /// Swaps `myPeerID`/`myPeerIDData` to match the current Noise identity. - /// The swap runs as a `messageQueue` barrier so in-flight work items that - /// read the identity (e.g. `sendMessage` building packets) complete - /// against the old value and everything after sees the new one atomically. - /// Callers (init, panic reset on the main thread) are never on - /// `messageQueue`; the re-entrancy check keeps any future on-queue caller - /// from deadlocking. private func refreshPeerIdentity() { - onEngine { - let fingerprint = noiseService.getIdentityFingerprint() - localIdentityState.replacePeerIdentity( - with: PeerID(str: fingerprint.prefix(16)) - ) - meshTopology.reset() - } + let fingerprint = noiseService.getIdentityFingerprint() + myPeerID = PeerID(str: fingerprint.prefix(16)) + myPeerIDData = Data(hexString: myPeerID.id) ?? Data() + meshTopology.reset() } private func sendNoisePayload(_ typedPayload: Data, to peerID: PeerID) { - // Hop like sendMessage: the Transport-facing wrappers (verify/vouch/ - // group payloads) call this from the main actor, and the send path - // sync-waits on bleQueue for link state. - if DispatchQueue.getSpecific(key: messageQueueKey) == nil { - messageQueue.async { [weak self] in - self?.sendNoisePayload(typedPayload, to: peerID) - } - return - } - guard noiseService.hasEstablishedSession(with: peerID) else { - // No established session yet - queue the payload synchronously - // before initiating a handshake + guard noiseService.hasSession(with: peerID) else { + // No session yet - queue the payload SYNCHRONOUSLY before initiating handshake // to prevent race where fast handshake completion drains empty queue - onEngine { - self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID) - SecureLogger.debug("📥 Queued noise payload for \(peerID.id.prefix(8))… pending handshake", category: .session) + collectionsQueue.sync(flags: .barrier) { + if self.pendingNoisePayloadsAfterHandshake[peerID] == nil { + self.pendingNoisePayloadsAfterHandshake[peerID] = [] + } + self.pendingNoisePayloadsAfterHandshake[peerID]?.append(typedPayload) + SecureLogger.debug("📥 Queued noise payload for \(peerID) pending handshake", category: .session) } initiateNoiseHandshake(with: peerID) return } do { - broadcastPacket(try makeEncryptedNoisePacket(typedPayload, to: peerID)) + let encrypted = try noiseService.encrypt(typedPayload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) } catch { SecureLogger.error("Failed to send verification payload: \(error)") } } - - private func makeEncryptedNoisePacket( - _ typedPayload: Data, - to peerID: PeerID, - requiresAuthenticatedPrivateMediaReceipts: Bool = false - ) throws -> BitchatPacket { - let encrypted: Data - let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) - if isPrivateFile { - let provenGeneration = privateMediaSessions.provenGeneration( - for: peerID, - requireReceipts: requiresAuthenticatedPrivateMediaReceipts - ) - guard let provenGeneration else { - throw NoiseEncryptionError.sessionNotEstablished - } - encrypted = try noiseService.encryptPrivateFilePayload( - typedPayload, - for: peerID, - sessionGeneration: provenGeneration - ) - } else { - encrypted = try noiseService.encrypt(typedPayload, for: peerID) - } - return BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: myPeerIDData, - recipientID: Data(hexString: peerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encrypted, - signature: nil, - ttl: messageTTL, - // v1 has a 16-bit payload length; finalized media can exceed it. - version: isPrivateFile ? 2 : 1 - ) - } - - // MARK: Courier Store-and-Forward - - /// Seal `content` for the recipient and hand the envelope to the given - /// couriers for physical delivery. When a verified one-time prekey bundle - /// is cached for the recipient, sealing targets one of its prekeys - /// (forward secret, envelope v2); otherwise it falls back to their static - /// key (one-way Noise X, v1) exactly as before. Returns false when no - /// courier is connected, the payload cannot be built, or sealing fails; - /// link writes are queued asynchronously after the envelope is ready. - func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { - let connected = couriers.filter { isPeerConnected($0) } - guard !connected.isEmpty, - let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else { - return false - } - - let payload: Data - do { - let now = Date() - let sealed: Data - let prekeyID: UInt32? - if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) { - sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey) - prekeyID = prekey.id - } else { - sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) - prekeyID = nil - } - let envelope = CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: recipientNoiseKey, - epochDay: CourierEnvelope.epochDay(for: now) - ), - expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), - ciphertext: sealed, - copies: TransportConfig.courierInitialCopies, - prekeyID: prekeyID - ) - guard let encoded = envelope.encode() else { return false } - payload = encoded - } catch { - SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption) - return false - } - - messageQueue.async { [weak self] in - guard let self else { return } - for courier in connected { - SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier) - } - } - return true - } - - // MARK: Courier over the bridge - - /// Seals `content` into a courier envelope for relay parking (a bridge - /// courier drop). Same sealing rules as `sendCourierMessage` — prekey - /// (v2) when a verified bundle is cached, static Noise X (v1) otherwise — - /// but carry-only: a relay copy never sprays. - func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? { - guard let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else { - return nil - } - do { - let now = Date() - let sealed: Data - let prekeyID: UInt32? - if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) { - sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey) - prekeyID = prekey.id - } else { - sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) - prekeyID = nil - } - return CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: recipientNoiseKey, - epochDay: CourierEnvelope.epochDay(for: now) - ), - expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), - ciphertext: sealed, - copies: 1, - prekeyID: prekeyID - ) - } catch { - SecureLogger.error("Failed to seal bridge courier envelope: \(error)", category: .encryption) - return nil - } - } - - /// Opens a courier envelope that arrived as a bridge drop (relay fetch, - /// not a directed mesh packet). Returns false when the rotating tag does - /// not match our static key — a drop for someone else, or a stale tag. - /// The inner Noise X seal authenticates the sender; there is no packet - /// signature to check on this path. - @discardableResult - func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool { - guard !envelope.isExpired else { return false } - let myKey = noiseService.getStaticPublicKeyData() - guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else { - return false - } - return openCourierEnvelope(envelope) - } - - /// Hands a bridge-fetched envelope directly to the matching local peer - /// as a directed courier packet. Delivery-only by design: the recipient's - /// tag matched, so this never lands in a stranger's carry quota. - /// Returns true only if a current Noise-authenticated physical link - /// accepted the packet; a stale peer-level session, reachability record, - /// replay-rebound link, or process-local spool is not delivery. - @discardableResult - func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool { - guard let payload = envelope.encode() else { return false } - let packet = makeCourierPacket(payload, to: peerID) - return onEngine { - // Engine slot: the auth-link check and the directed send see one - // consistent view of the identity domain. - guard hasCurrentNoiseAuthenticatedLink(to: peerID) else { return false } - return sendPacketDirected( - packet, - to: peerID, - requireDirectPeerLink: true, - requireNoiseAuthenticatedPeerLink: true - ) - } - } - - /// Our own Noise static public key (for computing our courier tags). - func myNoiseStaticPublicKey() -> Data { - noiseService.getStaticPublicKeyData() - } - - /// Verified reachable peers with known Noise keys — the set a bridge - /// gateway watches courier drops for. - func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] { - let now = Date() - return onEngine { - peerRegistry.snapshotByID.values.compactMap { info in - guard info.isVerifiedNickname, - let key = info.noisePublicKey, - peerRegistry.isReachable(info.peerID, now: now) else { return nil } - return (info.peerID, key) - } - } - } - - /// The prekey to seal a courier message with, or nil to fall back to - /// static sealing. The real signal is a verified, unexpired bundle with a - /// spare prekey; the advertised `.prekeys` capability only acts as a veto - /// for peers we currently see on the mesh (a cached bundle can outlive a - /// peer's downgrade to a build that no longer holds the privates). - /// Re-deposits of the same message reuse its assigned prekey, so one - /// message consumes exactly one prekey ID regardless of courier count. - private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? { - let shortID = PeerID(publicKey: recipientNoiseKey) - let knownOnMesh = peerRegistry.info(for: shortID) != nil - if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) { - return nil - } - return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) - } - - private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket { - let packet = BitchatPacket( - type: MessageType.courierEnvelope.rawValue, - senderID: myPeerIDData, - recipientID: Data(hexString: peerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: messageTTL - ) - // Signed so a courier can authenticate the depositor before carrying - // mail under their quota. Handover to the recipient doesn't need the - // packet signature — the inner Noise X seal authenticates the sender. - return noiseService.signPacket(packet) ?? packet - } - - /// Handles both courier roles for an incoming envelope addressed to us: - /// recipient (the rotating tag matches our static key → open and deliver) - /// or courier (a trusted peer is depositing mail for someone else → store). - private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) { - // Directed packets only; envelopes addressed elsewhere ride the - // generic relay path untouched. - guard packet.recipientID == myPeerIDData else { return } - guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return } - - let myKey = noiseService.getStaticPublicKeyData() - if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) { - openCourierEnvelope(envelope) - } else { - acceptCourierDeposit(envelope, from: peerID, packet: packet) - } - } - - @discardableResult - private func openCourierEnvelope(_ envelope: CourierEnvelope) -> Bool { - do { - let typedPayload: Data - let senderStaticKey: Data - if let prekeyID = envelope.prekeyID { - // Envelope v2: sealed to one of our one-time prekeys. Opening - // consumes the prekey (48h redelivery grace), which shrinks our - // published bundle under a strictly newer generatedAt. Re-gossip - // so peers replace their cached copy and stop assigning the - // consumed ID before the grace lapses; force the broadcast when - // the batch also topped back up (low-water), otherwise let the - // rebroadcast throttle coalesce bursts. - let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID) - (typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey) - if opened.consumedPrekey { - let replenished = noiseService.replenishPrekeysIfNeeded() - sendPrekeyBundle(force: replenished) - } - } else { - (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext) - } - guard let typeRaw = typedPayload.first, - let payloadType = NoisePayloadType(rawValue: typeRaw), - payloadType == .privateMessage else { - SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session) - return true // decrypted but deterministically unsupported - } - let payload = Data(typedPayload.dropFirst()) - guard let innerMessageID = PrivateMessagePacket.decode(from: payload)?.messageID else { - SecureLogger.warning("⚠️ Courier envelope carried undecodable private message", category: .session) - return true // decrypted but deterministically malformed - } - // Redundant copies of one message arrive as distinct envelopes - // (fresh seal each: mesh couriers, bridge drops across relays), - // so dedup here on the inner message ID — before delivery, ack, - // and handshake work. A duplicate costs only the decrypt above - // and at most one ack ever goes out per message ID. - let firstOpen = onEngine { - openedCourierMessageIDs.insert(innerMessageID) - } - guard firstOpen else { - SecureLogger.debug("📦 Dropping duplicate courier envelope for message \(innerMessageID.prefix(8))…", category: .session) - return true - } - // Couriered mail arrives while the sender is absent, so the UI's - // block check can't resolve their fingerprint from a live session. - // Gate here, where the full static key is in hand. - guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else { - SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security) - return true - } - // A present sender resolves to their live mesh thread via the - // derived short ID. An absent sender — the usual courier case — - // uses the full noise-key ID so the message lands on the stable - // favorite conversation instead of an unresolvable short-ID - // thread labeled "Unknown". - let shortID = PeerID(publicKey: senderStaticKey) - let isKnownOnMesh = peerRegistry.info(for: shortID) != nil - let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey) - SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session) - sfMetrics?.record(.courierOpened) - notifyUI { [weak self] in - self?.deliverTransportEvent(.noisePayloadReceived( - peerID: senderPeerID, - type: payloadType, - payload: payload, - timestamp: Date() - )) - } - return true - } catch { - // Tag collision or stale key: not addressed to us after all. - SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption) - return false - } - } - - private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) { - // A deposit must come from its depositor over the direct link: the - // claimed sender has to be the ingress peer, and the packet signature - // has to verify against that peer's announced signing key. Otherwise - // an untrusted sender could route an envelope through any trusted - // neighbor and have us carry it under the neighbor's quota. - guard PeerID(hexData: packet.senderID) == peerID else { - SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security) - return - } - let depositorInfo = peerRegistry.info(for: peerID) - guard let depositorKey = depositorInfo?.noisePublicKey else { - SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session) - return - } - guard let signingKey = depositorInfo?.signingPublicKey, - noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { - SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security) - return - } - let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false - let store = courierStore - let policy = courierDepositPolicy - let metrics = sfMetrics - notifyUI { - guard let tier = policy(depositorKey, isVerifiedPeer) else { - SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session) - return - } - if store.deposit(envelope, from: depositorKey, tier: tier) { - SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session) - metrics?.record(.courierAccepted) - } - } - } - - /// Hand over any carried envelopes addressed to a peer we just heard from. - private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) { - let metrics = sfMetrics - let accepted = courierStore.handoverEnvelopes(for: noiseKey) { [weak self] envelope in - guard let self, - let payload = envelope.encode(), - self.sendPacketDirected( - self.makeCourierPacket(payload, to: peerID), - to: peerID, - requireDirectPeerLink: true, - requireNoiseAuthenticatedPeerLink: true - ) else { - return false - } - metrics?.record(.courierHandedOver) - return true - } - if accepted > 0 { - SecureLogger.debug("📦 Handed over \(accepted) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session) - } - } - - /// Speculative handover toward a recipient heard only via a relayed - /// announce: the envelope floods the mesh as a directed packet (relays - /// treat it like a directed DM). Non-destructive — the carried copy stays - /// until a direct handover or expiry, throttled per envelope so repeated - /// announces don't re-flood. - private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) { - let envelopes = courierStore.envelopesForRemoteHandover( - recipientNoiseKey: noiseKey, - cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds - ) - guard !envelopes.isEmpty else { return } - SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))…", category: .session) - for envelope in envelopes { - guard let payload = envelope.encode() else { continue } - broadcastPacket(makeCourierPacket(payload, to: peerID)) - sfMetrics?.record(.courierRemoteHandover) - } - } - - /// Spray-and-wait: split copy budgets with another courier we just - /// encountered, so carried mail diffuses through a moving crowd instead - /// of riding a single carrier. Only favorites and verified peers qualify, - /// mirroring the deposit policy they would apply to us. - private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) { - let store = courierStore - let metrics = sfMetrics - let sendSpray: () -> Void = { [weak self] in - guard let self else { return } - let accepted = store.transferSprayCopies(to: noiseKey) { envelope in - guard let payload = envelope.encode(), - self.sendPacketDirected( - self.makeCourierPacket(payload, to: peerID), - to: peerID, - requireDirectPeerLink: true, - requireNoiseAuthenticatedPeerLink: true - ) else { - return false - } - metrics?.record(.courierSprayed) - return true - } - if accepted > 0 { - SecureLogger.debug("📦 Sprayed \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session) - } - } - let policy = courierDepositPolicy - notifyUI { - // Same trust gate as deposits: don't hand mail to a peer who - // would reject it from us. - guard policy(noiseKey, isVerifiedPeer) != nil else { return } - sendSpray() - } - } - - // MARK: One-Time Prekey Bundles - - /// Broadcasts our signed prekey bundle and tracks it for gossip sync. - /// Unforced sends (piggybacked on announces) are throttled — gossip does - /// the spreading, the broadcast just keeps our own gossip entry fresh. - /// Forced sends (bundle changed after consumption) go immediately. - private func sendPrekeyBundle(force: Bool = false) { - let now = Date() - let shouldSend: Bool = onEngine { - if !force, - let last = lastPrekeyBundleSentAt, - now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds { - return false - } - lastPrekeyBundleSentAt = now - return true - } - guard shouldSend else { return } - guard let bundle = noiseService.currentPrekeyBundle(), - let payload = bundle.encode() else { - SecureLogger.error("❌ Failed to build prekey bundle", category: .security) - return - } - let packet = BitchatPacket( - type: MessageType.prekeyBundle.rawValue, - senderID: myPeerIDData, - recipientID: nil, - timestamp: UInt64(now.timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: messageTTL - ) - guard let signedPacket = noiseService.signPacket(packet) else { - SecureLogger.error("❌ Failed to sign prekey bundle packet", category: .security) - return - } - if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - broadcastPacket(signedPacket) - } else { - messageQueue.async { [weak self] in - self?.broadcastPacket(signedPacket) - } - } - gossipSyncManager?.onPublicPacketSeen(signedPacket) - } - - /// Ingests a gossiped prekey bundle. Attribution is layered: the outer - /// packet must originate from the bundle owner (fabricated sender IDs, used - /// to multiply cache/gossip entries, are rejected), and BOTH the inner - /// bundle signature and the outer packet signature must verify against the - /// owner's announce-bound signing key. Verifying the outer packet — whose - /// signed bytes cover senderID and timestamp — stops a valid bundle from - /// being replayed under a fresh timestamp or spoofed sender to pass - /// freshness or poison attribution. Only after that does the packet enter - /// our own gossip store, so we never help spread a bundle we couldn't - /// attribute. - private func handlePrekeyBundle(_ packet: BitchatPacket, from peerID: PeerID) { - guard let bundle = PrekeyBundle.decode(packet.payload) else { - SecureLogger.debug("🔑 Ignoring malformed prekey bundle from \(peerID.id.prefix(8))…", category: .security) - return - } - // Our own bundle is tracked at send time; a copy echoing back adds nothing. - guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return } - let owner = PeerID(publicKey: bundle.noiseStaticPublicKey) - // The owner's genuine bundle (direct or relayed) always carries the - // owner's senderID + outer signature; gossip resends preserve both. A - // packet whose senderID isn't the owner can't be authenticated here. - guard PeerID(hexData: packet.senderID) == owner else { - SecureLogger.debug("🔑 Ignoring prekey bundle whose sender ≠ owner \(owner.id.prefix(8))…", category: .security) - return - } - // Look up the announce-bound signing key and stash-if-unbound in ONE - // barrier: the receive queue is concurrent, so this bundle can race - // ahead of the announce that binds the key. Reading the live registry - // and stashing atomically closes the check-then-act gap against - // handleAnnounce's drain (see drainPendingPrekeyBundles). - let signingKey: Data? = onEngine { - if let info = peerRegistry.info(for: owner), - info.noisePublicKey == bundle.noiseStaticPublicKey, - let key = info.signingPublicKey { - return key - } - // Offline-verified identities are stable across this race. - for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(owner) - where candidate.publicKey == bundle.noiseStaticPublicKey { - if let key = candidate.signingPublicKey { return key } - } - // No binding yet: retain the latest bundle per owner, bounded, and - // retry once the verified announce lands. - if pendingPrekeyBundles[owner] != nil - || pendingPrekeyBundles.count < Self.pendingPrekeyBundleCap { - pendingPrekeyBundles[owner] = packet - } - return nil - } - guard let signingKey else { - SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security) - return - } - ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) - } - - /// Verify a bundle's inner + outer signatures against the owner's bound - /// signing key and, on success, cache it and let it enter our gossip store. - private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) { - guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey), - noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { - SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security) - return - } - if prekeyBundleStore.ingest(bundle) { - SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security) - } - gossipSyncManager?.onPublicPacketSeen(packet) - } - - /// Re-attempt any prekey bundle that arrived before this owner's announce - /// bound a signing key. Called from handleAnnounce after a verified - /// announce, in a barrier ordered after the registry write, so a bundle - /// stashed before the write is always observed here. - private func drainPendingPrekeyBundles(for owner: PeerID) { - let pending: BitchatPacket? = onEngine { - pendingPrekeyBundles.removeValue(forKey: owner) - } - guard let packet = pending, - let bundle = PrekeyBundle.decode(packet.payload), - let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey) else { return } - ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) - } - - /// Ed25519 signing key bound to a Noise static key by a verified - /// announce: from the live registry when the owner is on the mesh, else - /// from identities persisted for offline verification. - private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? { - let shortID = PeerID(publicKey: noiseKey) - if let info = peerRegistry.info(for: shortID), - info.noisePublicKey == noiseKey, - let signingKey = info.signingPublicKey { - return signingKey - } - for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(shortID) - where candidate.publicKey == noiseKey { - if let signingKey = candidate.signingPublicKey { - return signingKey - } - } - return nil - } - - // MARK: Gateway carrier (nostrCarrier) - - /// Sign and send an encoded `toGateway` carrier payload directed at a - /// gateway peer. The packet is signed so the gateway can key its uplink - /// quotas to an authenticated depositor; the carried Nostr event has its - /// own Schnorr signature for content authenticity. Returns false when - /// the gateway is not reachable or signing fails. - func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool { - guard isPeerReachable(gatewayPeer) else { return false } - let packet = BitchatPacket( - type: MessageType.nostrCarrier.rawValue, - senderID: myPeerIDData, - recipientID: Data(hexString: gatewayPeer.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: messageTTL - ) - guard let signed = noiseService.signPacket(packet) else { return false } - messageQueue.async { [weak self] in - // broadcastPacket applies a known route when one exists and - // otherwise floods the directed packet like a DM, so a gateway - // that is reachable but multi-hop still gets the deposit. - self?.broadcastPacket(signed) - } - return true - } - - /// Broadcast an encoded `fromGateway` carrier payload on the mesh with - /// the default TTL. Unsigned at the packet layer — receivers verify the - /// carried event's own Schnorr signature. - func broadcastNostrCarrier(_ payload: Data) { - let packet = BitchatPacket( - type: MessageType.nostrCarrier.rawValue, - senderID: myPeerIDData, - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: messageTTL - ) - messageQueue.async { [weak self] in - self?.broadcastPacket(packet) - } - } - - /// Transport-level handling for a received nostrCarrier packet; policy - /// (verification of the carried event, quotas, loop prevention) lives in - /// `GatewayService` behind `onNostrCarrierPacket`. - private func handleNostrCarrier(_ packet: BitchatPacket, from _: PeerID) { - let senderID = PeerID(hexData: packet.senderID) - let directedToUs: Bool - if let recipientID = packet.recipientID { - // Carriers addressed elsewhere ride the generic relay path untouched. - guard recipientID == myPeerIDData else { return } - // Uplink deposit: quotas are keyed by the depositor, so the - // packet signature must verify against the sender's announced - // signing key. Unlike courier deposits the depositor may be - // multi-hop away, so ingress-link identity is not required. - let signingKey = peerRegistry.info(for: senderID)?.signingPublicKey - guard let signingKey, - noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { - SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security) - return - } - directedToUs = true - } else { - directedToUs = false - } - let payload = packet.payload - notifyUI { [weak self] in - self?.onNostrCarrierPacket?(payload, senderID, directedToUs) - } - } - - // MARK: Link capability snapshots - // Physical link state is bleQueue-owned; the engine (and main) may - // sync-read it here. The bindings half of a combined view comes from - // the engine-owned identity domain directly. - - private func readLinkState(_ body: (BLELinkStateStore) -> T) -> T { + + // MARK: Link capability snapshots (thread-safe via bleQueue) + + private func snapshotPeripheralStates() -> [PeripheralState] { if DispatchQueue.getSpecific(key: bleQueueKey) != nil { - return body(linkStateStore) + return Array(peripherals.values) } else { - return bleQueue.sync { body(linkStateStore) } + return bleQueue.sync { Array(peripherals.values) } } } - - private func snapshotDirectPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { - directPeripheralState(for: peerID) - } - - private func snapshotPeripheralStates() -> [BLEPeripheralLinkState] { - readLinkState(\.peripheralStates) - } - - private func snapshotSubscribedCentrals() -> BLESubscribedCentralSnapshot { - subscribedCentralSnapshot() + private func snapshotSubscribedCentrals() -> ([CBCentral], [String: PeerID]) { + if DispatchQueue.getSpecific(key: bleQueueKey) != nil { + return (self.subscribedCentrals, self.centralToPeerID) + } else { + return bleQueue.sync { (self.subscribedCentrals, self.centralToPeerID) } + } } // MARK: Helpers: IDs, selection, and write backpressure - private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic, priority: BLEOutboundWritePriority) { + private func makeMessageID(for packet: BitchatPacket) -> String { + let senderID = packet.senderID.hexEncodedString() + let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString() + return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)" + } + + private func subsetSizeForFanout(_ n: Int) -> Int { + guard n > 0 else { return 0 } + if n <= 2 { return n } + // approx ceil(log2(n)) + 1 without floating point + var v = n - 1 + var bits = 0 + while v > 0 { v >>= 1; bits += 1 } + return min(n, max(1, bits + 1)) + } + + private func selectDeterministicSubset(ids: [String], k: Int, seed: String) -> Set { + guard k > 0 && ids.count > k else { return Set(ids) } + // Stable order by SHA256(seed || "::" || id) + var scored: [(score: [UInt8], id: String)] = [] + for id in ids { + let msg = (seed + "::" + id).data(using: .utf8) ?? Data() + let digest = Array(SHA256.hash(data: msg)) + scored.append((digest, id)) + } + scored.sort { a, b in + for i in 0.. OutboundPriority { + guard let messageType = MessageType(rawValue: packet.type) else { return .low } + switch messageType { + case .fragment: + let total = fragmentTotalCount(from: packet.payload) + return OutboundPriority.fragment(totalFragments: total) + case .fileTransfer: + return .fileTransfer + default: + return .high + } + } + + private func fragmentTotalCount(from payload: Data) -> Int { + guard payload.count >= 12 else { return Int(UInt16.max) } + let totalHigh = Int(payload[10]) + let totalLow = Int(payload[11]) + let total = (totalHigh << 8) | totalLow + return max(total, 1) + } + + private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic, priority: OutboundPriority) { // BLE operations run on bleQueue; keep queue affinity bleQueue.async { [weak self] in guard let self = self else { return } @@ -5072,92 +3012,49 @@ extension BLEService { if peripheral.canSendWriteWithoutResponse { peripheral.writeValue(data, for: characteristic, type: .withoutResponse) } else { - let result = self.pendingPeripheralWrites.enqueue( - data: data, - for: uuid, - priority: priority, - capBytes: TransportConfig.blePendingWriteBufferCapBytes - ) - - switch result { - case .oversized(let bytes): - SecureLogger.warning("⚠️ Dropping oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session) - case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0: - SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session) - case .enqueued: - break + self.collectionsQueue.async(flags: .barrier) { + var queue = self.pendingPeripheralWrites[uuid] ?? [] + let capBytes = TransportConfig.blePendingWriteBufferCapBytes + let newSize = data.count + // If single chunk exceeds cap, drop it immediately + if newSize > capBytes { + SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session) + } else { + let item = PendingWrite(priority: priority, data: data) + var total = queue.reduce(0) { $0 + $1.data.count } + newSize + let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count + queue.insert(item, at: insertIndex) + if total > capBytes { + var removedBytes = 0 + while total > capBytes && !queue.isEmpty { + let removed = queue.removeLast() + removedBytes += removed.data.count + total -= removed.data.count + } + if removedBytes > 0 { + SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session) + } + } + self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue + } } } } } - /// Writes immediately or synchronously admits the packet to this - /// peripheral's bounded retry queue. Unlike `writeOrEnqueue`, the return - /// value distinguishes a retained queue item from one rejected or trimmed - /// immediately, which lets durable courier state commit truthfully. - /// - /// The authenticated-link eligibility check runs on the engine (which - /// owns bindings and rebinds, so it is serialized against identity - /// changes by construction); only the physical admission hops to - /// `bleQueue`. - private func writeOrEnqueueIfAccepted( - _ data: Data, - to peripheral: CBPeripheral, - characteristic: CBCharacteristic, - priority: BLEOutboundWritePriority, - requiredAuthenticatedPeer: PeerID? - ) -> Bool { - let uuid = peripheral.identifier.uuidString - if let peerID = requiredAuthenticatedPeer { - let link = BLEIngressLinkID.peripheral(uuid) - guard linkBindings.peer(forPeripheralID: uuid) == peerID, - linkAuth.isAuthenticated(link, for: peerID) else { - return false - } - } - let accept = { [self] in - guard let state = linkStateStore.state(forPeripheralID: uuid), - state.isConnected, - state.characteristic?.uuid == characteristic.uuid else { - return false - } - - if peripheral.canSendWriteWithoutResponse { - peripheral.writeValue(data, for: characteristic, type: .withoutResponse) - return true - } - - let attempt = pendingPeripheralWrites.enqueueReportingAcceptance( - data: data, - for: uuid, - priority: priority, - capBytes: TransportConfig.blePendingWriteBufferCapBytes - ) - switch attempt.result { - case .oversized(let bytes): - SecureLogger.warning("⚠️ Rejecting oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session) - case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0: - SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session) - case .enqueued: - break - } - return attempt.accepted - } - - if DispatchQueue.getSpecific(key: bleQueueKey) != nil { - return accept() - } - return bleQueue.sync(execute: accept) - } - - func drainPendingWrites(for peripheral: CBPeripheral) { + private func drainPendingWrites(for peripheral: CBPeripheral) { let uuid = peripheral.identifier.uuidString bleQueue.async { [weak self] in guard let self = self else { return } - guard !self.isPanicSuspended else { return } - guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return } + guard let state = self.peripherals[uuid], let ch = state.characteristic else { return } - let itemsToSend = self.pendingPeripheralWrites.takeAll(for: uuid) + // Atomically take all pending items from the queue to avoid race conditions + // where new items could be enqueued between read and update + let itemsToSend: [PendingWrite] = self.collectionsQueue.sync(flags: .barrier) { + let items = self.pendingPeripheralWrites[uuid] ?? [] + self.pendingPeripheralWrites[uuid] = nil + return items + } guard !itemsToSend.isEmpty else { return } // Send as many as possible @@ -5174,21 +3071,56 @@ extension BLEService { // Re-enqueue any items that couldn't be sent (maintaining order) let unsent = Array(itemsToSend.dropFirst(sent)) if !unsent.isEmpty { - self.pendingPeripheralWrites.prepend(unsent, for: uuid) + self.collectionsQueue.async(flags: .barrier) { + var existing = self.pendingPeripheralWrites[uuid] ?? [] + // Prepend unsent items to maintain priority order + existing.insert(contentsOf: unsent, at: 0) + self.pendingPeripheralWrites[uuid] = existing + } } } } /// Periodically try to drain pending notifications as a backup mechanism private func drainPendingNotificationsIfPossible() { - drainPendingNotifications(logPrefix: "🔄 Periodic drain: sent") + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self, + let characteristic = self.characteristic, + !self.pendingNotifications.isEmpty else { return } + + let pending = self.pendingNotifications + self.pendingNotifications.removeAll() + + var sentCount = 0 + for (index, (data, centrals)) in pending.enumerated() { + let success: Bool + if let centrals = centrals { + success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false + } else { + success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false + } + + if !success { + // Re-queue this and all remaining items + let remaining = pending.dropFirst(index) + self.pendingNotifications.append(contentsOf: remaining) + break + } else { + sentCount += 1 + } + } + + if sentCount > 0 { + SecureLogger.debug("🔄 Periodic drain: sent \(sentCount) pending notifications", category: .session) + } + } } /// Periodically try to drain pending writes for all connected peripherals private func drainAllPendingWrites() { - let uuids = pendingPeripheralWrites.peripheralIDs + let uuids = collectionsQueue.sync { Array(pendingPeripheralWrites.keys) } for uuid in uuids { - guard let state = linkStateStore.state(forPeripheralID: uuid), state.isConnected else { continue } + guard let state = peripherals[uuid], state.isConnected else { continue } drainPendingWrites(for: state.peripheral) } } @@ -5198,30 +3130,23 @@ extension BLEService { #if os(iOS) @objc private func appDidBecomeActive() { isAppActive = true - refreshCachedBackgroundTimeRemaining() // Restart scanning with allow duplicates when app becomes active if centralManager?.state == .poweredOn { centralManager?.stopScan() - radio.startScanning() + startScanning() } - radio.cancelStalePendingConnects() logBluetoothStatus("became-active") scheduleBluetoothStatusSample(after: 5.0, context: "active-5s") // No Local Name; nothing to refresh for advertising policy } - + @objc private func appDidEnterBackground() { isAppActive = false - refreshCachedBackgroundTimeRemaining() // Restart scanning without allow duplicates in background if centralManager?.state == .poweredOn { centralManager?.stopScan() - radio.startScanning() + startScanning() } - radio.armPendingBackgroundConnects() - // Backgrounding may precede a kill; flush the public-history archive - // outside its 30s maintenance cadence. - gossipSyncManager?.persistNow() logBluetoothStatus("entered-background") scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") // No Local Name; nothing to refresh for advertising policy @@ -5231,379 +3156,339 @@ extension BLEService { // MARK: Private Message Handling private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) { - // Hop like sendMessage: the Transport-facing wrappers call this from - // the main actor (router sends, favorite notifications), and the send - // path sync-waits on bleQueue for link state. - if DispatchQueue.getSpecific(key: messageQueueKey) == nil { - messageQueue.async { [weak self] in - self?.sendPrivateMessage(content, to: recipientID, messageID: messageID) - } - return - } - // Sessions and wire recipient IDs are keyed by the short 16-hex form; - // callers may pass the full 64-hex noise key (mirrors sendFilePrivate). - let recipientID = recipientID.toShort() - SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session) - + SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session) + // Check if we have an established Noise session if noiseService.hasEstablishedSession(with: recipientID) { // Encrypt and send do { - guard let messagePayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else { + // Create TLV-encoded private message + let privateMessage = PrivateMessagePacket(messageID: messageID, content: content) + guard let tlvData = privateMessage.encode() else { SecureLogger.error("Failed to encode private message with TLV") return } - broadcastPacket(try makeEncryptedNoisePacket(messagePayload, to: recipientID)) + // Create message payload with TLV: [type byte] + [TLV data] + var messagePayload = Data([NoisePayloadType.privateMessage.rawValue]) + messagePayload.append(tlvData) + + let encrypted = try noiseService.encrypt(messagePayload, for: recipientID) + + // Convert recipientID to Data (assuming it's a hex string) + var recipientData = Data() + var tempID = recipientID.id + while tempID.count >= 2 { + let hexByte = String(tempID.prefix(2)) + if let byte = UInt8(hexByte, radix: 16) { + recipientData.append(byte) + } + tempID = String(tempID.dropFirst(2)) + } + if tempID.count == 1 { + if let byte = UInt8(tempID, radix: 16) { + recipientData.append(byte) + } + } + + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: myPeerIDData, + recipientID: recipientData, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + + broadcastPacket(packet) // Notify delegate that message was sent notifyUI { [weak self] in - self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: messageID, status: .sent)) + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) } } catch { SecureLogger.error("Failed to encrypt message: \(error)") } } else { // Queue message for sending after handshake completes - SecureLogger.debug("🤝 No session with \(recipientID.id.prefix(8))…, initiating handshake and queueing message", category: .session) + SecureLogger.debug("🤝 No session with \(recipientID), initiating handshake and queueing message", category: .session) // Queue the message (especially important for favorite notifications) - onEngine { - pendingNoiseSessionQueues.appendPrivateMessage(content: content, messageID: messageID, for: recipientID) + collectionsQueue.sync(flags: .barrier) { + if pendingMessagesAfterHandshake[recipientID] == nil { + pendingMessagesAfterHandshake[recipientID] = [] + } + pendingMessagesAfterHandshake[recipientID]?.append((content, messageID)) } initiateNoiseHandshake(with: recipientID) // Notify delegate that message is pending notifyUI { [weak self] in - self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: messageID, status: .sending)) + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending) } } } private func initiateNoiseHandshake(with peerID: PeerID) { - let service = noiseService + // Use NoiseEncryptionService for handshake + guard !noiseService.hasSession(with: peerID) else { return } + do { - guard let initiation = try service.initiateHandshakeIfNeeded( - with: peerID, - retryOnTimeout: true - ) else { - return - } - messageQueue.async { [weak self, weak service] in - guard let self, - let service, - self.noiseService === service, - let handshakeData = service.claimHandshakeInitiation( - initiation, - for: peerID - ) else { - return - } - self.broadcastNoiseHandshake(handshakeData, to: peerID) - } + let handshakeData = try noiseService.initiateHandshake(with: peerID) + + // Send handshake init + let packet = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: handshakeData, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) } catch { SecureLogger.error("Failed to initiate handshake: \(error)") } } - - private func broadcastNoiseHandshake(_ handshakeData: Data, to peerID: PeerID) { - let packet = BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: myPeerIDData, - recipientID: Data(hexString: peerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: handshakeData, - signature: nil, - ttl: messageTTL - ) - broadcastPacket(packet) - } - - /// Starts a wire-compatible ordinary XX reconnect. The manager prepares - /// the initiator before atomically retiring the cached transport; the - /// one-shot claim prevents a crossed inbound message from making a stale - /// message 1 leave after this peer has already become responder. - private func initiateNoiseReconnectHandshake(with peerID: PeerID) { - let service = noiseService - do { - let initiation = try service.initiateReconnectHandshake( - with: peerID, - retryOnTimeout: true - ) - messageQueue.async { [weak self, weak service] in - guard let self, - let service, - self.noiseService === service else { - return - } - self.noteNoiseSessionCleared(for: peerID) - guard let handshakeData = service.claimHandshakeInitiation( - initiation, - for: peerID - ) else { - return - } - self.broadcastNoiseHandshake(handshakeData, to: peerID) - } - } catch NoiseSessionError.notEstablished { - initiateNoiseHandshake(with: peerID) - } catch { - SecureLogger.error( - "Failed to initiate ordinary reconnect: \(error)", - category: .session - ) - } - } private func sendPendingMessagesAfterHandshake(for peerID: PeerID) { // Atomically take all pending messages to process (prevents concurrent modification) - let pendingMessages = onEngine { () -> [BLEPendingPrivateMessage] in - pendingNoiseSessionQueues.takePrivateMessages(for: peerID) + let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [(content: String, messageID: String)]? in + let messages = pendingMessagesAfterHandshake[peerID] + pendingMessagesAfterHandshake.removeValue(forKey: peerID) + return messages } - guard !pendingMessages.isEmpty else { return } + guard let messages = pendingMessages, !messages.isEmpty else { return } - SecureLogger.debug("📤 Sending \(pendingMessages.count) pending messages after handshake to \(peerID.id.prefix(8))…", category: .session) + SecureLogger.debug("📤 Sending \(messages.count) pending messages after handshake to \(peerID)", category: .session) // Track failed messages for re-queuing - var failedMessages: [BLEPendingPrivateMessage] = [] + var failedMessages: [(content: String, messageID: String)] = [] // Send each pending message directly (we know session is established) - for message in pendingMessages { + for (content, messageID) in messages { do { // Use the same TLV format as normal sends to keep receiver decoding consistent - guard let messagePayload = BLENoisePayloadFactory.privateMessage(content: message.content, messageID: message.messageID) else { + let privateMessage = PrivateMessagePacket(messageID: messageID, content: content) + guard let tlvData = privateMessage.encode() else { SecureLogger.error("Failed to encode pending private message TLV") - failedMessages.append(message) + failedMessages.append((content, messageID)) continue } + var messagePayload = Data([NoisePayloadType.privateMessage.rawValue]) + messagePayload.append(tlvData) + + let encrypted = try noiseService.encrypt(messagePayload, for: peerID) + + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + // We're already on messageQueue from the callback - broadcastPacket(try makeEncryptedNoisePacket(messagePayload, to: peerID)) + broadcastPacket(packet) // Notify delegate that message was sent notifyUI { [weak self] in - self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .sent)) + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) } - SecureLogger.debug("✅ Sent pending message id=\(message.messageID.prefix(8))… to \(peerID.id.prefix(8))… after handshake", category: .session) + SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session) } catch { SecureLogger.error("Failed to send pending message after handshake: \(error)") - failedMessages.append(message) + failedMessages.append((content, messageID)) // Notify delegate of failure notifyUI { [weak self] in - self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer")))) + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed")) } } } // Re-queue any failed messages for retry on next handshake if !failedMessages.isEmpty { - messageQueue.async { [weak self] in + collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } + if self.pendingMessagesAfterHandshake[peerID] == nil { + self.pendingMessagesAfterHandshake[peerID] = [] + } // Prepend failed messages to maintain order - self.pendingNoiseSessionQueues.prependPrivateMessages(failedMessages, for: peerID) - SecureLogger.warning("⚠️ Re-queued \(failedMessages.count) failed messages for \(peerID.id.prefix(8))…", category: .session) + self.pendingMessagesAfterHandshake[peerID]?.insert(contentsOf: failedMessages, at: 0) + SecureLogger.warning("⚠️ Re-queued \(failedMessages.count) failed messages for \(peerID)", category: .session) } } } // MARK: Fragmentation (Required for messages > BLE MTU) - @discardableResult - private func sendFragmentedPacket( - _ packet: BitchatPacket, - pad: Bool, - maxChunk: Int? = nil, - directedOnlyPeer: PeerID? = nil, - transferId: String? = nil, - requireDirectPeerLink: Bool = false, - requireNoiseAuthenticatedPeerLink: Bool = false, - requiresPrivateMediaAdmission: Bool = false - ) -> Bool { - let request = BLEOutboundFragmentTransferRequest( - packet: packet, - pad: pad, - maxChunk: maxChunk, - directedPeer: directedOnlyPeer, - transferId: transferId, - requireDirectPeerLink: requireDirectPeerLink, - requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink - ) - - let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = onEngine { - if requiresPrivateMediaAdmission { - guard let transferId else { return nil } - // This lock is taken while the scheduler is already - // engine-confined. Cancellation takes the admission lock - // synchronously but never waits on the engine, avoiding - // lock inversion while giving submit/cancel one linear order. - return privateMediaTransferAdmissions.withActive(transferId) { - outboundFragmentTransfers.submit( - request, - maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers - ) - } + private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil, transferId: String? = nil) { + let context = PendingFragmentTransfer(packet: packet, pad: pad, maxChunk: maxChunk, directedPeer: directedOnlyPeer, transferId: transferId) + if packet.type == MessageType.fileTransfer.rawValue { + let shouldQueue = collectionsQueue.sync { + self.activeTransfers.count >= TransportConfig.bleMaxConcurrentTransfers } - return outboundFragmentTransfers.submit( - request, - maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers - ) - } - guard let result else { - if let transferId, requiresPrivateMediaAdmission { - privateMediaTransferAdmissions.finish(transferId) + if shouldQueue { + queueFragmentTransfer(context, prioritizeFront: false) + return } - return false } - if let transferId, requiresPrivateMediaAdmission { - // The scheduler now owns normal cancellation (active or pending). - privateMediaTransferAdmissions.finish(transferId) - } - return handleFragmentTransferSubmitResult(result) + startFragmentedPacket(context) } - @discardableResult - private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) -> Bool { - switch result { - case let .start(request, reservedTransferId): - return startFragmentedPacket(request, reservedTransferId: reservedTransferId) - - case let .queued(_, transferId, _): - if let transferId { - SecureLogger.debug("🚦 Queued media transfer \(transferId.prefix(8))… waiting for slot", category: .session) - } else { - SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session) - } - return false - - case let .rejectedStrict(_, transferId): - SecureLogger.debug( - "🚫 Strict directed fragment transfer \(transferId?.prefix(8) ?? "?")… rejected while scheduler busy", - category: .session - ) - return false - - case let .droppedDuplicate(_, activeTransferId): - SecureLogger.debug( - "🔁 Skipping duplicate outbound transfer — same content already in flight as \(activeTransferId?.prefix(8) ?? "?")…", - category: .session - ) - return false - } - } - - @discardableResult - private func startFragmentedPacket( - _ request: BLEOutboundFragmentTransferRequest, - reservedTransferId: String? - ) -> Bool { - let releaseReservedSlot: (String) -> Void = { [weak self] id in + private func queueFragmentTransfer(_ context: PendingFragmentTransfer, prioritizeFront: Bool) { + collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } + if prioritizeFront { + self.pendingFragmentTransfers.insert(context, at: 0) + } else { + self.pendingFragmentTransfers.append(context) + } + } + if let transferId = context.transferId { + SecureLogger.debug("🚦 Queued media transfer \(transferId.prefix(8))… waiting for slot", category: .session) + } else { + SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session) + } + } + + private func startFragmentedPacket(_ context: PendingFragmentTransfer) { + let packet = context.packet + let isFileTransfer = packet.type == MessageType.fileTransfer.rawValue + var reservedTransferId: String? + + let releaseReservedSlot: (String) -> Void = { id in TransferProgressManager.shared.cancel(id: id) - messageQueue.async { [weak self] in - _ = self?.outboundFragmentTransfers.releaseReservation(id) + self.collectionsQueue.async(flags: .barrier) { [weak self] in + self?.activeTransfers.removeValue(forKey: id) } self.messageQueue.async { [weak self] in self?.startNextPendingTransferIfNeeded() } } - guard let plan = BLEOutboundFragmentPlanner.makePlan( - for: request, - defaultChunkSize: defaultFragmentSize, - bleMaxMTU: bleMaxMTU - ) else { + if isFileTransfer { + let candidateId = context.transferId ?? packet.payload.sha256Hex() + var didReserve = false + collectionsQueue.sync(flags: .barrier) { + if self.activeTransfers.count < TransportConfig.bleMaxConcurrentTransfers, + self.activeTransfers[candidateId] == nil { + self.activeTransfers[candidateId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: []) + didReserve = true + } + } + guard didReserve else { + queueFragmentTransfer(context, prioritizeFront: true) + return + } + reservedTransferId = candidateId + } + + guard let fullData = packet.toBinaryData(padding: context.pad) else { if let id = reservedTransferId { releaseReservedSlot(id) } - return false + return + } + // Fragment the unpadded frame; each fragment will be encoded independently + let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) + // Dynamic Fragment Sizing (Source Routing v2) + // See docs/SOURCE_ROUTING.md Section 5.1 + var fragmentVersion: UInt8 = 1 + var calculatedChunk = defaultFragmentSize + + if let route = packet.route, !route.isEmpty { + fragmentVersion = 2 + // RouteSize = 1 + (Hops * 8) + let routeSize = 1 + (route.count * 8) + // Overhead = HeaderV2(16) + SenderID(8) + RecipientID(8) + RouteSize + FragmentHeader(13) + PaddingBuffer(16) + let overhead = 16 + 8 + 8 + routeSize + 13 + 16 + calculatedChunk = max(64, bleMaxMTU - overhead) + } + + let chunk = context.maxChunk ?? calculatedChunk + let safeChunk = max(64, chunk) + let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in + Data(fullData[offset.. 4 { bleQueue.async { [weak self] in guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return } if c.isScanning { c.stopScan() } - let totalFragments = plan.totalFragments let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in - self?.radio.startScanning() + self?.startScanning() } } } + let perFragMs = (context.directedPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs - let transferIdentifier: String? - if let id = reservedTransferId { - let activated = onEngine { - self.outboundFragmentTransfers.activateReservedTransfer( - id: id, - totalFragments: plan.totalFragments, - workItems: [] - ) + let transferIdentifier: String? = { + guard let id = reservedTransferId else { return nil } + collectionsQueue.sync(flags: .barrier) { + self.activeTransfers[id] = ActiveTransferState(totalFragments: totalFragments, sentFragments: 0, workItems: []) } - // Cancellation may remove the reservation between submit and plan - // construction. Treat that as cancellation, not as permission to - // schedule an untracked fragment train. - guard activated else { return false } - TransferProgressManager.shared.start(id: id, totalFragments: plan.totalFragments) - transferIdentifier = id - } else { - transferIdentifier = nil - } - - let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in - guard let self else { return false } - if request.requireDirectPeerLink, let directedPeer = request.directedPeer { - return self.sendPacketDirected( - fragmentPacket, - to: directedPeer, - requireDirectPeerLink: true, - requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink - ) - } - self.broadcastPacket(fragmentPacket) - return true - } - - // Strict courier handoff is transactional at the fragment-admission - // boundary: every fragment must enter the intended authenticated - // link or its bounded retry queue before the durable owner may commit. - // A partial train is harmlessly abandoned and the envelope stays - // retryable with a fresh fragment ID on the next encounter. - if request.requireDirectPeerLink { - let admitted = BLEStrictFragmentAdmission.admitAll(plan.fragmentPackets) { fragmentPacket in - guard sendFragment(fragmentPacket) else { return false } - if let transferId = transferIdentifier { - markFragmentSent(transferId: transferId) - } - return true - } - guard admitted else { - if let id = reservedTransferId { - releaseReservedSlot(id) - } - return false - } - return true - } + TransferProgressManager.shared.start(id: id, totalFragments: totalFragments) + return id + }() var scheduledItems: [(item: DispatchWorkItem, index: Int)] = [] - for (index, fragmentPacket) in plan.fragmentPackets.enumerated() { + for (index, fragment) in fragments.enumerated() { + var payload = Data() + payload.append(fragmentID) + payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) }) + payload.append(contentsOf: withUnsafeBytes(of: UInt16(fragments.count).bigEndian) { Data($0) }) + payload.append(packet.type) + payload.append(fragment) + + let fragmentRecipient: Data? = { + if let only = context.directedPeer { return Data(hexString: only.id) } + return packet.recipientID + }() + + let fragmentPacket = BitchatPacket( + type: MessageType.fragment.rawValue, + senderID: packet.senderID, + recipientID: fragmentRecipient, + timestamp: packet.timestamp, + payload: payload, + signature: nil, + ttl: packet.ttl, + version: fragmentVersion, + route: packet.route, + isRSR: packet.isRSR + ) + let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } if let transferId = transferIdentifier { - let isActive = onEngine { self.outboundFragmentTransfers.isActive(transferId) } + let isActive = self.collectionsQueue.sync { self.activeTransfers[transferId] != nil } guard isActive else { return } } - if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true { + if fragmentRecipient == nil || fragmentRecipient?.allSatisfy({ $0 == 0xFF }) == true { self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket) } - _ = sendFragment(fragmentPacket) + self.broadcastPacket(fragmentPacket) if let transferId = transferIdentifier { self.markFragmentSent(transferId: transferId) } @@ -5614,33 +3499,33 @@ extension BLEService { if let transferId = transferIdentifier { let workItems = scheduledItems.map { $0.item } - messageQueue.async { [weak self] in - _ = self?.outboundFragmentTransfers.updateWorkItems(workItems, for: transferId) + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self, var state = self.activeTransfers[transferId] else { return } + state.workItems = workItems + self.activeTransfers[transferId] = state } } for (workItem, index) in scheduledItems { - let delayMs = index * plan.spacingMs - engineScheduler.schedule(after: Double(delayMs) / 1_000, execute: workItem) + let delayMs = index * perFragMs + messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem) } - return true } // MARK: - Fragmentation (Required for messages > BLE MTU) private func markFragmentSent(transferId: String) { - messageQueue.async { [weak self] in - guard let self = self else { return } - - switch self.outboundFragmentTransfers.markFragmentSent(transferId: transferId) { - case .progress, .complete: - TransferProgressManager.shared.recordFragmentSent(id: transferId) - - case .missing: - return + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self, var state = self.activeTransfers[transferId] else { return } + state.sentFragments = min(state.sentFragments + 1, state.totalFragments) + let isComplete = state.sentFragments >= state.totalFragments + if isComplete { + self.activeTransfers.removeValue(forKey: transferId) + } else { + self.activeTransfers[transferId] = state } - - if !self.outboundFragmentTransfers.isActive(transferId) { + TransferProgressManager.shared.recordFragmentSent(id: transferId) + if isComplete { self.messageQueue.async { [weak self] in self?.startNextPendingTransferIfNeeded() } @@ -5649,345 +3534,229 @@ extension BLEService { } private func startNextPendingTransferIfNeeded() { - let results = onEngine { - outboundFragmentTransfers.reservePendingStarts(maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers) - } - - for result in results { - messageQueue.async { [weak self] in - self?.handleFragmentTransferSubmitResult(result) + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + let limit = TransportConfig.bleMaxConcurrentTransfers + var availableSlots = max(0, limit - self.activeTransfers.count) + guard availableSlots > 0, !self.pendingFragmentTransfers.isEmpty else { return } + var toStart: [PendingFragmentTransfer] = [] + while availableSlots > 0, !self.pendingFragmentTransfers.isEmpty { + toStart.append(self.pendingFragmentTransfers.removeFirst()) + availableSlots -= 1 + } + for context in toStart { + self.messageQueue.async { [weak self] in + self?.startFragmentedPacket(context) + } } } } private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) { if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - fragmentHandler.handle(packet, from: peerID) + _handleFragment(packet, from: peerID) } else { - messageQueue.async { [weak self] in - self?.fragmentHandler.handle(packet, from: peerID) + messageQueue.async(flags: .barrier) { [weak self] in + self?._handleFragment(packet, from: peerID) } } } - /// Builds the fragment handler environment. All queue hops stay here so - /// `BLEFragmentHandler` remains queue-agnostic and synchronously testable. - private func makeFragmentHandlerEnvironment() -> BLEFragmentHandlerEnvironment { - BLEFragmentHandlerEnvironment( - localPeerID: { [weak self] in - self?.myPeerID ?? PeerID(str: "") - }, - trackPacketSeen: { [weak self] packet in - self?.gossipSyncManager?.onPublicPacketSeen(packet) - }, - appendFragment: { [weak self] header in - guard let self = self else { - return .stored(header: header, started: false) + private func _handleFragment(_ packet: BitchatPacket, from peerID: PeerID) { + // Don't process our own fragments + if peerID == myPeerID { + return + } + + // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type + guard packet.payload.count >= 13 else { return } + + // Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian + var senderU64: UInt64 = 0 + for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) } + var fragU64: UInt64 = 0 + for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) } + // Parse big-endian UInt16 safely without alignment assumptions + let idxHi = UInt16(packet.payload[8]) + let idxLo = UInt16(packet.payload[9]) + let index = Int((idxHi << 8) | idxLo) + let totHi = UInt16(packet.payload[10]) + let totLo = UInt16(packet.payload[11]) + let total = Int((totHi << 8) | totLo) + let originalType = packet.payload[12] + let fragmentData = packet.payload.suffix(from: 13) + + // Sanity checks - add reasonable upper bound on total to prevent DoS + guard total > 0 && total <= 10000 && index >= 0 && index < total else { return } + + let isBroadcastFragment: Bool = { + guard let recipient = packet.recipientID else { return true } + return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF } + }() + if isBroadcastFragment { + gossipSyncManager?.onPublicPacketSeen(packet) + } + + // Compute fragment key for this assembly + let key = FragmentKey(sender: senderU64, id: fragU64) + + // Critical section: Store fragment and check completion status + var shouldReassemble: Bool = false + var fragmentsToReassemble: [Int: Data]? = nil + + collectionsQueue.sync(flags: .barrier) { + if incomingFragments[key] == nil { + // Cap in-flight assemblies to prevent memory/battery blowups + if incomingFragments.count >= maxInFlightAssemblies { + // Evict the oldest assembly by timestamp + if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key { + incomingFragments.removeValue(forKey: oldest) + fragmentMetadata.removeValue(forKey: oldest) + } } - return onEngine { - self.fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: self.maxInFlightAssemblies) - } - }, - isAcceptedIngressPayload: { [weak self] packet, innerSender in - self?.isAcceptedIngressPayload(packet, from: innerSender) ?? false - }, - processReassembledPacket: { [weak self] packet, peerID in - self?.handleReceivedPacket(packet, from: peerID) + incomingFragments[key] = [:] + fragmentMetadata[key] = (originalType, total, Date()) + SecureLogger.debug("📦 Started fragment assembly id=\(String(format: "%016llx", fragU64)) total=\(total)", category: .session) } - ) + + // Check cumulative size before storing this fragment + let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0 + let assemblyLimit: Int = { + if originalType == MessageType.fileTransfer.rawValue { + // Allow headroom for TLV metadata and binary framing overhead. + return FileTransferLimits.maxFramedFileBytes + } + return FileTransferLimits.maxPayloadBytes + }() + let projectedSize = currentSize + fragmentData.count + guard projectedSize <= assemblyLimit else { + // Exceeds size limit - evict this assembly + SecureLogger.warning( + "🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(assemblyLimit)), evicting. Type=\(originalType) Index=\(index)/\(total)", + category: .security + ) + incomingFragments.removeValue(forKey: key) + fragmentMetadata.removeValue(forKey: key) + shouldReassemble = false + fragmentsToReassemble = nil + return + } + + incomingFragments[key]?[index] = Data(fragmentData) + SecureLogger.debug("📦 Fragment \(index + 1)/\(total) (len=\(fragmentData.count)) for id=\(String(format: "%016llx", fragU64))", category: .session) + + // Check if complete + if let fragments = incomingFragments[key], fragments.count == total { + shouldReassemble = true + fragmentsToReassemble = fragments + } else { + shouldReassemble = false + fragmentsToReassemble = nil + } + } + + // Heavy work outside lock: reassemble and decode + guard shouldReassemble, let fragments = fragmentsToReassemble else { return } + + var reassembled = Data() + for i in 0.. 2 { + collectionsQueue.async(flags: .barrier) { [weak self] in + if let task = self?.scheduledRelays.removeValue(forKey: messageID) { + task.cancel() + } + } + } + return // Duplicate ignored + } // Update peer info without verbose logging - update the peer we received from, not the original sender updatePeerLastSeen(peerID) - // Track recent traffic timestamps for adaptive behavior; the same - // barrier hop confirms route health for the packet's originator. - messageQueue.async { [weak self] in + // Track recent traffic timestamps for adaptive behavior + collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - self.recentTrafficTracker.recordPacket(at: Date()) - self.sourceRouteFailures.noteInboundActivity(from: senderID) - } - - // Per-peer protocol version: originated source routes only use hops - // observed speaking v2 (a v1-only node cannot decode v2 frames). - if packet.version >= 2 { - meshTopology.recordObservedVersion(packet.version, for: packet.senderID) - if peerID != senderID { - meshTopology.recordObservedVersion(packet.version, for: routingData(for: peerID)) + let now = Date() + self.recentPacketTimestamps.append(now) + // keep last N timestamps within window + let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds) + if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount { + self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount) } + self.recentPacketTimestamps.removeAll { $0 < cutoff } } - #if os(iOS) - // The maintenance timer is suspended with the app, so a packet arriving - // while backgrounded means the radio woke us — use the wake window to - // run the announce/flush/drain pass the timer would have run. - if !isAppActive { - bleQueue.async { [weak self] in self?.performBackgroundWakeMaintenanceIfStale() } - } - #endif - - + // Process by type - switch context.messageType { + switch MessageType(rawValue: packet.type) { case .announce: handleAnnounce(packet, from: senderID) - - case .announceV2: - // Parsed and ignored on purpose. The wire format and derivations are - // implemented and tested (see PeerIDRotation, AnnounceV2Packet), but - // consuming presence from it needs the replacement identity binding - // and the peer-list policy for unverified presence, both of which are - // still open questions in docs/PEER-ID-ROTATION.md. Accepting it now - // would add unauthenticated entries to the peer list. - break - + case .message: handleMessage(packet, from: senderID) @@ -6004,645 +3773,243 @@ extension BLEService { handleFragment(packet, from: senderID) case .fileTransfer: - // Broadcast files that fail sender authentication must not spread - // to downstream (possibly older, ungated) nodes; skip the relay - // step below, like invalid board posts and voice frames. - guard handleFileTransfer(packet, from: senderID) else { return } - - case .courierEnvelope: - handleCourierEnvelope(packet, from: peerID) - - case .groupMessage: - handleGroupMessage(packet, from: senderID) - - case .prekeyBundle: - handlePrekeyBundle(packet, from: senderID) - - case .boardPost: - // Invalid or deleted posts must not spread; skip the relay step. - guard handleBoardPost(packet, from: senderID) else { return } - case .nostrCarrier: - handleNostrCarrier(packet, from: peerID) - - case .voiceFrame: - // Rejected frames (unsigned/stale/spoofed) must not spread; skip - // the relay step below, like invalid board posts. - guard handleVoiceFrame(packet, from: senderID) else { return } - - case .ping: - // Rate limiting must key on the ingress link (`peerID`), not the - // packet-claimed sender: pings are unsigned, so `senderID` is - // attacker-controlled and rotating it would reset the budget. - handleMeshPing(packet, fromLink: peerID) - - case .pong: - handleMeshPong(packet, from: senderID) - + handleFileTransfer(packet, from: senderID) + case .leave: - // A forged leave must neither evict the claimed peer nor spread - // to downstream nodes. - guard handleLeave(packet, from: senderID) else { return } - + handleLeave(packet, from: senderID) + case .none: SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session) + break } if forwardAlongRouteIfNeeded(packet) { return } - scheduleRelayIfNeeded(packet, senderID: senderID, messageID: messageID) - } - - private func dropDuplicatePacketIfNeeded(context: BLEReceivedPacketContext, messageID: String) -> Bool { - guard context.shouldDeduplicate, messageDeduplicator.isDuplicate(messageID) else { - return false - } - - if context.logsHandlingDetails { - SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID.prefix(24))…", category: .session) - } - - let connectedCount = peerRegistry.connectedCount - if BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: connectedCount) { - messageQueue.async { [weak self] in - self?.scheduledRelays.cancel(messageID: messageID) + // Relay if TTL > 1 and we're not the original sender + // Relay decision and scheduling (extracted via RelayController) + do { + let degree = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } + let decision = RelayController.decide( + ttl: packet.ttl, + senderIsSelf: senderID == myPeerID, + isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, + isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil), + isFragment: packet.type == MessageType.fragment.rawValue, + isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, + isHandshake: packet.type == MessageType.noiseHandshake.rawValue, + isAnnounce: packet.type == MessageType.announce.rawValue, + degree: degree, + highDegreeThreshold: highDegreeThreshold + ) + guard decision.shouldRelay else { return } + let work = DispatchWorkItem { [weak self] in + guard let self = self else { return } + // Remove scheduled task before executing + self.collectionsQueue.async(flags: .barrier) { [weak self] in + _ = self?.scheduledRelays.removeValue(forKey: messageID) + } + var relayPacket = packet + relayPacket.ttl = decision.newTTL + self.broadcastPacket(relayPacket) } - } - - return true - } - - private func scheduleRelayIfNeeded(_ packet: BitchatPacket, senderID: PeerID, messageID: String) { - let degree = peerRegistry.connectedCount - let decision = BLEReceivePipeline.relayDecision( - for: packet, - senderID: senderID, - localPeerID: myPeerID, - degree: degree, - highDegreeThreshold: highDegreeThreshold - ) - guard decision.shouldRelay else { return } - - let work = DispatchWorkItem { [weak self] in - guard let self = self else { return } - messageQueue.async { [weak self] in - self?.scheduledRelays.remove(messageID: messageID) + // Track the scheduled relay so duplicates can cancel it + collectionsQueue.async(flags: .barrier) { [weak self] in + self?.scheduledRelays[messageID] = work } - var relayPacket = packet - relayPacket.ttl = decision.newTTL - self.broadcastPacket(relayPacket) + messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work) } - - messageQueue.async { [weak self] in - self?.scheduledRelays.schedule(work, messageID: messageID) - } - engineScheduler.schedule(after: Double(decision.delayMs) / 1_000, execute: work) } private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { - let result = announceHandler.handle(packet, from: peerID) - - // A capability bit in the public announce is only a discovery hint. - // Start authentication promptly for a directly connected candidate, - // but never pin or pre-queue private bytes until encrypted 0x21 state - // arrives from the completed Noise session. - if let result, - result.isVerified, - result.isDirectAnnounce, - result.announcement.capabilities?.contains(.privateMedia) == true, - privateMediaSendPolicy(to: result.peerID) == .awaitingCapabilityProof, - !noiseService.hasSession(with: result.peerID) { - initiateNoiseHandshake(with: result.peerID) + guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { + SecureLogger.error("❌ Failed to decode announce packet from \(peerID)", category: .session) + return } - - // A verified announce is the moment a signing key becomes bound to this - // owner's noise key: retry any prekey bundle that raced ahead of it. - if let result, result.isVerified { - drainPendingPrekeyBundles(for: result.peerID) + + // Verify that the sender's derived ID from the announced noise public key matches the packet senderID + // This helps detect relayed or spoofed announces. Only warn in release; assert in debug. + let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey) + if derivedFromKey != peerID { + SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) + return } - - // A verified direct announce proves the sender owns the link it came - // in on: heal any stale binding left by a peer-ID rotation, and - // consolidate duplicate same-role connections onto that link. - if let result, result.isVerified, result.isDirectAnnounce { - rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID) - retireRedundantPeripheralLinks(packet, to: result.peerID) - } - - // Bridge courier watch: a verified announce may add a peer whose - // relay-parked drops we should start watching for. - if let result, result.isVerified { - onVerifiedPeerAnnounce?(result.peerID) - } - - // Courier work: an announce is the moment we learn a peer's Noise - // static key, so check whether we're carrying mail addressed to them - // (or spray-able mail they could carry). Verified announces only. - guard !courierStore.isEmpty, - let result, - result.isVerified else { return } - let noiseKey = result.announcement.noisePublicKey - let authenticatedIngress = result.isDirectAnnounce - && canDeliverSecurely(to: result.peerID) - && isNoiseAuthenticatedIngressLink(for: packet, peerID: result.peerID) - if authenticatedIngress { - // The session was established on this still-bound ingress link. - // A peer-level Noise session alone is not enough: it can outlive - // its physical link while a replay rebinds an attacker's link to - // the victim's ID. - deliverCourierMail(to: result.peerID, noiseKey: noiseKey) - sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true) - } else { - // Relayed announce, or a direct-looking announce that has not yet - // proved link ownership with Noise: push a speculative copy while - // retaining the durable carried original. - deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey) - if result.isDirectAnnounce, - !hasCurrentNoiseAuthenticatedLink(to: result.peerID) { - // A cached session may predate this physical link. - // rebindLinkAfterVerifiedDirectAnnounce performs its atomic - // ordinary reconnect after the binding is published. - if !noiseService.hasSession(with: result.peerID) { - initiateNoiseHandshake(with: result.peerID) - } - } - } - } - - /// When a peer relaunches it rotates its ephemeral peer ID, but an - /// already-open BLE connection keeps its old peripheral/central→peerID - /// binding. Until that binding heals, the rotated peer shows up twice in - /// the peer list and its directed traffic on this link is dropped as - /// spoofed. A signature-verified direct announce proves the claimed - /// sender owns the link it arrived on, so rebind the link to the new ID - /// and retire the old identity. - /// Engine-confined: the whole rebind — containment checks, proof - /// retirement, binding flip, reconnect decision, and rotated-identity - /// retirement — is one engine slot, so no observer can see a - /// half-applied rotation. Only the physical connection cancels hop to - /// bleQueue. - private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) { - guard let link = ingressLinks.link(for: packet) else { return } - let linkUUID: String - let previousPeerID: PeerID? - switch link { - case .peripheral(let peripheralUUID): - linkUUID = peripheralUUID - previousPeerID = linkBindings.peer(forPeripheralID: peripheralUUID) - case .central(let centralUUID): - linkUUID = centralUUID - previousPeerID = linkBindings.peer(forCentralUUID: centralUUID) - } - guard let previousPeerID else { return } - guard previousPeerID != peerID else { - refreshNoiseSessionForVerifiedDirectLink( - link: link, - peerID: peerID - ) + + // Don't add ourselves as a peer + if peerID == myPeerID { return } - // The signature does not authenticate directness (TTL is excluded - // from signing because relays mutate it), so a "verified direct" - // announce can be a replay of another peer's fresh announce with - // its TTL restored. Contain what a forged rebind could do: - // never steal an identity another live link already owns, and - // allow at most one rebind per link per cooldown window so two - // identities can't fight over a link in a replay flip-flop. - guard linkBindings.links(to: peerID).isEmpty else { - SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: identity already owns another live link", category: .security) - return - } - let now = Date() - guard linkAuth.permitRebind( - linkUUID: linkUUID, - now: now, - cooldown: TransportConfig.bleLinkRebindCooldownSeconds - ) else { - SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security) - return - } - - // A Noise proof belongs to the old physical binding. Never carry - // it across an announce-driven rebind, whose direct TTL is - // replayable; the new owner must complete a fresh handshake. - linkAuth.retireLink(link) - switch link { - case .peripheral(let peripheralUUID): - bindPeripheralLink(peripheralUUID, to: peerID) - case .central(let centralUUID): - linkBindings.bindCentral(centralUUID, to: peerID) - } - // Same engine slot as the rebind: no observer may see the new - // binding while a cached peer-level sender is still considered - // established. - refreshNoiseSessionForVerifiedDirectLink( - link: link, - peerID: peerID - ) - SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session) - refreshLocalTopology() - // The announce that triggered this rebind was upserted as - // disconnected: the registry ran while the link still belonged - // to the previous ID (the ambiguous state BLEAnnounceHandler - // denies the connected shortcut). The rebind has now - // containment-checked the claim and the identity owns a live - // link, so promote it — otherwise a healed rotation leaves a - // live link that reads as disconnected until the next announce. - promoteReboundPeerToConnected(peerID) - // Any other peripheral links still bound to the rotated-away ID - // are stale duplicates of the same physical device (its restored - // connections outlived the relaunch that rotated the ID): cancel - // them now instead of leaving ghost links that spray duplicate - // traffic until the inactivity timeout. - cancelBoundPeripheralLinks(to: previousPeerID, keeping: linkUUID) - // Links we cannot cancel (the remote owns its central connections) - // must still stop claiming the dead identity, or it lingers as a - // ghost peer that the NEW identity's own traffic keeps refreshing - // (issue #1538). - releaseLinksBoundToRotatedPeer(previousPeerID) - retireRotatedPeer(previousPeerID) - } - - /// Unbinds every link still bound to an identity a verified direct - /// announce just rotated away from, and retires those links' Noise - /// proofs. - /// - /// Release, deliberately not rebind: a rotation announce proves only - /// that *its own* link's device now presents as the new ID, so binding - /// a different link to that ID on this evidence is exactly what the - /// #1401 containment rule ("never steal an identity another live link - /// already owns") forbids — and that rule stays intact. Unbinding is - /// strictly less trusting than any binding, and it is correct under - /// both readings of a second link bound to the retired ID: either it is - /// the same physical device (dual links to one phone, the field case), - /// or one of the two links is a spoofer holding a forged binding — - /// since a peer ID is derived from a Noise key fingerprint, two devices - /// cannot both legitimately own it. Dropping the binding is right in - /// the first case and a win in the second. - /// - /// Released links then converge through the ordinary unbound-link path: - /// the next raw direct announce on the link binds it to whoever it - /// actually carries. Until then the link's frames attribute to their - /// claimed sender rather than to a dead ID. - /// - /// Residual (unchanged in kind from what the containment already - /// accepts): an attacker who has bound their own link to X — possible - /// by replaying X's raw announce onto an unbound link — can drive a - /// rebind on it and so evict X's registry entry. X's next announce - /// re-binds its real links and restores presence, and the per-link - /// rebind cooldown bounds the repetition rate. - private func releaseLinksBoundToRotatedPeer(_ peerID: PeerID) { - for link in linkBindings.links(to: peerID) { - linkAuth.retireLink(link) - switch link { - case .peripheral(let peripheralUUID): - // No survivor: every link this peer holds is being released. - _ = linkBindings.peripheralRemoved(peripheralUUID) { _ in nil } - case .central(let centralUUID): - _ = linkBindings.centralRemoved(centralUUID) - } - } - } - - /// After a restore relaunch the same phone can reappear under a fresh - /// peripheral UUID while its restored connection lives on, leaving - /// several live central-role connections to one peer that each carry - /// every packet (field-verified: every voice frame arrived 2-3x). A - /// verified direct announce is the consolidation point: keep the link it - /// proves live (or the peer's most recently bound one) and cancel the - /// rest. Only same-role duplicates are touched — one connection per role - /// is the normal dual-role topology — and only connections we own as - /// central: the peer's central subscriptions on our peripheral manager - /// are its connections to cancel, and it runs this same policy. - /// - /// Directness is forgeable (TTL is unsigned), so a replayed announce - /// could nominate the replayer's link as the survivor. Containment - /// mirrors the rotation rebind: only links already BOUND to the peer are - /// retired (announce-evidenced, never pre-announce links), at most one - /// retirement per peer per cooldown window, and the peer keeps a live - /// link either way. - private func retireRedundantPeripheralLinks(_ packet: BitchatPacket, to peerID: PeerID) { - let ingressLink = ingressLinks.link(for: packet) - let now = Date() - var ingressPeripheralUUID: String? - if case .peripheral(let uuid) = ingressLink { - ingressPeripheralUUID = uuid - } - guard let keptUUID = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: ingressPeripheralUUID, - mostRecentlyBoundUUID: linkBindings.preferredPeripheralUUID(for: peerID), - links: peripheralLinkPolicySnapshot(), - peerID: peerID - ) else { return } - - guard linkAuth.permitRedundantRetirement( - peerID: peerID, - now: now, - cooldown: TransportConfig.bleLinkRebindCooldownSeconds - ) else { return } - // The survivor becomes the peer's reverse-mapped link so directed - // sends follow the consolidation. - bindPeripheralLink(keptUUID, to: peerID) - cancelBoundPeripheralLinks(to: peerID, keeping: keptUUID) - refreshLocalTopology() - } - - /// Cancels our central-role connections whose link is bound to `peerID`, - /// except `keptUUID`. Engine-confined: each binding is retired BEFORE - /// the cancel is issued, so didDisconnectPeripheral's identity hop sees - /// no peer binding and skips its peer-disconnect bookkeeping — the peer - /// is still live (on the kept link, or under its rotated identity). - /// Only the physical discard and the CoreBluetooth cancel hop to - /// bleQueue. - private func cancelBoundPeripheralLinks(to peerID: PeerID, keeping keptUUID: String?) { - let retiring = BLERedundantLinkPolicy.peripheralUUIDsToRetire( - links: peripheralLinkPolicySnapshot(), - peerID: peerID, - keeping: keptUUID ?? "" - ) - for uuid in retiring { - retirePeripheralLinkIdentity(uuid) - SecureLogger.info( - "🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")", - category: .session - ) - bleQueue.async { [weak self] in - guard let self, - let state = self.linkStateStore.state(forPeripheralID: uuid) else { return } - self.discardPeripheralLinkPhysical(uuid) - self.centralManager?.cancelPeripheralConnection(state.peripheral) - } - } - } - - /// Engine-confined: physical link rows joined with their engine-owned - /// bindings. - private func peripheralLinkPolicySnapshot() -> [BLERedundantLinkPolicy.PeripheralLink] { - let physical = readLinkState { store in - store.peripheralStates.map { - (uuid: $0.peripheral.identifier.uuidString, - isConnected: $0.isConnected, - hasCharacteristic: $0.characteristic != nil, - lastConnectedAt: $0.lastConnectedAt) - } - } - return physical.map { - BLERedundantLinkPolicy.PeripheralLink( - uuid: $0.uuid, - peerID: linkBindings.peer(forPeripheralID: $0.uuid), - isConnected: $0.isConnected, - hasCharacteristic: $0.hasCharacteristic, - lastConnectedAt: $0.lastConnectedAt - ) - } - } - - /// After a successful verified rebind the new identity owns a live link, - /// but its announce was stored disconnected (the link was still bound to - /// the rotated-away ID when the registry upsert ran). Flip it to - /// connected and republish so routing and the peer list see the healed - /// link. The `.peerConnected` UI event already fired from the announce - /// path (new/reconnected + direct), so only list state needs refreshing. - private func promoteReboundPeerToConnected(_ peerID: PeerID) { - let promoted = peerRegistry.mutate { $0.markConnected(peerID) } - guard promoted else { return } - refreshLocalTopology() - publishFullPeerData() - notifyUI { [weak self] in - guard let self else { return } - let currentPeerIDs = self.peerRegistry.peerIDs - self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) - } - } - - /// Rotation is an implicit leave of the old identity: drop it immediately - /// instead of letting a ghost duplicate linger for the reachability - /// retention window. - private func retireRotatedPeer(_ peerID: PeerID) { - let removed = peerRegistry.mutate { $0.remove(peerID) != nil } - guard removed else { return } - gossipSyncManager?.removeAnnouncementForPeer(peerID) - refreshLocalTopology() - notifyUI { [weak self] in - guard let self else { return } - let currentPeerIDs = self.peerRegistry.peerIDs - self.deliverTransportEvent(.peerDisconnected(peerID)) - self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) - } - } - - /// Builds the announce handler environment. All queue hops stay here so - /// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable. - private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment { - BLEAnnounceHandlerEnvironment( - localPeerID: { [weak self] in - self?.myPeerID ?? PeerID(str: "") - }, - messageTTL: messageTTL, - now: { Date() }, - existingPeerKeys: { [weak self] peerID in - guard let self = self else { return (nil, nil) } - return onEngine { - let info = self.peerRegistry.info(for: peerID) - return (info?.noisePublicKey, info?.signingPublicKey) - } - }, - persistedSigningPublicKey: { [weak self] peerID in - // Same synchronous identity-manager read pattern as - // signedSenderDisplayName(for:from:); the manager serializes - // access on its own internal queue. - guard let self = self else { return nil } - return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) - .compactMap { $0.signingPublicKey } - .first - }, - authenticatedSigningPublicKey: { [weak self] noisePublicKey in - self?.identityManager.authenticatedSigningPublicKey( - forFingerprint: noisePublicKey.sha256Fingerprint() - ) - }, - verifySignature: { [weak self] packet, signingPublicKey in - self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false - }, - linkState: { [weak self] peerID in - self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false) - }, - linkBoundToOtherPeer: { [weak self] packet, peerID in - // Reads the CURRENT binding — i.e. the state before - // rebindLinkAfterVerifiedDirectAnnounce (which runs after the - // handler) may steal the link and promote the new owner to - // connected. See the caller in BLEAnnounceHandler for why the - // residual forged-presence window this leaves is accepted. - guard let self else { return false } - guard let link = self.ingressLinks.link(for: packet) else { return false } - guard let boundPeerID = self.linkBindings.boundPeer(for: link) else { return false } - return boundPeerID != peerID - }, - withRegistryBarrier: { [weak self] body in - self?.onEngine { body() } - }, - upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in - // Called from inside withRegistryBarrier; access registry directly. - guard let self = self else { - return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) - } - return self.peerRegistry.mutate { - $0.upsertVerifiedAnnounce( - peerID: peerID, - nickname: announcement.nickname, - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - isConnected: isConnected, - // Propagate `nil` (registry refused the announce because it - // carries a signing key different from the pinned one) so - // the handler's guard rejects it instead of overwriting the - // pinned identity. Main's capabilities/bridgeGeohash are - // preserved. - now: now, - capabilities: announcement.capabilities, - bridgeGeohash: announcement.bridgeGeohash - ) - } - }, - shouldEmitReconnectLog: { [weak self] peerID, now in - // Called from inside withRegistryBarrier; access debouncer directly. - self?.reconnectLogDebouncer.shouldEmit( - peerID: peerID, - now: now, - minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds - ) ?? false - }, - updateTopology: { [weak self] peerID, neighbors in - self?.meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors) - }, - persistIdentity: { [weak self] announcement in - self?.identityManager.upsertCryptographicIdentity( - fingerprint: announcement.noisePublicKey.sha256Fingerprint(), - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - claimedNickname: announcement.nickname - ) - }, - dedupContains: { [weak self] id in - self?.messageDeduplicator.contains(id) ?? true - }, - dedupMarkProcessed: { [weak self] id in - self?.messageDeduplicator.markProcessed(id) - }, - deliverAnnounceUIEvents: { [weak self] peerID, notifyPeerConnected, scheduleInitialSync in - // Single main-actor hop so event order is guaranteed: - // .peerConnected → initial sync scheduling → .peerListUpdated. - self?.notifyUI { [weak self] in - guard let self = self else { return } - if notifyPeerConnected { - self.deliverTransportEvent(.peerConnected(peerID)) - } - if scheduleInitialSync { - self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) - } - // Get current peer list (after addition) - let currentPeerIDs = self.peerRegistry.peerIDs - self.requestPeerDataPublish() - self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) - } - }, - trackPacketSeen: { [weak self] packet in - self?.gossipSyncManager?.onPublicPacketSeen(packet) - }, - sendAnnounceBack: { [weak self] in - self?.sendAnnounce(forceSend: true) - }, - scheduleAfterglow: { [weak self] delay in - self?.engineScheduler.schedule(after: delay) { [weak self] in - self?.sendAnnounce(forceSend: true) - } - } - ) - } - - // MARK: - Board (geohash bulletin board) - - /// Validates and stores an incoming board post or tombstone. Returns - /// whether the packet is worth relaying onward. - private func handleBoardPost(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { - guard let wire = BoardWire.decode(from: packet.payload) else { - SecureLogger.warning("⚠️ Malformed board packet from \(peerID.id.prefix(8))…", category: .session) - return false - } - // Posts are self-authenticating: the payload embeds the author's - // Ed25519 key and signature, so verification does not depend on the - // author still being around to announce. - guard wire.verifySignature() else { - if logRateLimiter.shouldLog(key: "board-sig:\(peerID.id)") { - SecureLogger.warning("🚫 Dropping board packet with invalid signature from \(peerID.id.prefix(8))…", category: .security) - } - return false - } - switch boardStore.ingest(wire, packet: packet) { - case .accepted, .duplicate: - return true - case .rejected: - return false - } - } - - /// Broadcasts a pre-signed board payload (post or tombstone) built by the - /// board manager, and ingests it locally so it shows up on our own board - /// and joins gossip sync immediately. - func sendBoardPayload(_ payload: Data) { - guard let wire = BoardWire.decode(from: payload), wire.verifySignature() else { - SecureLogger.error("❌ Refusing to send invalid board payload", category: .session) - return - } - messageQueue.async { [weak self] in - guard let self = self else { return } - let basePacket = BitchatPacket( - type: MessageType.boardPost.rawValue, - senderID: Data(hexString: self.myPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: self.messageTTL - ) - guard let signedPacket = self.noiseService.signPacket(basePacket) else { - SecureLogger.error("❌ Failed to sign board packet", category: .security) + // Reject stale announces to prevent ghost peers from appearing + // Use same 15-minute window as gossip sync (900 seconds) + let maxAnnounceAgeSeconds: TimeInterval = 900 + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + let ageThresholdMs = UInt64(maxAnnounceAgeSeconds * 1000) + if nowMs >= ageThresholdMs { + let cutoffMs = nowMs - ageThresholdMs + if packet.timestamp < cutoffMs { + SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(Double(nowMs - packet.timestamp) / 1000.0)s)", category: .session) return } - // Pre-mark our own broadcast as processed to avoid handling a relayed self copy - let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket) - self.messageDeduplicator.markProcessed(dedupID) - self.boardStore.ingest(wire, packet: signedPacket) - self.broadcastPacket(signedPacket) + } + + // Suppress announce logs to reduce noise + + // Precompute signature verification outside barrier to reduce contention + let existingPeerForVerify = collectionsQueue.sync { peers[peerID] } + var verifiedAnnounce = false + if packet.signature != nil { + verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) + if !verifiedAnnounce { + SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security) + } + } + if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey { + SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) + verifiedAnnounce = false + } + + // Track if this is a new or reconnected peer + var isNewPeer = false + var isReconnectedPeer = false + let directLinkState = linkState(for: peerID) + + collectionsQueue.sync(flags: .barrier) { + // Check if we have an actual BLE connection to this peer + let hasPeripheralConnection = directLinkState.hasPeripheral + + // Check if this peer is subscribed to us as a central + // Note: We can't identify which specific central is which peer without additional mapping + let hasCentralSubscription = directLinkState.hasCentral + + // Direct announces arrive with full TTL (no prior hop) + let isDirectAnnounce = (packet.ttl == messageTTL) + + // Check if we already have this peer (might be reconnecting) + let existingPeer = peers[peerID] + let wasDisconnected = existingPeer?.isConnected == false + + // Set flags for use outside the sync block + isNewPeer = (existingPeer == nil) + isReconnectedPeer = wasDisconnected + + // Use precomputed verification result + let verified = verifiedAnnounce + + // Require verified announce; ignore otherwise (no backward compatibility) + if !verified { + SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security) + // Reset flags to prevent post-barrier code from acting on unverified announces + isNewPeer = false + isReconnectedPeer = false + return + } + + // Update or create peer info + if let existing = existingPeer, existing.isConnected { + // Update lastSeen and identity info + peers[peerID] = PeerInfo( + peerID: existing.peerID, + nickname: announcement.nickname, + isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isVerifiedNickname: true, + lastSeen: Date() + ) + } else { + // New peer or reconnecting peer + peers[peerID] = PeerInfo( + peerID: peerID, + nickname: announcement.nickname, + isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isVerifiedNickname: true, + lastSeen: Date() + ) + } + + // Log connection status only for direct connectivity changes; debounce to reduce spam + if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { + let now = Date() + if existingPeer == nil { + SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session) + } else if wasDisconnected { + // Debounce 'reconnected' logs within short window + if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds { + // Skip duplicate log + } else { + SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session) + lastReconnectLogAt[peerID] = now + } + } else if existingPeer?.nickname != announcement.nickname { + SecureLogger.debug("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: .session) + } + } + } + + // Update topology with verified neighbor claims (only for authenticated announces) + if verifiedAnnounce, let neighbors = announcement.directNeighbors { + meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors) + } + + // Persist cryptographic identity and signing key for robust offline verification + identityManager.upsertCryptographicIdentity( + fingerprint: announcement.noisePublicKey.sha256Fingerprint(), + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + claimedNickname: announcement.nickname + ) + + // Notify UI on main thread + notifyUI { [weak self] in + guard let self = self else { return } + + // Get current peer list (after addition) + let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } + + // Only notify of connection for new or reconnected peers when it is a direct announce + if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { + self.delegate?.didConnectToPeer(peerID) + // Schedule initial unicast sync to this peer + self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) + } + + self.requestPeerDataPublish() + self.delegate?.didUpdatePeerList(currentPeerIDs) + } + + // Track for sync (include our own and others' announces) + gossipSyncManager?.onPublicPacketSeen(packet) + + // Send announce back for bidirectional discovery (only once per peer) + let announceBackID = "announce-back-\(peerID)" + let shouldSendBack = !messageDeduplicator.contains(announceBackID) + if shouldSendBack { + messageDeduplicator.markProcessed(announceBackID) + } + + if shouldSendBack { + // Reciprocate announce for bidirectional discovery + // Force send to ensure the peer receives our announce + sendAnnounce(forceSend: true) + } + + // Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop + if isNewPeer { + let delay = Double.random(in: 0.3...0.6) + messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.sendAnnounce(forceSend: true) + } } } // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) { - // REQUEST_SYNC is link-local by design (always sent with ttl 0): a - // nonzero TTL means a crafted or relayed request, and answering one - // would let a single small packet fan a full store replay out of - // every node it reaches. - guard packet.ttl == 0 else { - if logRateLimiter.shouldLog(key: "sync-ttl:\(peerID.id)") { - SecureLogger.warning("🚫 Dropping REQUEST_SYNC with nonzero TTL from \(peerID.id.prefix(8))…", category: .security) - } - return - } - // A response can replay the entire gossip store, so require proof the - // requester owns the claimed sender ID: the request must verify - // against the signing key from that peer's announce. - let signingKey = peerRegistry.info(for: peerID)?.signingPublicKey - guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { - if logRateLimiter.shouldLog(key: "sync-sig:\(peerID.id)") { - SecureLogger.warning("🚫 Dropping REQUEST_SYNC without verifiable signature from \(peerID.id.prefix(8))…", category: .security) - } - return - } guard let req = RequestSyncPacket.decode(from: packet.payload) else { - SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .session) + SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID)", category: .session) return } gossipSyncManager?.handleRequestSync(from: peerID, request: req) @@ -6651,341 +4018,289 @@ extension BLEService { // Mention parsing moved to ChatViewModel private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) { - publicMessageHandler.handle(packet, from: peerID) - } + // Ignore self-origin public messages except when returned via sync (TTL==0). + // This allows our own messages to be surfaced when they come back via + // the sync path without re-processing regular relayed copies. + if peerID == myPeerID && packet.ttl != 0 { return } - /// Builds the public-message handler environment. All queue hops stay here - /// so `BLEPublicMessageHandler` remains queue-agnostic and synchronously - /// testable. - private func makePublicMessageHandlerEnvironment() -> BLEPublicMessageHandlerEnvironment { - BLEPublicMessageHandlerEnvironment( - localPeerID: { [weak self] in - self?.myPeerID ?? PeerID(str: "") - }, - localNickname: { [weak self] in - self?.myNickname ?? "" - }, - now: { Date() }, - peersSnapshot: { [weak self] in - guard let self = self else { return [:] } - return self.peerRegistry.snapshotByID - }, - verifyPacketSignature: { [weak self] packet, signingPublicKey in - self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false - }, - signedSenderDisplayName: { [weak self] packet, peerID in - self?.signedSenderDisplayName(for: packet, from: peerID) - }, - trackPacketSeen: { [weak self] packet in - self?.gossipSyncManager?.onPublicPacketSeen(packet) - }, - linkState: { [weak self] peerID in - self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false) - }, - takeSelfBroadcastMessageID: { [weak self] packet in - // Caller is on messageQueue, where the tracker is owned. - self?.selfBroadcastTracker.takeMessageID(for: packet) - }, - deliverPublicMessage: { [weak self] peerID, nickname, content, timestamp, messageID in - // Single main-actor hop delivering `.publicMessageReceived`. - self?.notifyUI { [weak self] in - self?.deliverTransportEvent( - .publicMessageReceived( - peerID: peerID, - nickname: nickname, - content: content, - timestamp: timestamp, - messageID: messageID - ) - ) - } - } - ) - } - - /// Group broadcasts are opaque ciphertext to this layer: track them for - /// gossip backfill and hand the payload to the UI layer, where the group - /// coordinator decrypts and authenticates against the roster. Non-members - /// still relay (generic broadcast relay path) but never decode. - private func handleGroupMessage(_ packet: BitchatPacket, from _: PeerID) { - let isBroadcastRecipient: Bool = { - guard let recipient = packet.recipientID else { return true } - return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF } + // Reject stale broadcast messages to prevent old messages from appearing + // Use same 15-minute window as gossip sync (900 seconds) + // Check if this is a broadcast message (recipient is all 0xFF or nil) + let isBroadcast: Bool = { + guard let r = packet.recipientID else { return true } + return r.count == 8 && r.allSatisfy { $0 == 0xFF } }() - guard isBroadcastRecipient, !packet.payload.isEmpty else { return } - - gossipSyncManager?.onPublicPacketSeen(packet) - - let payload = packet.payload - let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp)) - } - } - - /// Inbound public live-voice packet: broadcast-only, freshness-gated, and - /// signature-verified against the claimed sender's announce (mirrors the - /// public-message identity gate — `senderID` is attacker-controlled, so a - /// valid packet signature is required before any audio reaches the UI). - /// Returns whether the packet was accepted; rejected packets must not be - /// relayed either, or spoofed 0x29 floods would still amplify. - private func handleVoiceFrame(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { - guard peerID != myPeerID else { return false } - guard BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) else { return false } - guard !BLEPacketFreshnessPolicy.isStale( - timestampMilliseconds: packet.timestamp, - now: Date(), - maxAgeSeconds: TransportConfig.pttPublicFrameMaxAgeSeconds - ) else { return false } - - let peersSnapshot = peerRegistry.snapshotByID - let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey - let verifiedViaRegistry = registrySigningKey.map { noiseService.verifyPacketSignature(packet, publicKey: $0) } ?? false - let signedDisplayName = verifiedViaRegistry ? nil : signedSenderDisplayName(for: packet, from: peerID) - guard verifiedViaRegistry || signedDisplayName != nil else { - SecureLogger.warning("🚫 Dropping voice frame with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security) - return false - } - guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: myPeerID, - localNickname: myNickname, - peers: peersSnapshot, - allowConnectedUnverified: false - ) ?? signedDisplayName else { - return false - } - - let payload = packet.payload - let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.deliverTransportEvent(.publicVoiceFrameReceived( - peerID: peerID, - nickname: senderNickname, - payload: payload, - timestamp: timestamp - )) - } - return true - } - - private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { - let result = noisePacketHandler.handleHandshakeWithResult( - packet, - from: peerID - ) - // An inbound message 1 quarantines the old transport receive-only. - // Keep its generation-bound BLE state intact: the manager's new - // handshaking generation already gates every outbound policy, while - // a rollback can become ready again without repeating capability - // proof or announce side effects. Only the exact handshake candidate's - // authenticated completion may promote the physical ingress link. - if result.didEstablishAuthenticatedSession { - markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID) - } - } - - private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { - noisePacketHandler.handleEncrypted(packet, from: peerID) - } - - /// Builds the Noise packet handler environment. All queue hops and - /// `noiseService` crypto calls stay here so `BLENoisePacketHandler` - /// remains queue-agnostic and synchronously testable. - private func makeNoisePacketHandlerEnvironment() -> BLENoisePacketHandlerEnvironment { - BLENoisePacketHandlerEnvironment( - localPeerID: { [weak self] in - self?.myPeerID ?? PeerID(str: "") - }, - localPeerIDData: { [weak self] in - self?.myPeerIDData ?? Data() - }, - messageTTL: messageTTL, - now: { Date() }, - processHandshakeMessage: { [weak self] peerID, message in - guard let self else { - return NoiseHandshakeProcessingResult( - response: nil, - didEstablishAuthenticatedSession: false - ) - } - return try self.noiseService.processHandshakeMessageWithResult( - from: peerID, - message: message - ) - }, - hasNoiseSession: { [weak self] peerID in - self?.noiseService.hasSession(with: peerID) ?? false - }, - isAwaitingResponderHandshakeCompletion: { [weak self] peerID in - self?.noiseService.isAwaitingResponderHandshakeCompletion( - with: peerID - ) ?? false - }, - initiateHandshake: { [weak self] peerID in - self?.initiateNoiseHandshake(with: peerID) - }, - broadcastPacket: { [weak self] packet in - self?.broadcastPacket(packet) - }, - updatePeerLastSeen: { [weak self] peerID in - self?.updatePeerLastSeen(peerID) - }, - decrypt: { [weak self] payload, peerID in - guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished } - // Decrypt runs on the engine queue; the readiness callback - // fires on the noise manager's queue; the session store is - // a leaf lock, so the read is safe from there. - let result = try self.noiseService.decryptWithSessionGeneration( - payload, - from: peerID, - establishedGenerationIsReady: { generation in - self.privateMediaSessions.currentGeneration( - for: peerID.toShort() - ) == generation - } - ) - return BLENoiseDecryptionResult( - plaintext: result.plaintext, - sessionGeneration: result.sessionGeneration - ) - }, - clearSession: { [weak self] peerID in - self?.clearNoiseSession(for: peerID) - }, - handleAuthenticatedPeerState: { [weak self] peerID, payload, generation in - self?.handleAuthenticatedPeerState( - payload, - from: peerID, - sessionGeneration: generation - ) - }, - deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in - if type == .privateFile { - self?.fileTransferHandler.handlePrivatePayload( - payload, - from: peerID, - timestamp: timestamp - ) + if isBroadcast { + let maxMessageAgeSeconds: TimeInterval = 900 + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + let ageThresholdMs = UInt64(maxMessageAgeSeconds * 1000) + if nowMs >= ageThresholdMs { + let cutoffMs = nowMs - ageThresholdMs + if packet.timestamp < cutoffMs { + SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(Double(nowMs - packet.timestamp) / 1000.0)s)", category: .session) return } - // Single main-actor hop delivering `.noisePayloadReceived`. - self?.notifyUI { [weak self] in - self?.deliverTransportEvent(.noisePayloadReceived( - peerID: peerID, - type: type, - payload: payload, - timestamp: timestamp - )) + } + } + + var accepted = false + var senderNickname: String = "" + // Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks. + let peersSnapshot = collectionsQueue.sync { peers } + + // If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately + if peerID == myPeerID { + accepted = true + senderNickname = myNickname + } + else if let info = peersSnapshot[peerID], info.isVerifiedNickname { + // Known verified peer path + accepted = true + senderNickname = info.nickname + // Handle nickname collisions + let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) + if hasCollision { + senderNickname += "#" + String(peerID.id.prefix(4)) + } + } else { + // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix + if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { + // Find candidate identities by peerID prefix (16 hex) + let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + for candidate in candidates { + if let signingKey = candidate.signingPublicKey, + noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { + accepted = true + // Prefer persisted social petname or claimed nickname + if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { + senderNickname = social.localPetname ?? social.claimedNickname + } else { + senderNickname = "anon" + String(peerID.id.prefix(4)) + } + break + } } } - ) + } + + guard accepted else { + SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) + return + } + + let isBroadcastRecipient: Bool = { + guard let r = packet.recipientID else { return true } + return r.count == 8 && r.allSatisfy { $0 == 0xFF } + }() + if isBroadcastRecipient && packet.type == MessageType.message.rawValue { + gossipSyncManager?.onPublicPacketSeen(packet) + } + + guard let content = String(data: packet.payload, encoding: .utf8) else { + SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session) + return + } + // Determine if we have a direct link to the sender + let directLink = linkState(for: peerID) + let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral + + let pathTag = hasDirectLink ? "direct" : "mesh" + SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session) + + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + var resolvedSelfMessageID: String? = nil + if peerID == myPeerID { + let senderHex = packet.senderID.hexEncodedString() + let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)" + resolvedSelfMessageID = selfBroadcastMessageIDs.removeValue(forKey: dedupID)?.id + } + notifyUI { [weak self] in + self?.delegate?.didReceivePublicMessage(from: peerID, + nickname: senderNickname, + content: content, + timestamp: ts, + messageID: resolvedSelfMessageID) + } + } + + private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { + // Use NoiseEncryptionService for handshake processing + if PeerID(hexData: packet.recipientID) == myPeerID { + // Handshake is for us + do { + if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) { + // Send response + let responsePacket = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: response, + signature: nil, + ttl: messageTTL + ) + // We're on messageQueue from delegate callback + broadcastPacket(responsePacket) + } + + // Session establishment will trigger onPeerAuthenticated callback + // which will send any pending messages at the right time + } catch { + SecureLogger.error("Failed to process handshake: \(error)") + // Try initiating a new handshake + if !noiseService.hasSession(with: peerID) { + initiateNoiseHandshake(with: peerID) + } + } + } + } + + private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { + SecureLogger.debug("🔐 handleNoiseEncrypted called for packet from \(peerID)") + + guard let recipientID = PeerID(hexData: packet.recipientID) else { + SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session) + return + } + + if recipientID != myPeerID { + SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID), I am \(myPeerID))", category: .session) + return + } + + // Update lastSeen for the peer we received from (important for private messages) + updatePeerLastSeen(peerID) + + do { + let decrypted = try noiseService.decrypt(packet.payload, from: peerID) + guard decrypted.count > 0 else { return } + + // First byte indicates the payload type + let payloadType = decrypted[0] + let payloadData = decrypted.dropFirst() + + switch NoisePayloadType(rawValue: payloadType) { + case .privateMessage: + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts) + } + case .delivered: + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts) + } + case .readReceipt: + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts) + } + case .verifyChallenge: + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts) + } + case .verifyResponse: + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts) + } + case .none: + SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)") + } + } catch NoiseEncryptionError.sessionNotEstablished { + // We received an encrypted message before establishing a session with this peer. + // Trigger a handshake so future messages can be decrypted. + SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake") + if !noiseService.hasSession(with: peerID) { + initiateNoiseHandshake(with: peerID) + } + } catch { + // Decryption failed - clear the corrupted session and re-initiate handshake + // This handles cases where session state got out of sync (nonce mismatch, etc.) + SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error) - clearing session and re-initiating handshake") + noiseService.clearSession(for: peerID) + initiateNoiseHandshake(with: peerID) + } } // MARK: Helper Functions private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) { - let payloads = onEngine { () -> [BLEPendingTypedPayload] in - pendingNoiseSessionQueues.takeTypedPayloads(for: peerID) + let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in + let list = pendingNoisePayloadsAfterHandshake[peerID] ?? [] + pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID) + return list } guard !payloads.isEmpty else { return } - SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session) - for pending in payloads { - let isPrivateMedia = NoisePayloadType.isPrivateFile(rawValue: pending.payload.first) - let privateMediaTransferId = isPrivateMedia ? pending.transferId : nil - - if isPrivateMedia { - switch privateMediaSendPolicy(to: peerID) { - case .encrypted: - break - - case .awaitingCapabilityProof: - // Handshake completion alone is insufficient. Put the - // exact payload back until authenticated 0x21 state - // arrives; that handler calls this drain again. - onEngine { - pendingNoiseSessionQueues.appendTypedPayload( - pending.payload, - transferId: pending.transferId, - for: peerID - ) - } - continue - - case .legacyRequiresConsent, .blockedDowngrade: - if let transferId = pending.transferId { - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String( - localized: "content.delivery.reason.private_media_capability_unresolved", - defaultValue: "Could not confirm encrypted media support", - comment: "Failure reason when queued private media cannot be authenticated after handshake" - ) - ) - privateMediaTransferAdmissions.finish(transferId) - } - continue - } - } - if let transferId = privateMediaTransferId, - !privateMediaTransferAdmissions.isActive(transferId) { - privateMediaTransferAdmissions.finish(transferId) - continue - } + SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", category: .session) + for payload in payloads { do { - if let transferId = privateMediaTransferId, - !privateMediaTransferAdmissions.isActive(transferId) { - privateMediaTransferAdmissions.finish(transferId) - continue - } - let packet = try makeEncryptedNoisePacket(pending.payload, to: peerID) - if let transferId = privateMediaTransferId, - !privateMediaTransferAdmissions.isActive(transferId) { - privateMediaTransferAdmissions.finish(transferId) - continue - } - broadcastPacket( - packet, - transferId: pending.transferId, - requiresPrivateMediaAdmission: privateMediaTransferId != nil + let encrypted = try noiseService.encrypt(payload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL ) + broadcastPacket(packet) } catch { - SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)") - if let transferId = pending.transferId { - TransferProgressManager.shared.rejectBeforeStart( - id: transferId, - reason: String( - localized: "content.delivery.reason.encryption_failed", - defaultValue: "Failed to encrypt media", - comment: "Failure reason shown when queued private media cannot be encrypted after handshake" - ) - ) - privateMediaTransferAdmissions.finish(transferId) - } + SecureLogger.error("❌ Failed to send pending noise payload to \(peerID): \(error)") } } } private func updatePeerLastSeen(_ peerID: PeerID) { - peerRegistry.mutate { $0.updateLastSeen(peerID, at: Date()) } + // Use async to avoid deadlock - we don't need immediate consistency for last seen updates + collectionsQueue.async(flags: .barrier) { + if var peer = self.peers[peerID] { + peer.lastSeen = Date() + self.peers[peerID] = peer + } + } } // Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window - @MainActor private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) { - if disconnectNotifyDebouncer.shouldEmit( - peerID: peerID, - now: Date(), - minimumInterval: TransportConfig.bleDisconnectNotifyDebounceSeconds - ) { - deliverTransportEvent(.peerDisconnected(peerID)) + let now = Date() + let last = recentDisconnectNotifies[peerID] + if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds { + delegate?.didDisconnectFromPeer(peerID) + recentDisconnectNotifies[peerID] = now + } else { + // Suppressed duplicate disconnect notification } } // NEW: Publish peer snapshots to subscribers and notify Transport delegates private func publishFullPeerData() { - let transportPeers = peerRegistry.transportSnapshots(selfNickname: myNickname) - notifyUI { [weak self] in + let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync { + // Compute nickname collision counts for connected peers + let connected = peers.values.filter { $0.isConnected } + var counts: [String: Int] = [:] + for p in connected { counts[p.nickname, default: 0] += 1 } + counts[myNickname, default: 0] += 1 + return peers.values.map { info in + var display = info.nickname + if info.isConnected, (counts[info.nickname] ?? 0) > 1 { + display += "#" + String(info.peerID.id.prefix(4)) + } + return TransportPeerSnapshot( + peerID: info.peerID, + nickname: display, + isConnected: info.isConnected, + noisePublicKey: info.noisePublicKey, + lastSeen: info.lastSeen + ) + } + } + // Notify non-UI listeners + peerSnapshotSubject.send(transportPeers) + // Notify UI on MainActor via delegate + Task { @MainActor [weak self] in self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers) } } @@ -6993,55 +4308,57 @@ extension BLEService { // MARK: Consolidated Maintenance private func performMaintenance() { - guard !isPanicSuspended else { return } maintenanceCounter += 1 - lastMaintenanceAt = Date() - + + // Adaptive announce: reduce frequency when we have connected peers let now = Date() - let connectedCount = peerRegistry.connectedCount - let elapsed = announceThrottle.elapsed(since: now) - let recentSeen = recentTrafficTracker.hasTraffic(within: 5.0, now: now) - let hasNoPeers = peerRegistry.isEmpty - let plan = BLEMaintenancePolicy.plan( - cycle: maintenanceCounter, - connectedCount: connectedCount, - peerRegistryIsEmpty: hasNoPeers, - elapsedSinceLastAnnounce: elapsed, - hasRecentTraffic: recentSeen - ) - - if plan.shouldSendAnnounce { - sendAnnounce(forceSend: true) + let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } + let elapsed = now.timeIntervalSince(lastAnnounceSent) + if connectedCount == 0 { + // Discovery mode: keep frequent announces + if elapsed >= TransportConfig.bleAnnounceIntervalSeconds { sendAnnounce(forceSend: true) } + } else { + // Connected mode: announce less often; much less in dense networks + let base = connectedCount >= TransportConfig.bleHighDegreeThreshold ? + TransportConfig.bleConnectedAnnounceBaseSecondsDense : TransportConfig.bleConnectedAnnounceBaseSecondsSparse + let jitter = connectedCount >= TransportConfig.bleHighDegreeThreshold ? + TransportConfig.bleConnectedAnnounceJitterDense : TransportConfig.bleConnectedAnnounceJitterSparse + let target = base + Double.random(in: -jitter...jitter) + if elapsed >= target { sendAnnounce(forceSend: true) } } - if plan.shouldEnsureAdvertising { + // Activity-driven quick-announce: if we've seen any packet in last 5s and it has + // been >=10s since the last announce, send a presence nudge. + let recentSeen = collectionsQueue.sync { () -> Bool in + let cutoff = now.addingTimeInterval(-5.0) + return recentPacketTimestamps.contains(where: { $0 >= cutoff }) + } + if recentSeen && elapsed >= 10.0 { + sendAnnounce(forceSend: true) + } + + // If we have no peers, ensure we're scanning and advertising + if peers.isEmpty { // Ensure we're advertising as peripheral if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising { - pm.startAdvertising(BLERadioController.advertisementData()) + pm.startAdvertising(buildAdvertisementData()) } } // Update scanning duty-cycle based on connectivity - radio.updateScanningDutyCycle(connectedCount: connectedCount) - radio.updateRSSIThreshold(connectedCount: connectedCount) - - // Drain the connection candidate queue. Weak-RSSI discoveries are - // enqueued rather than connected immediately, and the event-driven - // drains (disconnect/failure/timeout) never fire when we're idle — - // without this, an isolated node surrounded only by weak (distant) - // peers would queue them all and never connect to anyone. - radio.tryConnectFromQueue() + updateScanningDutyCycle(connectedCount: connectedCount) + updateRSSIThreshold(connectedCount: connectedCount) // Check peer connectivity every cycle for snappier UI updates checkPeerConnectivity() // Every 30 seconds (3 cycles): Cleanup - if plan.shouldRunCleanup { + if maintenanceCounter % 3 == 0 { performCleanup() } // Attempt to flush any spooled directed messages periodically (~every 5 seconds) - if plan.shouldFlushDirectedSpool { + if maintenanceCounter % 2 == 1 { flushDirectedSpool() } @@ -7053,65 +4370,66 @@ extension BLEService { // No rotating alias: nothing to refresh // Reset counter to prevent overflow (every 60 seconds) - if plan.shouldResetCounter { + if maintenanceCounter >= 6 { maintenanceCounter = 0 } } - #if os(iOS) - /// Catch-up maintenance for background wake windows (bleQueue-confined). - /// Rate-limited to the normal maintenance cadence so a burst of inbound - /// packets during one wake still runs at most one extra pass. - private func performBackgroundWakeMaintenanceIfStale() { - guard meshBackgroundEnabled, - !isAppActive, - Date().timeIntervalSince(lastMaintenanceAt) >= TransportConfig.bleMaintenanceInterval else { return } - performMaintenance() - } - #endif - private func checkPeerConnectivity() { - // Maintenance ticks on bleQueue; connectivity reconciliation reads - // the engine-owned bindings, so it rides an engine slot. - messageQueue.async { [weak self] in - self?.checkPeerConnectivityOnEngine() - } - } - - private func checkPeerConnectivityOnEngine() { let now = Date() - let peerIDsForLinkState: [PeerID] = peerRegistry.peerIDs - var cachedLinkStates: [PeerID: BLEPeerLinkPresence] = [:] + var disconnectedPeers: [PeerID] = [] + let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { Array(peers.keys) } + var cachedLinkStates: [PeerID: (hasPeripheral: Bool, hasCentral: Bool)] = [:] for peerID in peerIDsForLinkState { - let state = linkState(for: peerID) - cachedLinkStates[peerID] = BLEPeerLinkPresence( - hasPeripheral: state.hasPeripheral, - hasCentral: state.hasCentral - ) + cachedLinkStates[peerID] = linkState(for: peerID) } - let changes = peerRegistry.mutate { - $0.reconcileConnectivity(now: now, linkStates: cachedLinkStates) - } - for removedPeer in changes.removedPeers { - SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(removedPeer.peerID.id.prefix(8))… (\(removedPeer.nickname))", category: .session) - gossipSyncManager?.removeAnnouncementForPeer(removedPeer.peerID) + var removedOfflineCount = 0 + collectionsQueue.sync(flags: .barrier) { + for (peerID, peer) in peers { + let age = now.timeIntervalSince(peer.lastSeen) + let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds + if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds { + // Check if we still have an active BLE connection to this peer + let state = cachedLinkStates[peerID] ?? (hasPeripheral: false, hasCentral: false) + let hasPeripheralConnection = state.hasPeripheral + let hasCentralConnection = state.hasCentral + + // If direct link is gone, mark as not connected (retain entry for reachability) + if !hasPeripheralConnection && !hasCentralConnection { + var updated = peer + updated.isConnected = false + peers[peerID] = updated + disconnectedPeers.append(peerID) + } + } + // Cleanup: remove peers that are not connected and past reachability retention + if !peer.isConnected { + if age > retention { + SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session) + // Also remove any stored announcement from sync candidates + gossipSyncManager?.removeAnnouncementForPeer(peerID) + peers.removeValue(forKey: peerID) + removedOfflineCount += 1 + } + } + } } // Update UI if there were direct disconnections or offline removals - if !changes.disconnectedPeerIDs.isEmpty || !changes.removedPeers.isEmpty { + if !disconnectedPeers.isEmpty || removedOfflineCount > 0 { notifyUI { [weak self] in guard let self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.peerRegistry.peerIDs + let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } - for peerID in changes.disconnectedPeerIDs { - self.deliverTransportEvent(.peerDisconnected(peerID)) + for peerID in disconnectedPeers { + self.delegate?.didDisconnectFromPeer(peerID) } // Publish snapshots so UnifiedPeerService updates connection/reachability icons self.requestPeerDataPublish() - self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) + self.delegate?.didUpdatePeerList(currentPeerIDs) } } @@ -7123,65 +4441,143 @@ extension BLEService { private func performCleanup() { let now = Date() - - // Admission expiry is a visible transfer failure, never a silent - // eviction. The registry delivers notifications after releasing its - // lock, so this maintenance pass cannot deadlock a concurrent cancel. - privateMediaTransferAdmissions.prune(now: now) // Clean old processed messages efficiently messageDeduplicator.cleanup() - // Clean old fragments (> configured seconds old), then ask peers for - // the specific fragment streams whose reassembly has stalled instead - // of waiting for the next periodic GCS fragment round. - messageQueue.async { [weak self] in - guard let self else { return } + // Clean old fragments (> configured seconds old) + collectionsQueue.sync(flags: .barrier) { let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds) - self.fragmentAssemblyBuffer.removeExpired(before: cutoff) - self.sourceRouteFailures.prune(now: now) - let stalledFragmentIDs = self.fragmentAssemblyBuffer.stalledBroadcastFragmentIDs( - stalledAfter: TransportConfig.bleFragmentResyncStallSeconds, - retryAfter: TransportConfig.bleFragmentResyncRetrySeconds, - now: now - ) - if !stalledFragmentIDs.isEmpty { - // GossipSyncManager serializes on its own internal queue. - self.gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs) + let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key } + for fragmentID in oldFragments { + incomingFragments.removeValue(forKey: fragmentID) + fragmentMetadata.removeValue(forKey: fragmentID) } } // Clean old connection timeout backoff entries (> window) let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds) - radio.pruneConnectionTimeouts(before: timeoutCutoff) + recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff } // Clean up stale scheduled relays that somehow persisted (> 2s) - messageQueue.async { [weak self] in + collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - // Nothing to compare times to; just cap the size defensively - self.scheduledRelays.removeAllIfOverCapacity(512) + if !self.scheduledRelays.isEmpty { + // Nothing to compare times to; just cap the size defensively + if self.scheduledRelays.count > 512 { + self.scheduledRelays.removeAll() + } + } } // Clean ingress link records older than configured seconds - messageQueue.async { [weak self] in + collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds) - if !self.ingressLinks.isEmpty { - self.ingressLinks.prune(before: cutoff) + if !self.ingressByMessageID.isEmpty { + self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff } } // Clean expired directed spooled items - self.pendingDirectedRelays.pruneExpired( - now: now, - window: TransportConfig.bleDirectedSpoolWindowSeconds - ) + if !self.pendingDirectedRelays.isEmpty { + var cleaned: [PeerID: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:] + for (recipient, dict) in self.pendingDirectedRelays { + let pruned = dict.filter { now.timeIntervalSince($0.value.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds } + if !pruned.isEmpty { cleaned[recipient] = pruned } + } + self.pendingDirectedRelays = cleaned + } } - messageQueue.async { [weak self] in + messageQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - guard !self.selfBroadcastTracker.isEmpty else { return } + guard !self.selfBroadcastMessageIDs.isEmpty else { return } let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds) - self.selfBroadcastTracker.prune(before: cutoff) + self.selfBroadcastMessageIDs = self.selfBroadcastMessageIDs.filter { cutoff <= $0.value.timestamp } } } + private func updateScanningDutyCycle(connectedCount: Int) { + guard let central = centralManager, central.state == .poweredOn else { return } + // Duty cycle only when app is active and at least one peer connected + #if os(iOS) + let active = isAppActive + #else + let active = true + #endif + // Force full-time scanning if we have very few neighbors or very recent traffic + let hasRecentTraffic: Bool = collectionsQueue.sync { + let cutoff = Date().addingTimeInterval(-TransportConfig.bleRecentTrafficForceScanSeconds) + return recentPacketTimestamps.contains(where: { $0 >= cutoff }) + } + let forceScanOn = (connectedCount <= 2) || hasRecentTraffic + let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn + if shouldDuty { + if scanDutyTimer == nil { + // Start timer to toggle scanning on/off + let t = DispatchSource.makeTimerSource(queue: bleQueue) + // Start with scanning ON; we'll turn OFF after onDuration + if !central.isScanning { startScanning() } + dutyActive = true + // Adjust duty cycle under dense networks to save battery + if connectedCount >= TransportConfig.bleHighDegreeThreshold { + dutyOnDuration = TransportConfig.bleDutyOnDurationDense + dutyOffDuration = TransportConfig.bleDutyOffDurationDense + } else { + dutyOnDuration = TransportConfig.bleDutyOnDuration + dutyOffDuration = TransportConfig.bleDutyOffDuration + } + t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) + t.setEventHandler { [weak self] in + guard let self = self, let c = self.centralManager else { return } + if self.dutyActive { + // Turn OFF scanning for offDuration + if c.isScanning { c.stopScan() } + self.dutyActive = false + // Schedule turning back ON after offDuration + self.bleQueue.asyncAfter(deadline: .now() + self.dutyOffDuration) { + if self.centralManager?.state == .poweredOn { self.startScanning() } + self.dutyActive = true + } + } + } + t.resume() + scanDutyTimer = t + } + } else { + // Cancel duty cycle and ensure scanning is ON for discovery + scanDutyTimer?.cancel() + scanDutyTimer = nil + if !central.isScanning { startScanning() } + } + } + + private func updateRSSIThreshold(connectedCount: Int) { + // Adjust RSSI threshold based on connectivity, candidate pressure, and failures + if connectedCount == 0 { + // Isolated: relax floor slowly to hunt for distant nodes + if lastIsolatedAt == nil { lastIsolatedAt = Date() } + let iso = lastIsolatedAt ?? Date() + let elapsed = Date().timeIntervalSince(iso) + if elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds { + dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedRelaxed + } else { + dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedBase + } + return + } + lastIsolatedAt = nil + // Base threshold when connected + var threshold = TransportConfig.bleDynamicRSSIThresholdDefault + // If we're at budget or queue is large, prefer closer peers + let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count + if linkCount >= maxCentralLinks || connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { + threshold = TransportConfig.bleRSSIConnectedThreshold + } + // If we have many recent timeouts, raise further + let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < TransportConfig.bleRecentTimeoutWindowSeconds }.count + if recentTimeouts >= TransportConfig.bleRecentTimeoutCountThreshold { + threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold) + } + dynamicRSSIThreshold = threshold + } } diff --git a/bitchat/Services/BLE/BLESourceRouteFailureCache.swift b/bitchat/Services/BLE/BLESourceRouteFailureCache.swift deleted file mode 100644 index fcf23d2c..00000000 --- a/bitchat/Services/BLE/BLESourceRouteFailureCache.swift +++ /dev/null @@ -1,95 +0,0 @@ -import BitFoundation -import Foundation - -/// Tracks whether source-routed sends to a recipient appear to be working. -/// -/// A routed unicast rides exactly one path, so a broken hop silently loses the -/// packet where a flood would have healed around it. Rather than building a -/// retransmission machine (MessageRouter already retries at a higher layer), -/// this cache degrades: a routed send that sees no inbound traffic from the -/// recipient within the confirmation window marks the route as failed, and -/// subsequent sends fall back to flooding until the suppression TTL lapses. -struct BLESourceRouteFailureCache { - struct Config { - /// How long a routed send may go unconfirmed before it counts as a - /// route failure. - var confirmationWindowSeconds: TimeInterval = TransportConfig.bleSourceRouteConfirmationWindowSeconds - /// How long to flood instead of routing after a failure. - var suppressionSeconds: TimeInterval = TransportConfig.bleSourceRouteSuppressionSeconds - } - - private struct State { - var pendingSince: Date? - var suppressedUntil: Date? - } - - private let config: Config - private var states: [PeerID: State] = [:] - - init(config: Config = Config()) { - self.config = config - } - - /// Whether the next directed send to `recipient` may carry a source - /// route. Flips the recipient into suppression when the last routed send - /// went unconfirmed past the confirmation window. - mutating func shouldAttemptRoute(to recipient: PeerID, now: Date = Date()) -> Bool { - guard var state = states[recipient] else { return true } - - if let until = state.suppressedUntil { - guard now >= until else { return false } - state.suppressedUntil = nil - } - - if let pending = state.pendingSince, - now.timeIntervalSince(pending) > config.confirmationWindowSeconds { - // The routed send was never confirmed: treat the route as broken - // and flood until the suppression window lapses. - state.pendingSince = nil - state.suppressedUntil = now.addingTimeInterval(config.suppressionSeconds) - states[recipient] = state - return false - } - - states[recipient] = state - return true - } - - /// Records that a source-routed packet was sent to `recipient`. Keeps the - /// earliest unconfirmed send so back-to-back packets share one deadline. - mutating func noteRoutedSend(to recipient: PeerID, now: Date = Date()) { - var state = states[recipient] ?? State() - if state.pendingSince == nil { - state.pendingSince = now - } - states[recipient] = state - } - - /// Any inbound packet authored by `peer` confirms the pending routed send - /// (delivery acks and replies arrive this way). Deliberately does not - /// lift an active suppression: that traffic may have arrived via flood. - mutating func noteInboundActivity(from peer: PeerID) { - guard var state = states[peer] else { return } - state.pendingSince = nil - if state.suppressedUntil == nil { - states.removeValue(forKey: peer) - } else { - states[peer] = state - } - } - - /// Drops entries that can no longer influence a routing decision. An - /// expired-but-unconverted pending entry is kept for as long as the - /// suppression it would trigger could still be active. - mutating func prune(now: Date = Date()) { - let pendingRetention = config.confirmationWindowSeconds + config.suppressionSeconds - states = states.filter { _, state in - if let until = state.suppressedUntil, now < until { return true } - if let pending = state.pendingSince, - now.timeIntervalSince(pending) <= pendingRetention { - return true - } - return false - } - } -} diff --git a/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift b/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift deleted file mode 100644 index 5cc8bf9c..00000000 --- a/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift +++ /dev/null @@ -1,40 +0,0 @@ -import BitFoundation -import Foundation - -/// Decides whether an outbound directed packet should carry a v2 source -/// route. Pure gating logic so BLEService's hot send path stays a thin wire. -enum BLESourceRouteOriginationPolicy { - /// Returns the intermediate-hop route to attach, or nil to keep the - /// current flood/direct-write behavior unchanged. - /// - /// Routes are only originated when every gate passes: - /// - we authored the packet (relays must not rewrite and re-sign someone - /// else's packet; route-following for in-flight routed packets lives in - /// `BLERouteForwardingPolicy`), - /// - the packet is directed at a single peer (not broadcast), - /// - the packet has TTL headroom to traverse hops (link-local TTL-0 - /// packets like REQUEST_SYNC never route), - /// - the recipient is not directly connected (a direct write already - /// delivers in one hop), - /// - routing to the recipient is not suppressed by a recent unconfirmed - /// routed send, and - /// - the topology yields a complete path. - static func route( - for packet: BitchatPacket, - to recipient: PeerID, - localPeerIDData: Data, - isRecipientConnected: (PeerID) -> Bool, - shouldAttemptRoute: (PeerID) -> Bool, - computeRoute: (PeerID) -> [Data]? - ) -> [Data]? { - guard packet.senderID == localPeerIDData else { return nil } - guard let recipientData = packet.recipientID, - recipientData.count == 8, - !recipientData.allSatisfy({ $0 == 0xFF }) else { return nil } - guard packet.ttl > 1 else { return nil } - guard !isRecipientConnected(recipient) else { return nil } - guard shouldAttemptRoute(recipient) else { return nil } - guard let route = computeRoute(recipient), !route.isEmpty else { return nil } - return route - } -} diff --git a/bitchat/Services/BLE/BLESubscriptionAnnounceLimiter.swift b/bitchat/Services/BLE/BLESubscriptionAnnounceLimiter.swift deleted file mode 100644 index 9846e22b..00000000 --- a/bitchat/Services/BLE/BLESubscriptionAnnounceLimiter.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation - -enum BLESubscriptionAnnounceDecision: Equatable { - case allowed - case rateLimited(backoffSeconds: TimeInterval, attemptCount: Int, suppressAnnounce: Bool) -} - -struct BLESubscriptionAnnounceLimiter { - private struct State { - var lastAnnounceTime: Date - var attemptCount: Int - var currentBackoffSeconds: TimeInterval - } - - private var states: [String: State] = [:] - - var trackedCentralCount: Int { - states.count - } - - mutating func removeAll() { - states.removeAll() - } - - mutating func decision(for centralID: String, now: Date) -> BLESubscriptionAnnounceDecision { - pruneStaleEntries(now: now) - - guard let existing = states[centralID] else { - recordAllowedAttempt(for: centralID, now: now) - return .allowed - } - - let timeSinceLastAnnounce = now.timeIntervalSince(existing.lastAnnounceTime) - guard timeSinceLastAnnounce < existing.currentBackoffSeconds else { - recordAllowedAttempt(for: centralID, now: now) - return .allowed - } - - let newAttemptCount = existing.attemptCount + 1 - let newBackoff = min( - existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor, - TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds - ) - states[centralID] = State( - lastAnnounceTime: now, - attemptCount: newAttemptCount, - currentBackoffSeconds: newBackoff - ) - - return .rateLimited( - backoffSeconds: existing.currentBackoffSeconds, - attemptCount: existing.attemptCount, - suppressAnnounce: newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts - ) - } - - private mutating func recordAllowedAttempt(for centralID: String, now: Date) { - states[centralID] = State( - lastAnnounceTime: now, - attemptCount: 1, - currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds - ) - } - - private mutating func pruneStaleEntries(now: Date) { - let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds - states = states.filter { _, state in - now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds - } - } -} diff --git a/bitchat/Services/Board/BoardAlertsModel.swift b/bitchat/Services/Board/BoardAlertsModel.swift deleted file mode 100644 index 35daedfe..00000000 --- a/bitchat/Services/Board/BoardAlertsModel.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// BoardAlertsModel.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Combine -import Foundation - -/// Turns newly arriving board posts into local, scope-matched chat alerts. -/// Everything here is derived from posts the mesh already synced — no extra -/// wire traffic, nothing another peer can't already see. -/// -/// - Urgent, recent pins get one system line in the matching chat (geo pin → -/// that geohash's timeline, mesh pin → mesh chat), collapsed when several -/// arrive together. -/// - Every other new pin just marks the header's pin icon until the notices -/// sheet is opened. -@MainActor -final class BoardAlertsModel: ObservableObject { - struct Dependencies { - /// Own posts never alert; the author already knows. - var isOwnPost: @MainActor (BoardPostPacket) -> Bool - /// Appends a local system line to a scope's chat timeline - /// (geohash, or "" for mesh chat). - var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void - var now: () -> Date = Date.init - /// Schedules the collapsed flush of pending urgent alerts; tests - /// inject a synchronous hook. - var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in - Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000)) - flush() - } - } - } - - /// Posts older than this at arrival are backfilled history carried in by - /// a peer, not something happening now; they badge but never line the chat. - static let inlineRecencyWindow: TimeInterval = 30 * 60 - /// Urgent arrivals within this window collapse into one line. - static let collapseDelaySeconds: TimeInterval = 4 - private static let alertContentMaxChars = 120 - - /// Unseen new pins by postID (hex) → geohash scope, cleared when the - /// notices sheet opens. - @Published private(set) var unseenPostScopes: [String: String] = [:] - - /// PostIDs already handled this session, so store eviction/re-sync churn - /// can't re-alert. Bounded by session wire volume (32-byte strings). - private var handledPostIDs = Set() - private var pendingUrgent: [String: [BoardPostPacket]] = [:] - private var flushScheduled = false - private let dependencies: Dependencies - private var cancellable: AnyCancellable? - private var wipeCancellable: AnyCancellable? - - private enum Strings { - static func urgentSingle(author: String, content: String) -> String { - String( - format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"), - locale: .current, - author, content - ) - } - - static func urgentCollapsed(_ count: Int) -> String { - String( - format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"), - locale: .current, - count - ) - } - } - - init( - arrivals: AnyPublisher, - wipes: AnyPublisher = Empty(completeImmediately: false).eraseToAnyPublisher(), - dependencies: Dependencies - ) { - self.dependencies = dependencies - cancellable = arrivals - .receive(on: DispatchQueue.main) - .sink { [weak self] post in - self?.handleArrival(post) - } - wipeCancellable = wipes - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.reset() - } - } - - func unseenCount(forGeohash geohash: String) -> Int { - unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) } - } - - /// Marks pins in the given scopes as seen — only the scopes the notices - /// sheet actually shows, so unseen pins for other geohash channels keep - /// their badge until visited. - func markSeen(forScopes scopes: Set) { - guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return } - unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) } - } - - /// Panic wipe: drop everything derived from pre-wipe posts, including - /// urgent lines still waiting on the collapse flush. - func reset() { - pendingUrgent.removeAll() - handledPostIDs.removeAll() - guard !unseenPostScopes.isEmpty else { return } - unseenPostScopes.removeAll() - } - - func handleArrival(_ post: BoardPostPacket) { - let postID = post.postID.hexEncodedString() - guard !handledPostIDs.contains(postID) else { return } - handledPostIDs.insert(postID) - guard !dependencies.isOwnPost(post) else { return } - - unseenPostScopes[postID] = post.geohash - - let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000) - guard post.isUrgent, - dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else { - return - } - pendingUrgent[post.geohash, default: []].append(post) - if !flushScheduled { - flushScheduled = true - dependencies.scheduleFlush { [weak self] in - self?.flushPendingUrgent() - } - } - } - - private func flushPendingUrgent() { - flushScheduled = false - let pending = pendingUrgent - pendingUrgent.removeAll() - for (geohash, posts) in pending { - guard let first = posts.first else { continue } - let line: String - if posts.count == 1 { - let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon" - line = Strings.urgentSingle(author: author, content: Self.truncated(first.content)) - } else { - line = Strings.urgentCollapsed(posts.count) - } - dependencies.emitSystemLine(line, geohash) - } - } - - private static func truncated(_ content: String) -> String { - guard content.count > alertContentMaxChars else { return content } - return content.prefix(alertContentMaxChars) + "…" - } -} diff --git a/bitchat/Services/Board/BoardManager.swift b/bitchat/Services/Board/BoardManager.swift deleted file mode 100644 index 82f78a8f..00000000 --- a/bitchat/Services/Board/BoardManager.swift +++ /dev/null @@ -1,198 +0,0 @@ -// -// BoardManager.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitLogger -import Combine -import Foundation - -/// UI-facing coordinator for the bulletin board: builds and signs posts and -/// tombstones with the device's Noise signing key, hands them to the mesh -/// transport, and mirrors the store's live posts for SwiftUI. -@MainActor -final class BoardManager: ObservableObject { - /// Live posts across all boards, newest state from the store. - @Published private(set) var posts: [BoardPostPacket] = [] - - private let transport: Transport - /// Board broadcast rides the mesh only; absent on other transports. - private var boardTransport: MeshBoardBroadcasting? { transport as? MeshBoardBroadcasting } - /// Publishes a bridged kind-1 note (expiring with the board post via - /// NIP-40) and returns its Nostr event id, or nil when bridging failed or - /// was skipped. - private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64, _ urgent: Bool) -> String? - /// Requests NIP-09 deletion of a previously bridged note. - private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void - /// Bridged Nostr event ids by postID, for merged deletes. In-memory only: - /// after a relaunch a delete still tombstones the board copy, but the - /// Nostr copy is left to expire with relay retention. - private var bridgedEventIDs: [Data: String] = [:] - private var cancellable: AnyCancellable? - - init( - transport: Transport, - store: BoardStore = .shared, - publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil, - deleteFromNostr: ((String, String) -> Void)? = nil - ) { - self.transport = transport - self.publishToNostr = publishToNostr ?? Self.livePublishToNostr - self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr - cancellable = store.$postsSnapshot - .receive(on: DispatchQueue.main) - .sink { [weak self] snapshot in - self?.posts = snapshot - } - } - - /// Posts for one board context, urgent first, then newest first. - func posts(forGeohash geohash: String) -> [BoardPostPacket] { - posts - .filter { $0.geohash == geohash } - .sorted { - if $0.isUrgent != $1.isUrgent { return $0.isUrgent } - return $0.createdAt > $1.createdAt - } - } - - func isOwnPost(_ post: BoardPostPacket) -> Bool { - let key = transport.noiseSigningPublicKeyData() - return !key.isEmpty && key == post.authorSigningKey - } - - /// Creates, signs, and broadcasts a board post. Returns false when the - /// content is empty/oversized or signing fails. - @discardableResult - func createPost( - content: String, - geohash: String, - urgent: Bool, - expiryDays: Int, - nickname: String - ) -> Bool { - guard let trimmed = content.trimmedOrNilIfEmpty, - trimmed.utf8.count <= BoardWireConstants.contentMaxBytes else { - return false - } - let signingKey = transport.noiseSigningPublicKeyData() - guard signingKey.count == BoardWireConstants.signingKeyLength else { return false } - - var cleanNickname = nickname - while cleanNickname.utf8.count > BoardWireConstants.nicknameMaxBytes { - cleanNickname.removeLast() - } - let createdAt = UInt64(Date().timeIntervalSince1970 * 1000) - let lifetimeMs = min( - UInt64(max(1, expiryDays)) * 24 * 60 * 60 * 1000, - BoardWireConstants.maxLifetimeMs - ) - let expiresAt = createdAt + lifetimeMs - let flags: UInt8 = urgent ? BoardPostPacket.urgentFlag : 0 - var postID = Data(count: BoardWireConstants.postIDLength) - let status = postID.withUnsafeMutableBytes { buffer -> Int32 in - guard let base = buffer.baseAddress else { return -1 } - return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base) - } - guard status == errSecSuccess else { return false } - - let signingBytes = BoardPostPacket.signingBytes( - postID: postID, - geohash: geohash, - content: trimmed, - authorSigningKey: signingKey, - authorNickname: cleanNickname, - createdAt: createdAt, - expiresAt: expiresAt, - flags: flags - ) - guard let signature = transport.noiseSignData(signingBytes) else { - SecureLogger.error("Board: failed to sign post", category: .session) - return false - } - let post = BoardPostPacket( - postID: postID, - geohash: geohash, - content: trimmed, - authorSigningKey: signingKey, - authorNickname: cleanNickname, - createdAt: createdAt, - expiresAt: expiresAt, - flags: flags, - signature: signature - ) - boardTransport?.sendBoardPayload(BoardWire.post(post).encode()) - - // Nostr bridge: geohash posts also go out as kind-1 location notes so - // online users see them. Remember the event id for merged deletes. - if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) { - bridgedEventIDs[postID] = eventID - } - return true - } - - /// Signs and broadcasts a tombstone for one of our own posts. - @discardableResult - func deletePost(_ post: BoardPostPacket) -> Bool { - guard isOwnPost(post) else { return false } - let deletedAt = UInt64(Date().timeIntervalSince1970 * 1000) - let signingBytes = BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt) - guard let signature = transport.noiseSignData(signingBytes) else { - SecureLogger.error("Board: failed to sign tombstone", category: .session) - return false - } - let tombstone = BoardTombstonePacket( - postID: post.postID, - authorSigningKey: post.authorSigningKey, - deletedAt: deletedAt, - signature: signature - ) - boardTransport?.sendBoardPayload(BoardWire.tombstone(tombstone).encode()) - - // Merged delete: also retract the bridged Nostr copy when we still - // know its event id. - if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) { - deleteFromNostr(eventID, post.geohash) - } - return true - } - - private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64, urgent: Bool) -> String? { - let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) - guard !relays.isEmpty else { - SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session) - return nil - } - do { - let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) - let event = try NostrProtocol.createGeohashTextNote( - content: content, - geohash: geohash, - senderIdentity: identity, - nickname: nickname, - expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000), - urgent: urgent - ) - NostrRelayManager.shared.sendEvent(event, to: relays) - return event.id - } catch { - SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session) - return nil - } - } - - private static func liveDeleteFromNostr(eventID: String, geohash: String) { - let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) - guard !relays.isEmpty else { return } - do { - let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) - let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity) - NostrRelayManager.shared.sendEvent(deletion, to: relays) - } catch { - SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session) - } - } -} diff --git a/bitchat/Services/Board/BoardStore.swift b/bitchat/Services/Board/BoardStore.swift deleted file mode 100644 index db6a9605..00000000 --- a/bitchat/Services/Board/BoardStore.swift +++ /dev/null @@ -1,368 +0,0 @@ -// -// BoardStore.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import Combine -import Foundation - -/// Outcome of feeding a board packet into the store, so the transport can -/// decide whether the packet is still worth relaying. -enum BoardIngestResult { - /// New post or tombstone accepted (or a quota rejected it locally while - /// it remains valid for other devices). - case accepted - /// Already known; nothing changed. - case duplicate - /// Invalid, expired, or deleted; do not relay. - case rejected -} - -/// Persistent storage for bulletin-board posts and their tombstones. -/// -/// Posts are signed public notices designed to outlive chat: they stay on -/// disk until their author-chosen expiry (max 7 days) and re-enter gossip -/// sync after a restart. Tombstones are retained until the deleted post's -/// original expiry so the delete keeps outrunning stale copies of the post. -/// -/// The on-disk format is the raw signed packets themselves (like -/// `GossipMessageArchive`); state is rebuilt by re-verifying and re-ingesting -/// them on launch. Wiped on panic. -final class BoardStore { - enum Limits { - static let maxPosts = 200 - static let maxPostsPerAuthor = 5 - /// Retention for a tombstone whose post we never saw: we cannot know - /// the original expiry, so cap at the max post lifetime. - static let orphanTombstoneLifetimeMs = BoardWireConstants.maxLifetimeMs - /// Orphan tombstones name posts nobody here has seen, so their volume - /// is entirely sender-controlled; cap them like posts. - static let maxOrphanTombstones = 100 - static let maxOrphanTombstonesPerAuthor = 5 - /// Allowance for clock skew between peers when judging received - /// timestamps against local time. - static let clockSkewMs: UInt64 = 60 * 60 * 1000 - } - - private struct StoredPost { - let post: BoardPostPacket - let packet: BitchatPacket - let rawPacket: Data - } - - private struct StoredTombstone { - let tombstone: BoardTombstonePacket - let packet: BitchatPacket - let rawPacket: Data - let retainUntil: UInt64 - /// True when no matching post was known at ingest time; only these - /// count against the orphan caps. - let isOrphan: Bool - } - - /// On-disk entry: the raw signed packet, plus the retention deadline for - /// tombstones (derived from the deleted post's original expiry, which is - /// no longer recoverable once the post is gone). - private struct PersistedEntry: Codable { - let packet: Data - let retainUntil: UInt64? - } - - static let shared = BoardStore() - - /// Live posts, published on the main thread for the board UI. - @Published private(set) var postsSnapshot: [BoardPostPacket] = [] - - /// Fires on the main thread for each post newly accepted from the wire - /// (radio, sync, or local echo) — not for disk restores. Drives the - /// local new-pin chat alerts; duplicates never fire twice because the - /// store rejects them. - let postArrivals = PassthroughSubject() - - /// Fires on the main thread after a panic wipe so derived state (pending - /// alerts, unseen badges) is dropped along with the posts themselves. - let didWipe = PassthroughSubject() - - private var posts: [StoredPost] = [] - private var tombstones: [StoredTombstone] = [] - private let queue = DispatchQueue(label: "chat.bitchat.board.store") - private let fileURL: URL? - private let now: () -> Date - - /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored - /// when `persistsToDisk` is false. - init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) { - self.now = now - self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil - loadFromDisk() - } - - // MARK: - Ingest - - /// Ingest a board packet whose payload decodes to `wire`. The caller must - /// have verified the wire signature already (`BoardWire.verifySignature`). - @discardableResult - func ingest(_ wire: BoardWire, packet: BitchatPacket) -> BoardIngestResult { - guard let rawPacket = packet.toBinaryData(padding: false) else { return .rejected } - let nowMs = currentMs() - return queue.sync { - let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs) - if result == .accepted { - persistLocked() - if case .post(let post) = wire { - DispatchQueue.main.async { [weak self] in - self?.postArrivals.send(post) - } - } - } - return result - } - } - - // MARK: - Reads - - /// Live posts scoped to one board (geohash, or "" for the mesh board). - func posts(forGeohash geohash: String) -> [BoardPostPacket] { - let nowMs = currentMs() - return queue.sync { - pruneExpiredLocked(nowMs: nowMs) - return posts.map(\.post).filter { $0.geohash == geohash } - } - } - - /// Raw signed packets (posts and live tombstones) for gossip sync rounds. - func syncCandidates() -> [BitchatPacket] { - let nowMs = currentMs() - return queue.sync { - pruneExpiredLocked(nowMs: nowMs) - return posts.map(\.packet) + tombstones.map(\.packet) - } - } - - // MARK: - Maintenance - - /// Panic wipe: drop all board data from memory and disk. - func wipe() { - queue.sync { - posts.removeAll() - tombstones.removeAll() - if let fileURL { - try? FileManager.default.removeItem(at: fileURL) - } - publishSnapshotLocked() - } - DispatchQueue.main.async { [weak self] in - self?.didWipe.send() - } - } - - // MARK: - Internals (call only on `queue`) - - private func ingestLocked( - _ wire: BoardWire, - packet: BitchatPacket, - rawPacket: Data, - nowMs: UInt64, - retainUntilOverride: UInt64? = nil - ) -> BoardIngestResult { - pruneExpiredLocked(nowMs: nowMs) - switch wire { - case .post(let post): - return ingestPostLocked(post, packet: packet, rawPacket: rawPacket, nowMs: nowMs) - case .tombstone(let tombstone): - return ingestTombstoneLocked(tombstone, packet: packet, rawPacket: rawPacket, nowMs: nowMs, retainUntilOverride: retainUntilOverride) - } - } - - private func ingestPostLocked(_ post: BoardPostPacket, packet: BitchatPacket, rawPacket: Data, nowMs: UInt64) -> BoardIngestResult { - guard post.expiresAt > nowMs else { return .rejected } - // Receive-time sanity (this is the single chokepoint for radio, sync, - // and disk restores): the decoder only enforces the createdAt to - // expiresAt span, so a forged future createdAt would sort ahead of - // honest posts and hold a store slot without ever pruning. - guard post.createdAt <= nowMs &+ Limits.clockSkewMs, - post.expiresAt <= nowMs &+ BoardWireConstants.maxLifetimeMs &+ Limits.clockSkewMs else { - return .rejected - } - if tombstones.contains(where: { $0.tombstone.postID == post.postID && $0.tombstone.authorSigningKey == post.authorSigningKey }) { - return .rejected - } - guard !posts.contains(where: { $0.post.postID == post.postID }) else { return .duplicate } - - posts.append(StoredPost(post: post, packet: packet, rawPacket: rawPacket)) - - // Per-author cap, then global cap; oldest posts are evicted first. - let authorPosts = posts.filter { $0.post.authorSigningKey == post.authorSigningKey } - if authorPosts.count > Limits.maxPostsPerAuthor { - evictOldestLocked(from: authorPosts, keep: Limits.maxPostsPerAuthor) - } - if posts.count > Limits.maxPosts { - evictOldestLocked(from: posts, keep: Limits.maxPosts) - } - publishSnapshotLocked() - // Even when the new post itself was the eviction victim it stays - // valid mesh-wide; peers with room should still receive it. - return .accepted - } - - private func ingestTombstoneLocked( - _ tombstone: BoardTombstonePacket, - packet: BitchatPacket, - rawPacket: Data, - nowMs: UInt64, - retainUntilOverride: UInt64? = nil - ) -> BoardIngestResult { - guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate } - - // Cap retention by both the claimed deletion time (so a doctored file - // cannot pin a tombstone past any legal expiry) and the receive time: - // deletedAt is sender-chosen, so a far-future value must not retain - // the tombstone longer than any post still able to arrive could live. - let maxRetain = min( - tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs, - nowMs &+ Limits.orphanTombstoneLifetimeMs &+ Limits.clockSkewMs - ) - let retainUntil: UInt64 - let isOrphan: Bool - if let index = posts.firstIndex(where: { $0.post.postID == tombstone.postID }) { - let target = posts[index].post - // Only the author's key can delete: the tombstone signature was - // already verified against its embedded key, so it suffices to - // require that key to be the post's author key. - guard target.authorSigningKey == tombstone.authorSigningKey else { return .rejected } - retainUntil = target.expiresAt - isOrphan = false - posts.remove(at: index) - publishSnapshotLocked() - } else if let retainUntilOverride { - // Restored from disk: the post is long gone, so trust the - // retention deadline recorded when the delete was first applied. - // Orphans were already capped when first ingested off the air. - retainUntil = min(retainUntilOverride, maxRetain) - isOrphan = false - } else { - // Post unknown (tombstone raced ahead); keep it around so the - // post is suppressed if it arrives later. - retainUntil = maxRetain - isOrphan = true - } - guard retainUntil > nowMs else { return .rejected } - tombstones.append(StoredTombstone(tombstone: tombstone, packet: packet, rawPacket: rawPacket, retainUntil: retainUntil, isOrphan: isOrphan)) - if isOrphan { - enforceOrphanTombstoneCapsLocked(author: tombstone.authorSigningKey) - } - // Like posts, a locally evicted tombstone stays valid mesh-wide. - return .accepted - } - - /// Orphan tombstones reference posts we never saw, so a peer can mint - /// unlimited valid ones for random IDs; bound them per author and - /// globally, evicting the oldest received first (array order). - private func enforceOrphanTombstoneCapsLocked(author: Data) { - let authorOrphans = tombstones.filter { $0.isOrphan && $0.tombstone.authorSigningKey == author } - if authorOrphans.count > Limits.maxOrphanTombstonesPerAuthor { - removeTombstonesLocked(authorOrphans.prefix(authorOrphans.count - Limits.maxOrphanTombstonesPerAuthor)) - } - let orphans = tombstones.filter(\.isOrphan) - if orphans.count > Limits.maxOrphanTombstones { - removeTombstonesLocked(orphans.prefix(orphans.count - Limits.maxOrphanTombstones)) - } - } - - private func removeTombstonesLocked(_ victims: ArraySlice) { - guard !victims.isEmpty else { return } - let victimIDs = Set(victims.map { $0.tombstone.postID }) - tombstones.removeAll { victimIDs.contains($0.tombstone.postID) } - } - - private func evictOldestLocked(from candidates: [StoredPost], keep: Int) { - let victims = candidates - .sorted { $0.post.createdAt < $1.post.createdAt } - .prefix(max(0, candidates.count - keep)) - guard !victims.isEmpty else { return } - let victimIDs = Set(victims.map { $0.post.postID }) - posts.removeAll { victimIDs.contains($0.post.postID) } - } - - private func pruneExpiredLocked(nowMs: UInt64) { - let postsBefore = posts.count - posts.removeAll { $0.post.expiresAt <= nowMs } - tombstones.removeAll { $0.retainUntil <= nowMs } - if posts.count != postsBefore { - publishSnapshotLocked() - } - } - - private func publishSnapshotLocked() { - let snapshot = posts.map(\.post) - DispatchQueue.main.async { [weak self] in - self?.postsSnapshot = snapshot - } - } - - private func currentMs() -> UInt64 { - UInt64(max(0, now().timeIntervalSince1970) * 1000) - } - - // MARK: - Persistence - - private func persistLocked() { - guard let fileURL else { return } - let payloads = posts.map { PersistedEntry(packet: $0.rawPacket, retainUntil: nil) } - + tombstones.map { PersistedEntry(packet: $0.rawPacket, retainUntil: $0.retainUntil) } - do { - if payloads.isEmpty { - try? FileManager.default.removeItem(at: fileURL) - return - } - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let data = try JSONEncoder().encode(payloads) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtection) - #endif - try data.write(to: fileURL, options: options) - } catch { - SecureLogger.error("Failed to persist board store: \(error)", category: .session) - } - } - - private func loadFromDisk() { - guard let fileURL, - let data = try? Data(contentsOf: fileURL), - let payloads = try? JSONDecoder().decode([PersistedEntry].self, from: data) else { - return - } - let nowMs = currentMs() - queue.sync { - for entry in payloads { - guard let packet = BitchatPacket.from(entry.packet), - packet.type == MessageType.boardPost.rawValue, - let wire = BoardWire.decode(from: packet.payload), - wire.verifySignature() else { continue } - _ = ingestLocked(wire, packet: packet, rawPacket: entry.packet, nowMs: nowMs, retainUntilOverride: entry.retainUntil) - } - publishSnapshotLocked() - } - } - - private static func defaultFileURL() -> URL? { - guard let base = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) else { return nil } - return base - .appendingPathComponent("board", isDirectory: true) - .appendingPathComponent("posts.json") - } -} diff --git a/bitchat/Services/Board/UnifiedNotices.swift b/bitchat/Services/Board/UnifiedNotices.swift deleted file mode 100644 index 31bc14c8..00000000 --- a/bitchat/Services/Board/UnifiedNotices.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// UnifiedNotices.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -/// One row in the unified notices sheet: a mesh board post or a Nostr -/// location note, normalized for display. -struct NoticeItem: Identifiable, Equatable { - enum Source: Equatable { - /// Signed board post carried by the mesh. - case board(BoardPostPacket) - /// Kind-1 location note seen on geo relays. - case nostr(LocationNotesManager.Note) - } - - let id: String - let author: String - let content: String - let createdAt: Date - let isUrgent: Bool - /// When the notice fades (board expiry or a note's NIP-40 tag, as dead - /// drops carry). Nil means it only ages out of the relay window. - let expiresAt: Date? - let source: Source - - var isBoardPost: Bool { - if case .board = source { return true } - return false - } - - init(post: BoardPostPacket) { - id = post.postID.hexEncodedString() - author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon" - content = post.content - createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000) - isUrgent = post.isUrgent - expiresAt = post.expiresAt > 0 - ? Date(timeIntervalSince1970: TimeInterval(post.expiresAt) / 1000) - : nil - source = .board(post) - } - - init(note: LocationNotesManager.Note) { - id = note.id - let display = note.displayName - author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) - .first.map(String.init) ?? display - content = note.content - createdAt = note.createdAt - isUrgent = note.isUrgent - expiresAt = note.expiresAt - source = .nostr(note) - } -} - -/// Merges mesh board posts and Nostr location notes into one deduplicated -/// list for the notices sheet's geo tab. -enum UnifiedNotices { - /// Board posts on geohash channels are bridged to Nostr as kind-1 notes at - /// post time, so the same notice arrives twice. The copies share content - /// and nickname but are signed by unlinkable keys; match them - /// heuristically by content + author within a time window. - static let bridgeDedupeWindow: TimeInterval = 15 * 60 - - /// Returns board posts and notes as one list, urgent posts first, then - /// newest first. Notes that look like bridged copies of a board post are - /// dropped — the board copy wins because it carries urgency and supports - /// merged deletion. The geohash must match exactly: the notes - /// subscription also surfaces neighboring cells, and a same-text note - /// from a neighbor is not the bridged copy. - static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] { - var items = posts.map(NoticeItem.init(post:)) - for note in notes { - let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon" - let isBridgedCopy = posts.contains { post in - post.geohash == note.geohash - && post.content == note.content - && (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname - && abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow - } - if !isBridgedCopy { - items.append(NoticeItem(note: note)) - } - } - return items.sorted { - if $0.isUrgent != $1.isUrgent { return $0.isUrgent } - return $0.createdAt > $1.createdAt - } - } -} diff --git a/bitchat/Services/CashuTokenDecoder.swift b/bitchat/Services/CashuTokenDecoder.swift deleted file mode 100644 index 87dfee02..00000000 --- a/bitchat/Services/CashuTokenDecoder.swift +++ /dev/null @@ -1,339 +0,0 @@ -// -// CashuTokenDecoder.swift -// bitchat -// -// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` = -// base64url CBOR) just far enough to summarize them for the UI: total -// amount, unit, mint host, and memo. The app never contacts a mint — tokens -// are bearer strings and redemption is delegated to an external wallet. -// -// This parses attacker-controlled message content, so every path is -// bounds-checked, size-capped, and returns nil instead of trapping. -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -enum CashuTokenDecoder { - - struct TokenInfo: Equatable { - /// Token serialization version: "A" (JSON) or "B" (CBOR). - let version: String - /// Sum of all proof amounts; nil when no valid amounts were found. - let amount: Int? - /// Currency unit as declared by the token (commonly "sat"), if any. - let unit: String? - /// Host of the (first) mint URL, for display. - let mintHost: String? - /// Optional sender memo, sanitized for display. - let memo: String? - - /// "500 sat" style summary, defaulting the unit to sats per NUT-00. - var displayAmount: String? { - amount.map { "\($0) \(unit ?? "sat")" } - } - } - - /// Upper bound on accepted token length in characters. Real tokens are a - /// few KB; anything much bigger is abuse we shouldn't spend CPU on. - static let maxTokenLength = 60_000 - /// Per-proof and total amount sanity caps (order of total sats in existence). - private static let maxAmount: Int64 = 2_100_000_000_000_000 - - // MARK: - Public API - - /// Extracts the bare `cashuA…`/`cashuB…` token from raw text that may be - /// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the - /// input doesn't look like a Cashu token at all. - static func bareToken(from raw: String) -> String? { - var token = raw.trimmingCharacters(in: .whitespacesAndNewlines) - let lower = token.lowercased() - if lower.hasPrefix("cashu://") { - token = String(token.dropFirst(8)) - } else if lower.hasPrefix("cashu:") { - token = String(token.dropFirst(6)) - } - if token.contains("%"), let decoded = token.removingPercentEncoding { - token = decoded - } - guard token.count >= 12, token.count <= maxTokenLength else { return nil } - guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil } - // Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens) - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=.")) - guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } - return token - } - - /// Decodes a token (raw or `cashu:` URI form) into a display summary. - /// - /// In the default (permissive) mode this is for *rendering*: V3 tokens - /// must parse as JSON, but a V4 token whose CBOR we cannot walk still - /// returns a generic `TokenInfo` (version "B", no amount) because the - /// payload may use encodings this minimal reader doesn't support — an - /// unknown chip is fine for display. - /// - /// In `strict` mode (used by the `/pay` SEND path) there is no permissive - /// fallback: the token must cleanly decode to a known version *and* carry - /// a positive amount, otherwise this returns nil. This stops base64 junk - /// and truncated V4 tokens from being relayed as if they were valid money. - static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? { - guard let token = bareToken(from: raw) else { return nil } - let version = String(token[token.index(token.startIndex, offsetBy: 5)]) - guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else { - return nil - } - let info: TokenInfo? - switch version { - case "A": - info = decodeV3(payload) - case "B": - if let walked = decodeV4(payload) { - info = walked - } else if strict { - // Couldn't cleanly walk the CBOR — refuse to send it. - return nil - } else { - info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil) - } - default: - return nil - } - guard let info else { return nil } - if strict { - // A sendable token must resolve to a positive, sane amount. - guard let amount = info.amount, amount > 0 else { return nil } - } - return info - } - - // MARK: - Base64url - - private static func base64URLDecode(_ input: String) -> Data? { - var s = input - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - // Normalize padding (wallets emit both padded and unpadded forms) - s = s.replacingOccurrences(of: "=", with: "") - let remainder = s.count % 4 - if remainder == 1 { return nil } - if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) } - return Data(base64Encoded: s) - } - - // MARK: - V3 (JSON) - - private static func decodeV3(_ payload: Data) -> TokenInfo? { - guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any], - let entries = obj["token"] as? [[String: Any]], - !entries.isEmpty else { - return nil - } - var total: Int64 = 0 - var sawAmount = false - var mintHost: String? - for entry in entries { - if mintHost == nil, let mint = entry["mint"] as? String { - mintHost = sanitizedHost(from: mint) - } - for proof in (entry["proofs"] as? [[String: Any]]) ?? [] { - guard let number = proof["amount"] as? NSNumber else { continue } - let value = number.int64Value - guard value > 0, value <= maxAmount else { continue } - total += value - guard total <= maxAmount else { return nil } - sawAmount = true - } - } - return TokenInfo( - version: "A", - amount: sawAmount ? Int(total) : nil, - unit: sanitizedUnit(obj["unit"] as? String), - mintHost: mintHost, - memo: sanitizedMemo(obj["memo"] as? String) - ) - } - - // MARK: - V4 (CBOR) - - /// Minimal walk of the NUT-00 TokenV4 CBOR map: - /// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, … } ] } ] } - private static func decodeV4(_ payload: Data) -> TokenInfo? { - var reader = CBORReader(data: payload) - guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil } - var mintHost: String? - var unit: String? - var memo: String? - var total: Int64 = 0 - var sawAmount = false - for (key, value) in pairs { - guard case .text(let name) = key else { continue } - switch (name, value) { - case ("m", .text(let mint)): - mintHost = sanitizedHost(from: mint) - case ("u", .text(let u)): - unit = sanitizedUnit(u) - case ("d", .text(let d)): - memo = sanitizedMemo(d) - case ("t", .array(let groups)): - for case .map(let group) in groups { - for case (.text("p"), .array(let proofs)) in group { - for case .map(let proof) in proofs { - for case (.text("a"), .unsigned(let amount)) in proof { - guard amount > 0, amount <= UInt64(maxAmount) else { continue } - total += Int64(amount) - guard total <= maxAmount else { return nil } - sawAmount = true - } - } - } - } - default: - break - } - } - return TokenInfo( - version: "B", - amount: sawAmount ? Int(total) : nil, - unit: unit, - mintHost: mintHost, - memo: memo - ) - } - - // MARK: - Display Sanitization (values are attacker-controlled) - - private static func sanitizedHost(from mint: String) -> String? { - guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil } - return String(host.lowercased().prefix(48)) - } - - private static func sanitizedUnit(_ unit: String?) -> String? { - guard let unit, !unit.isEmpty, unit.count <= 12, - unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else { - return nil - } - return unit - } - - private static func sanitizedMemo(_ memo: String?) -> String? { - guard let memo, memo.count <= 512 else { return nil } - let stripped = CharacterSet.controlCharacters.union(.newlines) - var cleaned = "" - cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) }) - cleaned = cleaned.trimmingCharacters(in: .whitespaces) - guard !cleaned.isEmpty else { return nil } - return String(cleaned.prefix(80)) - } -} - -// MARK: - Minimal CBOR Reader - -/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in -/// depth, item count, and byte length; indefinite-length items and anything -/// else exotic make the parse fail (the caller degrades to a generic chip). -private struct CBORReader { - indirect enum Value { - case unsigned(UInt64) - case text(String) - case array([Value]) - case map([(Value, Value)]) - /// Parsed-and-skipped content we don't need (byte strings, negatives, floats…) - case opaque - } - - private let bytes: [UInt8] - private var index = 0 - /// Total item budget so hostile nesting can't run away. - private var itemBudget = 50_000 - private static let maxDepth = 16 - private static let maxContainerCount: UInt64 = 10_000 - - init(data: Data) { - bytes = [UInt8](data) - } - - mutating func parseValue(depth: Int) -> Value? { - guard depth < Self.maxDepth, itemBudget > 0 else { return nil } - itemBudget -= 1 - guard let (major, argument) = readHead() else { return nil } - switch major { - case 0: // unsigned int - return .unsigned(argument) - case 1: // negative int (argument already consumed) - return .opaque - case 2: // byte string - return readBytes(count: argument) != nil ? .opaque : nil - case 3: // text string - guard let raw = readBytes(count: argument) else { return nil } - return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque - case 4: // array - guard argument <= Self.maxContainerCount else { return nil } - var items: [Value] = [] - items.reserveCapacity(Int(min(argument, 64))) - for _ in 0.. (major: UInt8, argument: UInt64)? { - guard index < bytes.count else { return nil } - let head = bytes[index] - index += 1 - let major = head >> 5 - let info = head & 0x1F - switch info { - case 0...23: - return (major, UInt64(info)) - case 24: - return readUInt(width: 1).map { (major, $0) } - case 25: - return readUInt(width: 2).map { (major, $0) } - case 26: - return readUInt(width: 4).map { (major, $0) } - case 27: - return readUInt(width: 8).map { (major, $0) } - default: // 28-30 reserved, 31 indefinite - return nil - } - } - - private mutating func readUInt(width: Int) -> UInt64? { - guard bytes.count - index >= width else { return nil } - var value: UInt64 = 0 - for _ in 0.. [UInt8]? { - guard count <= UInt64(bytes.count - index) else { return nil } - let length = Int(count) - let slice = Array(bytes[index..<(index + length)]) - index += length - return slice - } -} diff --git a/bitchat/Services/ChannelShare.swift b/bitchat/Services/ChannelShare.swift deleted file mode 100644 index eec82fe8..00000000 --- a/bitchat/Services/ChannelShare.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ChannelShare.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -/// Builds plain-text location-channel invites for the system share sheet (#1497). -/// -/// Text-first on purpose: a `bitchat://` deep link is dead weight for people -/// who have not installed yet, and SMS does not reliably linkify custom -/// schemes. The payload always includes the App Store URL and the geohash a -/// person can type under location channels after installing. -enum ChannelShare { - /// App Store listing used in out-of-app invites. - static let appStoreURL = "https://apps.apple.com/us/app/bitchat-mesh/id6748219622" - - /// Neighborhood (6) and finer imply a small cell — sharing that over SMS - /// discloses location interest to the carrier and both handsets. - static let precisionWarningMinimumLength = 6 - - static func shouldWarn(forGeohash geohash: String) -> Bool { - geohash.count >= precisionWarningMinimumLength - } - - /// Channel-not-presence framing: "join #x", never "I'm in #x". - static func payload(forGeohash geohash: String) -> String { - let gh = geohash.lowercased() - return String( - format: String( - localized: "channel.share.payload", - defaultValue: "join the #%1$@ channel on bitchat: bitchat://geohash/%1$@ — new to bitchat? get it at %2$@ then type #%1$@ under location channels.", - comment: "Plain-text share payload for a location channel; %1$@ is the geohash, %2$@ is the App Store URL" - ), - locale: .current, - gh, - appStoreURL - ) - } -} - -/// Identifiable wrapper so `.sheet(item:)` can present the system share UI. -struct ChannelSharePayload: Identifiable { - let id = UUID() - let text: String -} diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index c37aa3dc..bba73aac 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -22,26 +22,15 @@ struct CommandGeoParticipant { let displayName: String } -/// The conversation a command was typed into, captured when the command is -/// issued so deferred output (e.g. an async /ping result, which can arrive -/// many seconds later) lands there even if the user switches chats first. -enum CommandOutputDestination: Equatable { - /// The #mesh public timeline. Commands that defer output (/ping) are - /// mesh-only, so a non-DM origin is always the mesh timeline. - case meshTimeline - /// The private chat that was open when the command was typed. - case privateChat(PeerID) -} - /// Protocol defining what CommandProcessor needs from its context. /// This breaks the circular dependency between CommandProcessor and ChatViewModel. @MainActor protocol CommandContextProvider: AnyObject { // MARK: - State Properties var nickname: String { get } - var activeChannel: ChannelID { get } var selectedPrivateChatPeer: PeerID? { get } var blockedUsers: Set { get } + var privateChats: [PeerID: [BitchatMessage]] { get set } var idBridge: NostrIdentityBridge { get } // MARK: - Peer Lookup @@ -53,36 +42,15 @@ protocol CommandContextProvider: AnyObject { func startPrivateChat(with peerID: PeerID) func sendPrivateMessage(_ content: String, to peerID: PeerID) func clearCurrentPublicTimeline() - /// Empties the peer's chat (single-writer store intent for `/clear`). - func clearPrivateChat(_ peerID: PeerID) func sendPublicRaw(_ content: String) - /// Sends a normal public message (with local echo) to the active channel. - func sendPublicMessage(_ content: String) // MARK: - System Messages func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) func addPublicSystemMessage(_ content: String) - /// The conversation the user is typing into right now. Commands that - /// finish asynchronously capture this BEFORE starting async work, so a - /// chat switch cannot misroute their deferred output. - func currentCommandDestination() -> CommandOutputDestination - /// Routes deferred command output (e.g. an async /ping result) into the - /// conversation captured when the command was issued. - func addCommandOutput(_ content: String, to destination: CommandOutputDestination) // MARK: - Favorites - /// Toggles the favorite via the unified peer flow, which persists by the - /// real noise key and notifies the peer over mesh or Nostr. func toggleFavorite(peerID: PeerID) - - // MARK: - Groups - // Group logic lives in `ChatGroupCoordinator`; these forward the parsed - // /group subcommands. - func groupCreate(named name: String) -> CommandResult - func groupInvite(nickname: String) -> CommandResult - func groupRemove(nickname: String) -> CommandResult - func groupLeave() -> CommandResult - func groupList() -> CommandResult + func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) } /// Processes chat commands in a focused, efficient way @@ -90,9 +58,6 @@ protocol CommandContextProvider: AnyObject { final class CommandProcessor { weak var contextProvider: CommandContextProvider? weak var meshService: Transport? - /// Mesh-only command surfaces, absent when the transport lacks them. - private var meshDiagnostics: MeshDiagnosing? { meshService as? MeshDiagnosing } - private var meshArchive: MeshPublicArchiving? { meshService as? MeshPublicArchiving } private let identityManager: SecureIdentityStateManagerProtocol init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) { @@ -105,12 +70,12 @@ final class CommandProcessor { @MainActor func process(_ command: String) -> CommandResult { let parts = command.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false) - guard let cmd = parts.first else { return .error(message: String(localized: "command.error.invalid", defaultValue: "invalid command", comment: "Error for an empty or unparseable slash command")) } + guard let cmd = parts.first else { return .error(message: "Invalid command") } let args = parts.count > 1 ? String(parts[1]) : "" // Geohash context: disable favoriting in public geohash or GeoDM let inGeoPublic: Bool = { - switch contextProvider?.activeChannel ?? .mesh { + switch LocationChannelManager.shared.selectedChannel { case .mesh: return false case .location: return true } @@ -132,82 +97,30 @@ final class CommandProcessor { return handleBlock(args) case "/unblock": return handleUnblock(args) - case "/group": - if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.groups_mesh_only", defaultValue: "groups are only for mesh peers in #mesh", comment: "Error when a group command is used outside the mesh channel")) } - return handleGroup(args) case "/fav": - if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.favorites_mesh_only", defaultValue: "favorites are only for mesh peers in #mesh", comment: "Error when a favorites command is used outside the mesh channel")) } + if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: true) case "/unfav": - if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.favorites_mesh_only", defaultValue: "favorites are only for mesh peers in #mesh", comment: "Error when a favorites command is used outside the mesh channel")) } + if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: false) - case "/ping": - if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.ping_mesh_only", defaultValue: "ping only works for mesh peers in #mesh", comment: "Error when /ping is used outside the mesh channel")) } - return handlePing(args) - case "/trace": - if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.trace_mesh_only", defaultValue: "trace only works for mesh peers in #mesh", comment: "Error when /trace is used outside the mesh channel")) } - return handleTrace(args) - case "/pay": - return handlePay(args) - case "/drop": - return handleDrop(args) - case "/help": - return .success(message: Self.helpText) default: - return .error(message: String(format: String(localized: "command.error.unknown", defaultValue: "unknown command: %@ — type /help for commands", comment: "Error for an unrecognized slash command; placeholder is the typed command"), locale: .current, String(cmd))) + return .error(message: "unknown command: \(cmd)") } } - /// Local-only command reference, printed as a system message. The - /// suggestion panel hides once arguments are typed, and typos used to - /// dead-end in a bare "unknown command" — this is the way out. - static var helpText: String { - String(localized: "command.help.text", defaultValue: "commands:\n/msg @name [message] — start a private chat\n/who — list who's here\n/clear — clear this chat\n/hug @name — send a hug\n/slap @name — slap with a large trout\n/block @name · /unblock @name\n/fav @name · /unfav @name — favorites (mesh only)\n/group create — start an encrypted group\n/group invite @name · /group remove @name — manage members (creator)\n/group leave · /group list — leave or list your groups\n/ping @name — measure round-trip time (mesh only)\n/trace @name — estimated mesh path (mesh only)\n/pay — send a cashu ecash token in this chat\n/drop — pin a note to this place for 24h (needs location)\n/help — this list", comment: "The /help reference list; command syntax stays verbatim, only the descriptions after each dash are translated") - } - - /// /drop — a dead drop: pins a note to the current building-level - /// geohash with a 24h NIP-40 expiry. Anyone who passes through here and - /// looks at notices (or hits the empty-timeline "notes left here" hint) - /// reads it. - private func handleDrop(_ args: String) -> CommandResult { - guard LocationNotesSettings.enabled else { - return .error(message: String(localized: "command.drop.notes_off", defaultValue: "location notes are off — enable them in the info screen", comment: "Error when /drop is used while location notes are disabled")) - } - guard let content = args.trimmedOrNilIfEmpty else { - return .error(message: String(localized: "command.drop.usage", defaultValue: "usage: /drop ", comment: "Usage hint for /drop")) - } - let location = LocationChannelManager.shared - guard location.permissionState == .authorized else { - return .error(message: String(localized: "command.drop.needs_location", defaultValue: "leaving a note needs location — enable it in the info screen", comment: "Error when /drop is used without location permission")) - } - guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else { - location.refreshChannels() - return .error(message: String(localized: "command.drop.finding_place", defaultValue: "still finding this place — try again in a moment", comment: "Error when /drop runs before a location fix arrives")) - } - guard let nickname = contextProvider?.nickname, - LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else { - return .error(message: String(localized: "command.drop.no_relays", defaultValue: "no geo relays reachable — note not left", comment: "Error when /drop finds no reachable geo relays")) - } - // Leaving a note is an explicit notes act: it unlocks the passive - // nearby-notes counter (tap-to-reveal) so the sender sees their own - // drop counted on the timeline. - NearbyNotesCounter.shared.reveal() - return .success(message: String(localized: "command.drop.left", defaultValue: "📍 note left here — it fades in 24h", comment: "Confirmation after /drop pins a note")) - } - // MARK: - Command Handlers private func handleMessage(_ args: String) -> CommandResult { let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false) guard !parts.isEmpty else { - return .error(message: String(localized: "command.msg.usage", defaultValue: "usage: /msg @nickname [message]", comment: "Usage hint for /msg")) + return .error(message: "usage: /msg @nickname [message]") } let targetName = String(parts[0]) let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else { - return .error(message: String(format: String(localized: "command.msg.not_found", defaultValue: "'%@' not found", comment: "Error when /msg can't resolve the nickname"), locale: .current, nickname)) + return .error(message: "'\(nickname)' not found") } contextProvider?.startPrivateChat(with: peerID) @@ -217,36 +130,36 @@ final class CommandProcessor { contextProvider?.sendPrivateMessage(message, to: peerID) } - return .success(message: String(format: String(localized: "command.msg.started", defaultValue: "started private chat with %@", comment: "Confirmation after /msg opens a private chat"), locale: .current, nickname)) + return .success(message: "started private chat with \(nickname)") } private func handleWho() -> CommandResult { // Show geohash participants when in a geohash channel; otherwise mesh peers - switch contextProvider?.activeChannel ?? .mesh { + switch LocationChannelManager.shared.selectedChannel { case .location(let ch): // Geohash context: show visible geohash participants (exclude self) - guard let vm = contextProvider else { return .success(message: String(localized: "command.who.nobody", defaultValue: "nobody around", comment: "Reply to /who when no context is available")) } + guard let vm = contextProvider else { return .success(message: "nobody around") } let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased() let people = vm.getVisibleGeoParticipants().filter { person in if let me = myHex { return person.id.lowercased() != me } return true } let names = people.map { $0.displayName } - if names.isEmpty { return .success(message: String(localized: "command.who.none_online", defaultValue: "no one else is online right now", comment: "Reply to /who when nobody else is online")) } - return .success(message: String(format: String(localized: "command.who.online", defaultValue: "online: %@", comment: "Reply to /who; placeholder is the list of names"), locale: .current, names.sorted().joined(separator: ", "))) + if names.isEmpty { return .success(message: "no one else is online right now") } + return .success(message: "online: " + names.sorted().joined(separator: ", ")) case .mesh: // Mesh context: show connected peer nicknames guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else { - return .success(message: String(localized: "command.who.none_online", defaultValue: "no one else is online right now", comment: "Reply to /who when nobody else is online")) + return .success(message: "no one else is online right now") } let onlineList = peers.values.sorted().joined(separator: ", ") - return .success(message: String(format: String(localized: "command.who.online", defaultValue: "online: %@", comment: "Reply to /who; placeholder is the list of names"), locale: .current, onlineList)) + return .success(message: "online: \(onlineList)") } } private func handleClear() -> CommandResult { if let peerID = contextProvider?.selectedPrivateChatPeer { - contextProvider?.clearPrivateChat(peerID) + contextProvider?.privateChats[peerID]?.removeAll() } else { contextProvider?.clearCurrentPublicTimeline() } @@ -256,14 +169,14 @@ final class CommandProcessor { private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult { let targetName = args.trimmed guard !targetName.isEmpty else { - return .error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ ", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, command)) + return .error(message: "usage: /\(command) ") } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname), let myNickname = contextProvider?.nickname else { - return .error(message: String(format: String(localized: "command.action.not_found", defaultValue: "cannot %1$@ %2$@: not found", comment: "Error when an action command can't resolve its target; placeholders are the command and the nickname"), locale: .current, command, nickname)) + return .error(message: "cannot \(command) \(nickname): not found") } let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *" @@ -330,7 +243,7 @@ final class CommandProcessor { let meshList = blockedNicknames.isEmpty ? "none" : blockedNicknames.sorted().joined(separator: ", ") let geoList = geoNames.isEmpty ? "none" : geoNames.sorted().joined(separator: ", ") - return .success(message: String(format: String(localized: "command.block.list", defaultValue: "blocked peers: %1$@ | geohash blocks: %2$@", comment: "Reply to /block with no argument; placeholders are the mesh and geohash block lists"), locale: .current, meshList, geoList)) + return .success(message: "blocked peers: \(meshList) | geohash blocks: \(geoList)") } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName @@ -338,7 +251,7 @@ final class CommandProcessor { if let peerID = contextProvider?.getPeerIDForNickname(nickname), let fingerprint = meshService?.getFingerprint(for: peerID) { if identityManager.isBlocked(fingerprint: fingerprint) { - return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname)) + return .success(message: "\(nickname) is already blocked") } // Block the user (mesh/noise identity) if var identity = identityManager.getSocialIdentity(for: fingerprint) { @@ -357,27 +270,24 @@ final class CommandProcessor { ) identityManager.updateSocialIdentity(blockedIdentity) } - // Scrub their carried public messages now, while the peerID is - // resolvable, so they can't resurface as archived echoes. - meshArchive?.purgeArchivedPublicMessages(from: peerID) - return .success(message: String(format: String(localized: "command.block.done", defaultValue: "blocked %@. you will no longer see their messages", comment: "Confirmation after blocking a mesh peer"), locale: .current, nickname)) + return .success(message: "blocked \(nickname). you will no longer receive messages from them") } // Mesh lookup failed; try geohash (Nostr) participant by display name if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) { if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) { - return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname)) + return .success(message: "\(nickname) is already blocked") } identityManager.setNostrBlocked(pub, isBlocked: true) - return .success(message: String(format: String(localized: "command.block.done_geo", defaultValue: "blocked %@ in geohash chats", comment: "Confirmation after blocking a geohash participant"), locale: .current, nickname)) + return .success(message: "blocked \(nickname) in geohash chats") } - return .error(message: String(format: String(localized: "command.block.failed", defaultValue: "cannot block %@: not found or unable to verify identity", comment: "Error when /block can't resolve or verify the target"), locale: .current, nickname)) + return .error(message: "cannot block \(nickname): not found or unable to verify identity") } private func handleUnblock(_ args: String) -> CommandResult { let targetName = args.trimmed guard !targetName.isEmpty else { - return .error(message: String(localized: "command.unblock.usage", defaultValue: "usage: /unblock ", comment: "Usage hint for /unblock")) + return .error(message: "usage: /unblock ") } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName @@ -385,192 +295,55 @@ final class CommandProcessor { if let peerID = contextProvider?.getPeerIDForNickname(nickname), let fingerprint = meshService?.getFingerprint(for: peerID) { if !identityManager.isBlocked(fingerprint: fingerprint) { - return .success(message: String(format: String(localized: "command.unblock.not_blocked", defaultValue: "%@ is not blocked", comment: "Reply when /unblock targets a nickname that isn't blocked"), locale: .current, nickname)) + return .success(message: "\(nickname) is not blocked") } identityManager.setBlocked(fingerprint, isBlocked: false) - return .success(message: String(format: String(localized: "command.unblock.done", defaultValue: "unblocked %@", comment: "Confirmation after unblocking a mesh peer"), locale: .current, nickname)) + return .success(message: "unblocked \(nickname)") } // Try geohash unblock if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) { if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) { - return .success(message: String(format: String(localized: "command.unblock.not_blocked", defaultValue: "%@ is not blocked", comment: "Reply when /unblock targets a nickname that isn't blocked"), locale: .current, nickname)) + return .success(message: "\(nickname) is not blocked") } identityManager.setNostrBlocked(pub, isBlocked: false) - return .success(message: String(format: String(localized: "command.unblock.done_geo", defaultValue: "unblocked %@ in geohash chats", comment: "Confirmation after unblocking a geohash participant"), locale: .current, nickname)) + return .success(message: "unblocked \(nickname) in geohash chats") } - return .error(message: String(format: String(localized: "command.unblock.failed", defaultValue: "cannot unblock %@: not found", comment: "Error when /unblock can't resolve the target"), locale: .current, nickname)) + return .error(message: "cannot unblock \(nickname): not found") } - private static var groupUsage: String { String(localized: "command.group.usage", defaultValue: "usage: /group create · invite @name · remove @name · leave · list", comment: "Usage hint for /group subcommands") } - - private func handleGroup(_ args: String) -> CommandResult { - let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) - guard let subcommand = parts.first else { - return .error(message: Self.groupUsage) - } - let rest = parts.count > 1 ? String(parts[1]) : "" - guard let provider = contextProvider else { return .handled } - - switch subcommand { - case "create": - return provider.groupCreate(named: rest) - case "invite": - return provider.groupInvite(nickname: rest) - case "remove": - return provider.groupRemove(nickname: rest) - case "leave": - return provider.groupLeave() - case "list": - return provider.groupList() - default: - return .error(message: Self.groupUsage) - } - } - - // MARK: - Mesh Diagnostics - - private enum MeshPeerResolution { - case resolved(peerID: PeerID, nickname: String) - case failed(CommandResult) - } - - /// Resolves a mesh peer for /ping and /trace. Geohash identities are - /// rejected — diagnostics measure the BLE mesh, not Nostr. - private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution { - let targetName = args.trimmed - guard !targetName.isEmpty else { - return .failed(.error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ ", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, command))) - } - let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - guard let peerID = contextProvider?.getPeerIDForNickname(nickname), - !peerID.isGeoDM, !peerID.isGeoChat else { - return .failed(.error(message: String(format: String(localized: "command.action.not_found_mesh", defaultValue: "cannot %1$@ %2$@: not found on mesh", comment: "Error when a mesh-only command can't resolve its target; placeholders are the command and the nickname"), locale: .current, command, nickname))) - } - return .resolved(peerID: peerID, nickname: nickname) - } - - private func handlePing(_ args: String) -> CommandResult { - let target: (peerID: PeerID, nickname: String) - switch resolveMeshPeer(args, command: "ping") { - case .resolved(let peerID, let nickname): target = (peerID, nickname) - case .failed(let result): return result - } - - let nickname = target.nickname - let currentProvider = contextProvider - // Capture the origin conversation now: the pong can arrive up to - // meshPingTimeoutSeconds later, and reading the selected chat at - // callback time would misroute the result after a chat switch. - let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline - meshDiagnostics?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in - let provider = currentProvider - guard let result else { - provider?.addCommandOutput("no reply from \(nickname)", to: destination) - return - } - let hopText: String = result.hops.map { hops in - hops == 1 ? " · direct (1 hop)" : " · \(hops) hops" - } ?? "" - provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination) - } - return .success(message: String(format: String(localized: "command.ping.started", defaultValue: "pinging %@…", comment: "Confirmation that /ping sent a probe"), locale: .current, nickname)) - } - - private func handleTrace(_ args: String) -> CommandResult { - let target: (peerID: PeerID, nickname: String) - switch resolveMeshPeer(args, command: "trace") { - case .resolved(let peerID, let nickname): target = (peerID, nickname) - case .failed(let result): return result - } - - guard let mesh = meshService, - let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else { - return .success(message: String(format: String(localized: "command.trace.no_path", defaultValue: "no known path to %@", comment: "Reply when /trace has no mesh path to the target"), locale: .current, target.nickname)) - } - // Graph-derived from gossiped neighbor claims, not route-recorded — - // present it as an estimate. - let hopNames = intermediates.map { hop in - mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…" - } - let you = String(localized: "command.trace.you", defaultValue: "you", comment: "Label for the local device at the start of a /trace path") - let chain = ([you] + hopNames + [target.nickname]).joined(separator: " → ") - let hops = intermediates.count + 1 - let pathMessage = hops == 1 - ? String(format: String(localized: "command.trace.path_one", defaultValue: "estimated path: %@ (1 hop)", comment: "Reply to /trace for a single-hop path; placeholder is the node chain"), locale: .current, chain) - : String(format: String(localized: "command.trace.path_many", defaultValue: "estimated path: %1$@ (%2$lld hops)", comment: "Reply to /trace; placeholders are the node chain and hop count"), locale: .current, chain, hops) - return .success(message: pathMessage) - } - - /// `/pay ` — validates the token decodes, then sends it as - /// the message body in the current chat. Cashu tokens are bearer - /// instruments (whoever redeems first gets the funds), so posting one to - /// a public channel requires an explicit `/pay public` confirm. - /// The app never contacts a mint; it only relays the string. - private func handlePay(_ args: String) -> CommandResult { - var parts = args.trimmed.split(separator: " ").map(String.init) - guard !parts.isEmpty else { - return .success(message: String(localized: "command.pay.usage", defaultValue: "usage: /pay — paste a cashu token: /pay cashuA…", comment: "Usage hint for /pay")) - } - - let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public" - if confirmedPublic { parts.removeLast() } - - guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else { - return .error(message: String(localized: "command.pay.not_token", defaultValue: "that doesn't look like a cashu token — expected cashuA… or cashuB…", comment: "Error when /pay input has no cashu prefix")) - } - guard let info = CashuTokenDecoder.decode(token, strict: true) else { - return .error(message: String(localized: "command.pay.invalid", defaultValue: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it", comment: "Error when /pay input fails to decode")) - } - - let summary = info.displayAmount ?? "a cashu token" - - if let peerID = contextProvider?.selectedPrivateChatPeer { - contextProvider?.sendPrivateMessage(token, to: peerID) - return .success(message: String(format: String(localized: "command.pay.sent_private", defaultValue: "sent %@ — cashu is a bearer token; whoever redeems it first gets the funds", comment: "Confirmation after sending a cashu token in a private chat; placeholder is the amount summary"), locale: .current, summary)) - } - - guard confirmedPublic else { - return .error(message: String(localized: "command.pay.public_confirm", defaultValue: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay public", comment: "Confirmation gate before sending a cashu token to a public channel")) - } - - contextProvider?.sendPublicMessage(token) - return .success(message: String(format: String(localized: "command.pay.sent_public", defaultValue: "sent %@ to the public channel — anyone here can redeem it", comment: "Confirmation after sending a cashu token to a public channel; placeholder is the amount summary"), locale: .current, summary)) - } - private func handleFavorite(_ args: String, add: Bool) -> CommandResult { let targetName = args.trimmed guard !targetName.isEmpty else { - return .error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ ", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, (add ? "fav" : "unfav"))) + return .error(message: "usage: /\(add ? "fav" : "unfav") ") } - + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - - guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else { - return .error(message: String(format: String(localized: "command.fav.not_found", defaultValue: "can't find peer: %@", comment: "Error when /fav or /unfav can't resolve the nickname"), locale: .current, nickname)) + + guard let peerID = contextProvider?.getPeerIDForNickname(nickname), + let noisePublicKey = Data(hexString: peerID.id) else { + return .error(message: "can't find peer: \(nickname)") } - - // Resolve current state by the peer's real noise key. The resolved - // peerID is either the short 16-hex mesh ID or the full 64-hex - // noise-key ID (offline favorite row) — never the noise key itself. - let isCurrentlyFavorite: Bool - if let noiseKey = peerID.noiseKey { - isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey) + + if add { + let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noisePublicKey, + peerNostrPublicKey: existingFavorite?.peerNostrPublicKey, + peerNickname: nickname + ) + + contextProvider?.toggleFavorite(peerID: peerID) + contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true) + + return .success(message: "added \(nickname) to favorites") } else { - isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) + + contextProvider?.toggleFavorite(peerID: peerID) + contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false) + + return .success(message: "removed \(nickname) from favorites") } - - guard add != isCurrentlyFavorite else { - return .success(message: add - ? String(format: String(localized: "command.fav.already", defaultValue: "%@ is already a favorite", comment: "Reply when /fav targets an existing favorite"), locale: .current, nickname) - : String(format: String(localized: "command.fav.not_favorite", defaultValue: "%@ is not a favorite", comment: "Reply when /unfav targets someone who isn't a favorite"), locale: .current, nickname)) - } - - // toggleFavorite persists by the real noise key and notifies the peer. - contextProvider?.toggleFavorite(peerID: peerID) - - return .success(message: add - ? String(format: String(localized: "command.fav.added", defaultValue: "added %@ to favorites", comment: "Confirmation after /fav"), locale: .current, nickname) - : String(format: String(localized: "command.fav.removed", defaultValue: "removed %@ from favorites", comment: "Confirmation after /unfav"), locale: .current, nickname)) } } diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift deleted file mode 100644 index 565eef17..00000000 --- a/bitchat/Services/Courier/CourierStore.swift +++ /dev/null @@ -1,629 +0,0 @@ -// -// CourierStore.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import Combine -import Foundation -#if os(iOS) -import UIKit -#endif - -/// Trust level of a courier deposit, decided by the caller's policy. -/// Favorites get the larger quota and are never evicted to make room for -/// verified-tier mail; verified (signature-verified announce, not a mutual -/// favorite) get a small quota so a crowd of strangers can still carry mail. -enum CourierDepositTier: String, Codable { - case favorite - case verified -} - -/// Holds courier envelopes this device is carrying for offline third parties. -/// -/// Envelopes are opaque ciphertext; this store never learns sender, -/// recipient, or content. Strict quotas keep the device from becoming a -/// public mailbag: bounded count, bounded per-depositor count by trust tier, -/// bounded size, and a 24-hour lifetime aligned with the outbox retention -/// policy. Carried mail is included in the panic wipe. -final class CourierStore { - struct StoredEnvelope: Codable, Equatable { - let recipientTag: Data - let expiry: UInt64 - let ciphertext: Data - let depositorNoiseKey: Data - let storedAt: Date - var tier: CourierDepositTier - /// Remaining spray-and-wait budget (1 = carry-only). - var copies: UInt8 - /// Couriers this envelope was already sprayed to, so a repeat announce - /// from the same peer doesn't burn budget on a copy they already hold. - var sprayedTo: Set - /// Last speculative multi-hop handover toward a relayed announce. - var lastRemoteHandoverAt: Date? - /// Last publish of this envelope as a bridge courier drop on relays. - var lastBridgePublishAt: Date? - /// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1. - let prekeyID: UInt32? - - var envelope: CourierEnvelope { - CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) - } - - init( - recipientTag: Data, - expiry: UInt64, - ciphertext: Data, - depositorNoiseKey: Data, - storedAt: Date, - tier: CourierDepositTier, - copies: UInt8, - sprayedTo: Set = [], - lastRemoteHandoverAt: Date? = nil, - lastBridgePublishAt: Date? = nil, - prekeyID: UInt32? = nil - ) { - self.recipientTag = recipientTag - self.expiry = expiry - self.ciphertext = ciphertext - self.depositorNoiseKey = depositorNoiseKey - self.storedAt = storedAt - self.tier = tier - self.copies = copies - self.sprayedTo = sprayedTo - self.lastRemoteHandoverAt = lastRemoteHandoverAt - self.lastBridgePublishAt = lastBridgePublishAt - self.prekeyID = prekeyID - } - - // Files written before tiers/spray lack the newer fields; treat that - // mail as favorite-tier carry-only, which is what it was. - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - recipientTag = try container.decode(Data.self, forKey: .recipientTag) - expiry = try container.decode(UInt64.self, forKey: .expiry) - ciphertext = try container.decode(Data.self, forKey: .ciphertext) - depositorNoiseKey = try container.decode(Data.self, forKey: .depositorNoiseKey) - storedAt = try container.decode(Date.self, forKey: .storedAt) - tier = try container.decodeIfPresent(CourierDepositTier.self, forKey: .tier) ?? .favorite - copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1 - sprayedTo = try container.decodeIfPresent(Set.self, forKey: .sprayedTo) ?? [] - lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt) - lastBridgePublishAt = try container.decodeIfPresent(Date.self, forKey: .lastBridgePublishAt) - prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID) - } - } - - enum Limits { - static let maxEnvelopes = 40 - /// Verified-tier mail can never crowd out favorites' share. - static let maxVerifiedEnvelopes = 20 - static let maxPerFavoriteDepositor = 5 - static let maxPerVerifiedDepositor = 2 - /// Slack on top of the 24h lifetime for depositor clock skew. - static let maxExpirySlack: TimeInterval = 60 * 60 - } - - static let shared = CourierStore() - - /// Number of envelopes currently carried, published on the main thread - /// so the UI can show a "carrying mail" indicator. - @Published private(set) var carriedCount: Int = 0 - - /// Fast path so hot code (announce handling) can skip tag computation. - var isEmpty: Bool { - queue.sync { envelopes.isEmpty } - } - - private var envelopes: [StoredEnvelope] = [] - private let queue = DispatchQueue(label: "chat.bitchat.courier.store") - private let fileURL: URL? - private let now: () -> Date - private let readData: (URL) throws -> Data - /// A protected file can be present but unreadable during an iOS - /// background restoration before first unlock. Keep that distinct from - /// an absent file: mutations may proceed in memory, but must not replace - /// the unreadable durable snapshot until it can be merged. - private var diskLoadDeferred = false - #if os(iOS) - private var protectedDataObserver: NSObjectProtocol? - #endif - - /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored - /// when `persistsToDisk` is false. - init( - persistsToDisk: Bool = true, - fileURL: URL? = nil, - now: @escaping () -> Date = Date.init, - readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) } - ) { - self.now = now - self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil - self.readData = readData - loadFromDisk() - #if os(iOS) - protectedDataObserver = NotificationCenter.default.addObserver( - forName: UIApplication.protectedDataDidBecomeAvailableNotification, - object: nil, - queue: nil - ) { [weak self] _ in - self?.retryDeferredPersistence() - } - #endif - } - - deinit { - #if os(iOS) - if let protectedDataObserver { - NotificationCenter.default.removeObserver(protectedDataObserver) - } - #endif - } - - // MARK: - Depositing (courier side) - - /// Accept an envelope from a depositor. Returns false when quotas or - /// validity checks reject it. Trust policy (which tier a depositor gets, - /// if any) is the caller's responsibility; this store only enforces - /// resource bounds. - @discardableResult - func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool { - let date = now() - guard envelope.recipientTag.count == CourierEnvelope.tagLength, - !envelope.ciphertext.isEmpty, - envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes, - !envelope.isExpired(at: date) else { - return false - } - // Reject expiries beyond the policy lifetime so depositors can't pin - // storage longer than the outbox would retain the message itself. - let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack) - guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else { - return false - } - - return queue.sync { - pruneExpiredLocked(at: date) - - // Identical ciphertext is the same envelope. Before any spray, - // a carry-only copy may legitimately arrive ahead of the original - // higher-budget copy, so keep the larger initial budget. Once a - // branch has sprayed, however, replaying the depositor's original - // packet must never replenish spent copies: that would defeat - // spray-and-wait and let `sprayedTo` grow without bound. - if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) { - if envelopes[existing].sprayedTo.isEmpty { - envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies) - } - persistLocked() - return true - } - - let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor - guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < perDepositorLimit else { - SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached (\(tier.rawValue))", category: .session) - return false - } - if tier == .verified, - envelopes.filter({ $0.tier == .verified }).count >= Limits.maxVerifiedEnvelopes { - SecureLogger.debug("📦 Courier deposit rejected: verified-tier pool full", category: .session) - return false - } - if envelopes.count >= Limits.maxEnvelopes { - // Oldest-first eviction, shedding verified-tier mail before - // favorites' so open couriering can't crowd out trusted mail. - // A verified deposit never displaces a favorite: when only - // favorite mail is stored, it is rejected instead. - if let victim = envelopes.firstIndex(where: { $0.tier == .verified }) { - let evicted = envelopes.remove(at: victim) - SecureLogger.debug("📦 Courier store full - evicted verified envelope stored at \(evicted.storedAt)", category: .session) - } else if tier == .favorite { - let evicted = envelopes.removeFirst() - SecureLogger.debug("📦 Courier store full - evicted favorite envelope stored at \(evicted.storedAt)", category: .session) - } else { - SecureLogger.debug("📦 Courier deposit rejected: store full of favorite-tier mail", category: .session) - return false - } - } - - envelopes.append(StoredEnvelope( - recipientTag: envelope.recipientTag, - expiry: envelope.expiry, - ciphertext: envelope.ciphertext, - depositorNoiseKey: depositorNoiseKey, - storedAt: date, - tier: tier, - copies: envelope.copies, - prekeyID: envelope.prekeyID - )) - persistLocked() - return true - } - } - - // MARK: - Handover (on encountering a peer) - - /// Remove and return all envelopes addressed to the given peer, matching - /// the rotating recipient tag across adjacent days. This compatibility - /// helper accepts every offer; transport callers should use - /// `handoverEnvelopes(for:accepting:)` so failed sends remain durable. - func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] { - var handedOver: [CourierEnvelope] = [] - handoverEnvelopes(for: noiseStaticKey) { envelope in - handedOver.append(envelope) - return true - } - return handedOver - } - - /// Attempts direct handover without retiring the durable carried copy - /// until the transport accepts it onto the intended peer's physical link. - /// A failed encode, stale binding, or backpressure rejection leaves the - /// envelope unchanged for the next authenticated encounter. - @discardableResult - func handoverEnvelopes( - for noiseStaticKey: Data, - accepting: (CourierEnvelope) -> Bool - ) -> Int { - let date = now() - let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date) - let offered = queue.sync { - pruneExpiredLocked(at: date) - return envelopes - .filter { candidates.contains($0.recipientTag) } - .map(\.envelope) - } - - var acceptedCount = 0 - for envelope in offered where accepting(envelope) { - // Do not hold the store queue while the acceptance closure enters - // BLE/collections queues. Commit in a second short critical - // section, rechecking that another handover did not win first. - let committed = queue.sync { - guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else { - return false - } - envelopes.remove(at: index) - persistLocked() - return true - } - if committed { acceptedCount += 1 } - } - return acceptedCount - } - - /// Envelopes addressed to a recipient we heard from via a *relayed* - /// announce. Non-destructive: a multi-hop send is speculative, so the - /// envelope stays carried until a direct handover or expiry. The per- - /// envelope cooldown keeps repeated announces from re-flooding the mesh. - func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] { - let date = now() - let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date) - return queue.sync { - pruneExpiredLocked(at: date) - var matched: [CourierEnvelope] = [] - for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) { - if let last = envelopes[index].lastRemoteHandoverAt, - date.timeIntervalSince(last) < cooldown { - continue - } - envelopes[index].lastRemoteHandoverAt = date - // The delivered copy carries no spray budget. - matched.append(envelopes[index].envelope.withCopies(1)) - } - if !matched.isEmpty { persistLocked() } - return matched - } - } - - /// Envelopes eligible to park on relays as bridge courier drops. Merely - /// offering one does not start its cooldown: the caller commits that only - /// after a relay explicitly accepts the event via NIP-01 `OK`. - func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] { - let date = now() - return queue.sync { - pruneExpiredLocked(at: date) - return envelopes.compactMap { stored in - if let last = stored.lastBridgePublishAt, - date.timeIntervalSince(last) < cooldown { - return nil - } - // The relay copy carries no spray budget. - return stored.envelope.withCopies(1) - } - } - } - - /// Starts the bridge-publish cooldown only for a relay-confirmed copy. - func markBridgePublished(_ envelope: CourierEnvelope) { - let date = now() - queue.sync { - guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else { - return - } - envelopes[index].lastBridgePublishAt = date - persistLocked() - } - } - - // MARK: - Spray-and-wait (on encountering another courier) - - /// Envelopes to re-deposit with a courier we just encountered, each with - /// half its remaining budget (binary spray). Skips envelopes the courier - /// deposited, envelopes addressed to them (those ride the handover path), - /// carry-only envelopes, and couriers already sprayed. - func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] { - var sprayed: [CourierEnvelope] = [] - transferSprayCopies(to: courierNoiseKey) { envelope in - sprayed.append(envelope) - return true - } - return sprayed - } - - /// Offers binary-spray copies one at a time and commits the reduced local - /// budget plus `sprayedTo` marker only after the directed transport accepts - /// that copy. A false result is a rollback: retrying the same courier sees - /// the original budget and eligibility. - @discardableResult - func transferSprayCopies( - to courierNoiseKey: Data, - accepting: (CourierEnvelope) -> Bool - ) -> Int { - let date = now() - let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date) - let offered = queue.sync { - pruneExpiredLocked(at: date) - return envelopes.compactMap { stored -> CourierEnvelope? in - guard stored.copies > 1, - stored.depositorNoiseKey != courierNoiseKey, - !stored.sprayedTo.contains(courierNoiseKey), - !courierTags.contains(stored.recipientTag) else { return nil } - return stored.envelope.withCopies(stored.copies / 2) - } - } - - var acceptedCount = 0 - for copy in offered where accepting(copy) { - // As with direct handover, BLE acceptance runs outside the store - // queue. Revalidate and commit the exact budget that left this - // device; a competing successful transfer makes this a no-op. - let committed = queue.sync { - guard let index = envelopes.firstIndex(where: { $0.ciphertext == copy.ciphertext }) else { - return false - } - let stored = envelopes[index] - guard stored.copies > copy.copies, - stored.depositorNoiseKey != courierNoiseKey, - !stored.sprayedTo.contains(courierNoiseKey), - !courierTags.contains(stored.recipientTag) else { - return false - } - envelopes[index].copies = stored.copies - copy.copies - envelopes[index].sprayedTo.insert(courierNoiseKey) - persistLocked() - return true - } - if committed { acceptedCount += 1 } - } - return acceptedCount - } - - // MARK: - Maintenance - - /// Panic wipe: drop all carried mail from memory and disk. - func wipe() { - queue.sync { - envelopes.removeAll() - diskLoadDeferred = false - if let fileURL { - try? FileManager.default.removeItem(at: fileURL) - } - publishCountLocked() - } - } - - /// Retries a protected-data read and merges any envelopes accepted while - /// the file was unavailable. Internal so persistence tests can drive the - /// same transition as iOS's protected-data notification. - func retryDeferredPersistence() { - queue.sync { - guard diskLoadDeferred, resolveDeferredLoadLocked() else { return } - persistLocked() - } - } - - // MARK: - Internals (call only on `queue`) - - private func pruneExpiredLocked(at date: Date) { - let before = envelopes.count - envelopes.removeAll { $0.envelope.isExpired(at: date) } - if envelopes.count != before { - SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session) - } - } - - private func publishCountLocked() { - let count = envelopes.count - DispatchQueue.main.async { [weak self] in - self?.carriedCount = count - } - } - - private func persistLocked() { - publishCountLocked() - guard let fileURL else { return } - // Never turn a transient protected-data read failure into an - // authoritative empty/new file. Once readable, resolve first by - // merging the durable and in-memory snapshots. - if diskLoadDeferred, !resolveDeferredLoadLocked() { - return - } - do { - if envelopes.isEmpty { - try? FileManager.default.removeItem(at: fileURL) - return - } - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let data = try JSONEncoder().encode(envelopes) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtectionUntilFirstUserAuthentication) - #endif - try data.write(to: fileURL, options: options) - } catch { - SecureLogger.error("Failed to persist courier store: \(error)", category: .session) - } - } - - private func loadFromDisk() { - guard let fileURL else { return } - queue.sync { - guard FileManager.default.fileExists(atPath: fileURL.path) else { - diskLoadDeferred = false - return - } - do { - let data = try readData(fileURL) - do { - envelopes = try JSONDecoder().decode([StoredEnvelope].self, from: data) - diskLoadDeferred = false - pruneExpiredLocked(at: now()) - publishCountLocked() - Self.migrateFileProtectionIfNeeded(at: fileURL) - } catch { - // The bytes were readable, so this is corruption/schema - // failure rather than protected-data unavailability. - diskLoadDeferred = false - SecureLogger.error("Failed to decode courier store: \(error)", category: .session) - } - } catch { - diskLoadDeferred = true - SecureLogger.warning("Courier store unavailable; deferring load until protected data is available: \(error)", category: .session) - } - } - } - - /// Must be called on `queue`. - private func resolveDeferredLoadLocked() -> Bool { - guard diskLoadDeferred, let fileURL else { return true } - guard FileManager.default.fileExists(atPath: fileURL.path) else { - diskLoadDeferred = false - return true - } - do { - let data = try readData(fileURL) - let durable = try JSONDecoder().decode([StoredEnvelope].self, from: data) - envelopes = Self.merge(durable: durable, inMemory: envelopes) - diskLoadDeferred = false - pruneExpiredLocked(at: now()) - publishCountLocked() - Self.migrateFileProtectionIfNeeded(at: fileURL) - return true - } catch let error as DecodingError { - // Readability returned but the snapshot is corrupt. Do not pin - // persistence forever; retain the valid in-memory snapshot. - diskLoadDeferred = false - SecureLogger.error("Failed to decode deferred courier store: \(error)", category: .session) - return true - } catch { - SecureLogger.warning("Courier store still unavailable: \(error)", category: .session) - return false - } - } - - private static func merge(durable: [StoredEnvelope], inMemory: [StoredEnvelope]) -> [StoredEnvelope] { - var merged = durable - for candidate in inMemory { - if let index = merged.firstIndex(where: { $0.ciphertext == candidate.ciphertext }) { - // Union progress before deciding the budget. Once either copy - // has sprayed, the lower remaining budget is authoritative; - // an older durable/original snapshot must not replenish it. - let durableHasProgress = !merged[index].sprayedTo.isEmpty - let memoryHasProgress = !candidate.sprayedTo.isEmpty - let combinedSprayedTo = merged[index].sprayedTo.union(candidate.sprayedTo) - switch (durableHasProgress, memoryHasProgress) { - case (false, false): - merged[index].copies = max(merged[index].copies, candidate.copies) - case (true, false): - break // durable progress owns its remaining budget - case (false, true): - merged[index].copies = candidate.copies - case (true, true): - // Concurrent progress can only spend budget; choosing the - // lower branch prevents a merge from minting copies. - merged[index].copies = min(merged[index].copies, candidate.copies) - } - merged[index].sprayedTo = combinedSprayedTo - if candidate.tier == .favorite { merged[index].tier = .favorite } - merged[index].lastRemoteHandoverAt = [merged[index].lastRemoteHandoverAt, candidate.lastRemoteHandoverAt] - .compactMap { $0 } - .max() - merged[index].lastBridgePublishAt = [merged[index].lastBridgePublishAt, candidate.lastBridgePublishAt] - .compactMap { $0 } - .max() - } else { - merged.append(candidate) - } - } - - // A long locked wake can accept new mail alongside a full durable - // store. Re-apply deposit quotas: existing per-depositor mail keeps - // its slot, while total-cap eviction sheds oldest verified mail first. - merged.sort { $0.storedAt < $1.storedAt } - var perDepositorCounts: [Data: Int] = [:] - merged = merged.filter { envelope in - let limit = envelope.tier == .favorite - ? Limits.maxPerFavoriteDepositor - : Limits.maxPerVerifiedDepositor - let count = perDepositorCounts[envelope.depositorNoiseKey, default: 0] - guard count < limit else { return false } - perDepositorCounts[envelope.depositorNoiseKey] = count + 1 - return true - } - while merged.filter({ $0.tier == .verified }).count > Limits.maxVerifiedEnvelopes { - guard let victim = merged.firstIndex(where: { $0.tier == .verified }) else { break } - merged.remove(at: victim) - } - while merged.count > Limits.maxEnvelopes { - if let victim = merged.firstIndex(where: { $0.tier == .verified }) { - merged.remove(at: victim) - } else { - merged.removeFirst() - } - } - return merged - } - - private static func migrateFileProtectionIfNeeded(at fileURL: URL) { - #if os(iOS) - do { - try FileManager.default.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: fileURL.path - ) - } catch { - SecureLogger.warning("Failed to migrate courier store file protection: \(error)", category: .session) - } - #endif - } - - private static func defaultFileURL() -> URL? { - guard let base = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) else { return nil } - return base - .appendingPathComponent("courier", isDirectory: true) - .appendingPathComponent("envelopes.json") - } -} diff --git a/bitchat/Services/Courier/MessageOutboxStore.swift b/bitchat/Services/Courier/MessageOutboxStore.swift deleted file mode 100644 index 9bb07be9..00000000 --- a/bitchat/Services/Courier/MessageOutboxStore.swift +++ /dev/null @@ -1,898 +0,0 @@ -// -// MessageOutboxStore.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import CryptoKit -import Foundation -import Security -#if os(iOS) -import UIKit -#endif - -/// Disk persistence for the MessageRouter outbox, so private messages queued -/// for an offline peer survive an app kill instead of silently evaporating. -/// -/// Nothing else in the app persists message plaintext, and this store keeps -/// that property: the outbox is sealed with a ChaChaPoly key that lives only -/// in the Keychain (after-first-unlock, this device only), on top of iOS file -/// protection. Wiped on panic alongside the courier store. -final class MessageOutboxStore { - struct QueuedMessage: Codable, Equatable { - let content: String - let nickname: String - let messageID: String - let timestamp: Date - var sendAttempts: Int - /// Noise keys of couriers already carrying this message, so deposit - /// retries add couriers instead of re-burning the same ones. - var depositedCourierKeys: Set - - init( - content: String, - nickname: String, - messageID: String, - timestamp: Date, - sendAttempts: Int = 0, - depositedCourierKeys: Set = [] - ) { - self.content = content - self.nickname = nickname - self.messageID = messageID - self.timestamp = timestamp - self.sendAttempts = sendAttempts - self.depositedCourierKeys = depositedCourierKeys - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - content = try container.decode(String.self, forKey: .content) - nickname = try container.decode(String.self, forKey: .nickname) - messageID = try container.decode(String.self, forKey: .messageID) - timestamp = try container.decode(Date.self, forKey: .timestamp) - sendAttempts = try container.decodeIfPresent(Int.self, forKey: .sendAttempts) ?? 0 - depositedCourierKeys = try container.decodeIfPresent(Set.self, forKey: .depositedCourierKeys) ?? [] - } - } - - private static let keychainService = "chat.bitchat.outbox" - private static let keychainKey = "outbox-encryption-key" - - typealias Snapshot = [PeerID: [QueuedMessage]] - - private enum DiskState { - case unknown - case loaded - case deferred - } - - private enum DiskReadResult { - case missing - case loaded(Snapshot) - case deferred(Error?) - case corrupt(Error) - } - - private enum EncryptionKeyReadResult { - case available(SymmetricKey) - case missing - case invalid - case unavailable(Error?) - } - - private struct RecoveredSnapshot { - let snapshot: Snapshot - let generation: UInt64 - let unseenDurable: Snapshot - } - - private let fileURL: URL? - private let keychain: KeychainManagerProtocol - private let readData: (URL) throws -> Data - private let writeData: (Data, URL, Data.WritingOptions) throws -> Void - private let beforeRecoveryNotification: () -> Void - private let lock = NSLock() - private var diskState: DiskState = .unknown - private var cachedSnapshot: Snapshot = [:] - /// Mutations made after a protected-data load failed. They are merged - /// with the durable snapshot once it becomes readable, never written on - /// top of an unreadable file. - private var pendingSnapshot: Snapshot? - /// True after a write failed after the durable baseline was already - /// loaded. That full-router snapshot includes removals and must replace, - /// rather than union with, the older disk contents on retry. - private var pendingSnapshotIsAuthoritative = false - /// Delivery/read acknowledgments received before a deferred cold-load - /// reveals the durable queue. Applied to every merge before persistence. - private var pendingRemovalMessageIDs = Set() - /// Peer-scoped acknowledgments received before a deferred cold-load - /// reveals the durable queue. Unlike the legacy global tombstones above, - /// these must not remove a colliding message ID queued for another peer. - private var pendingScopedRemovalMessageIDs: [PeerID: Set] = [:] - private var recoveryHandler: (@MainActor (Snapshot) -> Void)? - /// Recovery loaded durable state that MessageRouter has not merged yet. - /// While true, router saves must union with `cachedSnapshot` instead of - /// replacing unseen durable messages. - private var recoveryDeliveryPending = false - /// Recovery read durable state and classified the unseen subset, but the - /// merged snapshot could not yet be persisted. Preserve that classification - /// across retries instead of treating the cached union as router-known. - private var unseenRecoveryPendingPersistence = false - /// MessageRouter's latest authoritative in-memory snapshot while a - /// recovery classification is awaiting persistence or delivery. This is - /// deliberately separate from `pendingSnapshot`, which may contain the - /// union of router-known and unseen durable work after a failed write. - private var recoveryRouterSnapshot: Snapshot = [:] - /// The durable messages absent from MessageRouter's locked-wake snapshot - /// when recovery completed. Only this subset is unioned into authoritative - /// router saves before the recovery callback is claimed. - private var unseenRecoveredSnapshot: Snapshot = [:] - /// Covers the narrow launch race where protected data becomes available - /// after `load()` returned but before MessageRouter installs its handler. - private var unreportedRecoveredSnapshot: RecoveredSnapshot? - /// Invalidates recovery callbacks already queued onto the main actor when - /// panic wipe begins. - private var lifecycleGeneration: UInt64 = 0 - #if os(iOS) - private var protectedDataObserver: NSObjectProtocol? - #endif - - init( - keychain: KeychainManagerProtocol, - fileURL: URL? = nil, - readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) }, - writeData: @escaping (Data, URL, Data.WritingOptions) throws -> Void = { - try $0.write(to: $1, options: $2) - }, - beforeRecoveryNotification: @escaping () -> Void = {} - ) { - self.keychain = keychain - self.fileURL = fileURL ?? Self.defaultFileURL() - self.readData = readData - self.writeData = writeData - self.beforeRecoveryNotification = beforeRecoveryNotification - #if os(iOS) - protectedDataObserver = NotificationCenter.default.addObserver( - forName: UIApplication.protectedDataDidBecomeAvailableNotification, - object: nil, - queue: nil - ) { [weak self] _ in - self?.retryDeferredLoad() - } - #endif - } - - deinit { - #if os(iOS) - if let protectedDataObserver { - NotificationCenter.default.removeObserver(protectedDataObserver) - } - #endif - } - - // MARK: - API (call from the router's actor; IO is small and atomic) - - func load() -> Snapshot { - lock.lock() - defer { lock.unlock() } - - if case .loaded = diskState { - return cachedSnapshot - } - // Once a deferred recovery has classified the durable baseline, - // `cachedSnapshot` is the only safe synchronous view. Re-reading via - // the generic load path would confuse its durable+router union with a - // fully router-known authoritative snapshot. - if unseenRecoveryPendingPersistence || recoveryDeliveryPending { - return cachedSnapshot - } - - let wasDeferred = diskState == .deferred - switch readSnapshotLocked() { - case .loaded(let durable): - cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshotIsAuthoritative - ? (pendingSnapshot ?? [:]) - : Self.merge(durable, pendingSnapshot ?? [:])) - diskState = .loaded - if pendingSnapshot != nil || hasPendingRemovalsLocked { - if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { - pendingSnapshot = nil - pendingSnapshotIsAuthoritative = false - } else { - pendingSnapshot = cachedSnapshot - pendingSnapshotIsAuthoritative = true - diskState = .deferred - } - } - // The recovery callback is driven by `retryDeferredLoad`; `load` - // itself returns the recovered value synchronously to its caller. - return cachedSnapshot - - case .missing: - cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:]) - diskState = .loaded - if pendingSnapshot != nil || hasPendingRemovalsLocked { - if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { - pendingSnapshot = nil - pendingSnapshotIsAuthoritative = false - } else { - pendingSnapshot = cachedSnapshot - pendingSnapshotIsAuthoritative = true - diskState = .deferred - } - } - return cachedSnapshot - - case .deferred(let error): - diskState = .deferred - if !wasDeferred { - SecureLogger.warning("Outbox unavailable; deferring load until protected data is available: \(String(describing: error))", category: .session) - } - return pendingSnapshot ?? [:] - - case .corrupt(let error): - diskState = .loaded - cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:]) - SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session) - if pendingSnapshot != nil || hasPendingRemovalsLocked { - if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { - pendingSnapshot = nil - pendingSnapshotIsAuthoritative = false - } else { - pendingSnapshot = cachedSnapshot - pendingSnapshotIsAuthoritative = true - diskState = .deferred - } - } - return cachedSnapshot - } - } - - func save(_ outbox: Snapshot) { - var recovered: RecoveredSnapshot? - - lock.lock() - let recoveryWasPending = recoveryDeliveryPending - let unseenClassificationWasPending = unseenRecoveryPendingPersistence - let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending - let flattened = applyingPendingRemovalsLocked(outbox.filter { !$0.value.isEmpty }) - if recoveryStateWasPending { - // `save` is the router's complete current view. Keep it separate - // from the durable union retained for disk retry. - recoveryRouterSnapshot = flattened - } - switch diskState { - case .loaded: - cachedSnapshot = applyingPendingRemovalsLocked( - recoveryStateWasPending - ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) - : flattened - ) - if !persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { - // Retain the latest complete router snapshot. Because its - // durable baseline was already loaded, it replaces the older - // disk file after protected data returns (preserving removals). - pendingSnapshot = cachedSnapshot - pendingSnapshotIsAuthoritative = true - diskState = .deferred - } - - case .unknown, .deferred: - let wasDeferred = diskState == .deferred - // A non-authoritative deferred snapshot means the initial cold - // load never completed. An authoritative deferred snapshot with - // no recovery state is merely an ordinary post-load write retry. - let isRecoveryAttempt = recoveryStateWasPending || - (wasDeferred && !pendingSnapshotIsAuthoritative) - switch readSnapshotLocked() { - case .loaded(let durable): - // `save` before a successful `load` is not authoritative over - // an unreadable snapshot. Union by message ID so neither the - // durable queue nor work accepted during the locked wake is - // lost. - let newlyUnseen = recoveryStateWasPending - ? unseenRecoveredSnapshot - : (isRecoveryAttempt - ? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: flattened)) - : [:]) - if isRecoveryAttempt && !recoveryStateWasPending { - unseenRecoveredSnapshot = newlyUnseen - recoveryRouterSnapshot = flattened - unseenRecoveryPendingPersistence = true - } - let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence - let merged = preservesUnseenRecovery - ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) - : (pendingSnapshotIsAuthoritative ? flattened : Self.merge(durable, flattened)) - cachedSnapshot = applyingPendingRemovalsLocked(merged) - diskState = .loaded - if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { - pendingSnapshot = nil - pendingSnapshotIsAuthoritative = false - if isRecoveryAttempt && !recoveryWasPending { - recovered = RecoveredSnapshot( - snapshot: cachedSnapshot, - generation: lifecycleGeneration, - unseenDurable: newlyUnseen - ) - } - } else { - pendingSnapshot = cachedSnapshot - pendingSnapshotIsAuthoritative = true - diskState = .deferred - } - - case .missing: - if isRecoveryAttempt && !recoveryStateWasPending { - unseenRecoveredSnapshot = [:] - recoveryRouterSnapshot = flattened - unseenRecoveryPendingPersistence = true - } - let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence - let merged = applyingPendingRemovalsLocked( - preservesUnseenRecovery - ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) - : flattened - ) - cachedSnapshot = merged - diskState = .loaded - if persistSnapshotAndClearRemovalsLocked(merged) { - pendingSnapshot = nil - pendingSnapshotIsAuthoritative = false - if isRecoveryAttempt && !recoveryWasPending { - recovered = RecoveredSnapshot( - snapshot: merged, - generation: lifecycleGeneration, - unseenDurable: unseenRecoveredSnapshot - ) - } - } else { - pendingSnapshot = merged - pendingSnapshotIsAuthoritative = true - diskState = .deferred - } - - case .deferred: - // `save` receives MessageRouter's complete current in-memory - // snapshot. Replace prior locked-wake state so delivery acks - // and expiry removals become tombstones for that state; only - // the still-unknown durable snapshot is unioned on recovery. - pendingSnapshot = applyingPendingRemovalsLocked( - recoveryStateWasPending - ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) - : flattened - ) - diskState = .deferred - - case .corrupt(let error): - SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session) - if isRecoveryAttempt && !recoveryStateWasPending { - unseenRecoveredSnapshot = [:] - recoveryRouterSnapshot = flattened - unseenRecoveryPendingPersistence = true - } - let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence - let merged = applyingPendingRemovalsLocked( - preservesUnseenRecovery - ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) - : flattened - ) - cachedSnapshot = merged - diskState = .loaded - if persistSnapshotAndClearRemovalsLocked(merged) { - pendingSnapshot = nil - pendingSnapshotIsAuthoritative = false - if isRecoveryAttempt && !recoveryWasPending { - recovered = RecoveredSnapshot( - snapshot: merged, - generation: lifecycleGeneration, - unseenDurable: unseenRecoveredSnapshot - ) - } - } else { - pendingSnapshot = merged - pendingSnapshotIsAuthoritative = true - diskState = .deferred - } - } - } - if let recovered { - unseenRecoveryPendingPersistence = false - recoveryDeliveryPending = true - unseenRecoveredSnapshot = recovered.unseenDurable - } - lock.unlock() - - if let recovered { - beforeRecoveryNotification() - notifyRecovered(recovered.snapshot, generation: recovered.generation) - } - } - - /// Installs the router-side merge hook used when a cold, locked launch - /// initially received an empty snapshot and protected data later becomes - /// readable. - func setRecoveryHandler(_ handler: @escaping @MainActor (Snapshot) -> Void) { - lock.lock() - recoveryHandler = handler - let unreported = unreportedRecoveredSnapshot - unreportedRecoveredSnapshot = nil - lock.unlock() - if let unreported { - Task { @MainActor [weak self] in - guard let latest = self?.claimPendingRecovery(generation: unreported.generation) else { return } - handler(latest) - } - } - } - - /// Records an ack even when a locked cold-load has not revealed the - /// matching durable message yet. The next `save`/recovery applies this - /// tombstone before writing or notifying MessageRouter. - func recordRemoval(messageID: String) { - lock.lock() - pendingRemovalMessageIDs.insert(messageID) - cachedSnapshot = Self.removing([messageID], from: cachedSnapshot) - unseenRecoveredSnapshot = Self.removing([messageID], from: unseenRecoveredSnapshot) - recoveryRouterSnapshot = Self.removing([messageID], from: recoveryRouterSnapshot) - if let pendingSnapshot { - self.pendingSnapshot = Self.removing([messageID], from: pendingSnapshot) - } - lock.unlock() - } - - /// Records an ack for only the supplied peer aliases. This preserves - /// another recipient's queued entry when message IDs happen to collide, - /// including while the durable snapshot is hidden by protected data. - func recordRemoval(messageID: String, for peerIDs: Set) { - guard !peerIDs.isEmpty else { return } - - lock.lock() - for peerID in peerIDs { - pendingScopedRemovalMessageIDs[peerID, default: []].insert(messageID) - } - cachedSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: cachedSnapshot) - unseenRecoveredSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: unseenRecoveredSnapshot) - recoveryRouterSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: recoveryRouterSnapshot) - if let pendingSnapshot { - self.pendingSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: pendingSnapshot) - } - lock.unlock() - } - - /// Retries a deferred protected-data load. The returned snapshot includes - /// both durable messages and any messages queued during the locked wake. - @discardableResult - func retryDeferredLoad() -> Snapshot? { - var recovered: RecoveredSnapshot? - lock.lock() - guard diskState == .deferred else { - lock.unlock() - return nil - } - let recoveryWasPending = recoveryDeliveryPending - let unseenClassificationWasPending = unseenRecoveryPendingPersistence - let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending - // `pendingSnapshotIsAuthoritative` alone means a normal write failed - // after the router had already loaded its baseline. It must retry, but - // must not masquerade as cold-load recovery or schedule a callback. - let isRecoveryAttempt = recoveryStateWasPending || !pendingSnapshotIsAuthoritative - switch readSnapshotLocked() { - case .loaded(let durable): - let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:]) - let newlyUnseen = recoveryStateWasPending - ? unseenRecoveredSnapshot - : (isRecoveryAttempt - ? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: known)) - : [:]) - if isRecoveryAttempt && !recoveryStateWasPending { - unseenRecoveredSnapshot = newlyUnseen - recoveryRouterSnapshot = known - unseenRecoveryPendingPersistence = true - } - let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence - let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery - ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) - : (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known))) - cachedSnapshot = merged - diskState = .loaded - if (pendingSnapshot == nil && !hasPendingRemovalsLocked) || - persistSnapshotAndClearRemovalsLocked(merged) { - pendingSnapshot = nil - pendingSnapshotIsAuthoritative = false - if isRecoveryAttempt && !recoveryWasPending { - recovered = RecoveredSnapshot( - snapshot: merged, - generation: lifecycleGeneration, - unseenDurable: newlyUnseen - ) - } - } else { - pendingSnapshot = merged - pendingSnapshotIsAuthoritative = true - diskState = .deferred - } - case .missing: - let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:]) - if isRecoveryAttempt && !recoveryStateWasPending { - unseenRecoveredSnapshot = [:] - recoveryRouterSnapshot = known - unseenRecoveryPendingPersistence = true - } - let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence - let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery - ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) - : known) - cachedSnapshot = merged - diskState = .loaded - if (pendingSnapshot == nil && !hasPendingRemovalsLocked) || - persistSnapshotAndClearRemovalsLocked(merged) { - pendingSnapshot = nil - pendingSnapshotIsAuthoritative = false - if isRecoveryAttempt && !recoveryWasPending { - recovered = RecoveredSnapshot( - snapshot: merged, - generation: lifecycleGeneration, - unseenDurable: unseenRecoveredSnapshot - ) - } - } else { - pendingSnapshot = merged - pendingSnapshotIsAuthoritative = true - diskState = .deferred - } - case .deferred: - break - case .corrupt(let error): - SecureLogger.error("Failed to decode encrypted outbox after protected-data recovery: \(error)", category: .session) - let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:]) - if isRecoveryAttempt && !recoveryStateWasPending { - unseenRecoveredSnapshot = [:] - recoveryRouterSnapshot = known - unseenRecoveryPendingPersistence = true - } - let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence - let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery - ? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot) - : known) - cachedSnapshot = merged - diskState = .loaded - if (pendingSnapshot == nil && !hasPendingRemovalsLocked) || - persistSnapshotAndClearRemovalsLocked(merged) { - pendingSnapshot = nil - pendingSnapshotIsAuthoritative = false - if isRecoveryAttempt && !recoveryWasPending { - recovered = RecoveredSnapshot( - snapshot: merged, - generation: lifecycleGeneration, - unseenDurable: unseenRecoveredSnapshot - ) - } - } else { - pendingSnapshot = merged - pendingSnapshotIsAuthoritative = true - diskState = .deferred - } - } - if let recovered { - unseenRecoveryPendingPersistence = false - recoveryDeliveryPending = true - unseenRecoveredSnapshot = recovered.unseenDurable - } - lock.unlock() - - if let recovered { - beforeRecoveryNotification() - notifyRecovered(recovered.snapshot, generation: recovered.generation) - } - return recovered?.snapshot - } - - /// Panic wipe: drop the queued mail and the key that could ever read it. - func wipe() { - lock.lock() - diskState = .loaded - cachedSnapshot = [:] - pendingSnapshot = nil - pendingSnapshotIsAuthoritative = false - pendingRemovalMessageIDs.removeAll() - pendingScopedRemovalMessageIDs.removeAll() - recoveryDeliveryPending = false - unseenRecoveryPendingPersistence = false - unseenRecoveredSnapshot = [:] - recoveryRouterSnapshot = [:] - unreportedRecoveredSnapshot = nil - lifecycleGeneration &+= 1 - if let fileURL { - try? FileManager.default.removeItem(at: fileURL) - } - keychain.delete(key: Self.keychainKey, service: Self.keychainService) - lock.unlock() - } - - // MARK: - Internals - - private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? { - switch readEncryptionKey() { - case .available(let key): - return key - case .missing: - break - case .invalid: - guard createIfMissing else { return nil } - keychain.delete(key: Self.keychainKey, service: Self.keychainService) - case .unavailable: - return nil - } - guard createIfMissing else { return nil } - - let key = SymmetricKey(size: .bits256) - let data = key.withUnsafeBytes { Data($0) } - // After-first-unlock so queued mail can flush from background BLE wakes. - keychain.save( - key: Self.keychainKey, - data: data, - service: Self.keychainService, - accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - ) - // The protocol's generic save predates result-bearing writes. Verify - // the item before sealing a file: otherwise a locked/full Keychain - // could drop the key while we successfully write unrecoverable mail. - guard case .success(let stored) = keychain.loadWithResult( - key: Self.keychainKey, - service: Self.keychainService - ), stored == data else { - keychain.delete(key: Self.keychainKey, service: Self.keychainService) - SecureLogger.error("Outbox encryption key was not retained by Keychain", category: .session) - return nil - } - return key - } - - private func readEncryptionKey() -> EncryptionKeyReadResult { - switch keychain.loadWithResult(key: Self.keychainKey, service: Self.keychainService) { - case .success(let data): - guard data.count == 32 else { return .invalid } - return .available(SymmetricKey(data: data)) - case .itemNotFound: - return .missing - case .deviceLocked, .authenticationFailed: - return .unavailable(nil) - case .accessDenied: - return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(errSecNotAvailable))) - case .otherError(let status): - return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(status))) - } - } - - /// Must be called with `lock` held. - private func readSnapshotLocked() -> DiskReadResult { - guard let fileURL, - FileManager.default.fileExists(atPath: fileURL.path) else { - return .missing - } - let sealed: Data - do { - sealed = try readData(fileURL) - } catch { - return .deferred(error) - } - // A locked Keychain is transient; a genuine item-not-found next to an - // existing sealed file is permanent (notably after restoring onto a - // new device, because the key is ThisDeviceOnly). Remove that - // unrecoverable ciphertext before allowing a replacement key/file. - let key: SymmetricKey - switch readEncryptionKey() { - case .available(let availableKey): - key = availableKey - case .missing: - return discardOrphanedSnapshotLocked(reason: "encryption key is missing") - case .invalid: - keychain.delete(key: Self.keychainKey, service: Self.keychainService) - return discardOrphanedSnapshotLocked(reason: "encryption key has an invalid length") - case .unavailable(let error): - return .deferred(error) - } - do { - let box = try ChaChaPoly.SealedBox(combined: sealed) - let plaintext = try ChaChaPoly.open(box, using: key) - let decoded = try JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) - var outbox: Snapshot = [:] - for (peerID, queue) in decoded where !queue.isEmpty { - outbox[PeerID(str: peerID)] = queue - } - Self.migrateFileProtectionIfNeeded(at: fileURL) - return .loaded(outbox) - } catch { - return .corrupt(error) - } - } - - /// Must be called with `lock` held. A ciphertext whose ThisDeviceOnly key - /// is definitively absent can never become readable; removing it is safer - /// than deferring forever or overwriting it while pretending it loaded. - private func discardOrphanedSnapshotLocked(reason: String) -> DiskReadResult { - guard let fileURL else { return .missing } - do { - try FileManager.default.removeItem(at: fileURL) - SecureLogger.warning("Removed unrecoverable encrypted outbox because its \(reason)", category: .session) - return .missing - } catch { - SecureLogger.error("Could not remove unrecoverable encrypted outbox: \(error)", category: .session) - return .deferred(error) - } - } - - /// Must be called with `lock` held. - @discardableResult - private func persistSnapshotLocked(_ snapshot: Snapshot) -> Bool { - guard let fileURL else { return true } - do { - if snapshot.isEmpty { - if FileManager.default.fileExists(atPath: fileURL.path) { - try FileManager.default.removeItem(at: fileURL) - } - return true - } - guard let key = encryptionKey(createIfMissing: true) else { - SecureLogger.error("Outbox not persisted: no encryption key available", category: .session) - return false - } - let keyed = Dictionary(uniqueKeysWithValues: snapshot.map { ($0.key.id, $0.value) }) - let plaintext = try JSONEncoder().encode(keyed) - let sealed = try ChaChaPoly.seal(plaintext, using: key).combined - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtectionUntilFirstUserAuthentication) - #endif - try writeData(sealed, fileURL, options) - return true - } catch { - SecureLogger.error("Failed to persist outbox: \(error)", category: .session) - return false - } - } - - /// Must be called with `lock` held. - private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool { - guard persistSnapshotLocked(snapshot) else { return false } - pendingRemovalMessageIDs.removeAll() - pendingScopedRemovalMessageIDs.removeAll() - return true - } - - /// Must be called with `lock` held. - private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot { - Self.removing( - pendingScopedRemovalMessageIDs, - from: Self.removing(pendingRemovalMessageIDs, from: snapshot) - ) - } - - /// Must be read with `lock` held. - private var hasPendingRemovalsLocked: Bool { - !pendingRemovalMessageIDs.isEmpty || !pendingScopedRemovalMessageIDs.isEmpty - } - - private static func removing(_ messageIDs: Set, from snapshot: Snapshot) -> Snapshot { - guard !messageIDs.isEmpty else { return snapshot } - var filtered: Snapshot = [:] - for (peerID, queue) in snapshot { - let remaining = queue.filter { !messageIDs.contains($0.messageID) } - if !remaining.isEmpty { filtered[peerID] = remaining } - } - return filtered - } - - private static func removing( - _ messageIDsByPeer: [PeerID: Set], - from snapshot: Snapshot - ) -> Snapshot { - guard !messageIDsByPeer.isEmpty else { return snapshot } - var filtered = snapshot - for (peerID, messageIDs) in messageIDsByPeer { - guard !messageIDs.isEmpty, let queue = filtered[peerID] else { continue } - let remaining = queue.filter { !messageIDs.contains($0.messageID) } - filtered[peerID] = remaining.isEmpty ? nil : remaining - } - return filtered - } - - private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot { - var unseen: Snapshot = [:] - for (peerID, durableQueue) in durable { - let knownIDs = Set(known[peerID]?.map(\.messageID) ?? []) - let remaining = durableQueue.filter { !knownIDs.contains($0.messageID) } - if !remaining.isEmpty { unseen[peerID] = remaining } - } - return unseen - } - - private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot { - var merged = durable - for (peerID, pendingQueue) in pending { - var queue = merged[peerID] ?? [] - for var candidate in pendingQueue { - if let index = queue.firstIndex(where: { $0.messageID == candidate.messageID }) { - candidate.sendAttempts = max(candidate.sendAttempts, queue[index].sendAttempts) - candidate.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys) - queue[index] = candidate - } else { - queue.append(candidate) - } - } - queue.sort { $0.timestamp < $1.timestamp } - if !queue.isEmpty { merged[peerID] = queue } - } - return merged.filter { !$0.value.isEmpty } - } - - private func notifyRecovered(_ snapshot: Snapshot, generation: UInt64) { - lock.lock() - guard lifecycleGeneration == generation else { - lock.unlock() - return - } - let handler = recoveryHandler - if handler == nil { - unreportedRecoveredSnapshot = RecoveredSnapshot( - snapshot: snapshot, - generation: generation, - unseenDurable: unseenRecoveredSnapshot - ) - } - lock.unlock() - guard let handler else { return } - Task { @MainActor [weak self] in - guard let latest = self?.claimPendingRecovery(generation: generation) else { return } - handler(latest) - } - } - - private func claimPendingRecovery(generation: UInt64) -> Snapshot? { - lock.lock() - defer { lock.unlock() } - guard lifecycleGeneration == generation, recoveryDeliveryPending else { return nil } - let latest = cachedSnapshot - recoveryDeliveryPending = false - unseenRecoveryPendingPersistence = false - unseenRecoveredSnapshot = [:] - recoveryRouterSnapshot = [:] - unreportedRecoveredSnapshot = nil - return latest - } - - private static func migrateFileProtectionIfNeeded(at fileURL: URL) { - #if os(iOS) - do { - try FileManager.default.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: fileURL.path - ) - } catch { - SecureLogger.warning("Failed to migrate outbox file protection: \(error)", category: .session) - } - #endif - } - - private static func defaultFileURL() -> URL? { - guard let base = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) else { return nil } - return base - .appendingPathComponent("courier", isDirectory: true) - .appendingPathComponent("outbox.sealed") - } -} diff --git a/bitchat/Services/Courier/StoreAndForwardMetrics.swift b/bitchat/Services/Courier/StoreAndForwardMetrics.swift deleted file mode 100644 index 4fddf251..00000000 --- a/bitchat/Services/Courier/StoreAndForwardMetrics.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// StoreAndForwardMetrics.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitLogger -import Foundation - -/// Privacy-safe local counters for the store-and-forward stack: bare event -/// tallies with no message IDs, peer identities, or timestamps, so delivery -/// behavior can be measured on-device without recording who talked to whom. -/// Log-only surface — nothing here ever leaves the device. -final class StoreAndForwardMetrics { - enum Event: String, CaseIterable { - /// A private message entered the outbox (no prompt route available). - case outboxQueued = "outbox.queued" - /// A retained message was re-sent on a flush. - case outboxResent = "outbox.resent" - /// A delivery/read ack cleared a retained message. - case outboxDelivered = "outbox.delivered" - /// A retained message was dropped (attempt cap, TTL, or overflow). - case outboxDropped = "outbox.dropped" - /// We handed sealed mail to a courier. - case courierDeposited = "courier.deposited" - /// We accepted sealed mail to carry for a third party. - case courierAccepted = "courier.accepted" - /// We handed carried mail to its recipient over a direct link. - case courierHandedOver = "courier.handedOver" - /// We pushed carried mail toward a recipient heard via relay. - case courierRemoteHandover = "courier.remoteHandover" - /// We split spray copies to another courier. - case courierSprayed = "courier.sprayed" - /// Couriered mail addressed to us was opened and delivered. - case courierOpened = "courier.opened" - } - - static let shared = StoreAndForwardMetrics() - - private let lock = NSLock() - private var counts: [String: Int] - private let defaults: UserDefaults - private static let defaultsKey = "chat.bitchat.storeAndForwardMetrics" - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - self.counts = defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int] ?? [:] - } - - func record(_ event: Event) { - lock.lock() - let total = (counts[event.rawValue] ?? 0) + 1 - counts[event.rawValue] = total - defaults.set(counts, forKey: Self.defaultsKey) - lock.unlock() - SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session) - } - - /// Included in the panic wipe alongside the stores it describes. - func reset() { - lock.lock() - counts = [:] - defaults.removeObject(forKey: Self.defaultsKey) - lock.unlock() - } -} diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index 84abe5d3..f6785c3a 100644 --- a/bitchat/Services/FavoritesPersistenceService.swift +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -34,7 +34,7 @@ final class FavoritesPersistenceService: ObservableObject { static let shared = FavoritesPersistenceService() - init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) { + init(keychain: KeychainManagerProtocol = KeychainManager()) { self.keychain = keychain loadFavorites() @@ -125,13 +125,7 @@ final class FavoritesPersistenceService: ObservableObject { peerNostrPublicKey: String? = nil ) { let existing = favorites[peerNoisePublicKey] - // Callers that can't resolve the live nickname pass the "Unknown" - // placeholder (e.g. a notification arriving before the announce); - // never let it clobber a real stored nickname. - let incoming = peerNickname.flatMap { name in - (name.isEmpty || name == "Unknown") ? nil : name - } - let displayName = incoming ?? existing?.peerNickname ?? "Unknown" + let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown" SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session) diff --git a/bitchat/Services/Gateway/BoundedIDSet.swift b/bitchat/Services/Gateway/BoundedIDSet.swift deleted file mode 100644 index 8509c295..00000000 --- a/bitchat/Services/Gateway/BoundedIDSet.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// BoundedIDSet.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -/// Insertion-ordered string set with a fixed capacity; the oldest entry is -/// evicted when full. Shared by the gateway and bridge loop-prevention -/// caches. -struct BoundedIDSet { - private var members: Set = [] - private var order: [String] = [] - let capacity: Int - - init(capacity: Int) { - self.capacity = capacity - } - - func contains(_ id: String) -> Bool { - members.contains(id) - } - - /// Returns false when the ID was already present. - @discardableResult - mutating func insert(_ id: String) -> Bool { - guard members.insert(id).inserted else { return false } - order.append(id) - if order.count > capacity { - members.remove(order.removeFirst()) - } - return true - } -} diff --git a/bitchat/Services/Gateway/BridgeCourierService.swift b/bitchat/Services/Gateway/BridgeCourierService.swift deleted file mode 100644 index 80a20a36..00000000 --- a/bitchat/Services/Gateway/BridgeCourierService.swift +++ /dev/null @@ -1,611 +0,0 @@ -// -// BridgeCourierService.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import Foundation -#if os(iOS) -import UIKit -#elseif os(macOS) -import AppKit -#endif - -/// Courier delivery over the internet bridge: sealed courier envelopes are -/// parked on relays as kind-1401 "drops" tagged with their rotating -/// recipient tag, so delivery stops requiring a physical courier to bump -/// into the recipient. -/// -/// Three duties, all gated on the bridge toggle: -/// - Sender: when the message router seals mail for an unreachable peer, a -/// copy is published as a drop (queued until relays connect). The drop is -/// signed with a fresh throwaway key per publish — the envelope -/// authenticates its sender internally via Noise-X, and a stable publisher -/// key would leak courier traffic patterns to relays. -/// - Recipient: subscribes for its own candidate tags (adjacent UTC days) -/// and opens matching drops directly. -/// - Gateway (bridge + gateway toggles): additionally watches the tags of -/// verified local mesh peers and hands matching drops to them as directed -/// courier packets, so mesh-only recipients are served too. -/// -/// Privacy: a drop reveals to relays only that "someone" is messaging "some -/// 16-byte day-rotating tag". Only parties who already know the recipient's -/// Noise static key can compute the tag; the payload is an opaque Noise-X -/// seal. Duplicate deliveries (drop + physical courier + direct link) are -/// absorbed downstream by message-ID dedup. -@MainActor -final class BridgeCourierService: ObservableObject { - enum Limits { - /// Drops waiting for relay connectivity (bounded, drop-oldest). - static let maxPendingDrops = 20 - /// Republish cooldown for gateway-held envelopes. - static let heldEnvelopePublishCooldown: TimeInterval = 30 * 60 - /// Local peers a gateway watches drops for (x3 candidate tags each). - static let maxWatchedPeers = 16 - /// Tag-set refresh cadence (also covers UTC day rollover). - static let refreshIntervalSeconds: TimeInterval = 30 * 60 - /// Minimum spacing for announce-driven refreshes. - static let announceRefreshDebounceSeconds: TimeInterval = 60 - /// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack). - static let maxDropEnvelopeBytes = 20 * 1024 - static let maxTrackedIDs = 512 - /// Coalescing window for dedup-record writes: a backlog re-fetch - /// mutates the seen set once per event, and each snapshot save is a - /// full JSON encode + atomic write on the main actor. - static let dedupPersistCoalesceSeconds: TimeInterval = 1.0 - } - - static let shared = BridgeCourierService() - - // MARK: Wiring (set once by the bootstrapper; fakes in tests) - - var bridgeEnabled: (@MainActor () -> Bool)? - var relaysConnected: (@MainActor () -> Bool)? - /// Publishes a signed drop event directly to connected default (DM) - /// relays. Completion is true only after at least one relay explicitly - /// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM" - /// or merely "written to a socket". - var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)? - /// (Re)opens the drop subscription for the given hex tags. - var openSubscription: (@MainActor ([String]) -> Void)? - var closeSubscription: (@MainActor () -> Void)? - /// Our own Noise static public key. - var myNoiseKey: (@MainActor () -> Data?)? - /// Verified reachable local peers with known Noise keys. - var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])? - /// Seals content into a carry-only envelope for a recipient key. - var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)? - /// Opens a drop addressed to us. True means the inner envelope was - /// delivered, deduplicated, or intentionally rejected after decryption; - /// false leaves the relay event retryable after a transient crypto/key - /// failure. - var openEnvelope: (@MainActor (CourierEnvelope) -> Bool)? - /// Hands a drop to a matching local peer as a directed courier packet. - /// Returns true only when the transport accepted the packet onto a live - /// physical link or its link-specific backpressure queue. Stale - /// reachability and process-local directed spooling return false so the - /// drop event stays retryable. - var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Bool)? - /// Held envelopes eligible for (re)publish, honoring the cooldown. - var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])? - /// Commits a held envelope's cooldown after confirmed relay acceptance. - var markHeldEnvelopePublished: (@MainActor (CourierEnvelope) -> Void)? - /// Timer injection for tests; nil arms a real `Task`. - var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? - - // MARK: State - - private(set) var myTagsHex: Set = [] - private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set)] = [] - private(set) var pendingDrops: [( - envelope: CourierEnvelope, - dedupKey: String?, - operationID: UUID? - )] = [] - /// Opaque recipient/message keys already published as drops (sender-side - /// dedup) and drop event IDs already handled (multi-relay dedup). Both - /// persist across relaunches: relays hold drops for the full 24h NIP-40 - /// window and the persisted outbox keeps re-depositing, so in-memory-only - /// dedup meant every relaunch republished the same message as a fresh drop - /// and every gateway relaunch re-delivered the whole backlog - /// (field-verified amplification storm). Entries age out with the 24h drop - /// window. - private var publishedDropKeys: ExpiringIDSet - private var seenDropEventIDs: ExpiringIDSet - private var subscriptionOpen = false - private var lastSubscribedTags: Set = [] - private var refreshTimerArmed = false - private var announceRefreshTimerArmed = false - private var lastAnnounceRefresh = Date.distantPast - private struct ActiveDropOperation { - let id: UUID - let completion: @MainActor (Bool) -> Void - } - /// Sender operations queued locally or awaiting relay confirmation. - /// The per-attempt ID prevents a stale pre-wipe callback from completing - /// a newer attempt for the same recipient/message pair. - private var activeDropOperations: [String: ActiveDropOperation] = [:] - /// Held-envelope publishes have no sender message ID, but still need an - /// in-flight identity: repeated refreshes inside the relay-OK wait window - /// must not mint duplicate relay events for the same opaque envelope. - private var heldDropOperations: [Data: UUID] = [:] - /// Deterministically invalid envelopes are suppressed for this process, - /// but never persisted as if a relay accepted them. Bound and age them so - /// rotating oversize IDs cannot grow process memory forever. - private var rejectedDropKeys = ExpiringIDSet( - capacity: Limits.maxTrackedIDs, - lifetime: CourierEnvelope.maxLifetimeSeconds - ) - - private let now: () -> Date - private let dedupStore: BridgeDropDedupStore - - private var dedupPersistScheduled = false - - init(now: @escaping () -> Date = Date.init, dedupStore: BridgeDropDedupStore? = nil) { - self.now = now - self.dedupStore = dedupStore ?? BridgeDropDedupStore(persistsToDisk: !TestEnvironment.isRunningTests) - let snapshot = self.dedupStore.load() - let date = now() - self.publishedDropKeys = ExpiringIDSet( - capacity: Limits.maxTrackedIDs, - lifetime: CourierEnvelope.maxLifetimeSeconds, - entries: snapshot.publishedDropKeys, - now: date - ) - self.seenDropEventIDs = ExpiringIDSet( - capacity: Limits.maxTrackedIDs, - lifetime: CourierEnvelope.maxLifetimeSeconds, - entries: snapshot.seenDropEventIDs, - now: date - ) - // A coalesced dedup write scheduled just before a background kill - // would be lost; flush when the app backgrounds or terminates. - #if os(iOS) - let flushNotifications = [UIApplication.didEnterBackgroundNotification, UIApplication.willTerminateNotification] - #else - let flushNotifications = [NSApplication.willTerminateNotification] - #endif - for name in flushNotifications { - NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.flushDedupSnapshot() } - } - } - } - - /// Schedules a coalesced write of the dedup record (see - /// `Limits.dedupPersistCoalesceSeconds`); lifecycle notifications flush - /// any scheduled write before a background kill could drop it. - private func persistDedup() { - guard !dedupPersistScheduled else { return } - dedupPersistScheduled = true - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: UInt64(Limits.dedupPersistCoalesceSeconds * 1_000_000_000)) - guard let self else { return } - self.dedupPersistScheduled = false - self.flushDedupSnapshot() - } - } - - /// Writes the dedup record now. `publishedDropKeys` contains only drops - /// a relay explicitly accepted; queued and in-flight keys - /// live in `activeDropOperations` and are intentionally process-local. - func flushDedupSnapshot() { - dedupStore.save(BridgeDropDedupStore.Snapshot( - publishedDropKeys: publishedDropKeys.entries, - seenDropEventIDs: seenDropEventIDs.entries - )) - } - - /// Panic wipe: forget queued drops and the persisted dedup record. - func wipe() { - cancelActivePublishes() - rejectedDropKeys = ExpiringIDSet( - capacity: Limits.maxTrackedIDs, - lifetime: CourierEnvelope.maxLifetimeSeconds - ) - publishedDropKeys = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds) - seenDropEventIDs = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds) - dedupStore.wipe() - } - - // MARK: - Sender role - - /// Stable, opaque sender-side dedup key. Recipient scope prevents one - /// conversation's colliding message ID from suppressing another's drop, - /// while hashing keeps recipient keys out of the persisted snapshot. - private static func senderDropKey( - messageID: String, - recipientNoiseKey: Data - ) -> String { - var material = Data("bitchat-bridge-drop-dedup-v2".utf8) - appendLengthPrefixed(Data(messageID.utf8), to: &material) - appendLengthPrefixed(recipientNoiseKey, to: &material) - return "v2:\(material.sha256Hex())" - } - - private static func appendLengthPrefixed(_ value: Data, to output: inout Data) { - let length = UInt32(value.count) - output.append(UInt8((length >> 24) & 0xFF)) - output.append(UInt8((length >> 16) & 0xFF)) - output.append(UInt8((length >> 8) & 0xFF)) - output.append(UInt8(length & 0xFF)) - output.append(value) - } - - /// Previous releases persisted raw message IDs without recipient scope. - /// They remain conservative wildcards for their original 24-hour - /// lifetime: assigning one to a recipient would be guesswork and could - /// republish the original drop. New acceptances persist only v2 keys. - private func wasPublished( - legacyMessageID: String, - dedupKey: String, - now date: Date - ) -> Bool { - publishedDropKeys.contains(dedupKey, now: date) - || publishedDropKeys.contains(legacyMessageID, now: date) - } - - /// Parallel-deposit a sealed copy of an outbound private message as a - /// relay drop. Called by the message router alongside physical courier - /// deposits; idempotent per recipient/message pair. Completion becomes - /// true only after a real relay acceptance arrives, which is when the - /// router may show "carried". - func depositDrop( - content: String, - messageID: String, - recipientNoiseKey: Data, - completion: @escaping @MainActor (Bool) -> Void = { _ in } - ) { - guard bridgeEnabled?() ?? false else { - completion(false) - return - } - let date = now() - let dedupKey = Self.senderDropKey( - messageID: messageID, - recipientNoiseKey: recipientNoiseKey - ) - guard !wasPublished( - legacyMessageID: messageID, - dedupKey: dedupKey, - now: date - ), - activeDropOperations[dedupKey] == nil, - !rejectedDropKeys.contains(dedupKey, now: date) else { - completion(false) - return - } - guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { - completion(false) - return - } - // An envelope that can't encode within the drop size caps fails the - // same way on every attempt (size is a function of the content, not - // of the sealing); suppress it in-memory so the retry sweep does not - // churn, but never persist it as a published drop. - guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else { - rejectedDropKeys.insert(dedupKey, now: date) - completion(false) - return - } - let operationID = UUID() - activeDropOperations[dedupKey] = ActiveDropOperation(id: operationID, completion: completion) - publishDrop(envelope, dedupKey: dedupKey, operationID: operationID) - } - - /// Publishes held envelopes (mail we carry for others) as drops, - /// honoring the per-envelope cooldown. - func publishHeldEnvelopes() { - guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return } - for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] { - let key = envelope.ciphertext - guard heldDropOperations[key] == nil else { continue } - let operationID = UUID() - heldDropOperations[key] = operationID - publishDrop(envelope) { [weak self] succeeded in - guard let self, self.heldDropOperations[key] == operationID else { return } - self.heldDropOperations.removeValue(forKey: key) - if succeeded { - self.markHeldEnvelopePublished?(envelope) - } - } - } - } - -/// Publishes a drop, or queues it when relays are down. `dedupKey` is the - /// opaque sender-side recipient/message key (nil for held/relayed - /// envelopes we don't track); it rides the pending queue so an evicted or - /// failed drop can release its in-flight slot. Completion reports actual - /// NIP-01 relay acceptance. - private func publishDrop( - _ envelope: CourierEnvelope, - dedupKey: String? = nil, - operationID: UUID? = nil, - untrackedCompletion: (@MainActor (Bool) -> Void)? = nil - ) { - guard let encoded = envelope.encode(), - encoded.count <= Limits.maxDropEnvelopeBytes, - !envelope.isExpired else { - finishPublish( - dedupKey: dedupKey, - operationID: operationID, - succeeded: false, - untrackedCompletion: untrackedCompletion - ) - return - } - guard relaysConnected?() ?? false else { - // Held mail remains in CourierStore and has no sender operation to - // recover after an in-memory queue loss. Leave its cooldown unset - // and let the next connected refresh offer it again. - guard dedupKey != nil else { - untrackedCompletion?(false) - return - } - pendingDrops.append((envelope, dedupKey, operationID)) - while pendingDrops.count > Limits.maxPendingDrops { - let evicted = pendingDrops.removeFirst() - finishPublish( - dedupKey: evicted.dedupKey, - operationID: evicted.operationID, - succeeded: false - ) - } - return - } - guard let identity = try? NostrIdentity.generate(), - let event = try? NostrProtocol.createCourierDropEvent( - envelope: encoded, - recipientTagHex: envelope.recipientTag.hexEncodedString(), - expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000), - senderIdentity: identity - ) else { - SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption) - finishPublish( - dedupKey: dedupKey, - operationID: operationID, - succeeded: false, - untrackedCompletion: untrackedCompletion - ) - return - } - guard let publishEvent else { - SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session) - finishPublish( - dedupKey: dedupKey, - operationID: operationID, - succeeded: false, - untrackedCompletion: untrackedCompletion - ) - return - } - publishEvent(event) { [weak self] succeeded in - guard let self else { return } - guard self.finishPublish( - dedupKey: dedupKey, - operationID: operationID, - succeeded: succeeded, - untrackedCompletion: untrackedCompletion - ) else { return } - if succeeded { - SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))…", category: .session) - } else { - SecureLogger.warning("📦🌉 No relay accepted courier drop", category: .session) - } - } - } - - @discardableResult - private func finishPublish( - dedupKey: String?, - operationID: UUID?, - succeeded: Bool, - untrackedCompletion: (@MainActor (Bool) -> Void)? = nil - ) -> Bool { - guard let dedupKey else { - untrackedCompletion?(succeeded) - return true - } - // Missing/mismatched means this callback was duplicated, invalidated - // by panic wipe, or belongs to an older attempt for the same key. - guard let operationID, - let operation = activeDropOperations[dedupKey], - operation.id == operationID else { return false } - activeDropOperations.removeValue(forKey: dedupKey) - if succeeded { - publishedDropKeys.insert(dedupKey, now: now()) - persistDedup() - } - operation.completion(succeeded) - return true - } - - /// Drops queued while relays were unreachable publish on reconnect. - func flushPendingDrops() { - guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return } - let queued = pendingDrops - pendingDrops.removeAll() - for item in queued { - publishDrop( - item.envelope, - dedupKey: item.dedupKey, - operationID: item.operationID - ) - } - } - - // MARK: - Subscription (recipient + gateway watch) - - /// Recomputes the watched tag set and (re)opens the subscription. - /// Call on toggle changes, relay connectivity changes, and periodically - /// (tags rotate daily); idempotent. - func refresh() { - armRefreshTimerIfNeeded() - guard bridgeEnabled?() ?? false else { - cancelActivePublishes() - if subscriptionOpen { - closeSubscription?() - subscriptionOpen = false - } - return - } - guard relaysConnected?() ?? false else { - if subscriptionOpen { - closeSubscription?() - subscriptionOpen = false - } - return - } - let date = now() - if let myKey = myNoiseKey?() { - myTagsHex = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: date).map { $0.hexEncodedString() }) - } else { - myTagsHex = [] - } - // While bridging with internet, every device watches drops for its - // verified local peers — the single-switch analogue of gateway duty. - let peers = (localVerifiedPeers?() ?? []).prefix(Limits.maxWatchedPeers) - watchedPeerTags = peers.map { peer in - (peer.peerID, Set(CourierEnvelope.candidateTags(noiseStaticKey: peer.noiseKey, around: date).map { $0.hexEncodedString() })) - } - let allTags = myTagsHex.union(watchedPeerTags.flatMap(\.tagsHex)) - guard !allTags.isEmpty else { - if subscriptionOpen { - closeSubscription?() - subscriptionOpen = false - lastSubscribedTags = [] - } - return - } - // Resubscribe only when the watched set actually changed — refresh - // fires on every verified announce (field logs showed the drop - // subscription rebuilt every ~60s for an unchanged tag set). - if !subscriptionOpen || allTags != lastSubscribedTags { - openSubscription?(allTags.sorted()) - subscriptionOpen = true - lastSubscribedTags = allTags - } - flushPendingDrops() - publishHeldEnvelopes() - } - - /// Announce-driven refresh, debounced — a newly verified peer should be - /// watched promptly, but announce storms must not thrash subscriptions. - /// Calls inside the window coalesce into one trailing refresh so peers - /// learned after the leading edge are not omitted until the 30-minute - /// periodic timer. - func refreshAfterVerifiedAnnounce() { - guard bridgeEnabled?() ?? false else { return } - let date = now() - let elapsed = date.timeIntervalSince(lastAnnounceRefresh) - if elapsed >= Limits.announceRefreshDebounceSeconds { - lastAnnounceRefresh = date - refresh() - return - } - - guard !announceRefreshTimerArmed else { return } - announceRefreshTimerArmed = true - let delay = max(0, Limits.announceRefreshDebounceSeconds - elapsed) - let fire: @MainActor () -> Void = { [weak self] in - guard let self else { return } - self.announceRefreshTimerArmed = false - guard self.bridgeEnabled?() ?? false else { return } - self.lastAnnounceRefresh = self.now() - self.refresh() - } - if let scheduleTimer { - scheduleTimer(delay, fire) - } else { - Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - fire() - } - } - } - - private func armRefreshTimerIfNeeded() { - guard bridgeEnabled?() ?? false, !refreshTimerArmed else { return } - refreshTimerArmed = true - let fire: @MainActor () -> Void = { [weak self] in - guard let self else { return } - self.refreshTimerArmed = false - self.refresh() - } - if let scheduleTimer { - scheduleTimer(Limits.refreshIntervalSeconds, fire) - } else { - Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(Limits.refreshIntervalSeconds * 1_000_000_000)) - fire() - } - } - } - - // MARK: - Inbound drops - - /// Entry point for every drop event the subscription delivers (the relay - /// manager has already verified the event signature). - func handleDropEvent(_ event: NostrEvent) { - guard bridgeEnabled?() ?? false else { return } - guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return } - // A resubscribe can still deliver an event from the old watch set. - // Do not durably consume it until it actually belongs to us/current - // local peer and is opened or accepted for physical delivery. - guard !seenDropEventIDs.contains(event.id, now: now()) else { return } - guard let data = Data(base64Encoded: event.content), - data.count <= Limits.maxDropEnvelopeBytes, - let envelope = CourierEnvelope.decode(data), - !envelope.isExpired else { - return - } - let tagHex = envelope.recipientTag.hexEncodedString() - // The envelope's own tag must match the event's filterable tag — - // otherwise a mislabeled drop could ride a subscription it doesn't - // belong to. - guard event.tags.contains(where: { $0.count >= 2 && $0[0] == "x" && $0[1] == tagHex }) else { return } - - if myTagsHex.contains(tagHex) { - SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session) - if openEnvelope?(envelope) == true { - seenDropEventIDs.insert(event.id, now: now()) - persistDedup() - } - return - } - if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) { - SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))…", category: .session) - if deliverToPeer?(envelope, match.peerID) == true { - seenDropEventIDs.insert(event.id, now: now()) - persistDedup() - } - } - } - - // MARK: - Helpers - - /// Cancels queued and in-flight sender operations after bridge disable or - /// panic wipe. Invalidate first, then resolve false so callback re-entry - /// cannot be mistaken for an active operation; late relay callbacks no-op. - private func cancelActivePublishes() { - let invalidated = activeDropOperations.values.map(\.completion) - pendingDrops.removeAll() - activeDropOperations.removeAll() - // Untracked held publishes are invalidated too. Their late relay - // callbacks compare operation IDs and no-op after this reset. - heldDropOperations.removeAll() - invalidated.forEach { $0(false) } - } - - /// A fresh random Nostr identity for signing one drop. Delegates to the - /// canonical generator (Schnorr key that can't fail validity) instead of - /// hand-rolling SecRandom + retry. - static func makeThrowawayIdentity() -> NostrIdentity? { - try? NostrIdentity.generate() - } -} diff --git a/bitchat/Services/Gateway/BridgeDropDedupStore.swift b/bitchat/Services/Gateway/BridgeDropDedupStore.swift deleted file mode 100644 index 0e34a3bd..00000000 --- a/bitchat/Services/Gateway/BridgeDropDedupStore.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// BridgeDropDedupStore.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitLogger -import Foundation - -/// ID set with per-entry timestamps: entries expire after `lifetime` and the -/// oldest are evicted past `capacity`. The bridge's dedup caches use this -/// instead of `BoundedIDSet` because their contents persist across relaunches -/// and must age out with the 24h drop window they guard. -struct ExpiringIDSet { - private(set) var entries: [String: Date] - let capacity: Int - let lifetime: TimeInterval - - init(capacity: Int, lifetime: TimeInterval, entries: [String: Date] = [:], now: Date = Date()) { - self.capacity = capacity - self.lifetime = lifetime - self.entries = entries - prune(now: now) - } - - func contains(_ id: String, now: Date) -> Bool { - guard let recorded = entries[id] else { return false } - return now.timeIntervalSince(recorded) <= lifetime - } - - /// Returns false when the ID was already present (and unexpired). - @discardableResult - mutating func insert(_ id: String, now: Date) -> Bool { - guard !contains(id, now: now) else { return false } - entries[id] = now - prune(now: now) - return true - } - - /// Releases a previously inserted ID so it can be re-added later (e.g. a - /// queued drop evicted before it ever published must become retryable). - mutating func remove(_ id: String) { - entries.removeValue(forKey: id) - } - - private mutating func prune(now: Date) { - if entries.contains(where: { now.timeIntervalSince($0.value) > lifetime }) { - entries = entries.filter { now.timeIntervalSince($0.value) <= lifetime } - } - let overflow = entries.count - capacity - guard overflow > 0 else { return } - for (id, _) in entries.sorted(by: { $0.value < $1.value }).prefix(overflow) { - entries.removeValue(forKey: id) - } - } -} - -/// Disk persistence for the bridge courier's drop-dedup record. Relays hold -/// drops for the full 24h NIP-40 window and redeliver them on every launch, -/// and the 120s outbox sweep re-deposits anything undelivered — so with -/// in-memory-only dedup every relaunch republished the same message as a -/// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every -/// gateway relaunch re-delivered the whole backlog. Field-verified: ~20 -/// copies of one DM delivered in 40ms fed the storm behind a permanent -/// device freeze. Persisting both sides caps this at one drop per -/// recipient/message pair per 24h regardless of relaunch count. -/// -/// Contents are opaque hashes and relay event IDs — no plaintext or peer -/// identities — so until-first-unlock protection matches -/// `NostrProcessedEventStore`, and the file must load during a -/// locked-background restoration relaunch. Wiped on panic with the rest of -/// the courier state. -final class BridgeDropDedupStore { - struct Snapshot: Codable { - var publishedDropKeys: [String: Date] - var seenDropEventIDs: [String: Date] - } - - private let fileURL: URL? - - /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored - /// when `persistsToDisk` is false. - init(persistsToDisk: Bool = true, fileURL: URL? = nil) { - self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil - } - - func load() -> Snapshot { - guard let fileURL, - let data = try? Data(contentsOf: fileURL), - let snapshot = try? JSONDecoder().decode(Snapshot.self, from: data) else { - return Snapshot(publishedDropKeys: [:], seenDropEventIDs: [:]) - } - return snapshot - } - - func save(_ snapshot: Snapshot) { - guard let fileURL else { return } - guard !(snapshot.publishedDropKeys.isEmpty && snapshot.seenDropEventIDs.isEmpty) else { - try? FileManager.default.removeItem(at: fileURL) - return - } - do { - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let data = try JSONEncoder().encode(snapshot) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtectionUntilFirstUserAuthentication) - #endif - try data.write(to: fileURL, options: options) - } catch { - SecureLogger.error("Failed to persist bridge drop dedup record: \(error)", category: .session) - } - } - - /// Panic wipe: forget which drops we published or handled. - func wipe() { - guard let fileURL else { return } - try? FileManager.default.removeItem(at: fileURL) - } - - private static func defaultFileURL() -> URL? { - guard let base = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) else { return nil } - return base - .appendingPathComponent("courier", isDirectory: true) - .appendingPathComponent("bridge-drop-dedup.json") - } -} diff --git a/bitchat/Services/Gateway/BridgeService.swift b/bitchat/Services/Gateway/BridgeService.swift deleted file mode 100644 index 2138a397..00000000 --- a/bitchat/Services/Gateway/BridgeService.swift +++ /dev/null @@ -1,906 +0,0 @@ -// -// BridgeService.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import Combine -import Foundation - -/// Policy engine for the mesh bridge: an opt-in stitcher of disjoint BLE -/// mesh islands that share a place. While the toggle is on, this device's -/// public mesh messages are additionally signed (with a derived, unlinkable -/// per-cell Nostr identity) as rendezvous events for the local geohash cell -/// and published to the cell's deterministic geo relays — directly when we -/// have internet, or deposited with a bridge gateway peer over a directed -/// `toBridge` carrier when we are mesh-only. Inbound rendezvous events from -/// other islands render into the mesh timeline marked as bridged. -/// -/// A device with BOTH this toggle and the gateway toggle on serves the -/// island: it accepts `toBridge` deposits, publishes them, and rebroadcasts -/// remote rendezvous events onto the mesh as `fromBridge` carriers so -/// mesh-only peers see across the bridge too. -/// -/// Consent model: -/// - Nothing crosses a bridge unless its author signed it for the bridge: -/// gateways carry only finished, Schnorr-signed rendezvous events, so a -/// neighbor's gateway cannot exfiltrate radio-only traffic. The per-message -/// "nearby only" flag simply skips composing a rendezvous copy. -/// - Receiving over radio (`fromBridge` carriers) is always on — it is -/// passive and leaks nothing. Subscribing over the internet (which reveals -/// the coarse cell to relays) and publishing both require the toggle. -/// -/// Loop-prevention rules (adapted from `GatewayService`, unit-tested): -/// 1. An event learned from a `fromBridge` mesh broadcast is never published -/// and never rebroadcast (`meshBroadcastEventIDs`) — a second bridge -/// gateway on the same island cannot echo mesh-carried traffic. -/// 2. An event this device published (own messages or uplinked deposits, -/// `publishedEventIDs`) is never downlink-rebroadcast: it originated on -/// this island, so our own relay subscription redelivering it must not -/// double BLE airtime. -/// 3. A subscription event is rebroadcast at most once -/// (`rebroadcastEventIDs`, marked after send), and never when the island -/// already holds the radio copy (`isMessageSeenLocally` on the event's -/// mesh message ID) — remote islands' traffic is the only thing worth -/// airtime. -/// Receivers key bridge rows by the signed Nostr event ID. The event's `m` tag -/// is only a radio-copy hint: its mesh sender/timestamp fields are public and -/// cannot authenticate the event signer, so letting them own the timeline ID -/// would allow a different signer to front-run the genuine event's dedup slot. -/// When the radio copy is already present the hint avoids duplicate rendering -/// and downlink airtime. If the bridge copy wins the race, a later -/// authenticated radio copy replaces every bridge row that claimed the same -/// hint; the untrusted hint can therefore merge a duplicate but can never -/// suppress the radio-authenticated origin. -/// -/// All dependencies are closure-injected (repo convention) so the policy -/// layer is unit-testable without relays, radios, or CoreLocation. -@MainActor -final class BridgeService: ObservableObject { - enum Limits { - /// Uplink deposits held while relays are unreachable. - static let maxQueuedUplinks = 20 - static let maxQueuedUplinksPerDepositor = 5 - /// Uplink deposits accepted per depositor per minute. - static let uplinkEventsPerMinutePerDepositor = 10 - /// Downlink mesh rebroadcasts per minute — BLE airtime is precious, - /// and bridge traffic shares the radio with everything else. - static let downlinkEventsPerMinute = 20 - static let maxPendingDownlinks = 30 - /// Accepted clock skew for a rendezvous event. - static let maxEventAgeSeconds: TimeInterval = 15 * 60 - /// Bounded loop-prevention ID caches (oldest evicted). - static let maxTrackedEventIDs = 512 - /// Keep radio-replacement aliases for every bridge row that can still - /// be visible in the bounded mesh timeline. Retiring an alias earlier - /// would let valid high-volume ingress delete otherwise visible - /// history merely to preserve the radio-wins invariant. - static let maxTrackedRadioAliases = TransportConfig.meshTimelineCap - /// Presence heartbeat cadence while the bridge is active. - static let presenceIntervalSeconds: TimeInterval = 4 * 60 - /// A rendezvous participant counts toward "via bridge" for this long - /// after their last event. - static let participantFreshnessSeconds: TimeInterval = 10 * 60 - /// Relay ingress is adversarial: bound both accepted work and the - /// people-sheet state even when an attacker rotates signing keys. - static let inboundEventsPerMinute = 600 - static let inboundEventsPerMinutePerSigner = 120 - /// Cheap pre-crypto gate for both valid and invalid ingress. Without - /// it, invalid carrier events could force unbounded Schnorr work while - /// never reaching the accepted-event limiter below. - static let signatureVerificationAttemptsPerMinute = 720 - static let maxParticipants = 128 - /// Content cap, matching the public-message pipeline's own limit. - static let maxContentBytes = 16_000 - /// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km). - static let cellPrecision = 6 - } - - struct QueuedUplink { - let depositor: PeerID - let cell: String - let event: NostrEvent - } - - /// A validated rendezvous message ready for the timeline. - struct InboundBridgeMessage { - let messageID: String - /// Unauthenticated mesh coordinates can only be used to notice that a - /// verified radio copy is already present; they never own bridge dedup. - let radioMessageIDHint: String? - let senderNickname: String - let participantNickname: String? - let senderPubkey: String - let content: String - let timestamp: Date - } - - /// A person currently visible across the bridge (fresh, not attributed - /// to the local island), for the people sheet. - struct BridgedParticipant: Identifiable, Equatable { - let pubkey: String - let nickname: String? - let lastSeen: Date - var id: String { pubkey } - /// Geohash-chat convention: nickname#last-4-of-pubkey, so two remote - /// "anon"s stay distinguishable. - var displayName: String { - (nickname?.trimmedOrNilIfEmpty ?? "anon") + "#" + String(pubkey.suffix(4)) - } - } - - static let shared = BridgeService() - - /// The user toggle. While true this device publishes its own public mesh - /// messages to the rendezvous and subscribes to it when online. - @Published private(set) var isEnabled: Bool - /// Distinct rendezvous participants seen within the freshness window. - /// Radio-copy hints never alter signer locality: their public coordinates - /// can suppress a duplicate row but cannot authenticate a Nostr signer. - @Published private(set) var bridgedPeerCount: Int = 0 - /// The people behind the count, newest activity first. - @Published private(set) var bridgedParticipants: [BridgedParticipant] = [] - /// The rendezvous cell currently in use, when the bridge is active. - @Published private(set) var activeCell: String? - /// Per-session compose flag: while true, outgoing messages stay on the - /// radio — no rendezvous copy is composed, so no gateway can carry them. - @Published var nearbyOnly: Bool = false - - // MARK: Wiring (set once by the bootstrapper; fakes in tests) - - /// Publishes a signed event to the geo relays for a cell. - var publishToRelays: (@MainActor (NostrEvent, String) -> Void)? - /// Opens the rendezvous subscription for (cell + neighbors); events are - /// fed back via `handleRendezvousEvent`. - var openSubscription: (@MainActor ([String]) -> Void)? - /// Closes the rendezvous subscription. - var closeSubscription: (@MainActor () -> Void)? - /// Whether any Nostr relay connection is currently working. - var relaysConnected: (@MainActor () -> Bool)? - /// The local neighborhood cell from CoreLocation, if permitted. - var locationCell: (@MainActor () -> String?)? - /// Asks the location layer for a fresh one-shot fix. The bridge must - /// pump location itself: channel data otherwise only flows while some - /// other feature (channels sheet, location notes) happens to be active — - /// a field failure mode where the bridge silently never got a cell. - var requestLocationFix: (@MainActor () -> Void)? - /// A rendezvous cell advertised by a reachable mesh bridge peer's - /// announce — lets a mesh-only, location-less device still compose - /// correctly tagged events. - var meshAdvertisedCell: (@MainActor () -> String?)? - /// Sends an encoded `toBridge` carrier directed to a bridge peer. - var sendToBridgePeer: (@MainActor (Data, PeerID) -> Bool)? - /// Reachable mesh peers advertising the `.bridge` capability. - var availableBridgePeers: (@MainActor () -> [PeerID])? - /// Broadcasts an encoded `fromBridge` carrier on the mesh. - var broadcastToMesh: (@MainActor (Data) -> Void)? - /// Delivers a validated inbound bridge message to the mesh timeline. - var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)? - /// Removes a previously injected bridge row when an authenticated radio - /// copy arrives later. The UI hook also discards a not-yet-flushed row. - var removeInjectedInbound: (@MainActor (String) -> Void)? - /// Exact liveness check for a bridge row in either the pending UI pipeline - /// or bounded conversation store. Alias pruning may discard proof only - /// after the corresponding row is already gone. - var isInjectedInboundPresent: (@MainActor (String) -> Bool)? - /// True when the mesh timeline already holds this message ID (the radio - /// copy) — used to skip pointless downlink airtime. - var isMessageSeenLocally: (@MainActor (String) -> Bool)? - /// Derives the unlinkable per-cell rendezvous identity. - var deriveIdentity: (@MainActor (String) throws -> NostrIdentity)? - /// Local nickname for the `n` tag. - var myNickname: (@MainActor () -> String)? - /// Fired on toggle changes (advertise/withdraw `.bridge` + re-announce). - var onEnabledChanged: (@MainActor (Bool) -> Void)? - /// Fired when the active rendezvous cell changes (including to nil) so - /// the announce advertisement stays current. - var onActiveCellChanged: (@MainActor (String?) -> Void)? - /// Schedules a closure after a delay; nil arms a real `Task`. Injected so - /// timers are deterministic in tests. - var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? - - // MARK: State - - /// Loop rule 1: event IDs seen in `fromBridge` mesh broadcasts. - private var meshBroadcastEventIDs: BoundedIDSet - /// Loop rule 2: event IDs this device published (own or deposited). - private var publishedEventIDs: BoundedIDSet - /// Loop rule 3: event IDs this device already rebroadcast. - private var rebroadcastEventIDs: BoundedIDSet - /// Timeline message IDs already injected (either arrival path). - private var injectedMessageIDs: BoundedIDSet - /// Signed relay/carrier events already accepted. Kept separate from the - /// loop caches so a mesh arrival can still mark loop suppression even when - /// the relay copy won the race. - private var receivedEventIDs: BoundedIDSet - /// Authenticated radio IDs observed this session. These close the - /// bridge-first race even before the UI pipeline flushes its radio row. - private var observedRadioMessageIDs: BoundedIDSet - /// event ID -> untrusted radio hint. Event IDs own bridge - /// dedup; this bounded reverse index is used only to replace bridge rows - /// after the genuine radio packet has authenticated successfully. - private var injectedRadioAliases: [String: String] = [:] - private var injectedRadioAliasOrder: [String] = [] - - /// Cells the rendezvous subscription covers (own + neighbor ring). - private(set) var subscribedCells: Set = [] - private(set) var queuedUplinks: [QueuedUplink] = [] - private var uplinkDepositTimes: [PeerID: [Date]] = [:] - private var downlinkSendTimes: [Date] = [] - private var inboundEventTimes: [Date] = [] - private var inboundEventTimesBySigner: [String: [Date]] = [:] - private var signatureVerificationTokens = Double(Limits.signatureVerificationAttemptsPerMinute) - private var signatureVerificationLastRefillAt: Date? - private var pendingDownlinks: [(event: NostrEvent, cell: String)] = [] - private var downlinkDrainScheduled = false - private var presenceTimerArmed = false - private var lastPresenceAt = Date.distantPast - - /// pubkey -> (lastSeen, last known nickname). - private var participants: [String: (lastSeen: Date, nickname: String?)] = [:] - - private let defaults: UserDefaults - private let now: () -> Date - private let verifyEventSignature: (NostrEvent) -> Bool - private static let enabledKey = "bridge.userEnabled" - - init( - defaults: UserDefaults = .standard, - now: @escaping () -> Date = Date.init, - verifyEventSignature: @escaping (NostrEvent) -> Bool = { $0.isValidSignature() } - ) { - self.defaults = defaults - self.now = now - self.verifyEventSignature = verifyEventSignature - self.isEnabled = defaults.bool(forKey: Self.enabledKey) - self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) - self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) - self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) - self.injectedMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) - self.receivedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) - self.observedRadioMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) - } - - // MARK: - Toggle & lifecycle - - func setEnabled(_ enabled: Bool) { - guard enabled != isEnabled else { return } - isEnabled = enabled - defaults.set(enabled, forKey: Self.enabledKey) - if !enabled { - queuedUplinks.removeAll() - pendingDownlinks.removeAll() - uplinkDepositTimes.removeAll() - inboundEventTimes.removeAll() - inboundEventTimesBySigner.removeAll() - participants.removeAll() - bridgedPeerCount = 0 - bridgedParticipants = [] - } - SecureLogger.info("🌉 Bridge mode \(enabled ? "enabled" : "disabled")", category: .session) - refreshRendezvous() - onEnabledChanged?(enabled) - } - - /// Recomputes the active cell and (re)opens or closes the subscription. - /// Call on toggle changes, location updates, and relay connectivity - /// changes; idempotent. - func refreshRendezvous() { - let cell = isEnabled ? currentCell() : nil - // No cell yet: ask for a fix — the availableChannels change re-enters - // here once it lands. - if isEnabled, cell == nil { - requestLocationFix?() - } - guard cell != activeCell else { - // The maintenance timer must run even cell-less: it is what - // retries the location fix (launch races the permission - // callback, so the first request can silently no-op). - if isEnabled { armPresenceTimerIfNeeded() } - return - } - if activeCell != nil { - closeSubscription?() - subscribedCells = [] - } - activeCell = cell - onActiveCellChanged?(cell) - guard let cell else { - if isEnabled { armPresenceTimerIfNeeded() } - return - } - // Own cell + neighbors: islands straddling a cell edge still meet. - // Publishes go to the own cell only; symmetric because both sides - // subscribe to each other's cell via the neighbor ring. - let cells = [cell] + Geohash.neighbors(of: cell) - subscribedCells = Set(cells) - openSubscription?(cells) - SecureLogger.info("🌉 Bridge: rendezvous open for cell \(cell)", category: .session) - publishPresence() - armPresenceTimerIfNeeded() - } - - /// The rendezvous cell: our own location when we have it, else the cell - /// a reachable bridge gateway advertises in its announce. - private func currentCell() -> String? { - if let own = locationCell?(), !own.isEmpty { - return String(own.prefix(Limits.cellPrecision)) - } - if let advertised = meshAdvertisedCell?(), GatewayService.isValidGeohash(advertised) { - return String(advertised.prefix(Limits.cellPrecision)) - } - return nil - } - - // One switch does the right thing: while bridging, a device with - // internet automatically serves its island (accepts deposits, carries - // remote messages onto the radio). The marginal cost over bridging - // yourself is small — the relay connections and subscription already - // exist for you — and a separate "serve others" lever proved to be a - // silent trap for mesh-only neighbors. - - // MARK: - Outgoing (sender role) - - /// Composes and ships the bridged copy of an outgoing public mesh - /// message. Call after the radio send; no-op when the bridge is off, - /// no cell is known, or the message was flagged nearby-only upstream. - /// `senderPeerID`/`timestamp` are the origin coordinates of the radio - /// send — they (with the content) derive the cross-device-stable mesh - /// message ID that receivers dedup on. - func bridgeOutgoing(content: String, senderPeerID: PeerID, timestamp: Date) { - guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return } - guard content.utf8.count <= Limits.maxContentBytes else { return } - let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp) - guard let identity = try? deriveIdentity?(cell), - let event = try? NostrProtocol.createBridgeMeshEvent( - content: content, - cell: cell, - senderIdentity: identity, - nickname: myNickname?(), - meshSenderID: senderPeerID.id, - meshTimestampMs: timestampMs - ) else { - SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session) - return - } - publishedEventIDs.insert(event.id) - injectedMessageIDs.insert(event.id) // our own timeline already has it - if relaysConnected?() ?? false { - publishToRelays?(event, cell) - } else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event), - let payload = carrier.encode(), - let gateway = availableBridgePeers?().first { - if sendToBridgePeer?(payload, gateway) ?? false { - SecureLogger.debug("🌉 Bridge: uplinked own event via gateway \(gateway.id.prefix(8))…", category: .session) - } - } - } - - /// Publishes a presence heartbeat so silent participants still register - /// across the bridge. Throttled: several triggers (enable, cell change, - /// relay reconnect) can coincide, and same-second heartbeats are - /// byte-identical events anyway. - func publishPresence() { - guard isEnabled, let cell = activeCell, relaysConnected?() ?? false else { return } - guard now().timeIntervalSince(lastPresenceAt) >= 30 else { return } - lastPresenceAt = now() - guard let identity = try? deriveIdentity?(cell), - let event = try? NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: identity) else { return } - publishedEventIDs.insert(event.id) - publishToRelays?(event, cell) - } - - /// Maintenance heartbeat while bridging: presence, participant pruning, - /// and a location retry. Runs with or without a cell — the cell-less - /// case is exactly when the location retry matters. - private func armPresenceTimerIfNeeded() { - guard isEnabled, !presenceTimerArmed else { return } - presenceTimerArmed = true - let fire: @MainActor () -> Void = { [weak self] in - guard let self else { return } - self.presenceTimerArmed = false - self.publishPresence() - self.pruneParticipants() - // Location refresh: migrates cells on a moving device and - // recovers a launch that raced the permission callback. - if self.activeCell == nil { - self.refreshRendezvous() - } else { - self.requestLocationFix?() - } - self.armPresenceTimerIfNeeded() - } - if let scheduleTimer { - scheduleTimer(Limits.presenceIntervalSeconds, fire) - } else { - Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(Limits.presenceIntervalSeconds * 1_000_000_000)) - fire() - } - } - } - - // MARK: - Subscription ingress (internet role) - - /// Entry point for every event the rendezvous subscription delivers. - /// Handles presence accounting, timeline injection, and — when acting as - /// the island's gateway — downlink rebroadcast. - func handleRendezvousEvent(_ event: NostrEvent) { - guard isEnabled else { return } - // The subscription spans our cell + neighbors; trust only the - // event's own signed `r` tag, and only within that ring. - guard let cell = event.tags.first(where: { $0.count >= 2 && $0[0] == "r" })?[1], - subscribedCells.contains(cell) else { - return - } - // Events we published come back from our own subscription; they are - // presence-neutral (we never count ourselves) and never re-injected - // or rebroadcast. Two layers: the published-ID cache (this session) - // and pubkey self-recognition — the rendezvous identity is derived - // deterministically, so even after a relaunch wipes the cache our - // own relay-backfilled events are recognized (field bug: own - // pre-restart messages re-rendered as bridged). - guard !publishedEventIDs.contains(event.id) else { return } - if isOwnRendezvousEvent(event, cell: cell) { - publishedEventIDs.insert(event.id) // never downlink it either - return - } - guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else { return } - guard receivedEventIDs.insert(event.id), allowInboundEvent(from: event.pubkey) else { return } - guard let kind = classify(event, cell: cell) else { return } - - switch kind { - case .presence: - recordParticipant(event.pubkey, nickname: nil) - case .message(let message): - let isLocalRadioCopy = message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false - if isLocalRadioCopy { - // The public m-tag proves only that identical radio content is - // already present, not that this Nostr signer authored it. - // Skip the duplicate row without changing signer locality. - SecureLogger.debug("🌉 Bridge: authenticated radio copy already present; bridge alias skipped", category: .session) - } else if inject(message) { - recordParticipant(event.pubkey, nickname: message.participantNickname) - } - // Serving duty: carry remote islands' messages onto the radio for - // mesh-only peers. Local-origin events are skipped — the island - // already heard them (loop rule 3). The drain is jitter-delayed: - // with every online bridger serving, the holdoff lets gateways - // hear each other's broadcasts and skip duplicates. - if !isLocalRadioCopy, - !meshBroadcastEventIDs.contains(event.id), - !rebroadcastEventIDs.contains(event.id), - !pendingDownlinks.contains(where: { $0.event.id == event.id }) { - pendingDownlinks.append((event, cell)) - if pendingDownlinks.count > Limits.maxPendingDownlinks { - pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) - } - scheduleDownlinkDrainIfNeeded(jitter: true) - } - } - } - - /// Called only after the BLE public-message signature has authenticated. - /// A bridge hint is not trusted enough to drop this radio row. Instead, - /// the radio row wins and any earlier bridge aliases are removed, which - /// gives both arrival orders one timeline row without restoring the - /// origin-spoof vulnerability. - func handleAuthenticatedRadioMessage(messageID: String) { - guard !messageID.isEmpty else { return } - observedRadioMessageIDs.insert(messageID) - - let matching = injectedRadioAliases.filter { $0.value == messageID } - guard !matching.isEmpty else { return } - let eventIDs = Set(matching.keys) - for eventID in matching.keys { - removeInjectedInbound?(eventID) - injectedRadioAliases.removeValue(forKey: eventID) - } - injectedRadioAliasOrder.removeAll { eventIDs.contains($0) } - - // A relay event can already be waiting inside the multi-gateway jitter - // window. Remove it now so it cannot consume BLE airtime after the - // authenticated radio packet proved this island already has a copy. - pendingDownlinks.removeAll { item in - guard case .message(let message)? = classify(item.event, cell: item.cell) else { return false } - return message.radioMessageIDHint == messageID - } - } - - // MARK: - Mesh carrier ingress (both roles) - - /// Entry point for received `nostrCarrier` packets with bridge - /// directions. `directedToUs` is true for `toBridge` deposits addressed - /// to this device; false for `fromBridge` broadcasts. - func handleMeshCarrier(_ carrier: NostrCarrierPacket, from peerID: PeerID, directedToUs: Bool) { - switch carrier.direction { - case .toBridge: - guard directedToUs else { return } - handleUplinkDeposit(carrier, from: peerID) - case .fromBridge: - guard !directedToUs else { return } - handleDownlinkBroadcast(carrier) - case .toGateway, .fromGateway: - return // GatewayService territory; routed there by the caller. - } - } - - // MARK: - Uplink (gateway role: mesh peer -> internet) - - private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) { - guard isEnabled else { return } - // Cheap structural gates before any crypto, mirroring GatewayService. - guard let event = structurallyValidEvent(from: carrier) else { - SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security) - return - } - guard !meshBroadcastEventIDs.contains(event.id), - !publishedEventIDs.contains(event.id), - !queuedUplinks.contains(where: { $0.event.id == event.id }) else { - return - } - guard allowUplinkDeposit(from: depositor) else { - SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))…", category: .session) - return - } - guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else { - SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security) - return - } - if relaysConnected?() ?? false { - publish(event, cell: carrier.geohash) - } else { - enqueueUplink(QueuedUplink(depositor: depositor, cell: carrier.geohash, event: event)) - } - // No local injection: the depositor's radio broadcast already carried - // the message to this island, including us. - } - - /// Publish everything queued while relays were unreachable. - func flushQueuedUplinks() { - guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return } - let queued = queuedUplinks - queuedUplinks.removeAll() - for item in queued where !publishedEventIDs.contains(item.event.id) { - publish(item.event, cell: item.cell) - } - } - - private func publish(_ event: NostrEvent, cell: String) { - publishedEventIDs.insert(event.id) - publishToRelays?(event, cell) - SecureLogger.info("🌉 Bridge: published carried event \(event.id.prefix(8))… for cell \(cell)", category: .session) - } - - @discardableResult - private func enqueueUplink(_ item: QueuedUplink) -> Bool { - let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count - guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { return false } - if queuedUplinks.count >= Limits.maxQueuedUplinks { - queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1) - } - queuedUplinks.append(item) - return true - } - - private func allowUplinkDeposit(from depositor: PeerID) -> Bool { - let cutoff = now().addingTimeInterval(-60) - var times = uplinkDepositTimes[depositor, default: []] - times.removeAll { $0 < cutoff } - guard times.count < Limits.uplinkEventsPerMinutePerDepositor else { - uplinkDepositTimes[depositor] = times - return false - } - times.append(now()) - uplinkDepositTimes[depositor] = times - if uplinkDepositTimes.count > Limits.maxTrackedEventIDs { - uplinkDepositTimes = uplinkDepositTimes.filter { $0.value.contains { $0 >= cutoff } } - } - return true - } - - /// Bounds relay/carrier work independently of the downlink airtime budget. - /// A valid signature proves control of one key, not that the sender is - /// entitled to unbounded main-actor state or CPU. - private func allowInboundEvent(from signer: String) -> Bool { - let date = now() - let cutoff = date.addingTimeInterval(-60) - inboundEventTimes.removeAll { $0 < cutoff } - guard inboundEventTimes.count < Limits.inboundEventsPerMinute else { return false } - - var signerTimes = inboundEventTimesBySigner[signer, default: []] - signerTimes.removeAll { $0 < cutoff } - guard signerTimes.count < Limits.inboundEventsPerMinutePerSigner else { - inboundEventTimesBySigner[signer] = signerTimes - return false - } - - inboundEventTimes.append(date) - signerTimes.append(date) - inboundEventTimesBySigner[signer] = signerTimes - if inboundEventTimesBySigner.count > Limits.maxTrackedEventIDs { - inboundEventTimesBySigner = inboundEventTimesBySigner.filter { entry in - entry.value.contains { $0 >= cutoff } - } - } - return true - } - - /// Bounds expensive signature checks before signer identity is trusted. - /// Kept independent from accepted-event accounting so invalid events do - /// not create signer state or poison any dedup cache. - private func allowSignatureVerificationAttempt() -> Bool { - let date = now() - if let lastRefillAt = signatureVerificationLastRefillAt { - let elapsed = max(0, date.timeIntervalSince(lastRefillAt)) - let refillPerSecond = Double(Limits.signatureVerificationAttemptsPerMinute) / 60 - signatureVerificationTokens = min( - Double(Limits.signatureVerificationAttemptsPerMinute), - signatureVerificationTokens + elapsed * refillPerSecond - ) - } - signatureVerificationLastRefillAt = date - guard signatureVerificationTokens >= 1 else { return false } - signatureVerificationTokens -= 1 - return true - } - - // MARK: - Downlink (gateway role: internet -> mesh) - - private func drainPendingDownlinks() { - let cutoff = now().addingTimeInterval(-60) - downlinkSendTimes.removeAll { $0 < cutoff } - while !pendingDownlinks.isEmpty, - downlinkSendTimes.count < Limits.downlinkEventsPerMinute { - let (event, cell) = pendingDownlinks.removeFirst() - guard isFresh(event) else { continue } - // Suppression recheck at send time: another gateway may have - // broadcast this event, or the authenticated radio copy may have - // arrived, during our jitter holdoff. - guard !meshBroadcastEventIDs.contains(event.id), - !rebroadcastEventIDs.contains(event.id) else { continue } - if case .message(let message)? = classify(event, cell: cell), - let radioMessageID = message.radioMessageIDHint, - radioCopyAlreadyPresent(radioMessageID) { - continue - } - guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event), - let payload = carrier.encode() else { continue } - broadcastToMesh?(payload) - SecureLogger.debug("🌉 Bridge: downlinked remote event \(event.id.prefix(8))… onto the mesh", category: .session) - // Mark-after-send (loop rule 3): a queue-overflow drop stays - // retryable on relay redelivery. - rebroadcastEventIDs.insert(event.id) - downlinkSendTimes.append(now()) - } - scheduleDownlinkDrainIfNeeded() - } - - private func scheduleDownlinkDrainIfNeeded(jitter: Bool = false) { - guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return } - let delay: TimeInterval - if jitter { - // Multi-gateway suppression window: enough spread for another - // gateway's broadcast to land and mark the event mesh-carried. - delay = Double.random(in: 0.2...1.5) - } else { - let oldest = downlinkSendTimes.min() ?? now() - delay = max(0.05, 60 - now().timeIntervalSince(oldest)) - } - downlinkDrainScheduled = true - let fire: @MainActor () -> Void = { [weak self] in - guard let self else { return } - self.downlinkDrainScheduled = false - self.drainPendingDownlinks() - } - if let scheduleTimer { - scheduleTimer(delay, fire) - } else { - Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - fire() - } - } - } - - // MARK: - Downlink (receiver role: carried event arrives over radio) - - private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) { - // Reception is deliberately NOT gated on the toggle: it is passive - // radio, and two phones side by side should not disagree about what - // the channel said. Publishing/subscribing remain opt-in. - guard let event = structurallyValidEvent(from: carrier), - !publishedEventIDs.contains(event.id), - !isOwnRendezvousEvent(event, cell: carrier.geohash), - allowSignatureVerificationAttempt(), - verifyEventSignature(event) else { - return - } - // Mark after verification (a forged copy must not poison the cache), - // even when the relay copy won the injection race: pending gateway - // downlinks consult this cache and must stand down. - let firstMeshArrival = meshBroadcastEventIDs.insert(event.id) - guard firstMeshArrival, - receivedEventIDs.insert(event.id), - allowInboundEvent(from: event.pubkey) else { return } - guard case .message(let message)? = classify(event, cell: carrier.geohash) else { - return - } - if inject(message) { - recordParticipant(event.pubkey, nickname: message.participantNickname) - } - } - - // MARK: - Injection & participants - - @discardableResult - private func inject(_ message: InboundBridgeMessage) -> Bool { - guard injectedMessageIDs.insert(message.messageID) else { return false } - guard !(message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false) else { - return false - } - SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session) - injectInbound?(message) - if let radioMessageID = message.radioMessageIDHint { - recordRadioAlias( - eventID: message.messageID, - radioMessageID: radioMessageID - ) - } - return true - } - - private func radioCopyAlreadyPresent(_ messageID: String) -> Bool { - observedRadioMessageIDs.contains(messageID) || (isMessageSeenLocally?(messageID) ?? false) - } - - private func recordRadioAlias( - eventID: String, - radioMessageID: String - ) { - guard injectedRadioAliases[eventID] == nil else { return } - injectedRadioAliases[eventID] = radioMessageID - injectedRadioAliasOrder.append(eventID) - guard injectedRadioAliasOrder.count > Limits.maxTrackedRadioAliases, - let isInjectedInboundPresent else { return } - - // Arrival order and signed event timestamps are independent. The - // conversation cap trims by timestamp, so blindly evicting the oldest - // alias could discard the proof for a still-visible row and then - // actively delete that history. Prune only aliases whose exact row is - // already absent; temporary overflow is safer than losing radio-wins - // reconciliation for any visible bridge message. - var overflow = injectedRadioAliasOrder.count - Limits.maxTrackedRadioAliases - var retained: [String] = [] - retained.reserveCapacity(injectedRadioAliasOrder.count) - for candidate in injectedRadioAliasOrder { - if overflow > 0, !isInjectedInboundPresent(candidate) { - injectedRadioAliases.removeValue(forKey: candidate) - overflow -= 1 - } else { - retained.append(candidate) - } - } - injectedRadioAliasOrder = retained - } - - private func recordParticipant(_ pubkey: String, nickname: String?) { - let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds) - participants = participants.filter { $0.value.lastSeen >= cutoff } - if participants[pubkey] == nil, participants.count >= Limits.maxParticipants, - let oldest = participants.min(by: { $0.value.lastSeen < $1.value.lastSeen })?.key { - participants.removeValue(forKey: oldest) - } - let previous = participants[pubkey] - // Presence events carry no nickname, so a known name is never - // forgotten. Radio hints deliberately do not mutate this record. - participants[pubkey] = ( - lastSeen: now(), - nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname - ) - recomputeBridgedCount() - } - - private func pruneParticipants() { - let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds) - participants = participants.filter { $0.value.lastSeen >= cutoff } - recomputeBridgedCount() - } - - private func recomputeBridgedCount() { - let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds) - let visible = participants - .filter { $0.value.lastSeen >= cutoff } - .map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) } - .sorted { $0.lastSeen > $1.lastSeen } - if visible.count != bridgedPeerCount { - bridgedPeerCount = visible.count - } - if visible != bridgedParticipants { - bridgedParticipants = visible - } - } - - // MARK: - Validation - - private enum RendezvousKind { - case message(InboundBridgeMessage) - case presence - } - - /// Classifies a structurally acceptable rendezvous event; nil rejects. - private func classify(_ event: NostrEvent, cell: String) -> RendezvousKind? { - guard isFresh(event), - event.tags.contains(where: { $0.count >= 2 && $0[0] == "r" && $0[1] == cell }), - GatewayService.isValidGeohash(cell) else { - return nil - } - switch event.kind { - case NostrProtocol.EventKind.geohashPresence.rawValue: - return .presence - case NostrProtocol.EventKind.ephemeralEvent.rawValue: - let content = event.content - guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil } - let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1] - // The `m` tag is `[stable ID, sender ID, wire timestamp ms]` for - // radio/bridge duplicate detection. Those coordinates are public - // and not cryptographically bound to this Nostr signer, so they - // are never allowed to own bridge dedup. A copied tag can at most - // make the attacker's own event look like a radio duplicate; it - // cannot reserve the genuine signed event's timeline ID. - let m = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" }) - let radioMessageIDHint: String? - if let m, m.count >= 4, m[2].count == 16, m[2].allSatisfy(\.isHexDigit), - let timestampMs = UInt64(m[3]) { - radioMessageIDHint = MeshMessageIdentity.stableID( - senderIDHex: m[2], - timestampMs: timestampMs, - content: content - ) - } else { - radioMessageIDHint = nil - } - let baseNickname = nickname?.trimmedOrNilIfEmpty ?? "anon" - return .message(InboundBridgeMessage( - messageID: event.id, - radioMessageIDHint: radioMessageIDHint, - senderNickname: baseNickname + "#" + String(event.pubkey.suffix(4)), - participantNickname: nickname?.trimmedOrNilIfEmpty, - senderPubkey: event.pubkey, - content: content, - timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)) - )) - default: - return nil - } - } - - /// Parse + size + cell + kind + `r` tag + freshness, with NO signature - /// verification — callers dedup/rate-limit first, Schnorr-verify last. - private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { - guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes, - GatewayService.isValidGeohash(carrier.geohash), - let event = carrier.event(), - classify(event, cell: carrier.geohash) != nil else { - return nil - } - return event - } - - private func isFresh(_ event: NostrEvent) -> Bool { - abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds - } - - /// True when the event was signed by this device's own derived - /// rendezvous identity for the cell. Survives relaunches (unlike the - /// published-ID cache) because the derivation is deterministic; the - /// underlying identity cache makes this cheap. - private func isOwnRendezvousEvent(_ event: NostrEvent, cell: String) -> Bool { - guard let identity = try? deriveIdentity?(cell) else { return false } - return identity.publicKeyHex.lowercased() == event.pubkey.lowercased() - } -} diff --git a/bitchat/Services/Gateway/GatewayService.swift b/bitchat/Services/Gateway/GatewayService.swift deleted file mode 100644 index 0c7bdc9f..00000000 --- a/bitchat/Services/Gateway/GatewayService.swift +++ /dev/null @@ -1,468 +0,0 @@ -// -// GatewayService.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import Combine -import Foundation - -/// Policy engine for gateway mode: an opt-in "share my internet with the -/// mesh" bridge. While the toggle is on, this device advertises the -/// `.gateway` capability bit, publishes signed geohash events deposited by -/// mesh-only peers to Nostr relays (uplink), and rebroadcasts inbound relay -/// events onto the mesh (downlink) so mesh-only peers can take part in the -/// local geohash channel. Mesh-only peers need no toggle: their uplink -/// engages automatically when relays are unreachable and a gateway peer -/// exists. -/// -/// Threat model: -/// - Keys never leave the originating device. Mesh-only senders sign events -/// locally with their per-geohash ephemeral identity; the gateway carries -/// only the finished, signed event. -/// - The gateway cannot forge or alter events: every carried event is -/// Schnorr-verified here before it is published or rebroadcast, and again -/// independently by relays and receivers. -/// - Carried contents are public geohash chat, already plaintext on Nostr, -/// so the mesh carrier adds no confidentiality loss. -/// -/// Loop-prevention rules: -/// 1. An event learned from a `fromGateway` mesh broadcast is never -/// re-published to relays, never re-uplinked, and never rebroadcast -/// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot -/// echo mesh-carried traffic back out. Mesh-level propagation of the -/// original broadcast packet is the TTL relay's job, not ours. -/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and -/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so -/// repeat deposits and relay echoes are absorbed. An event this gateway -/// itself uplinked (`publishedEventIDs`) is additionally never -/// downlink-rebroadcast: it originated on this mesh, so echoing it back -/// when our own relay subscription redelivers it would double BLE airtime -/// (the device-confirmed self-echo bug). -/// 3. Uplink is only attempted for locally composed events at the send site -/// (`GeohashSubscriptionManager.sendGeohash`); events received over the -/// carrier never re-enter the uplink path. This is a call-site convention; -/// the `meshBroadcastEventIDs`/`publishedEventIDs` backstops in -/// `uplinkViaMesh` enforce it defensively and are unit-tested. -/// Rules 1 and 2 are enforced here and unit-tested. -/// Rebroadcast storms at the mesh layer are additionally bounded by the BLE -/// `MessageDeduplicator` and packet TTL, and receivers dedup carried events -/// against their own relay subscriptions via the Nostr event-ID cache in -/// `NostrInboundPipeline`. -/// -/// All dependencies are closure-injected (repo convention) so the policy -/// layer is unit-testable without relays or radios. -@MainActor -final class GatewayService: ObservableObject { - enum Limits { - /// Uplink deposits held while relays are unreachable (CourierStore-style - /// bounded mailbag: bounded total, bounded per depositor). - static let maxQueuedUplinks = 20 - static let maxQueuedUplinksPerDepositor = 5 - /// Uplink deposits accepted per depositor per minute. - static let uplinkEventsPerMinutePerDepositor = 10 - /// Downlink mesh rebroadcasts per minute — BLE airtime is precious. - /// Beyond the budget events queue (bounded, drop-oldest) and drain on - /// a scheduled timer once the window frees (also re-driven by the next - /// inbound relay event); a quiet channel does not strand its backlog. - static let downlinkEventsPerMinute = 30 - static let maxPendingDownlinks = 30 - /// Accepted clock skew for a carried ephemeral event; anything older - /// is stale replay the relays would drop anyway. - static let maxEventAgeSeconds: TimeInterval = 15 * 60 - /// Bounded loop-prevention ID caches (oldest evicted). - static let maxTrackedEventIDs = 512 - } - - struct QueuedUplink { - let depositor: PeerID - let geohash: String - let event: NostrEvent - } - - static let shared = GatewayService() - - /// The user toggle. While true this device advertises `.gateway` and - /// bridges mesh <-> Nostr for geohash channels. - @Published private(set) var isEnabled: Bool - - // MARK: Wiring (set once by the bootstrapper; fakes in tests) - - /// Publishes a verified event to the geo relays for a geohash. - var publishToRelays: (@MainActor (NostrEvent, String) -> Void)? - /// Broadcasts an encoded `fromGateway` carrier payload on the mesh. - var broadcastToMesh: (@MainActor (Data) -> Void)? - /// Sends an encoded `toGateway` carrier payload directed to a gateway - /// peer. Returns false when the transport could not accept it. - var sendToGatewayPeer: (@MainActor (Data, PeerID) -> Bool)? - /// Reachable mesh peers currently advertising the `.gateway` capability. - var availableGatewayPeers: (@MainActor () -> [PeerID])? - /// Whether any Nostr relay connection is currently working. - var relaysConnected: (@MainActor () -> Bool)? - /// The geohash channel the local user is viewing, if any. - var currentGeohash: (@MainActor () -> String?)? - /// Injects a verified carried event into the same inbound pipeline as - /// relay-received events (blocking, rate limits, dedup, rendering). - var injectInbound: (@MainActor (NostrEvent) -> Void)? - /// Fired on toggle changes (advertise/withdraw the capability bit and - /// force a re-announce). - var onEnabledChanged: (@MainActor (Bool) -> Void)? - /// Schedules a downlink-drain closure to run after a delay. Injected so - /// the drain timer is deterministic in tests; nil arms a real `Task`. - var scheduleDrainTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? - - // MARK: State - - /// Loop rule 1: event IDs seen in `fromGateway` mesh broadcasts. - private var meshBroadcastEventIDs: BoundedIDSet - /// Loop rule 2 (uplink): event IDs this gateway already published. - private var publishedEventIDs: BoundedIDSet - /// Loop rule 2 (downlink): event IDs this gateway already rebroadcast. - private var rebroadcastEventIDs: BoundedIDSet - - private(set) var queuedUplinks: [QueuedUplink] = [] - private var uplinkDepositTimes: [PeerID: [Date]] = [:] - private var downlinkSendTimes: [Date] = [] - private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = [] - /// True while a drain timer is armed, so a burst schedules at most one. - private var downlinkDrainScheduled = false - - private let defaults: UserDefaults - private let now: () -> Date - private static let enabledKey = "gateway.userEnabled" - - init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) { - self.defaults = defaults - self.now = now - self.isEnabled = defaults.bool(forKey: Self.enabledKey) - self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) - self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) - self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) - } - - // MARK: - Toggle - - func setEnabled(_ enabled: Bool) { - guard enabled != isEnabled else { return } - isEnabled = enabled - defaults.set(enabled, forKey: Self.enabledKey) - if !enabled { - queuedUplinks.removeAll() - pendingDownlinks.removeAll() - uplinkDepositTimes.removeAll() - } - SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session) - onEnabledChanged?(enabled) - } - - // MARK: - Mesh carrier ingress (both roles) - - /// Entry point for received `nostrCarrier` packets. `directedToUs` is - /// true for packets addressed to this device (uplink deposits); false - /// for broadcasts (downlink rebroadcasts from a gateway). - func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) { - guard let carrier = NostrCarrierPacket.decode(payload) else { - SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session) - return - } - switch carrier.direction { - case .toGateway: - // Uplink deposits are directed; a broadcast toGateway is malformed. - guard directedToUs else { return } - handleUplinkDeposit(carrier, from: peerID) - case .fromGateway: - // Downlink rides broadcast only; a directed fromGateway is malformed. - guard !directedToUs else { return } - handleDownlinkBroadcast(carrier) - case .toBridge, .fromBridge: - // Mesh-bridge carriers are BridgeService territory; the ingress - // router dispatches them there. - return - } - } - - // MARK: - Uplink (gateway role: mesh peer -> internet) - - private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) { - guard isEnabled else { return } - // Cheap structural checks first (parse, size, geohash, kind, #g tag, - // age) — no crypto — so junk and stale replays are dropped before we - // ever pay for a MainActor Schnorr verify. - guard let event = structurallyValidEvent(from: carrier) else { - SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security) - return - } - // Dedup by the carried event ID BEFORE verification. Loop rule 1: a - // fromGateway-learned event is mesh-carried and must never be - // re-published. Loop rule 2: repeat deposits of an already handled - // event are absorbed. A replay of one valid deposit is short-circuited - // here without a per-packet signature verify. - guard !meshBroadcastEventIDs.contains(event.id), - !publishedEventIDs.contains(event.id), - !queuedUplinks.contains(where: { $0.event.id == event.id }) else { - return - } - // Consume the per-depositor rate token BEFORE the expensive verify so - // a flood of distinct forged/junk deposits is bounded by cheap work, - // not by main-actor Schnorr verifications. - guard allowUplinkDeposit(from: depositor) else { - SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session) - return - } - // Only now pay for cryptographic verification; receivers verify again. - guard event.isValidSignature() else { - SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security) - return - } - - let accepted: Bool - if relaysConnected?() ?? false { - publish(event, geohash: carrier.geohash) - accepted = true - } else { - accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event)) - } - - // Only render on our own timeline what we actually accepted for - // publish or queue: a quota-dropped deposit is never published and, - // being directed, no other peer will ever see it, so showing it would - // diverge our timeline permanently from what reached the channel. - if accepted, currentGeohash?() == carrier.geohash { - injectInbound?(event) - } - } - - /// Publish everything queued while relays were unreachable. Called when - /// relay connectivity comes back. - func flushQueuedUplinks() { - guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return } - let queued = queuedUplinks - queuedUplinks.removeAll() - for item in queued where !publishedEventIDs.contains(item.event.id) { - publish(item.event, geohash: item.geohash) - } - } - - private func publish(_ event: NostrEvent, geohash: String) { - publishedEventIDs.insert(event.id) - publishToRelays?(event, geohash) - SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session) - } - - /// Returns true when the item was actually stored for later publish. - @discardableResult - private func enqueueUplink(_ item: QueuedUplink) -> Bool { - let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count - guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { - SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session) - return false - } - if queuedUplinks.count >= Limits.maxQueuedUplinks { - queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1) - } - queuedUplinks.append(item) - return true - } - - private func allowUplinkDeposit(from depositor: PeerID) -> Bool { - let cutoff = now().addingTimeInterval(-60) - var times = uplinkDepositTimes[depositor, default: []] - times.removeAll { $0 < cutoff } - guard times.count < Limits.uplinkEventsPerMinutePerDepositor else { - uplinkDepositTimes[depositor] = times - return false - } - times.append(now()) - uplinkDepositTimes[depositor] = times - // Bound the tracker itself against a churn of spoofed depositors. - if uplinkDepositTimes.count > Limits.maxTrackedEventIDs { - uplinkDepositTimes = uplinkDepositTimes.filter { !$0.value.isEmpty && $0.value.contains { $0 >= cutoff } } - } - return true - } - - // MARK: - Downlink (gateway role: internet -> mesh) - - /// Called for every event the gateway's own geohash-channel subscription - /// delivers. Wraps it in a `fromGateway` carrier and broadcasts it on - /// the mesh, within the airtime budget. - func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) { - guard isEnabled, broadcastToMesh != nil else { return } - guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } - // Freshness + geohash gate BEFORE spending any budget. A channel - // (re)subscribe backfills up to an hour of history (limit 200), but - // every receiver's `validatedEvent` drops anything older than the - // same window — so rebroadcasting backfill would burn the whole - // per-minute budget on events no mesh peer accepts. Also require the - // event's own `#g` tag to match the carrier geohash. - guard isFresh(event), - event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else { - return - } - // Loop rule 1: never rebroadcast mesh-carried events back onto the - // mesh. Loop rule 2 (self-echo): never rebroadcast an event this - // gateway itself uplinked (`publishedEventIDs`) — it originated on this - // very mesh, so our own relay subscription echoing it back must not - // double the BLE airtime by pushing it out again. Loop rule 2 - // (downlink): rebroadcast each genuine inbound relay event at most once - // — but mark only AFTER it is actually sent (in `drainPendingDownlinks`), - // so an event dropped by the queue overflow stays retryable on relay - // redelivery. Guard against a redelivery re-queueing an event that is - // still waiting to be sent. - guard !meshBroadcastEventIDs.contains(event.id), - !publishedEventIDs.contains(event.id), - !rebroadcastEventIDs.contains(event.id), - !pendingDownlinks.contains(where: { $0.event.id == event.id }) else { - return - } - // Verify before spending BLE airtime; receivers verify again. - guard event.isValidSignature() else { return } - - pendingDownlinks.append((event, geohash)) - if pendingDownlinks.count > Limits.maxPendingDownlinks { - // Bandwidth guard: drop-oldest — fresher chat is worth more. The - // dropped event is not yet in `rebroadcastEventIDs`, so a later - // relay redelivery can still carry it. - pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) - } - drainPendingDownlinks() - } - - private func drainPendingDownlinks() { - let cutoff = now().addingTimeInterval(-60) - downlinkSendTimes.removeAll { $0 < cutoff } - while !pendingDownlinks.isEmpty, - downlinkSendTimes.count < Limits.downlinkEventsPerMinute { - let (event, geohash) = pendingDownlinks.removeFirst() - // A queued event may have aged past the window while it waited; - // don't burn airtime on what receivers would now drop. - guard isFresh(event) else { continue } - guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event), - let payload = carrier.encode() else { continue } - broadcastToMesh?(payload) - // Mark-after-send: only now is the relay event definitively - // rebroadcast (loop rule 2). - rebroadcastEventIDs.insert(event.id) - downlinkSendTimes.append(now()) - } - // Budget exhausted with events still queued: arm a timer to drain when - // the window frees, instead of stranding them until the next inbound - // relay event (which may never come on a channel that went quiet). - scheduleDownlinkDrainIfNeeded() - } - - /// Arms a single timer to drain the backlog once the per-minute window - /// frees. No-op when nothing is pending or a drain is already scheduled. - private func scheduleDownlinkDrainIfNeeded() { - guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return } - // The window frees when the oldest recorded send ages out of 60s. - let oldest = downlinkSendTimes.min() ?? now() - let delay = max(0.05, 60 - now().timeIntervalSince(oldest)) - downlinkDrainScheduled = true - let fire: @MainActor () -> Void = { [weak self] in - guard let self else { return } - self.downlinkDrainScheduled = false - self.drainPendingDownlinks() - } - if let scheduleDrainTimer { - scheduleDrainTimer(delay, fire) - } else { - Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - fire() - } - } - } - - // MARK: - Downlink (receiver role: carried event arrives over mesh) - - private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) { - guard let event = validatedEvent(from: carrier) else { return } - // Mark only AFTER signature verification, so a forged copy carrying a - // real event's ID cannot poison the never-republish set, and use the - // marking as dedup: the same broadcast relayed along several mesh - // paths injects once (the pipeline's Nostr event-ID cache additionally - // dedups against our own relay subscription). - guard meshBroadcastEventIDs.insert(event.id) else { return } - // Only inject events for the channel we're viewing; the inbound - // pipeline files public messages under the current geohash. - guard currentGeohash?() == carrier.geohash else { return } - injectInbound?(event) - } - - // MARK: - Uplink (sender role: mesh-only peer with no relays) - - /// Hands a locally signed event to a mesh gateway peer when we have no - /// working relay connection. Returns true when the event was sent. - /// - /// v1 is deliberately fire-and-forget: no gateway ack. The event also - /// stays in `NostrRelayManager`'s own pending queue, so if our internet - /// comes back the relays dedup the duplicate publish by event ID. - /// - /// Loop rule 3: call sites only pass freshly composed events (see - /// `GeohashSubscriptionManager.sendGeohash`); received carrier events - /// never reach this path, and the mesh-carried guard below backstops it. - func uplinkViaMesh(event: NostrEvent, geohash: String) -> Bool { - if relaysConnected?() ?? true { return false } - guard !meshBroadcastEventIDs.contains(event.id), - !publishedEventIDs.contains(event.id) else { - return false - } - // A single gateway is enough — relays fan out from there, and BLE - // airtime is precious. - guard let gateway = availableGatewayPeers?().first else { return false } - guard let carrier = NostrCarrierPacket(direction: .toGateway, geohash: geohash, event: event), - let payload = carrier.encode() else { - return false - } - guard sendToGatewayPeer?(payload, gateway) ?? false else { return false } - SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))…", category: .session) - return true - } - - // MARK: - Validation - - /// Structural and cryptographic checks every carried event must pass - /// before a gateway publishes it or a receiver displays it. Ordered - /// cheap-first; Schnorr verification runs last. - private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { - guard let event = structurallyValidEvent(from: carrier), - event.isValidSignature() else { - return nil - } - return event - } - - /// The cheap half of `validatedEvent`: parse + size + geohash + kind + - /// `#g` tag + freshness, with NO signature verification. Callers that can - /// dedup or rate-limit on the carried ID run this first so the expensive - /// Schnorr verify is reached only for events that survive the cheap gates. - private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { - guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes, - Self.isValidGeohash(carrier.geohash), - let event = carrier.event(), - event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue, - event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }), - isFresh(event) else { - return nil - } - return event - } - - /// True when `event.created_at` is within the accepted clock skew — the - /// SAME freshness window receivers enforce, so a gateway never spends - /// airtime on events every receiver would drop as stale. - private func isFresh(_ event: NostrEvent) -> Bool { - abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds - } - - static func isValidGeohash(_ geohash: String) -> Bool { - let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") - return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count) - && geohash.allSatisfy { allowed.contains($0) } - } -} diff --git a/bitchat/Services/GeohashChatActivityTracker.swift b/bitchat/Services/GeohashChatActivityTracker.swift deleted file mode 100644 index def9d59f..00000000 --- a/bitchat/Services/GeohashChatActivityTracker.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// GeohashChatActivityTracker.swift -// bitchat -// -// Tracks actual chat-message activity per sampled geohash so the empty mesh -// timeline can point at a nearby channel where a conversation is happening — -// not merely where participants are present. -// This is free and unencumbered software released into the public domain. -// - -import Foundation - -/// A recent chat message observed in a sampled geohash channel. -struct GeohashChatPreview: Equatable, Sendable { - let senderName: String - let content: String - let timestamp: Date -} - -/// The liveliest nearby conversation, resolved against the user's regional -/// channels. -struct NearbyConversation: Equatable, Sendable { - let channel: GeohashChannel - /// Chat messages seen within the activity window. - let messageCount: Int - let lastMessage: GeohashChatPreview -} - -/// Records kind-20000 chat events seen by the background geohash sampling -/// subscriptions (blocked and self senders are filtered by the caller) and -/// answers "where nearby is a conversation actually happening?". -@MainActor -final class GeohashChatActivityTracker: ObservableObject { - static let shared = GeohashChatActivityTracker() - - /// How far back a message still counts as "a conversation is happening". - private let window: TimeInterval - /// Per-geohash recent message timestamps (pruned to the window). - private var messageTimes: [String: [Date]] = [:] - /// Per-geohash newest message preview. - private var lastMessages: [String: GeohashChatPreview] = [:] - private let now: () -> Date - - init( - window: TimeInterval = TransportConfig.uiGeohashChatActivityWindowSeconds, - now: @escaping () -> Date = { Date() } - ) { - self.window = window - self.now = now - } - - func recordChatMessage( - geohash: String, - senderName: String, - content: String, - timestamp: Date - ) { - let gh = geohash.lowercased() - let clamped = min(timestamp, now()) - guard now().timeIntervalSince(clamped) < window else { return } - - var times = messageTimes[gh] ?? [] - times.append(clamped) - messageTimes[gh] = prune(times) - - if let existing = lastMessages[gh], existing.timestamp > clamped { - // Keep the newer preview. - } else { - lastMessages[gh] = GeohashChatPreview(senderName: senderName, content: content, timestamp: clamped) - } - objectWillChange.send() - } - - /// Messages seen in the window for one geohash. - func messageCount(for geohash: String) -> Int { - prune(messageTimes[geohash.lowercased()] ?? []).count - } - - func lastMessage(for geohash: String) -> GeohashChatPreview? { - let gh = geohash.lowercased() - guard messageCount(for: gh) > 0 else { return nil } - return lastMessages[gh] - } - - /// The busiest channel with at least one chat message in the window. - /// Ties go to the more local (higher-precision) channel, so a lone - /// message on your block beats a lone message across the region. - func mostActiveConversation(among channels: [GeohashChannel]) -> NearbyConversation? { - var best: NearbyConversation? - for channel in channels { - let count = messageCount(for: channel.geohash) - guard count > 0, let last = lastMessage(for: channel.geohash) else { continue } - let candidate = NearbyConversation(channel: channel, messageCount: count, lastMessage: last) - if let current = best { - let better = count > current.messageCount - || (count == current.messageCount - && channel.level.precision > current.channel.level.precision) - if better { best = candidate } - } else { - best = candidate - } - } - return best - } - - func clear() { - messageTimes.removeAll() - lastMessages.removeAll() - objectWillChange.send() - } - - private func prune(_ times: [Date]) -> [Date] { - let cutoff = now().addingTimeInterval(-window) - return times.filter { $0 >= cutoff } - } -} diff --git a/bitchat/Services/GeohashParticipantTracker.swift b/bitchat/Services/GeohashParticipantTracker.swift index be1ea35e..0e46ad34 100644 --- a/bitchat/Services/GeohashParticipantTracker.swift +++ b/bitchat/Services/GeohashParticipantTracker.swift @@ -9,12 +9,12 @@ import Foundation /// Represents a participant in a geohash channel -struct GeoPerson: Identifiable, Equatable, Sendable { - let id: String // pubkey hex (lowercased) - let displayName: String - let lastSeen: Date +public struct GeoPerson: Identifiable, Equatable, Sendable { + public let id: String // pubkey hex (lowercased) + public let displayName: String + public let lastSeen: Date - init(id: String, displayName: String, lastSeen: Date) { + public init(id: String, displayName: String, lastSeen: Date) { self.id = id self.displayName = displayName self.lastSeen = lastSeen @@ -23,7 +23,7 @@ struct GeoPerson: Identifiable, Equatable, Sendable { /// Protocol for resolving display names and checking block status @MainActor -protocol GeohashParticipantContext: AnyObject { +public protocol GeohashParticipantContext: AnyObject { /// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4") func displayNameForPubkey(_ pubkeyHex: String) -> String /// Returns true if the pubkey is blocked @@ -32,16 +32,16 @@ protocol GeohashParticipantContext: AnyObject { /// Tracks participants across multiple geohash channels @MainActor -final class GeohashParticipantTracker: ObservableObject { +public final class GeohashParticipantTracker: ObservableObject { /// Activity cutoff duration (defaults to 5 minutes) - let activityCutoff: TimeInterval + public let activityCutoff: TimeInterval /// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]] private var participants: [String: [String: Date]] = [:] /// Currently visible people for the active geohash - @Published private(set) var visiblePeople: [GeoPerson] = [] + @Published public private(set) var visiblePeople: [GeoPerson] = [] /// The currently active geohash (if any) private var activeGeohash: String? @@ -52,17 +52,17 @@ final class GeohashParticipantTracker: ObservableObject { /// Timer for periodic refresh private var refreshTimer: Timer? - init(activityCutoff: TimeInterval = -300) { // default 5 minutes + public init(activityCutoff: TimeInterval = -300) { // default 5 minutes self.activityCutoff = activityCutoff } /// Configure with a context provider - func configure(context: GeohashParticipantContext) { + public func configure(context: GeohashParticipantContext) { self.context = context } /// Set the currently active geohash - func setActiveGeohash(_ geohash: String?) { + public func setActiveGeohash(_ geohash: String?) { activeGeohash = geohash if geohash == nil { visiblePeople = [] @@ -72,13 +72,13 @@ final class GeohashParticipantTracker: ObservableObject { } /// Record activity from a participant in the current active geohash - func recordParticipant(pubkeyHex: String) { + public func recordParticipant(pubkeyHex: String) { guard let gh = activeGeohash else { return } recordParticipant(pubkeyHex: pubkeyHex, geohash: gh) } /// Record activity from a participant in a specific geohash - func recordParticipant(pubkeyHex: String, geohash: String) { + public func recordParticipant(pubkeyHex: String, geohash: String) { let key = pubkeyHex.lowercased() var map = participants[geohash] ?? [:] map[key] = Date() @@ -94,7 +94,7 @@ final class GeohashParticipantTracker: ObservableObject { } /// Remove a participant from all geohashes (used when blocking) - func removeParticipant(pubkeyHex: String) { + public func removeParticipant(pubkeyHex: String) { let key = pubkeyHex.lowercased() for (gh, var map) in participants { map.removeValue(forKey: key) @@ -104,14 +104,14 @@ final class GeohashParticipantTracker: ObservableObject { } /// Get participant count for a specific geohash - func participantCount(for geohash: String) -> Int { + public func participantCount(for geohash: String) -> Int { let cutoff = Date().addingTimeInterval(activityCutoff) let map = participants[geohash] ?? [:] return map.values.filter { $0 >= cutoff }.count } /// Get the visible people list for the active geohash (read-only query) - func getVisiblePeople() -> [GeoPerson] { + public func getVisiblePeople() -> [GeoPerson] { guard let gh = activeGeohash, let context = context else { return [] } let cutoff = Date().addingTimeInterval(activityCutoff) let map = (participants[gh] ?? [:]) @@ -126,12 +126,12 @@ final class GeohashParticipantTracker: ObservableObject { } /// Refresh the visible people list - func refresh() { + public func refresh() { visiblePeople = getVisiblePeople() } /// Start the periodic refresh timer - func startRefreshTimer(interval: TimeInterval = 30.0) { + public func startRefreshTimer(interval: TimeInterval = 30.0) { stopRefreshTimer() refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in Task { @MainActor in @@ -141,19 +141,19 @@ final class GeohashParticipantTracker: ObservableObject { } /// Stop the periodic refresh timer - func stopRefreshTimer() { + public func stopRefreshTimer() { refreshTimer?.invalidate() refreshTimer = nil } /// Clear all participant data - func clear() { + public func clear() { participants.removeAll() visiblePeople = [] } /// Clear participant data for a specific geohash - func clear(geohash: String) { + public func clear(geohash: String) { participants.removeValue(forKey: geohash) if activeGeohash == geohash { visiblePeople = [] diff --git a/bitchat/Services/GeohashPresenceService.swift b/bitchat/Services/GeohashPresenceService.swift index 15e8ade2..e311c5c4 100644 --- a/bitchat/Services/GeohashPresenceService.swift +++ b/bitchat/Services/GeohashPresenceService.swift @@ -45,9 +45,6 @@ final class GeohashPresenceService: ObservableObject { private var subscriptions = Set() private var heartbeatTimer: GeohashPresenceTimerProtocol? - private var pendingBroadcastTasks: [UUID: Task] = [:] - private var heartbeatGeneration: UInt64 = 0 - private var started = false private let availableChannelsProvider: () -> [GeohashChannel] private let locationChanges: AnyPublisher<[GeohashChannel], Never> private let torReadyPublisher: AnyPublisher @@ -150,25 +147,10 @@ final class GeohashPresenceService: ObservableObject { /// Start the service (safe to call multiple times) func start() { - guard !started else { return } - started = true - heartbeatGeneration &+= 1 SecureLogger.info("Presence: service starting...", category: .session) scheduleNextHeartbeat() } - /// Stops the timer and every decorrelation task synchronously at the panic - /// boundary. Generation checks also protect against custom sleepers that - /// ignore task cancellation and return later. - func stopForPanic() { - started = false - heartbeatGeneration &+= 1 - heartbeatTimer?.invalidate() - heartbeatTimer = nil - pendingBroadcastTasks.values.forEach { $0.cancel() } - pendingBroadcastTasks.removeAll(keepingCapacity: false) - } - private func setupObservers() { // Monitor location channel changes locationChanges @@ -187,26 +169,20 @@ final class GeohashPresenceService: ObservableObject { } func handleLocationChange() { - guard started else { return } // When location changes, we trigger an immediate (but slightly delayed) heartbeat // to announce presence in the new zone, then reset the loop. SecureLogger.debug("Presence: location changed, scheduling update", category: .session) heartbeatTimer?.invalidate() // Small delay to allow location state to settle - let generation = heartbeatGeneration heartbeatTimer = scheduleTimer(5.0) { [weak self] in Task { @MainActor [weak self] in - guard let self, - self.started, - self.heartbeatGeneration == generation else { return } - self.performHeartbeat() + self?.performHeartbeat() } } } func handleConnectivityChange() { - guard started else { return } SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session) // If we were waiting for network, do it now if heartbeatTimer == nil || !heartbeatTimer!.isValid { @@ -215,29 +191,18 @@ final class GeohashPresenceService: ObservableObject { } func scheduleNextHeartbeat() { - guard started else { return } heartbeatTimer?.invalidate() let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval) - let generation = heartbeatGeneration heartbeatTimer = scheduleTimer(interval) { [weak self] in Task { @MainActor [weak self] in - guard let self, - self.started, - self.heartbeatGeneration == generation else { return } - self.performHeartbeat() + self?.performHeartbeat() } } } func performHeartbeat() { - guard started else { return } - let generation = heartbeatGeneration // Always schedule next loop first ensures continuity even if this one fails/skips - defer { - if started, heartbeatGeneration == generation { - scheduleNextHeartbeat() - } - } + defer { scheduleNextHeartbeat() } // 1. Check preconditions guard torIsReady() else { @@ -263,27 +228,14 @@ final class GeohashPresenceService: ObservableObject { } // Launch independent task for each channel's delay - let taskID = UUID() - let sleeper = self.sleeper - let delay = TimeInterval.random( - in: burstMinDelay...burstMaxDelay - ) - let nanoseconds = UInt64(delay * 1_000_000_000) - let task = Task { @MainActor [weak self] in + Task { @MainActor in // Random delay for decorrelation - await sleeper(nanoseconds) - - guard let self else { return } - guard !Task.isCancelled, - self.started, - self.heartbeatGeneration == generation else { - self.pendingBroadcastTasks.removeValue(forKey: taskID) - return - } - self.pendingBroadcastTasks.removeValue(forKey: taskID) + let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay) + let nanoseconds = UInt64(delay * 1_000_000_000) + await self.sleeper(nanoseconds) + self.broadcastPresence(for: channel.geohash) } - pendingBroadcastTasks[taskID] = task } } diff --git a/bitchat/Services/Groups/GroupProtocol.swift b/bitchat/Services/Groups/GroupProtocol.swift deleted file mode 100644 index 8f4a09d1..00000000 --- a/bitchat/Services/Groups/GroupProtocol.swift +++ /dev/null @@ -1,568 +0,0 @@ -// -// GroupProtocol.swift -// bitchat -// -// Wire formats and crypto for private groups: creator-signed group state -// (invites and key updates over Noise) and ChaCha20-Poly1305 group messages -// broadcast as MessageType.groupMessage (0x25). -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import CryptoKit -import Foundation - -// MARK: - Models - -/// A member of a private group as pinned in the creator-signed roster. -struct GroupMember: Codable, Equatable { - /// SHA-256 fingerprint (64 hex chars) of the member's Noise static key. - let fingerprint: String - /// The member's Ed25519 signing public key (32 bytes, from their announce). - let signingKey: Data - /// Nickname at invite time; display fallback when the peer is offline. - var nickname: String -} - -/// Creator-managed encrypted group. Metadata only — the symmetric key lives -/// in the keychain (see `GroupStore`). -struct BitchatGroup: Codable, Equatable { - static let maxMembers = 16 - static let groupIDLength = 16 - static let keyLength = 32 - - /// 16 random bytes; travels in cleartext on group message packets so - /// relays can dedup/filter without membership. - let groupID: Data - var name: String - /// Bumps on every key rotation; messages are bound to the epoch they - /// were sealed under. - var epoch: UInt32 - var members: [GroupMember] - /// Fingerprint of the creator — the only identity allowed to sign group - /// state (invites, key updates) in v1. - let creatorFingerprint: String - - /// Virtual conversation ID this group's chat is keyed under. - var peerID: PeerID { PeerID(groupID: groupID) } - - var creator: GroupMember? { - members.first { $0.fingerprint == creatorFingerprint } - } - - func isMember(fingerprint: String) -> Bool { - members.contains { $0.fingerprint == fingerprint } - } - - func member(withSigningKey signingKey: Data) -> GroupMember? { - members.first { $0.signingKey == signingKey } - } -} - -// MARK: - TLV helpers - -enum GroupTLVError: Error, Equatable { - /// A TLV value exceeded the 16-bit length field. Encoding fails instead - /// of silently truncating (which would ship a value the receiver drops). - case valueTooLong -} - -private enum GroupTLV { - /// Appends a (type, 16-bit length, value) triple. Throws rather than - /// truncating when `value` does not fit the 16-bit length field, so an - /// oversize field surfaces a send failure instead of a silently truncated - /// blob the recipient rejects during decrypt/verify. - static func put(_ type: UInt8, _ value: Data, into out: inout Data) throws { - guard value.count <= Int(UInt16.max) else { throw GroupTLVError.valueTooLong } - out.append(type) - let length = UInt16(value.count) - out.append(UInt8((length >> 8) & 0xFF)) - out.append(UInt8(length & 0xFF)) - out.append(value) - } - - /// Iterates (type, value) pairs; returns nil on malformed framing. - static func parse(_ data: Data) -> [(type: UInt8, value: Data)]? { - var fields: [(UInt8, Data)] = [] - var offset = data.startIndex - while offset < data.endIndex { - guard data.distance(from: offset, to: data.endIndex) >= 3 else { return nil } - let type = data[offset] - let high = Int(data[data.index(offset, offsetBy: 1)]) - let low = Int(data[data.index(offset, offsetBy: 2)]) - let length = (high << 8) | low - let valueStart = data.index(offset, offsetBy: 3) - guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil } - let valueEnd = data.index(valueStart, offsetBy: length) - fields.append((type, Data(data[valueStart.. Data { - var bigEndian = epoch.bigEndian - return withUnsafeBytes(of: &bigEndian) { Data($0) } - } - - static func epoch(from data: Data) -> UInt32? { - guard data.count == 4 else { return nil } - return data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } - } - - static func timestampData(_ timestampMs: UInt64) -> Data { - var bigEndian = timestampMs.bigEndian - return withUnsafeBytes(of: &bigEndian) { Data($0) } - } - - static func timestamp(from data: Data) -> UInt64? { - guard data.count == 8 else { return nil } - return data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } - } -} - -// MARK: - Roster wire form - -enum GroupRosterCoding { - private static let fingerprintLength = 32 - private static let signingKeyLength = 32 - private static let maxNicknameBytes = 64 - - /// Deterministic roster blob: count byte, then per member the raw 32-byte - /// fingerprint, 32-byte signing key, and length-prefixed UTF-8 nickname. - /// The creator signature covers the SHA-256 of these exact bytes. - static func encode(_ members: [GroupMember]) -> Data? { - guard members.count <= BitchatGroup.maxMembers else { return nil } - var out = Data([UInt8(members.count)]) - for member in members { - guard let fingerprintData = Data(hexString: member.fingerprint), - fingerprintData.count == fingerprintLength, - member.signingKey.count == signingKeyLength else { return nil } - out.append(fingerprintData) - out.append(member.signingKey) - // Truncate on a Character boundary so the byte prefix is always - // valid UTF-8; a raw byte-prefix could split a multi-byte scalar - // and make the whole signed roster undecodable on the recipient. - let nickname = truncatedNicknameBytes(member.nickname) - out.append(UInt8(nickname.count)) - out.append(nickname) - } - return out - } - - static func decode(_ data: Data) -> [GroupMember]? { - guard let count = data.first, count <= UInt8(BitchatGroup.maxMembers) else { return nil } - var members: [GroupMember] = [] - var offset = data.index(after: data.startIndex) - for _ in 0..= fixed else { return nil } - let fingerprintEnd = data.index(offset, offsetBy: fingerprintLength) - let fingerprint = Data(data[offset..= nickLength else { return nil } - let nickEnd = data.index(nickStart, offsetBy: nickLength) - guard let nickname = String(data: Data(data[nickStart.. Data { - var candidate = nickname - while Data(candidate.utf8).count > maxNicknameBytes { - candidate.removeLast() - } - return Data(candidate.utf8) - } -} - -// MARK: - Group state payload (groupInvite / groupKeyUpdate over Noise) - -/// Creator-signed group state. The same wire form serves invites (0x06) and -/// key updates (0x07); receivers verify the creator signature — computed over -/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster) — -/// against the creator's signing key pinned in the roster, and require the -/// Noise session peer to BE the creator before accepting any state. -struct GroupStatePayload: Equatable { - let groupID: Data - let name: String - /// Symmetric ChaCha20-Poly1305 group key (32 bytes) for `epoch`. - let key: Data - let epoch: UInt32 - let members: [GroupMember] - let creatorFingerprint: String - /// Ed25519 signature by the creator. - let signature: Data - - private enum FieldType: UInt8 { - case groupID = 0x01 - case name = 0x02 - case key = 0x03 - case epoch = 0x04 - case roster = 0x05 - case creatorFingerprint = 0x06 - case signature = 0x07 - } - - static let signingDomain = Data("bitchat-group-v1".utf8) - - /// The bytes the creator signs. Binding the key, roster, and name by hash - /// keeps the signed content fixed-size. The name is covered so a relay - /// that caches/replays a signed state (e.g. store-and-forward) cannot swap - /// the display name while keeping a valid creator signature. - static func signingContent(groupID: Data, epoch: UInt32, key: Data, rosterBlob: Data, name: String) -> Data { - var content = signingDomain - content.append(groupID) - content.append(GroupTLV.epochData(epoch)) - content.append(key.sha256Hash()) - content.append(rosterBlob.sha256Hash()) - content.append(Data(name.utf8).sha256Hash()) - return content - } - - /// Builds a signed state payload. Returns nil when the roster cannot be - /// encoded (over cap, malformed member) or signing fails. - static func makeSigned( - group: BitchatGroup, - key: Data, - sign: (Data) -> Data? - ) -> GroupStatePayload? { - guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil } - let content = signingContent(groupID: group.groupID, epoch: group.epoch, key: key, rosterBlob: rosterBlob, name: group.name) - guard let signature = sign(content) else { return nil } - return GroupStatePayload( - groupID: group.groupID, - name: group.name, - key: key, - epoch: group.epoch, - members: group.members, - creatorFingerprint: group.creatorFingerprint, - signature: signature - ) - } - - func encode() -> Data? { - guard let rosterBlob = GroupRosterCoding.encode(members), - let fingerprintData = Data(hexString: creatorFingerprint), - fingerprintData.count == 32 else { return nil } - var out = Data() - do { - try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) - try GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out) - try GroupTLV.put(FieldType.key.rawValue, key, into: &out) - try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) - try GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out) - try GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out) - try GroupTLV.put(FieldType.signature.rawValue, signature, into: &out) - } catch { - return nil - } - return out - } - - static func decode(_ data: Data) -> GroupStatePayload? { - guard let fields = GroupTLV.parse(data) else { return nil } - var groupID: Data? - var name: String? - var key: Data? - var epoch: UInt32? - var rosterBlob: Data? - var members: [GroupMember]? - var creatorFingerprint: String? - var signature: Data? - - for (type, value) in fields { - switch FieldType(rawValue: type) { - case .groupID where value.count == BitchatGroup.groupIDLength: - groupID = value - case .name: - name = String(data: value, encoding: .utf8) - case .key where value.count == BitchatGroup.keyLength: - key = value - case .epoch: - epoch = GroupTLV.epoch(from: value) - case .roster: - rosterBlob = value - members = GroupRosterCoding.decode(value) - case .creatorFingerprint where value.count == 32: - creatorFingerprint = value.hexEncodedString() - case .signature where value.count == 64: - signature = value - default: - break // forward compatible; ignore unknown TLVs - } - } - - guard let groupID, let name, let key, let epoch, - rosterBlob != nil, let members, !members.isEmpty, - let creatorFingerprint, let signature else { return nil } - return GroupStatePayload( - groupID: groupID, - name: name, - key: key, - epoch: epoch, - members: members, - creatorFingerprint: creatorFingerprint, - signature: signature - ) - } - - /// Verifies the creator signature against the creator's signing key - /// pinned in the roster, and that the creator is actually in the roster. - func verifyCreatorSignature() -> Bool { - guard members.count <= BitchatGroup.maxMembers, - let creator = members.first(where: { $0.fingerprint == creatorFingerprint }), - let rosterBlob = GroupRosterCoding.encode(members) else { return false } - let content = GroupStatePayload.signingContent(groupID: groupID, epoch: epoch, key: key, rosterBlob: rosterBlob, name: name) - return GroupCrypto.verify(signature: signature, for: content, publicKey: creator.signingKey) - } - - var asGroup: BitchatGroup { - BitchatGroup( - groupID: groupID, - name: name, - epoch: epoch, - members: members, - creatorFingerprint: creatorFingerprint - ) - } -} - -// MARK: - Group message envelope (MessageType 0x25 payload) - -/// Cleartext framing of a group message broadcast. Only the group ID, epoch, -/// and nonce are visible to relays; everything about the message — sender, -/// content, timestamps — is inside the ChaCha20-Poly1305 ciphertext. -struct GroupMessageEnvelope: Equatable { - let groupID: Data - let epoch: UInt32 - let nonce: Data - /// ChaChaPoly ciphertext || 16-byte tag. - let ciphertext: Data - - private enum FieldType: UInt8 { - case groupID = 0x01 - case epoch = 0x02 - case nonce = 0x03 - case ciphertext = 0x04 - } - - func encode() throws -> Data { - var out = Data() - try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) - try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) - try GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out) - try GroupTLV.put(FieldType.ciphertext.rawValue, ciphertext, into: &out) - return out - } - - static func decode(_ data: Data) -> GroupMessageEnvelope? { - guard let fields = GroupTLV.parse(data) else { return nil } - var groupID: Data? - var epoch: UInt32? - var nonce: Data? - var ciphertext: Data? - for (type, value) in fields { - switch FieldType(rawValue: type) { - case .groupID where value.count == BitchatGroup.groupIDLength: - groupID = value - case .epoch: - epoch = GroupTLV.epoch(from: value) - case .nonce where value.count == 12: - nonce = value - case .ciphertext where !value.isEmpty: - ciphertext = value - default: - break - } - } - guard let groupID, let epoch, let nonce, let ciphertext else { return nil } - return GroupMessageEnvelope(groupID: groupID, epoch: epoch, nonce: nonce, ciphertext: ciphertext) - } -} - -/// Decrypted, signature-verified inner content of a group message. -struct GroupMessagePlaintext: Equatable { - let messageID: String - let senderSigningKey: Data - let senderNickname: String - let timestampMs: UInt64 - let content: String -} - -// MARK: - Crypto - -enum GroupCryptoError: Error, Equatable { - case malformedPayload - case signingFailed - case sealFailed - case decryptionFailed - case badSenderSignature -} - -enum GroupCrypto { - static let messageSigningDomain = Data("bitchat-group-msg-v1".utf8) - - private enum InnerField: UInt8 { - case messageID = 0x01 - case senderSigningKey = 0x02 - case senderNickname = 0x03 - case timestamp = 0x04 - case content = 0x05 - case signature = 0x06 - } - - /// Bytes the sender signs: domain | groupID | epoch | messageID | timestamp | content. - /// Covering the epoch stops a current member from re-sealing another - /// member's decrypted inner bytes under a later epoch key (the signature - /// would no longer verify at the new epoch). - static func messageSigningContent(groupID: Data, epoch: UInt32, messageID: String, timestampMs: UInt64, content: String) -> Data { - var data = messageSigningDomain - data.append(groupID) - data.append(GroupTLV.epochData(epoch)) - data.append(Data(messageID.utf8)) - data.append(GroupTLV.timestampData(timestampMs)) - data.append(Data(content.utf8)) - return data - } - - static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool { - guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false } - return key.isValidSignature(signature, for: data) - } - - /// Seals a group message: builds the signed inner TLV and encrypts it with - /// the epoch key. The cleartext group ID and epoch are bound into the AEAD - /// as additional data so ciphertext cannot be replayed across groups or - /// epochs. Returns the encoded 0x25 packet payload. - static func sealMessage( - content: String, - messageID: String, - senderNickname: String, - senderSigningKey: Data, - timestampMs: UInt64, - groupID: Data, - epoch: UInt32, - key: Data, - sign: (Data) -> Data? - ) throws -> Data { - let signingContent = messageSigningContent( - groupID: groupID, - epoch: epoch, - messageID: messageID, - timestampMs: timestampMs, - content: content - ) - guard let signature = sign(signingContent), signature.count == 64 else { - throw GroupCryptoError.signingFailed - } - - var inner = Data() - try GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner) - try GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner) - try GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner) - try GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner) - try GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner) - try GroupTLV.put(InnerField.signature.rawValue, signature, into: &inner) - - do { - let symmetricKey = SymmetricKey(data: key) - var aad = groupID - aad.append(GroupTLV.epochData(epoch)) - let sealed = try ChaChaPoly.seal(inner, using: symmetricKey, authenticating: aad) - var ciphertext = sealed.ciphertext - ciphertext.append(sealed.tag) - let envelope = GroupMessageEnvelope( - groupID: groupID, - epoch: epoch, - nonce: Data(sealed.nonce), - ciphertext: ciphertext - ) - return try envelope.encode() - } catch { - throw GroupCryptoError.sealFailed - } - } - - /// Opens a group message envelope with the epoch key: decrypts, parses the - /// inner TLV, and verifies the sender's Ed25519 signature. Roster - /// membership of the sender is the CALLER's check — this function only - /// proves the payload was authored by `senderSigningKey`. - static func openMessage(_ envelope: GroupMessageEnvelope, key: Data) throws -> GroupMessagePlaintext { - let inner: Data - do { - let symmetricKey = SymmetricKey(data: key) - var aad = envelope.groupID - aad.append(GroupTLV.epochData(envelope.epoch)) - let nonce = try ChaChaPoly.Nonce(data: envelope.nonce) - guard envelope.ciphertext.count > 16 else { throw GroupCryptoError.decryptionFailed } - let tag = envelope.ciphertext.suffix(16) - let body = envelope.ciphertext.prefix(envelope.ciphertext.count - 16) - let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: body, tag: tag) - inner = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad) - } catch { - throw GroupCryptoError.decryptionFailed - } - - guard let fields = GroupTLV.parse(inner) else { throw GroupCryptoError.malformedPayload } - var messageID: String? - var senderSigningKey: Data? - var senderNickname: String? - var timestampMs: UInt64? - var content: String? - var signature: Data? - for (type, value) in fields { - switch InnerField(rawValue: type) { - case .messageID: - messageID = String(data: value, encoding: .utf8) - case .senderSigningKey where value.count == 32: - senderSigningKey = value - case .senderNickname: - senderNickname = String(data: value, encoding: .utf8) - case .timestamp: - timestampMs = GroupTLV.timestamp(from: value) - case .content: - content = String(data: value, encoding: .utf8) - case .signature where value.count == 64: - signature = value - default: - break - } - } - guard let messageID, !messageID.isEmpty, - let senderSigningKey, - let senderNickname, - let timestampMs, - let content, - let signature else { throw GroupCryptoError.malformedPayload } - - let signingContent = messageSigningContent( - groupID: envelope.groupID, - epoch: envelope.epoch, - messageID: messageID, - timestampMs: timestampMs, - content: content - ) - guard verify(signature: signature, for: signingContent, publicKey: senderSigningKey) else { - throw GroupCryptoError.badSenderSignature - } - - return GroupMessagePlaintext( - messageID: messageID, - senderSigningKey: senderSigningKey, - senderNickname: senderNickname, - timestampMs: timestampMs, - content: content - ) - } -} diff --git a/bitchat/Services/Groups/GroupStore.swift b/bitchat/Services/Groups/GroupStore.swift deleted file mode 100644 index 3a45dcb7..00000000 --- a/bitchat/Services/Groups/GroupStore.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// GroupStore.swift -// bitchat -// -// Persistence for private groups: symmetric keys in the keychain, metadata -// (roster, name, epoch) as protected JSON in Application Support. Both are -// dropped by the panic wipe. -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import Combine -import Foundation -import Security - -@MainActor -final class GroupStore: ObservableObject { - /// All groups this device is a member of, in creation/join order. - @Published private(set) var groups: [BitchatGroup] = [] - - private let keychain: KeychainManagerProtocol - private let fileURL: URL? - - /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored - /// when `persistsToDisk` is false. - init(keychain: KeychainManagerProtocol, persistsToDisk: Bool = true, fileURL: URL? = nil) { - self.keychain = keychain - self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil - loadFromDisk() - } - - // MARK: - Reads - - func group(withID groupID: Data) -> BitchatGroup? { - groups.first { $0.groupID == groupID } - } - - func group(for peerID: PeerID) -> BitchatGroup? { - guard let groupID = peerID.groupIDData else { return nil } - return group(withID: groupID) - } - - /// Current-epoch symmetric key for the group, from the keychain. - func key(forGroupID groupID: Data) -> Data? { - keychain.getIdentityKey(forKey: Self.keychainKey(for: groupID)) - } - - // MARK: - Mutations - - /// Creates a new group with a random 16-byte ID and 32-byte key at - /// epoch 1, with the creator as sole member. Returns nil when key - /// generation or persistence fails. - func createGroup(named name: String, creator: GroupMember) -> BitchatGroup? { - guard let groupID = Self.randomBytes(BitchatGroup.groupIDLength), - let key = Self.randomBytes(BitchatGroup.keyLength) else { return nil } - let group = BitchatGroup( - groupID: groupID, - name: name, - epoch: 1, - members: [creator], - creatorFingerprint: creator.fingerprint - ) - guard upsert(group, key: key) else { return nil } - return group - } - - /// Inserts or replaces a group and its current key. Rejects rosters over - /// the hard cap or groups whose creator is missing from the roster. - @discardableResult - func upsert(_ group: BitchatGroup, key: Data) -> Bool { - guard group.groupID.count == BitchatGroup.groupIDLength, - key.count == BitchatGroup.keyLength, - !group.members.isEmpty, - group.members.count <= BitchatGroup.maxMembers, - group.creator != nil else { return false } - guard keychain.saveIdentityKey(key, forKey: Self.keychainKey(for: group.groupID)) else { - SecureLogger.error("Failed to store group key in keychain", category: .security) - return false - } - if let index = groups.firstIndex(where: { $0.groupID == group.groupID }) { - groups[index] = group - } else { - groups.append(group) - } - persist() - return true - } - - /// Updates the roster of an existing group without changing key or epoch - /// (creator-side invite). Enforces the member cap. - @discardableResult - func updateRoster(groupID: Data, members: [GroupMember]) -> BitchatGroup? { - guard let index = groups.firstIndex(where: { $0.groupID == groupID }), - !members.isEmpty, - members.count <= BitchatGroup.maxMembers, - members.contains(where: { $0.fingerprint == groups[index].creatorFingerprint }) else { return nil } - groups[index].members = members - persist() - return groups[index] - } - - /// Rotates the group key (creator-side removal/rotation): new random key, - /// epoch + 1, and the given roster. Returns the updated group and new key. - func rotateKey(groupID: Data, members: [GroupMember]) -> (group: BitchatGroup, key: Data)? { - guard let existing = group(withID: groupID), - let newKey = Self.randomBytes(BitchatGroup.keyLength) else { return nil } - var rotated = existing - rotated.epoch = existing.epoch &+ 1 - rotated.members = members - guard upsert(rotated, key: newKey) else { return nil } - return (rotated, newKey) - } - - func removeGroup(withID groupID: Data) { - groups.removeAll { $0.groupID == groupID } - _ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: groupID)) - persist() - } - - /// Panic wipe: drop all group keys and metadata from memory and disk. - /// (The panic flow also nukes the whole keychain; deleting per-group keys - /// here keeps the store safe to wipe on its own.) - func wipe() { - for group in groups { - _ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: group.groupID)) - } - groups.removeAll() - if let fileURL { - try? FileManager.default.removeItem(at: fileURL) - } - } - - // MARK: - Internals - - private static func keychainKey(for groupID: Data) -> String { - "groupKey-\(groupID.hexEncodedString())" - } - - private static func randomBytes(_ count: Int) -> Data? { - var bytes = Data(count: count) - let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in - guard let baseAddress = buffer.baseAddress else { return errSecParam } - return SecRandomCopyBytes(kSecRandomDefault, count, baseAddress) - } - return status == errSecSuccess ? bytes : nil - } - - private func persist() { - guard let fileURL else { return } - do { - if groups.isEmpty { - try? FileManager.default.removeItem(at: fileURL) - return - } - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let data = try JSONEncoder().encode(groups) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtection) - #endif - try data.write(to: fileURL, options: options) - } catch { - SecureLogger.error("Failed to persist group store: \(error)", category: .session) - } - } - - private func loadFromDisk() { - guard let fileURL, - let data = try? Data(contentsOf: fileURL), - let stored = try? JSONDecoder().decode([BitchatGroup].self, from: data) else { - return - } - // Only groups whose key survived in the keychain are usable. - groups = stored.filter { key(forGroupID: $0.groupID) != nil } - } - - private static func defaultFileURL() -> URL? { - guard let base = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) else { return nil } - return base - .appendingPathComponent("groups", isDirectory: true) - .appendingPathComponent("groups.json") - } -} diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index abc86b10..574bdf9f 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -7,363 +7,84 @@ // import BitLogger -import BitFoundation import Foundation import Security -enum KeychainInstallLifecycleAction: Equatable { - case markerPresent - case bootstrapMarker - case clearStaleKeys - case retryLater -} +// MARK: - Keychain Error Types +// BCH-01-009: Proper error classification to distinguish expected states from critical failures -/// Process-local fail-closed gate for an unresolved install lifecycle. -/// -/// A blocked caller may perform one synchronous reconciliation attempt. -/// Concurrent callers fail closed instead of reading while that cleanup is -/// in flight. Once reconciliation succeeds, access remains open. -final class KeychainInstallAccessGate: @unchecked Sendable { - private let lock = NSLock() - private var blocked = false - private var reconciliationInProgress = false +/// Result of a keychain read operation with proper error classification +enum KeychainReadResult { + case success(Data) + case itemNotFound // Expected: key doesn't exist yet + case accessDenied // Critical: app lacks keychain access + case deviceLocked // Recoverable: device is locked + case authenticationFailed // Recoverable: biometric/passcode failed + case otherError(OSStatus) // Unexpected error - func block() { - lock.lock() - blocked = true - lock.unlock() - } - - func allowsAccess(reconcile: () -> Bool) -> Bool { - lock.lock() - if !blocked { - lock.unlock() + var isRecoverableError: Bool { + switch self { + case .deviceLocked, .authenticationFailed: return true - } - guard !reconciliationInProgress else { - lock.unlock() + default: return false } - reconciliationInProgress = true - lock.unlock() - - let completed = reconcile() - - lock.lock() - if completed { - blocked = false - } - reconciliationInProgress = false - lock.unlock() - return completed } } +/// Result of a keychain save operation with proper error classification +enum KeychainSaveResult { + case success + case duplicateItem // Can retry with update + case accessDenied // Critical: app lacks keychain access + case deviceLocked // Recoverable: device is locked + case storageFull // Critical: no space available + case otherError(OSStatus) + + var isRecoverableError: Bool { + switch self { + case .duplicateItem, .deviceLocked: + return true + default: + return false + } + } +} + +protocol KeychainManagerProtocol { + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool + func getIdentityKey(forKey key: String) -> Data? + func deleteIdentityKey(forKey key: String) -> Bool + func deleteAllKeychainData() -> Bool + + func secureClear(_ data: inout Data) + func secureClear(_ string: inout String) + + func verifyIdentityKeyExists() -> Bool + + // BCH-01-009: Methods with proper error classification + /// Get identity key with detailed result for error handling + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult + /// Save identity key with detailed result for error handling + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult + + // MARK: - Generic Data Storage (consolidated from KeychainHelper) + /// Save data with a custom service name + func save(key: String, data: Data, service: String, accessible: CFString?) + /// Load data from a custom service + func load(key: String, service: String) -> Data? + /// Delete data from a custom service + func delete(key: String, service: String) +} + final class KeychainManager: KeychainManagerProtocol { - /// Default keychain for components that construct their own rather than - /// having one injected. Under test this is an in-memory keychain: the - /// xctest runner's code signature changes every build, so any read of a - /// real login-keychain item triggers a macOS password prompt that - /// "Always Allow" can never satisfy — and tests must never read or - /// mutate the developer's real keychain (`SecItemCopyMatching` can also - /// hang in test environments). Production behavior is unchanged. - static func makeDefault() -> KeychainManagerProtocol { - // PreviewKeychainManager lives in _PreviewHelpers, a development - // asset excluded from archive builds — release code must not - // reference it. Tests always run Debug, so the guard is lossless. - #if DEBUG - if TestEnvironment.isRunningTests { return sharedTestKeychain } - #endif - return KeychainManager() - } - - #if DEBUG - /// One store per process, mirroring the real keychain: separate - /// default-constructed components (e.g. two NostrIdentityBridge - /// instances in BoardManager's publish and delete paths) must see each - /// other's writes, or they would derive different Nostr identities - /// under test. - private static let sharedTestKeychain = PreviewKeychainManager() - #endif - // Use consistent service name for all keychain items private let service = BitchatApp.bundleID private let appGroup = "group.\(BitchatApp.bundleID)" - #if os(iOS) - private let installAccessGate = KeychainInstallAccessGate() - #endif - /// Every generic-password service owned by this app, including names used - /// by older releases. Keep custom services here so one-time security - /// migrations and panic deletion cannot silently miss them. - private static let additionalApplicationOwnedServices = [ - "chat.bitchat.nostr", - "chat.bitchat.favorites", - "chat.bitchat.outbox", - "com.bitchat.passwords", - "com.bitchat.deviceidentity", - "com.bitchat.noise.identity", - "chat.bitchat.passwords", - "bitchat.keychain", - "bitchat", - "com.bitchat" - ] - // AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the - // device locked (identity-cache saves failed with -25308 throughout - // locked-phone testing), and a wake-on-proximity relaunch via BLE state - // restoration must be able to read the noise keys before the user - // unlocks. ThisDeviceOnly prevents private identities and group keys from - // migrating through device backups onto a second device. - private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - - init() { - #if os(iOS) - if reconcileInstallLifecycle() { - migrateAccessibilityIfNeeded() - } else { - installAccessGate.block() - } - #endif - } - - static func installLifecycleAction( - containerKnowsMarker: Bool, - cleanupPending: Bool = false, - markerRead: KeychainReadResult - ) -> KeychainInstallLifecycleAction { - // Once a reinstall cleanup has started, its container-local latch - // must win even if the keychain marker was deleted before a later - // keychain operation failed. Otherwise the next launch could mistake - // a partial cleanup for a fresh bootstrap and preserve stale secrets. - if cleanupPending { - return .clearStaleKeys - } - - switch markerRead { - case .success: - return containerKnowsMarker ? .markerPresent : .clearStaleKeys - case .itemNotFound: - return .bootstrapMarker - case .accessDenied, .deviceLocked, .authenticationFailed, .otherError: - return .retryLater - } - } - - static func applicationOwnedKeychainServices(primaryService: String) -> [String] { - var seen = Set() - return ([primaryService] + additionalApplicationOwnedServices).filter { - seen.insert($0).inserted - } - } - - /// Runs every service update even after one failure. Successful updates - /// are idempotent, while returning false keeps the one-time flag unset so - /// a later unlocked launch retries the incomplete migration. - static func migrateAccessibilityForApplicationOwnedServices( - primaryService: String, - updateService: (String) -> OSStatus - ) -> Bool { - var completed = true - for serviceName in applicationOwnedKeychainServices( - primaryService: primaryService - ) { - let status = updateService(serviceName) - if status != errSecSuccess && status != errSecItemNotFound { - completed = false - } - } - return completed - } - - /// Deletes every declared service even after one failure. An empty scope - /// is already clean, while any other status leaves the cleanup - /// incomplete so its durable retry marker remains set. - static func deleteApplicationOwnedKeychainServices( - primaryService: String, - deleteService: (String) -> OSStatus - ) -> Bool { - var completed = true - for serviceName in applicationOwnedKeychainServices( - primaryService: primaryService - ) { - let status = deleteService(serviceName) - if status != errSecSuccess && status != errSecItemNotFound { - completed = false - } - } - return completed - } - - /// The app currently has an application-group entitlement, not a - /// keychain-access-group entitlement. Keep the historical group cleanup - /// probe as best effort without making its expected -34018 response block - /// panic recovery forever. - static func completedApplicationGroupDelete(status: OSStatus) -> Bool { - status == errSecSuccess - || status == errSecItemNotFound - || status == -34018 - } - - #if os(iOS) - - private static let installMarkerAccount = "install_lifecycle_marker" - private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present" - private static let installCleanupPendingDefaultsKey = - "keychain.installLifecycleCleanup.pending" - - /// Keychain items can survive app removal while the app container and its - /// UserDefaults do not. The first version carrying this marker bootstraps - /// without deleting existing users' identities. On a later reinstall, a - /// surviving keychain marker plus a missing defaults marker proves the app - /// container was replaced, so stale secrets are removed before use. - @discardableResult - private func reconcileInstallLifecycle() -> Bool { - let defaults = UserDefaults.standard - let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey) - let cleanupPending = defaults.bool( - forKey: Self.installCleanupPendingDefaultsKey - ) - - let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount) - switch Self.installLifecycleAction( - containerKnowsMarker: containerKnowsMarker, - cleanupPending: cleanupPending, - markerRead: markerRead - ) { - case .markerPresent: - defaults.set(true, forKey: Self.installMarkerDefaultsKey) - return true - - case .bootstrapMarker: - if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) { - defaults.set(true, forKey: Self.installMarkerDefaultsKey) - } - // A missing marker is the intentional bootstrap path for both a - // fresh install and the first marker-carrying upgrade. Preserve - // existing users' identities even if marker creation must retry - // on a later construction. - return true - - case .clearStaleKeys: - // Establish a container-local retry latch before deleting the - // surviving keychain marker. If the process exits or any keychain - // operation fails, the next launch retries even when that marker - // can no longer be read. - defaults.set(true, forKey: Self.installCleanupPendingDefaultsKey) - guard defaults.synchronize(), - defaults.bool(forKey: Self.installCleanupPendingDefaultsKey) - else { - SecureLogger.error( - "Could not persist reinstall keychain-cleanup intent", - category: .security - ) - return false - } - - guard deleteAllKeychainData() else { - SecureLogger.error( - "Reinstall keychain cleanup incomplete; retry remains pending", - category: .security - ) - return false - } - - defaults.set(true, forKey: Self.installMarkerDefaultsKey) - defaults.removeObject( - forKey: Self.installCleanupPendingDefaultsKey - ) - guard defaults.synchronize(), - defaults.bool(forKey: Self.installMarkerDefaultsKey), - !defaults.bool( - forKey: Self.installCleanupPendingDefaultsKey - ) - else { - // Preserve the fail-closed state in memory and make one more - // best-effort persistence attempt before startup continues. - defaults.set( - true, - forKey: Self.installCleanupPendingDefaultsKey - ) - _ = defaults.synchronize() - SecureLogger.error( - "Could not commit reinstall keychain-cleanup state; retry remains pending", - category: .security - ) - return false - } - return true - - case .retryLater: - // Do not guess that a temporarily unreadable marker is absent. - // An established container may keep using ordinary protected-data - // semantics: reads fail while locked and recover after unlock. A - // container that has not committed the marker must stay blocked - // until the marker becomes readable and this state machine can - // distinguish bootstrap from reinstall. - return containerKnowsMarker - } - } - - /// One-time upgrade of items created under WhenUnlocked. New saves get - /// the right class on their own (saves are delete-then-add), but the - /// long-lived identity keys are written once and would otherwise stay - /// unreadable while the device is locked. - private func migrateAccessibilityIfNeeded() { - let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated" - guard !UserDefaults.standard.bool(forKey: flag) else { return } - - let update: [String: Any] = [ - kSecAttrAccessible as String: Self.itemAccessibility - ] - let completed = Self.migrateAccessibilityForApplicationOwnedServices( - primaryService: service - ) { serviceName in - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName - ] - return SecItemUpdate( - query as CFDictionary, - update as CFDictionary - ) - } - if completed { - // Missing services on a fresh install are terminal, but the flag is - // set only after every application-owned service was considered. - UserDefaults.standard.set(true, forKey: flag) - SecureLogger.info( - "Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly", - category: .keychain - ) - } else { - // Likely errSecInteractionNotAllowed (relaunched while locked) — - // leave the flag unset so the next launch retries. - SecureLogger.warning( - "Keychain accessibility migration deferred for at least one application-owned service", - category: .keychain - ) - } - } - #endif - - private func installAccessAllowed() -> Bool { - #if os(iOS) - return installAccessGate.allowsAccess { [self] in - guard reconcileInstallLifecycle() else { return false } - migrateAccessibilityIfNeeded() - return true - } - #else - return true - #endif - } - + // MARK: - Identity Keys func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { - guard installAccessAllowed() else { - SecureLogger.logKeyOperation(.save, keyType: key, success: false) - return false - } let fullKey = "identity_\(key)" let result = saveData(keyData, forKey: fullKey) SecureLogger.logKeyOperation(.save, keyType: key, success: result) @@ -371,16 +92,11 @@ final class KeychainManager: KeychainManagerProtocol { } func getIdentityKey(forKey key: String) -> Data? { - guard installAccessAllowed() else { return nil } let fullKey = "identity_\(key)" return retrieveData(forKey: fullKey) } func deleteIdentityKey(forKey key: String) -> Bool { - guard installAccessAllowed() else { - SecureLogger.logKeyOperation(.delete, keyType: key, success: false) - return false - } let result = delete(forKey: "identity_\(key)") SecureLogger.logKeyOperation(.delete, keyType: key, success: result) return result @@ -391,14 +107,12 @@ final class KeychainManager: KeychainManagerProtocol { /// Get identity key with detailed result for proper error handling /// Distinguishes between missing keys (expected) and critical failures func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { - guard installAccessAllowed() else { return .accessDenied } let fullKey = "identity_\(key)" return retrieveDataWithResult(forKey: fullKey) } /// Save identity key with detailed result and retry logic for transient errors func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { - guard installAccessAllowed() else { return .accessDenied } let fullKey = "identity_\(key)" return saveDataWithResult(keyData, forKey: fullKey) } @@ -414,7 +128,7 @@ final class KeychainManager: KeychainManagerProtocol { kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrService as String: service, - kSecAttrAccessible as String: Self.itemAccessibility, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, kSecAttrLabel as String: "bitchat-\(key)" ] #if os(macOS) @@ -564,6 +278,11 @@ final class KeychainManager: KeychainManagerProtocol { // MARK: - Generic Operations + private func save(_ value: String, forKey key: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + return saveData(data, forKey: key) + } + private func saveData(_ data: Data, forKey key: String) -> Bool { // Delete any existing item first to ensure clean state _ = delete(forKey: key) @@ -574,7 +293,7 @@ final class KeychainManager: KeychainManagerProtocol { kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrService as String: service, - kSecAttrAccessible as String: Self.itemAccessibility, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, kSecAttrLabel as String: "bitchat-\(key)" ] #if os(macOS) @@ -609,6 +328,11 @@ final class KeychainManager: KeychainManagerProtocol { return false } + private func retrieve(forKey key: String) -> String? { + guard let data = retrieveData(forKey: key) else { return nil } + return String(data: data, encoding: .utf8) + } + private func retrieveData(forKey key: String) -> Data? { // Base query let base: [String: Any] = [ @@ -664,169 +388,133 @@ final class KeychainManager: KeychainManagerProtocol { } // MARK: - Cleanup - + + func deleteAllPasswords() -> Bool { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword + ] + + // Add service if not empty + if !service.isEmpty { + query[kSecAttrService as String] = service + } + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + // Delete ALL keychain data for panic mode func deleteAllKeychainData() -> Bool { SecureLogger.warning("Panic mode - deleting all keychain data", category: .security) - - let ownedServices = Set( - Self.applicationOwnedKeychainServices( - primaryService: service - ) - ) - var enumerationCompleted = true + + var totalDeleted = 0 + + // Search without service restriction to catch all items let searchQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecMatchLimit as String: kSecMatchLimitAll, kSecReturnAttributes as String: true ] + var result: AnyObject? - let searchStatus = SecItemCopyMatching( - searchQuery as CFDictionary, - &result - ) - switch searchStatus { - case errSecSuccess: - guard let items = result as? [[String: Any]] else { - enumerationCompleted = false - SecureLogger.error( - "Unable to decode application-owned keychain inventory", - category: .security - ) - break - } - - // Preserve the access-group sweep for custom services that are - // not yet in the declared legacy-service list. + let searchStatus = SecItemCopyMatching(searchQuery as CFDictionary, &result) + + if searchStatus == errSecSuccess, let items = result as? [[String: Any]] { for item in items { - let account = - item[kSecAttrAccount as String] as? String ?? "" - let itemService = - item[kSecAttrService as String] as? String ?? "" - let accessGroup = - item[kSecAttrAccessGroup as String] as? String - guard accessGroup == appGroup - || ownedServices.contains(itemService) - else { - continue + var shouldDelete = false + let account = item[kSecAttrAccount as String] as? String ?? "" + let service = item[kSecAttrService as String] as? String ?? "" + let accessGroup = item[kSecAttrAccessGroup as String] as? String + + // More precise deletion criteria: + // 1. Check for our specific app group + // 2. OR check for our exact service name + // 3. OR check for known legacy service names + if accessGroup == appGroup { + shouldDelete = true + } else if service == self.service { + shouldDelete = true + } else if [ + "com.bitchat.passwords", + "com.bitchat.deviceidentity", + "com.bitchat.noise.identity", + "chat.bitchat.passwords", + "bitchat.keychain", + "bitchat", + "com.bitchat" + ].contains(service) { + shouldDelete = true } - - var deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword - ] - if !account.isEmpty { - deleteQuery[kSecAttrAccount as String] = account - } - if !itemService.isEmpty { - deleteQuery[kSecAttrService as String] = itemService - } - if let accessGroup, - !accessGroup.isEmpty, - accessGroup != "test" { - deleteQuery[kSecAttrAccessGroup as String] = accessGroup - } - - let status = SecItemDelete(deleteQuery as CFDictionary) - if status != errSecSuccess && status != errSecItemNotFound { - enumerationCompleted = false - SecureLogger.error( - NSError(domain: "Keychain", code: Int(status)), - context: "Unable to delete enumerated application-owned keychain item", - category: .keychain - ) + + if shouldDelete { + // Build delete query with all available attributes for precise deletion + var deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword + ] + + if !account.isEmpty { + deleteQuery[kSecAttrAccount as String] = account + } + if !service.isEmpty { + deleteQuery[kSecAttrService as String] = service + } + + // Add access group if present + if let accessGroup = item[kSecAttrAccessGroup as String] as? String, + !accessGroup.isEmpty && accessGroup != "test" { + deleteQuery[kSecAttrAccessGroup as String] = accessGroup + } + + let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) + if deleteStatus == errSecSuccess { + totalDeleted += 1 + SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain) + } } } - - case errSecItemNotFound: - break - - default: - enumerationCompleted = false - SecureLogger.error( - NSError(domain: "Keychain", code: Int(searchStatus)), - context: "Unable to enumerate application-owned keychain items", - category: .keychain - ) } - - // Bulk deletion by every application-owned service is authoritative - // and idempotent. It also verifies that every known service scope is - // empty even when the inventory pass found no items. - let servicesCompleted = - Self.deleteApplicationOwnedKeychainServices( - primaryService: service - ) { serviceName in - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName - ] - let status = SecItemDelete(query as CFDictionary) - if status != errSecSuccess && status != errSecItemNotFound { - SecureLogger.error( - NSError(domain: "Keychain", code: Int(status)), - context: "Unable to delete application-owned keychain service \(serviceName)", - category: .keychain - ) - } - return status + + // Also try to delete by known service names and app group + // This catches any items that might have been missed above + let knownServices = [ + self.service, // Current service name + "com.bitchat.passwords", + "com.bitchat.deviceidentity", + "com.bitchat.noise.identity", + "chat.bitchat.passwords", + "chat.bitchat.nostr", + "bitchat.keychain", + "bitchat", + "com.bitchat" + ] + + for serviceName in knownServices { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName + ] + + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess { + totalDeleted += 1 } - - // Historical builds attempted this application-group identifier as a - // keychain access group. It is not currently entitled, so -34018 - // means the scope is inapplicable rather than partially deleted. + } + + // Also delete by app group to ensure complete cleanup let groupQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccessGroup as String: appGroup ] + let groupStatus = SecItemDelete(groupQuery as CFDictionary) - let groupCompleted = Self.completedApplicationGroupDelete( - status: groupStatus - ) - if !groupCompleted { - SecureLogger.error( - NSError(domain: "Keychain", code: Int(groupStatus)), - context: "Unable to delete historical application-group keychain items", - category: .keychain - ) + if groupStatus == errSecSuccess { + totalDeleted += 1 } - - var markerCompleted = true - #if os(iOS) - // The non-secret marker is intentionally recreated after a panic so a - // later uninstall/reinstall can still be distinguished from an in-place - // upgrade. Do not commit the container-side marker here: reinstall - // reconciliation may still need to retry an incomplete cleanup. - if case .success = saveDataWithResult( - Data([1]), - forKey: Self.installMarkerAccount - ) { - markerCompleted = true - } else { - markerCompleted = false - SecureLogger.error( - "Unable to restore install-lifecycle keychain marker", - category: .security - ) - } - #endif - - let completed = - enumerationCompleted - && servicesCompleted - && groupCompleted - && markerCompleted - if completed { - SecureLogger.warning( - "Panic mode keychain cleanup completed", - category: .keychain - ) - } else { - SecureLogger.error( - "Panic mode keychain cleanup incomplete", - category: .security - ) - } - return completed + + SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain) + + return totalDeleted > 0 } // MARK: - Security Utilities @@ -852,7 +540,6 @@ final class KeychainManager: KeychainManagerProtocol { // MARK: - Debug func verifyIdentityKeyExists() -> Bool { - guard installAccessAllowed() else { return false } let key = "identity_noiseStaticKey" return retrieveData(forKey: key) != nil } @@ -861,54 +548,22 @@ final class KeychainManager: KeychainManagerProtocol { /// Save data with a custom service name func save(key: String, data: Data, service customService: String, accessible: CFString?) { - guard installAccessAllowed() else { return } - let primaryKeyQuery: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, - kSecAttrAccount as String: key + kSecAttrAccount as String: key, + kSecValueData as String: data ] - var addQuery = primaryKeyQuery - addQuery.merge([ - kSecValueData as String: data, - kSecAttrAccessible as String: accessible ?? Self.itemAccessibility, - kSecAttrSynchronizable as String: false - ]) { _, new in new } + if let accessible = accessible { + query[kSecAttrAccessible as String] = accessible + } - // Delete by the item's primary key only. Value/accessibility fields - // are add attributes, not valid selectors for replacing an existing - // item; including them can leave the old item in place and make the - // subsequent add fail as a duplicate. - let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary) - guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { - SecureLogger.error( - NSError(domain: "Keychain", code: Int(deleteStatus)), - context: "Unable to replace custom-service keychain item", - category: .keychain - ) - return - } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) - if addStatus != errSecSuccess { - SecureLogger.error( - NSError(domain: "Keychain", code: Int(addStatus)), - context: "Unable to save custom-service keychain item", - category: .keychain - ) - } + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) } /// Load data from a custom service func load(key: String, service customService: String) -> Data? { - guard case .success(let data) = loadWithResult(key: key, service: customService) else { - return nil - } - return data - } - - /// Load custom-service data without collapsing `itemNotFound` and - /// protected-data/keychain failures into the same nil result. - func loadWithResult(key: String, service customService: String) -> KeychainReadResult { - guard installAccessAllowed() else { return .accessDenied } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, @@ -918,12 +573,13 @@ final class KeychainManager: KeychainManagerProtocol { var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - return classifyReadStatus(status, data: result as? Data) + + guard status == errSecSuccess else { return nil } + return result as? Data } /// Delete data from a custom service func delete(key: String, service customService: String) { - guard installAccessAllowed() else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, @@ -932,31 +588,4 @@ final class KeychainManager: KeychainManagerProtocol { SecItemDelete(query as CFDictionary) } - - /// Delete every item stored under a custom service - func deleteAll(service customService: String) { - guard installAccessAllowed() else { return } - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: customService, - kSecMatchLimit as String: kSecMatchLimitAll, - kSecReturnAttributes as String: true - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let items = result as? [[String: Any]] else { - return - } - for item in items { - var deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: customService - ] - if let account = item[kSecAttrAccount as String] as? String { - deleteQuery[kSecAttrAccount as String] = account - } - SecItemDelete(deleteQuery as CFDictionary) - } - } } diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index 4657a7b3..22c5d0e2 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -1,5 +1,4 @@ import BitLogger -import Combine import Foundation /// Dependencies for location notes, allowing tests to stub relay/identity behavior. @@ -15,13 +14,7 @@ struct LocationNotesDependencies { var sendEvent: SendEvent var deriveIdentity: (_ geohash: String) throws -> NostrIdentity var now: () -> Date - // Fires when the geo relay directory refreshes; used to retry after "no relays". - var relayDirectoryUpdates: AnyPublisher = Empty(completeImmediately: false).eraseToAnyPublisher() - /// Whether any of the target relays has a live connection — distinguishes - /// "loaded, empty" from "still connecting (Tor warming up)" when EOSE - /// fires without data. Defaults to true so tests keep legacy behavior. - var anyRelayConnected: @MainActor (_ relayUrls: [String]) -> Bool = { _ in true } - + private static let idBridge = NostrIdentityBridge() static let live = LocationNotesDependencies( @@ -46,14 +39,7 @@ struct LocationNotesDependencies { deriveIdentity: { geohash in try idBridge.deriveIdentity(forGeohash: geohash) }, - now: { Date() }, - relayDirectoryUpdates: NotificationCenter.default - .publisher(for: .geoRelayDirectoryDidRefresh) - .map { _ in () } - .eraseToAnyPublisher(), - anyRelayConnected: { relayUrls in - NostrRelayManager.shared.isAnyRelayConnected(among: relayUrls) - } + now: { Date() } ) } @@ -64,10 +50,6 @@ final class LocationNotesManager: ObservableObject { enum State: Equatable { case idle case loading - /// The initial fetch timed out with zero target relays connected - /// (usually Tor still bootstrapping): not "empty", just not there - /// yet. Retries automatically once a relay comes up. - case connecting case ready case noRelays } @@ -78,33 +60,6 @@ final class LocationNotesManager: ObservableObject { let content: String let createdAt: Date let nickname: String? - /// The matched `g` tag: the cell the note was posted to, which can be - /// a neighbor of the subscribed geohash. - let geohash: String - /// NIP-40 expiration, when the note carries one (dead drops do). - let expiresAt: Date? - /// Carries a `["t","urgent"]` tag (parity with urgent board posts). - let isUrgent: Bool - - init( - id: String, - pubkey: String, - content: String, - createdAt: Date, - nickname: String?, - geohash: String, - expiresAt: Date? = nil, - isUrgent: Bool = false - ) { - self.id = id - self.pubkey = pubkey - self.content = content - self.createdAt = createdAt - self.nickname = nickname - self.geohash = geohash - self.expiresAt = expiresAt - self.isUrgent = isUrgent - } var displayName: String { let suffix = String(pubkey.suffix(4)) @@ -120,13 +75,8 @@ final class LocationNotesManager: ObservableObject { @Published private(set) var initialLoadComplete: Bool = false @Published private(set) var state: State = .loading @Published private(set) var errorMessage: String? - /// Public key of our per-geohash Nostr identity; identifies our own notes. - private var ownPubkey: String? private var subscriptionID: String? private var noteIDs = Set() // O(1) duplicate detection - private var directoryUpdateCancellable: AnyCancellable? - private var expiryPruneTimer: Timer? - private var connectivityRetryTimer: Timer? private let dependencies: LocationNotesDependencies private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200) @@ -146,64 +96,35 @@ final class LocationNotesManager: ObservableObject { let norm = geohash.lowercased() self.geohash = norm self.dependencies = dependencies - if !Geohash.isValidGeohash(norm) { - SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session) + // Validate geohash (building-level precision: 8 chars) + if !Geohash.isValidBuildingGeohash(norm) { + SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) } - ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex subscribe() - // The relay directory may load after init (remote fetch over Tor); - // retry automatically instead of staying stuck on "no relays". - directoryUpdateCancellable = dependencies.relayDirectoryUpdates - .sink { [weak self] in - Task { @MainActor [weak self] in - guard let self, self.state == .noRelays else { return } - self.subscribe() - } - } - // NIP-40 notes can expire while displayed (a 24h dead drop crossing - // its boundary); ingest-time filtering alone would keep it visible - // until the subscription is recreated. - expiryPruneTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.pruneExpiredNotes() - } - } } - deinit { - expiryPruneTimer?.invalidate() - connectivityRetryTimer?.invalidate() - // A live REQ must not outlive its manager: relays would keep - // streaming events nobody consumes. deinit is nonisolated, so hop to - // the main actor with just the captured closure and id. + func setGeohash(_ newGeohash: String) { + let norm = newGeohash.lowercased() + guard norm != geohash else { return } + // Validate geohash (building-level precision: 8 chars) + guard Geohash.isValidBuildingGeohash(norm) else { + SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) + return + } if let sub = subscriptionID { - let unsubscribe = dependencies.unsubscribe - Task { @MainActor in - unsubscribe(sub) - } + dependencies.unsubscribe(sub) + subscriptionID = nil } + // Set loading state before clearing to prevent empty state flicker + state = .loading + initialLoadComplete = false + errorMessage = nil + geohash = norm + notes.removeAll() + noteIDs.removeAll() + subscribe() } - /// Drops notes whose NIP-40 expiry has passed. Their ids stay in - /// `noteIDs` so a relay replay cannot resurrect them. - func pruneExpiredNotes() { - let now = dependencies.now() - let expired = notes.contains { note in - if let expiresAt = note.expiresAt { return expiresAt <= now } - return false - } - guard expired else { return } - notes.removeAll { note in - if let expiresAt = note.expiresAt { return expiresAt <= now } - return false - } - } - - // A manager's geohash is fixed for its lifetime: instances are pooled - // per geohash (`LocationNotesPool`), so retargeting one in place would - // corrupt the pool's keying and refcounts. Release the manager and - // acquire the new cell instead. - func refresh() { if let sub = subscriptionID { dependencies.unsubscribe(sub) @@ -225,8 +146,6 @@ final class LocationNotesManager: ObservableObject { private func subscribe() { state = .loading errorMessage = nil - connectivityRetryTimer?.invalidate() - connectivityRetryTimer = nil if let sub = subscriptionID { dependencies.unsubscribe(sub) subscriptionID = nil @@ -257,19 +176,14 @@ final class LocationNotesManager: ObservableObject { guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } // Ensure matching tag - accept any of our 9 geohashes - guard let matchedGeohash = event.tags.first(where: { tag in + guard event.tags.contains(where: { tag in tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased()) - })?[1].lowercased() else { return } + }) else { return } guard !self.noteIDs.contains(event.id) else { return } - // NIP-40: relays are not required to enforce expiration — drop - // expired notes client-side so 24h dead drops actually vanish. - let expiresAt = Self.expirationDate(of: event) - if let expiresAt, expiresAt <= self.dependencies.now() { return } self.noteIDs.insert(event.id) let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let urgent = event.tags.contains { $0.count >= 2 && $0[0].lowercased() == "t" && $0[1].lowercased() == "urgent" } - let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash, expiresAt: expiresAt, isUrgent: urgent) + let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) self.notes.append(note) self.notes.sort { $0.createdAt > $1.createdAt } self.enforceMemoryCap() @@ -277,51 +191,14 @@ final class LocationNotesManager: ObservableObject { }, { [weak self] in guard let self = self else { return } self.initialLoadComplete = true - guard self.state != .noRelays else { return } - // EOSE with no data and zero connected target relays means the - // 10s fallback fired while Tor was still warming up — showing - // "no notes" would be a lie. Wait visibly and retry. - if self.notes.isEmpty, !self.dependencies.anyRelayConnected(relays) { - self.state = .connecting - self.scheduleConnectivityRetry(relays: relays) - } else { + if self.state != .noRelays { self.state = .ready } }) } - /// While `.connecting`, poll for a live target relay and re-subscribe as - /// soon as one appears (fresh REQ, fresh EOSE tracking). The poll dies - /// with the state: any subscribe/cancel invalidates it. - private func scheduleConnectivityRetry(relays: [String]) { - connectivityRetryTimer?.invalidate() - connectivityRetryTimer = Timer.scheduledTimer( - withTimeInterval: TransportConfig.uiGeoNotesConnectivityRetrySeconds, - repeats: true - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.retryIfRelaysAvailable(relays: relays) - } - } - } - - func retryIfRelaysAvailable(relays: [String]) { - guard state == .connecting else { - connectivityRetryTimer?.invalidate() - connectivityRetryTimer = nil - return - } - guard dependencies.anyRelayConnected(relays) else { return } - connectivityRetryTimer?.invalidate() - connectivityRetryTimer = nil - SecureLogger.debug("LocationNotesManager: relay came up, retrying notes fetch for \(geohash)", category: .session) - refresh() - } - - /// Send a location note for the current geohash using the per-geohash - /// identity, optionally expiring via NIP-40 (dead drops pass 24h; the - /// composer's ∞ option passes nil) and optionally tagged urgent. - func send(content: String, nickname: String, expiresAt: Date? = nil, urgent: Bool = false) { + /// Send a location note for the current geohash using the per-geohash identity. + func send(content: String, nickname: String) { guard let trimmed = content.trimmedOrNilIfEmpty else { return } let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) guard !relays.isEmpty else { @@ -336,9 +213,7 @@ final class LocationNotesManager: ObservableObject { content: trimmed, geohash: geohash, senderIdentity: id, - nickname: nickname, - expiresAt: expiresAt, - urgent: urgent + nickname: nickname ) dependencies.sendEvent(event, relays) // Optimistic local-echo @@ -347,10 +222,7 @@ final class LocationNotesManager: ObservableObject { pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)), - nickname: nickname, - geohash: geohash, - expiresAt: expiresAt, - isUrgent: urgent + nickname: nickname ) self.noteIDs.insert(event.id) self.notes.insert(echo, at: 0) @@ -363,44 +235,6 @@ final class LocationNotesManager: ObservableObject { } } - /// Whether the note was published by this device's identity for the - /// current geohash (and can therefore be deleted with NIP-09). - func isOwnNote(_ note: Note) -> Bool { - guard let ownPubkey else { return false } - return note.pubkey == ownPubkey - } - - /// Requests NIP-09 deletion of one of our own notes and removes it locally. - @discardableResult - func delete(note: Note) -> Bool { - guard isOwnNote(note) else { return false } - let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) - guard !relays.isEmpty else { - state = .noRelays - errorMessage = Strings.noRelays - return false - } - do { - let identity = try dependencies.deriveIdentity(geohash) - let deletion = try NostrProtocol.createDeleteEvent(ofEventID: note.id, senderIdentity: identity) - dependencies.sendEvent(deletion, relays) - // Keep the id in noteIDs so a relay replay can't resurrect it. - notes.removeAll { $0.id == note.id } - return true - } catch { - SecureLogger.error("LocationNotesManager: failed to delete note: \(error)", category: .session) - return false - } - } - - /// The NIP-40 `expiration` tag as a date, if the event carries one. - static func expirationDate(of event: NostrEvent) -> Date? { - guard let tag = event.tags.first(where: { $0.count >= 2 && $0[0].lowercased() == "expiration" }), - let seconds = TimeInterval(tag[1]) - else { return nil } - return Date(timeIntervalSince1970: seconds) - } - /// Enforces defensive memory cap on notes array (keeps newest). private func enforceMemoryCap() { if notes.count > maxNotesInMemory { @@ -410,50 +244,12 @@ final class LocationNotesManager: ObservableObject { } } - /// One-shot dead-drop publish without holding a subscription: pins a - /// note to `geohash` that expires via NIP-40. Returns false when no geo - /// relays are known or signing fails. - @MainActor - static func postDrop( - content: String, - nickname: String, - geohash: String, - expiry: TimeInterval = TransportConfig.locationDropExpirySeconds, - dependencies: LocationNotesDependencies = .live - ) -> Bool { - guard let trimmed = content.trimmedOrNilIfEmpty else { return false } - let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) - guard !relays.isEmpty else { - SecureLogger.warning("LocationNotesManager: drop blocked, no geo relays for geohash=\(geohash)", category: .session) - return false - } - do { - let identity = try dependencies.deriveIdentity(geohash) - let event = try NostrProtocol.createGeohashTextNote( - content: trimmed, - geohash: geohash, - senderIdentity: identity, - nickname: nickname, - expiresAt: dependencies.now().addingTimeInterval(expiry) - ) - dependencies.sendEvent(event, relays) - return true - } catch { - SecureLogger.error("LocationNotesManager: failed to post drop: \(error)", category: .session) - return false - } - } - - /// Explicitly cancel the subscription. The prune timer stays alive (it - /// holds only a weak self) so a reused instance — the notices sheet - /// cancels on tab switch and refreshes on return — keeps pruning. + /// Explicitly cancel subscription and release resources. func cancel() { if let sub = subscriptionID { dependencies.unsubscribe(sub) subscriptionID = nil } - connectivityRetryTimer?.invalidate() - connectivityRetryTimer = nil state = .idle errorMessage = nil } diff --git a/bitchat/Services/LocationNotesPool.swift b/bitchat/Services/LocationNotesPool.swift deleted file mode 100644 index 9a6f2ec5..00000000 --- a/bitchat/Services/LocationNotesPool.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// LocationNotesPool.swift -// bitchat -// -// Refcounted pool of LocationNotesManager instances keyed by geohash, so -// surfaces watching the same place (the nearby-notes counter and the notices -// sheet's geo tab) share one relay subscription instead of opening two -// identical 9-cell REQs. -// This is free and unencumbered software released into the public domain. -// - -import Foundation - -@MainActor -final class LocationNotesPool { - static let shared = LocationNotesPool() - - private var entries: [String: (manager: LocationNotesManager, refs: Int)] = [:] - private let makeManager: @MainActor (String) -> LocationNotesManager - - /// The factory is injectable so tests can pool managers built over stub - /// dependencies; live use derives one real manager per geohash. - init(makeManager: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesManager(geohash: $0) }) { - self.makeManager = makeManager - } - - /// Returns the shared manager for `geohash` (case-insensitive), creating - /// it on first acquire and reviving a cancelled one on re-acquire. - /// Callers must never `cancel` a pooled manager — release it and acquire - /// the new geohash instead. - func acquire(_ geohash: String) -> LocationNotesManager { - let key = geohash.lowercased() - if let entry = entries[key] { - entries[key] = (entry.manager, entry.refs + 1) - if entry.manager.state == .idle { - entry.manager.refresh() - } - return entry.manager - } - let manager = makeManager(key) - entries[key] = (manager, 1) - return manager - } - - /// Balances `acquire`: the last release cancels the subscription and - /// drops the entry. Releasing an instance the pool doesn't own (a - /// test-injected manager) degrades to a plain `cancel()`. - func release(_ manager: LocationNotesManager?) { - guard let manager else { return } - guard let entry = entries[manager.geohash], entry.manager === manager else { - manager.cancel() - return - } - if entry.refs <= 1 { - entries[manager.geohash] = nil - manager.cancel() - } else { - entries[manager.geohash] = (entry.manager, entry.refs - 1) - } - } -} diff --git a/bitchat/Services/LocationNotesSettings.swift b/bitchat/Services/LocationNotesSettings.swift deleted file mode 100644 index 5bda810f..00000000 --- a/bitchat/Services/LocationNotesSettings.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// LocationNotesSettings.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -/// User preference for location notes (dead drops): leaving notes pinned to -/// nearby places with /drop and surfacing notes others left here. On by -/// default, but everything it powers additionally requires location -/// permission — the toggle in app info is the kill switch. -enum LocationNotesSettings { - private static let enabledKey = "locationNotes.enabled" - - /// Fired on every toggle write so live consumers (the nearby-notes - /// counter) can drop or restart their relay subscription immediately. - static let didChangeNotification = Notification.Name("bitchat.locationNotesSettingsDidChange") - - static var enabled: Bool { - get { UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true } - set { - UserDefaults.standard.set(newValue, forKey: enabledKey) - NotificationCenter.default.post(name: didChangeNotification, object: nil) - } - } -} diff --git a/bitchat/Services/LocationStateManager.swift b/bitchat/Services/LocationStateManager.swift index 209fab6c..8ac62df4 100644 --- a/bitchat/Services/LocationStateManager.swift +++ b/bitchat/Services/LocationStateManager.swift @@ -101,7 +101,9 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl private let cl: LocationStateManaging private let geocoder: LocationStateGeocoding + private var lastLocation: CLLocation? private var refreshTimer: Timer? + private var isGeocoding: Bool = false // MARK: - Persistence Keys @@ -109,8 +111,6 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl private let teleportedStoreKey = "locationChannel.teleportedSet" private let bookmarksKey = "locationChannel.bookmarks" private let bookmarkNamesKey = "locationChannel.bookmarkNames" - private let bookmarkNamesSchemaVersionKey = "locationChannel.bookmarkNamesSchemaVersion" - private let bookmarkNamesCurrentSchemaVersion = 1 // MARK: - Published State (Channel) @@ -224,26 +224,6 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl let dict = try? JSONDecoder().decode([String: String].self, from: data) { bookmarkNames = dict } - - migrateBookmarkNamesIfNeeded() - } - - /// Drops names cached before low-precision geohashes became country-first. - /// Correctly resolved names written after this migration must survive - /// subsequent launches, so the migration is explicitly versioned. - private func migrateBookmarkNamesIfNeeded() { - guard storage.integer(forKey: bookmarkNamesSchemaVersionKey) < bookmarkNamesCurrentSchemaVersion else { - return - } - - let retainedNames = bookmarkNames.filter { - Self.normalizeGeohash($0.key).count > 2 - } - if retainedNames.count != bookmarkNames.count { - bookmarkNames = retainedNames - persistBookmarkNames() - } - storage.set(bookmarkNamesCurrentSchemaVersion, forKey: bookmarkNamesSchemaVersionKey) } private func initializePermissionState() { @@ -288,7 +268,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl } } - func beginLiveRefresh(interval _: TimeInterval = TransportConfig.locationLiveRefreshInterval) { + func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) { guard permissionState == .authorized else { return } refreshTimer?.invalidate() refreshTimer = nil @@ -389,22 +369,23 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl } func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { - let newState = updatePermissionState(from: status) - if newState == .authorized { + updatePermissionState(from: status) + if case .authorized = permissionState { requestOneShotLocation() } } @available(iOS 14.0, macOS 11.0, *) func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let newState = updatePermissionState(from: manager.authorizationStatus) - if newState == .authorized { + updatePermissionState(from: manager.authorizationStatus) + if case .authorized = permissionState { requestOneShotLocation() } } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let loc = locations.last else { return } + lastLocation = loc computeChannels(from: loc.coordinate) reverseGeocodeLocation(loc) } @@ -415,8 +396,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl // MARK: - Private Helpers (Permission) - @discardableResult - private func updatePermissionState(from status: CLAuthorizationStatus) -> PermissionState { + private func updatePermissionState(from status: CLAuthorizationStatus) { let newState: PermissionState switch status { case .notDetermined: newState = .notDetermined @@ -425,15 +405,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized @unknown default: newState = .restricted } - // Do not rely on a mounted SwiftUI consumer to stop high-accuracy - // updates. Authorization can change while the app is backgrounded, - // and stopping here also closes the gap before the published state is - // delivered on the main actor. - if newState != .authorized { - endLiveRefresh() - } Task { @MainActor in self.permissionState = newState } - return newState } // MARK: - Private Helpers (Channel Computation) @@ -469,8 +441,10 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl private func reverseGeocodeLocation(_ location: CLLocation) { geocoder.cancelGeocode() + isGeocoding = true geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in guard let self = self else { return } + self.isGeocoding = false if let pm = placemarks?.first { let names = self.locationNamesByLevel(from: pm) Task { @MainActor in self.locationNames = names } @@ -547,15 +521,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl } private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) { - var uniqueRegions: [String] = [] - var seenRegions = Set() + var uniqueAdmins: [String] = [] + var seenAdmins = Set() var idx = 0 func step() { if idx >= points.count { let finalName: String? = { - if uniqueRegions.count >= 2 { return uniqueRegions[0] + " and " + uniqueRegions[1] } - return uniqueRegions.first + if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] } + return uniqueAdmins.first }() if let finalName = finalName, !finalName.isEmpty { DispatchQueue.main.async { @@ -571,16 +545,12 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in guard self != nil else { return } if let pm = placemarks?.first { - if let country = pm.country, !country.isEmpty { - if !seenRegions.contains(country) { - seenRegions.insert(country) - uniqueRegions.append(country) - } - } else if let admin = pm.administrativeArea, - !admin.isEmpty, - !seenRegions.contains(admin) { - seenRegions.insert(admin) - uniqueRegions.append(admin) + if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) { + seenAdmins.insert(admin) + uniqueAdmins.append(admin) + } else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) { + seenAdmins.insert(country) + uniqueAdmins.append(country) } } step() @@ -592,7 +562,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? { switch len { case 0...2: - return pm.country ?? pm.administrativeArea + return pm.administrativeArea ?? pm.country case 3...4: return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country case 5: @@ -624,22 +594,6 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl } } - /// Removes all persisted location state and resets the in-memory view. - /// Used by the panic wipe — selected channel, teleport set and bookmarks - /// (which reveal where the user has been) must not survive on device. - func panicWipe() { - storage.removeObject(forKey: selectedChannelKey) - storage.removeObject(forKey: teleportedStoreKey) - storage.removeObject(forKey: bookmarksKey) - storage.removeObject(forKey: bookmarkNamesKey) - teleportedSet.removeAll() - bookmarkMembership.removeAll() - bookmarks = [] - bookmarkNames = [:] - teleported = false - selectedChannel = .mesh - } - private static func normalizeGeohash(_ s: String) -> String { let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") return s @@ -662,5 +616,15 @@ extension LocationStateManager { func toggle(_ geohash: String) { toggleBookmark(geohash) } + + /// Backward compatibility: add bookmark (was GeohashBookmarksStore.add) + func add(_ geohash: String) { + addBookmark(geohash) + } + + /// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove) + func remove(_ geohash: String) { + removeBookmark(geohash) + } } #endif diff --git a/bitchat/Services/MeshEchoSettings.swift b/bitchat/Services/MeshEchoSettings.swift deleted file mode 100644 index 824f78c7..00000000 --- a/bitchat/Services/MeshEchoSettings.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// MeshEchoSettings.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -/// Watermark for "heard here earlier" echoes: clearing the mesh timeline -/// (triple-tap or /clear) records the moment, and the next launch only -/// re-seeds archived messages heard after it. -/// -/// Clearing also erases the archive itself, so cleared history is gone from -/// disk rather than merely hidden from the timeline. The watermark still -/// matters afterwards: it keeps messages this device hears again from peers, -/// which predate the clear, from reappearing as echoes. -enum MeshEchoSettings { - private static let clearedThroughKey = "meshEchoes.clearedThrough" - - static var clearedThrough: Date? { - get { UserDefaults.standard.object(forKey: clearedThroughKey) as? Date } - set { UserDefaults.standard.set(newValue, forKey: clearedThroughKey) } - } - - static func reset() { - UserDefaults.standard.removeObject(forKey: clearedThroughKey) - } -} diff --git a/bitchat/Services/MeshSightingsTracker.swift b/bitchat/Services/MeshSightingsTracker.swift deleted file mode 100644 index 12bd7255..00000000 --- a/bitchat/Services/MeshSightingsTracker.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// MeshSightingsTracker.swift -// bitchat -// -// Privacy-preserving daily tally of mesh peers that came within radio range, -// so an empty timeline can say "3 devices passed within range today" instead -// of feeling dead. Stores only a per-day salted hash per peer plus a count — -// no identities, no history beyond today. -// This is free and unencumbered software released into the public domain. -// - -import BitFoundation -import CryptoKit -import Foundation - -@MainActor -final class MeshSightingsTracker: ObservableObject { - static let shared = MeshSightingsTracker() - - private enum Keys { - static let dayKey = "meshSightings.dayKey" - static let salt = "meshSightings.salt" - static let hashes = "meshSightings.hashes" - static let lastSeenAt = "meshSightings.lastSeenAt" - } - - /// Distinct devices seen within range today (rotating peer IDs may count - /// a long-lived neighbor more than once across rotations; that is fine - /// for an ambient stat). - @Published private(set) var todayCount: Int = 0 - @Published private(set) var lastSightingAt: Date? - - private let defaults: UserDefaults - private let now: () -> Date - private var seenHashes: Set = [] - - init(defaults: UserDefaults = .standard, now: @escaping () -> Date = { Date() }) { - self.defaults = defaults - self.now = now - restore() - } - - func recordSighting(peerID: PeerID) { - rollOverIfNeeded() - let hash = saltedHash(peerID.id) - let seenAt = now() - lastSightingAt = seenAt - defaults.set(seenAt, forKey: Keys.lastSeenAt) - guard seenHashes.insert(hash).inserted else { return } - todayCount = seenHashes.count - defaults.set(Array(seenHashes), forKey: Keys.hashes) - } - - /// Re-evaluates the day boundary for the UI. `recordSighting` handles - /// rollover when peers are seen, but an idle app open across midnight - /// would otherwise keep showing yesterday's tally until the next sighting; - /// the empty-state view calls this on its periodic refresh tick. - func refreshForDisplay() { - rollOverIfNeeded() - } - - func clear() { - seenHashes.removeAll() - todayCount = 0 - lastSightingAt = nil - defaults.removeObject(forKey: Keys.dayKey) - defaults.removeObject(forKey: Keys.salt) - defaults.removeObject(forKey: Keys.hashes) - defaults.removeObject(forKey: Keys.lastSeenAt) - } - - private func restore() { - rollOverIfNeeded() - seenHashes = Set(defaults.stringArray(forKey: Keys.hashes) ?? []) - todayCount = seenHashes.count - lastSightingAt = defaults.object(forKey: Keys.lastSeenAt) as? Date - } - - /// Resets the tally when the local calendar day changes; the salt rotates - /// with it so hashes from different days can never be correlated. - private func rollOverIfNeeded() { - let today = Self.dayKey(for: now()) - guard defaults.string(forKey: Keys.dayKey) != today else { return } - defaults.set(today, forKey: Keys.dayKey) - defaults.set(Self.randomSalt(), forKey: Keys.salt) - defaults.removeObject(forKey: Keys.hashes) - defaults.removeObject(forKey: Keys.lastSeenAt) - seenHashes.removeAll() - todayCount = 0 - lastSightingAt = nil - } - - private func saltedHash(_ value: String) -> String { - let salt = defaults.data(forKey: Keys.salt) ?? { - let fresh = Self.randomSalt() - defaults.set(fresh, forKey: Keys.salt) - return fresh - }() - var digest = SHA256() - digest.update(data: salt) - digest.update(data: Data(value.utf8)) - return digest.finalize().map { String(format: "%02x", $0) }.joined() - } - - private static let dayKeyFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.calendar = Calendar.current - formatter.timeZone = TimeZone.current - formatter.dateFormat = "yyyy-MM-dd" - return formatter - }() - - private static func dayKey(for date: Date) -> String { - dayKeyFormatter.string(from: date) - } - - private static func randomSalt() -> Data { - Data((0..<16).map { _ in UInt8.random(in: .min ... .max) }) - } -} diff --git a/bitchat/Services/MeshTopologyTracker.swift b/bitchat/Services/MeshTopologyTracker.swift index 5596b5b1..fdd8749f 100644 --- a/bitchat/Services/MeshTopologyTracker.swift +++ b/bitchat/Services/MeshTopologyTracker.swift @@ -10,10 +10,6 @@ final class MeshTopologyTracker { private var claims: [RoutingID: Set] = [:] // Last time we received an update from a node private var lastSeen: [RoutingID: Date] = [:] - // Highest protocol version observed from each node's decoded packets. - // Nodes absent from this map are assumed v1-only and are never used as - // hops (or targets) for version-gated routes. - private var observedVersions: [RoutingID: (version: UInt8, seenAt: Date)] = [:] // Maximum age for topology claims to be considered fresh for routing // Routes computed using stale topology can fail when the network has changed @@ -23,75 +19,48 @@ final class MeshTopologyTracker { queue.sync(flags: .barrier) { self.claims.removeAll() self.lastSeen.removeAll() - self.observedVersions.removeAll() } } /// Update the topology with a node's self-reported neighbor list - func updateNeighbors(for sourceData: Data?, neighbors: [Data], at now: Date = Date()) { + func updateNeighbors(for sourceData: Data?, neighbors: [Data]) { guard let source = sanitize(sourceData) else { return } // Sanitize neighbors and exclude self-loops let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source]) - + queue.sync(flags: .barrier) { self.claims[source] = validNeighbors - self.lastSeen[source] = now + self.lastSeen[source] = Date() } } - /// Record the protocol version observed on a decoded packet from a node. - /// Only versions above the v1 baseline are stored; the highest wins. - func recordObservedVersion(_ version: UInt8, for peerData: Data?, at now: Date = Date()) { - guard version > 1, let peer = sanitize(peerData) else { return } - queue.sync(flags: .barrier) { - let current = self.observedVersions[peer]?.version ?? 1 - self.observedVersions[peer] = (version: max(version, current), seenAt: now) - } - } - - /// Raw directed neighbor claims, for diagnostics (topology map, /trace). - /// Callers treat the claims as advisory: announces cap `directNeighbors` - /// at 10, so an edge may be claimed by only one of its endpoints. - func adjacencySnapshot() -> [Data: Set] { - queue.sync { claims } - } - func removePeer(_ data: Data?) { guard let peer = sanitize(data) else { return } queue.sync(flags: .barrier) { self.claims.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer) - self.observedVersions.removeValue(forKey: peer) } } - + /// Prune nodes that haven't updated their topology in `age` seconds - func prune(olderThan age: TimeInterval, now: Date = Date()) { - let deadline = now.addingTimeInterval(-age) + func prune(olderThan age: TimeInterval) { + let deadline = Date().addingTimeInterval(-age) queue.sync(flags: .barrier) { let stale = self.lastSeen.filter { $0.value < deadline } for (peer, _) in stale { self.claims.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer) } - self.observedVersions = self.observedVersions.filter { $0.value.seenAt >= deadline } } } - /// BFS over confirmed, fresh edges. When `requiringVersion` is set, every - /// node on the path except the source (i.e. all intermediate hops and the - /// target) must have been observed speaking at least that protocol - /// version — a v1-only hop cannot decode a v2 routed packet. - func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10, requiringVersion: UInt8? = nil, now: Date = Date()) -> [Data]? { + func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? { guard let source = sanitize(start), let target = sanitize(goal) else { return nil } if source == target { return [] } // Direct connection, no intermediate hops return queue.sync { + let now = Date() let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold) - func meetsRequiredVersion(_ peer: RoutingID) -> Bool { - guard let requiringVersion else { return true } - return (observedVersions[peer]?.version ?? 1) >= requiringVersion - } // BFS var visited: Set = [source] @@ -117,10 +86,6 @@ final class MeshTopologyTracker { for neighbor in neighbors { if visited.contains(neighbor) { continue } - // Version gate: skip nodes not known to speak the - // required protocol version. - guard meetsRequiredVersion(neighbor) else { continue } - // CONFIRMED EDGE CHECK: // 'last' claims 'neighbor' (checked above) // Does 'neighbor' claim 'last'? diff --git a/bitchat/Services/MeshTransportCapabilities.swift b/bitchat/Services/MeshTransportCapabilities.swift deleted file mode 100644 index fd2203ea..00000000 --- a/bitchat/Services/MeshTransportCapabilities.swift +++ /dev/null @@ -1,152 +0,0 @@ -import BitFoundation -import CoreBluetooth -import Foundation - -/// Optional transport capabilities, discovered with `as?` instead of casting -/// to a concrete transport class. `Transport` stays the contract every -/// transport genuinely implements; a capability protocol here is the -/// contract for one mesh-only feature surface, so app wiring depends on the -/// feature it needs rather than on `BLEService` itself. - -/// Radio-state reporting for transports backed by a local radio. -protocol BluetoothStateReporting: AnyObject { - func getCurrentBluetoothState() -> CBManagerState -} - -/// Panic-mode lifecycle for transports that own durable identity state. -/// A transport implementing this owns its own restart sequencing: -/// `completePanicReset` decides whether services come back, so generic -/// `startServices()` calls after a panic belong only to transports that -/// don't implement it. -protocol PanicResettingTransport: AnyObject { - /// Quiesces the radio and drains in-flight work ahead of a panic wipe. - func suspendForPanicReset() - /// Finishes a panic wipe, optionally restarting services. - func completePanicReset(restartServices: Bool) - /// Rotates the transport identity as part of a panic reset. - func resetIdentityForPanic(currentNickname: String, restartServices: Bool) -} - -/// File and private-media transfer over a mesh transport, including the -/// capability-proof policy that gates encrypted private media. -protocol MeshFileTransferring: AnyObject { - func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) - func sendFilePrivate( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool - ) - /// Automatic whole-file retry is admitted only while this exact Noise - /// generation authenticates bit 9. It must never queue across a session - /// replacement or enter the signed raw legacy path. - func sendFilePrivateReceiptRetry( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String - ) - func cancelTransfer(_ transferId: String) - func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy - /// The exact current Noise generation that authenticated both encrypted - /// private media (bit 8) and durable receipts/retry (bit 9). - func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID? - func resolvePrivateMediaSendPolicy( - to peerID: PeerID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) -} - -/// Live voice / push-to-talk: one encoded `VoiceBurstPacket`, -/// fire-and-forget inside the Noise session (private) or as a signed -/// ephemeral broadcast (public). Frames are only useful now — the -/// transport drops them (never queues) without an established session. -protocol MeshVoiceStreaming: AnyObject { - func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) - func sendVoiceFrameBroadcast(_ burstContent: Data) -} - -/// Courier store-and-forward: seal a message to the recipient's static -/// key and hand it to connected couriers for physical delivery while the -/// recipient is offline. Returns false when the transport cannot courier. -protocol MeshCourierTransporting: AnyObject { - @discardableResult - func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool -} - -/// Private groups: creator-signed state travels 1:1 over Noise sessions; -/// group messages flood like public broadcasts. -protocol MeshGroupMessaging: AnyObject { - func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) - func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) - func broadcastGroupMessage(_ envelope: Data) -} - -/// Bulletin board: broadcast a pre-signed board payload (post or -/// tombstone) so it spreads over relay and gossip sync. -protocol MeshBoardBroadcasting: AnyObject { - func sendBoardPayload(_ payload: Data) -} - -/// Mesh diagnostics (/ping, /trace, topology map). -protocol MeshDiagnosing: AnyObject { - /// Sends a directed ping probe; the completion fires exactly once on - /// the main actor with the measured result, or nil on timeout. - func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) - /// Estimated intermediate hops toward `peerID` from gossiped topology - /// ([] = direct link, nil = no known path). - func computeMeshPath(to peerID: PeerID) -> [PeerID]? - /// Current mesh graph for the topology map. - func currentMeshTopology() -> MeshTopologySnapshot? -} - -/// QR verification and transitive vouching over the Noise session. -protocol MeshVerifying: AnyObject { - func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) - func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) - /// Sends an encoded vouch-attestation batch inside the Noise session. - func sendVouchAttestations(_ payload: Data, to peerID: PeerID) -} - -/// Store-and-forward archive: the public messages this device is carrying -/// for gossip sync, decoded for display as "heard here earlier" echoes. -protocol MeshPublicArchiving: AnyObject { - func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) - /// Drops any carried public messages from a (newly blocked) sender so - /// they can't resurface as archived echoes on a later launch. - func purgeArchivedPublicMessages(from peerID: PeerID) - /// Erases the whole carried public-message archive, on disk included. - func purgeAllArchivedPublicMessages() -} - -/// Internet-gateway and geohash-bridge wiring surface (BLE mesh today). -/// Everything the gateway/bridge/courier services need from the mesh -/// transport, so their bootstrap wiring never touches the concrete class. -protocol MeshBridgingTransport: AnyObject { - // Runtime-advertised capability bits - func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) - func setLocalBridgeGeohash(_ cell: String?) - func advertisedBridgeGeohash() -> String? - - // Peers currently advertising bridging roles - func reachableGatewayPeers() -> [PeerID] - func reachableBridgePeers() -> [PeerID] - - // Gateway carrier packets (mesh <-> Nostr uplink/downlink) - @discardableResult - func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool - func broadcastNostrCarrier(_ payload: Data) - /// Sink for received carrier packets (set once by app wiring; called on - /// the main actor after transport-level checks). - var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? { get set } - - // Bridge courier drops (sealed envelopes carried across the bridge) - func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? - @discardableResult - func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool - @discardableResult - func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool - func myNoiseStaticPublicKey() -> Data - func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] - /// Fired (off-main) when a signature-verified announce is processed. - var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? { get set } -} diff --git a/bitchat/Services/MessageDeduplicationService.swift b/bitchat/Services/MessageDeduplicationService.swift index 693365ca..f84c1f47 100644 --- a/bitchat/Services/MessageDeduplicationService.swift +++ b/bitchat/Services/MessageDeduplicationService.swift @@ -104,10 +104,12 @@ final class LRUDeduplicationCache { enum ContentNormalizer { /// Regex to simplify HTTP URLs by stripping query strings and fragments - private static let simplifyHTTPURL = SafeRegex.compile( - "https?://[^\\s?#]+(?:[?#][^\\s]*)?", - options: [.caseInsensitive] - ) + private static let simplifyHTTPURL: NSRegularExpression = { + try! NSRegularExpression( + pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", + options: [.caseInsensitive] + ) + }() /// Normalizes content for deduplication comparison. /// - Parameters: @@ -170,37 +172,17 @@ final class MessageDeduplicationService { /// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format) private let nostrAckCache: LRUDeduplicationCache - /// Optional cross-launch persistence for the Nostr event cache. BitChat - /// randomizes private-envelope timestamps, so DM subscriptions look back 24h and - /// relays redeliver the same events on every launch; without this record - /// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers - /// that don't opt in) keeps the cache purely in-memory. - private let nostrEventStore: NostrProcessedEventStore? - private let nostrEventCapacity: Int - private var persistScheduled = false - private var pendingPersistIDs: [String] = [] - /// Creates a new deduplication service with specified capacities. /// - Parameters: /// - contentCapacity: Max entries for content cache /// - nostrEventCapacity: Max entries for Nostr event cache - /// - nostrEventStore: Optional disk store preloading and persisting - /// processed Nostr event IDs across launches init( contentCapacity: Int = TransportConfig.contentLRUCap, - nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap, - nostrEventStore: NostrProcessedEventStore? = nil + nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap ) { self.contentCache = LRUDeduplicationCache(capacity: contentCapacity) self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity) self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity) - self.nostrEventStore = nostrEventStore - self.nostrEventCapacity = nostrEventCapacity - if let nostrEventStore { - for eventID in nostrEventStore.load() { - nostrEventCache.record(eventID, value: true) - } - } } // MARK: - Content Deduplication @@ -244,16 +226,6 @@ final class MessageDeduplicationService { ContentNormalizer.normalizedKey(content) } - /// Removes the near-duplicate marker for a row that is being replaced, - /// not merely deleted. Bridge-first/radio-second reconciliation needs the - /// authenticated radio copy to pass the next pipeline flush after its - /// unauthenticated bridge alias is removed. - func forgetContent(_ content: String, ifRecordedAt timestamp: Date) { - let key = ContentNormalizer.normalizedKey(content) - guard contentCache.value(for: key) == timestamp else { return } - contentCache.remove(key) - } - // MARK: - Nostr Event Deduplication /// Checks if a Nostr event has already been processed. @@ -267,26 +239,6 @@ final class MessageDeduplicationService { /// - Parameter eventId: The event ID func recordNostrEvent(_ eventId: String) { nostrEventCache.record(eventId, value: true) - if nostrEventStore != nil { - pendingPersistIDs.append(eventId) - schedulePersistIfNeeded() - } - } - - /// Debounced persistence: bursts of inbound events (reconnect redelivery) - /// collapse into one append. Append-merge rather than snapshot, so a - /// transient in-memory clear between flushes can't shrink the disk record. - private func schedulePersistIfNeeded() { - guard let nostrEventStore, !persistScheduled else { return } - persistScheduled = true - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 2_000_000_000) - guard let self else { return } - self.persistScheduled = false - let newIDs = self.pendingPersistIDs - self.pendingPersistIDs.removeAll() - nostrEventStore.append(newIDs, cap: self.nostrEventCapacity) - } } // MARK: - Nostr ACK Deduplication @@ -311,20 +263,14 @@ final class MessageDeduplicationService { // MARK: - Clear - /// Clears all caches. This is the wipe/panic path: the persisted - /// private-envelope record goes with everything else. + /// Clears all caches func clearAll() { contentCache.clear() nostrEventCache.clear() nostrAckCache.clear() - pendingPersistIDs.removeAll() - nostrEventStore?.wipe() } - /// Clears only the in-memory Nostr caches (events and ACKs). Runs on - /// every geohash channel switch, so the disk record deliberately - /// survives — wiping it here would forfeit cross-launch private-envelope dedup - /// each time the user changes channels (flagged by Codex on #1398). + /// Clears only the Nostr caches (events and ACKs) func clearNostrCaches() { nostrEventCache.clear() nostrAckCache.clear() diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 88a32e3c..71abcbe4 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -39,23 +39,41 @@ final class MessageFormattingEngine { /// Precompiled regex patterns for message content parsing enum Patterns { - static let hashtag = SafeRegex.compile("#([a-zA-Z0-9_]+)") + static let hashtag: NSRegularExpression = { + try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: []) + }() - static let mention = SafeRegex.compile("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)") + static let mention: NSRegularExpression = { + try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: []) + }() - static let cashu = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b") + static let cashu: NSRegularExpression = { + try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) + }() - static let bolt11 = SafeRegex.compile("(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b") + static let bolt11: NSRegularExpression = { + try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: []) + }() - static let lnurl = SafeRegex.compile("(?i)\\blnurl1[a-z0-9]{20,}\\b") + static let lnurl: NSRegularExpression = { + try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: []) + }() - static let lightningScheme = SafeRegex.compile("(?i)\\blightning:[^\\s]+") + static let lightningScheme: NSRegularExpression = { + try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: []) + }() static let linkDetector: NSDataDetector? = { try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) }() - static let quickCashuPresence = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b") + static let quickCashuPresence: NSRegularExpression = { + try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) + }() + + static let simplifyHTTPURL: NSRegularExpression = { + try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive]) + }() } // MARK: - Match Types @@ -110,12 +128,11 @@ final class MessageFormattingEngine { ) // Format content - let myNickname = context.nickname.normalizedNickname let contentResult = formatContent( message.content, baseColor: baseColor, isSelf: isSelf, - isMentioned: message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false + isMentioned: message.mentions?.contains(context.nickname) ?? false ) result.append(contentResult) @@ -178,7 +195,7 @@ final class MessageFormattingEngine { // MARK: - Private Helpers - private static func formatSystemMessage(_ message: BitchatMessage, isDark _: Bool) -> AttributedString { + private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString { var result = AttributedString() let content = AttributedString("* \(message.content) *") @@ -238,9 +255,9 @@ final class MessageFormattingEngine { isSelf: Bool, isMentioned: Bool ) -> AttributedString { - // For very long content, use plain formatting to avoid expensive - // regex/detector work. Cashu presence must not disable this guard. - if content.isOversizedForRichFormatting() { + // For very long content without special tokens, use plain formatting + let containsCashu = containsCashuToken(content) + if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu { return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf) } @@ -397,7 +414,7 @@ final class MessageFormattingEngine { return AttributedString(text).mergingAttributes(style) } - private static func formatMatch(_ text: String, type: MatchType, baseColor _: Color, isSelf _: Bool) -> AttributedString { + private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString { var style = AttributeContainer() switch type { diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 4d8286ce..971a8432 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -2,156 +2,27 @@ import BitLogger import BitFoundation import Foundation -/// Trust and identity lookups the router needs to pick couriers. Backed by -/// the favorites store in production; injectable for tests. -struct CourierDirectory { - /// Noise static key for a peer we can address while they're offline. - var noiseKey: (PeerID) -> Data? - /// Whether a peer (by Noise static key) is a mutual favorite — the - /// preferred courier tier. Verified non-favorites are the fallback tier, - /// read off the transport snapshot. - var isTrustedCourier: (Data) -> Bool - - @MainActor - static func favoritesBacked() -> CourierDirectory { - CourierDirectory( - noiseKey: { peerID in - // Offline favorites are addressed by the full 64-hex - // noise-key ID, which carries the key itself; the favorites - // lookup only resolves short 16-hex IDs. - peerID.noiseKey - ?? FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey - }, - isTrustedCourier: { noiseKey in - FavoritesPersistenceService.shared.isMutualFavorite(noiseKey) - } - ) - } -} - /// Routes messages using available transports (Mesh, Nostr, etc.) @MainActor final class MessageRouter { - typealias QueuedMessage = MessageOutboxStore.QueuedMessage - - private struct PeerMessageKey: Hashable { - // periphery:ignore - read only via the synthesized Hashable - // conformance (dictionary-key identity), which the indexer - // cannot attribute; see retain_codable_properties in .periphery.yml - // for the same class of false positive. - let peerID: PeerID - let messageID: String - } - private let transports: [Transport] - private let now: () -> Date - private let courierDirectory: CourierDirectory - private let outboxStore: MessageOutboxStore? - private let metrics: StoreAndForwardMetrics? - /// Invoked whenever a retained private message is dropped without a - /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) - /// so the UI can surface the failure instead of leaving the message in a - /// stale "sending/sent" state forever. - var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? - - /// Invoked when a message with no reachable transport was handed to at - /// least one courier (a connected peer who will physically carry the - /// sealed envelope). Delivery stays best-effort: the outbox retains the - /// message until an ack arrives. - var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)? - - /// Parallel deposit into the internet bridge: park a sealed copy on - /// relays as a courier drop, so delivery stops requiring a physical - /// courier encounter. No-op unless the bridge is enabled. Runs alongside - /// (not instead of) mesh couriers; receivers dedup by message ID. - /// Completion is true only after at least one default relay explicitly - /// accepts the event, so a socket write followed by rejection cannot - /// falsely show the sender's message as "carried". - var bridgeCourierDeposit: (( - _ content: String, - _ messageID: String, - _ recipientNoiseKey: Data, - _ completion: @escaping @MainActor (Bool) -> Void - ) -> Void)? - - /// Re-attempts bridge drops for retained messages whose recipient no - /// transport can promptly reach anymore. Covers sends that raced the BLE - /// reachability retention window: a peer stays "reachable" for a minute - /// after its radio disappears, so the original send trusted the mesh and - /// skipped the deposit — and nothing else ever retried (field-found). - /// Safe to call often: the drop layer dedups by message ID. - func retryBridgeCourierDeposits() { - guard bridgeCourierDeposit != nil else { return } - for (peerID, queue) in outbox { - guard let recipientKey = courierDirectory.noiseKey(peerID) else { continue } - let promptlyDeliverable = transports.contains { - $0.isPeerReachable(peerID) && $0.canDeliverPromptly(to: peerID) - } - guard !promptlyDeliverable else { continue } - for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds { - requestBridgeCourierDeposit(message, for: peerID, recipientKey: recipientKey) - } - } + // Outbox entry with timestamp for TTL-based eviction + private struct QueuedMessage { + let content: String + let nickname: String + let messageID: String + let timestamp: Date } - /// Arms the periodic sweep behind `retryBridgeCourierDeposits`. Called - /// once by the bootstrapper after the deposit closure is wired; separate - /// from init so tests drive the retry directly. - func startBridgeDepositSweep(interval: TimeInterval = 120) { - bridgeSweepTask?.cancel() - bridgeSweepTask = Task { @MainActor [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - // Expire stale outbox entries in-session too — otherwise a DM - // to a peer that never reconnects sits on "sending" until the - // next relaunch instead of surfacing as failed. - self?.cleanupExpiredMessages() - self?.retryBridgeCourierDeposits() - } - } - } - - private var bridgeSweepTask: Task? - private var bridgeDepositsInFlight = Set() - private var outbox: [PeerID: [QueuedMessage]] = [:] - /// Peer/message pairs whose latest router-owned transmission used an - /// already-established secure session and still await an ack. Peer scope - /// is required because message IDs are not globally unique across direct - /// conversations. This deliberately excludes messages handed to BLE while - /// a handshake is pending: BLE owns those sends and drains its queue after - /// authentication, so retrying them here would duplicate every normal - /// first-handshake DM. - private var secureTransmissions = Set() // Outbox limits to prevent unbounded memory growth private static let maxMessagesPerPeer = 100 private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours - // Bound actual sends that never receive an ack, whether they used weak - // reachability or an apparently secure session that keeps being replaced. - // Connected pre-handshake sends are transport-owned and do not burn this - // cap because BLE queues/drains them itself. - private static let maxSendAttempts = 8 - // Redundant couriers improve delivery odds; receivers dedup by message ID. - private static let maxCouriersPerMessage = 3 - init( - transports: [Transport], - now: @escaping () -> Date = Date.init, - courierDirectory: CourierDirectory? = nil, - outboxStore: MessageOutboxStore? = nil, - metrics: StoreAndForwardMetrics? = nil - ) { + init(transports: [Transport]) { self.transports = transports - self.now = now - self.courierDirectory = courierDirectory ?? .favoritesBacked() - self.outboxStore = outboxStore - self.metrics = metrics - self.outbox = outboxStore?.load() ?? [:] - outboxStore?.setRecoveryHandler { [weak self] recovered in - self?.mergeRecoveredOutbox(recovered) - } // Observe favorites changes to learn Nostr mapping and flush queued messages NotificationCenter.default.addObserver( @@ -168,7 +39,7 @@ final class MessageRouter { } // Handle key updates if let newKey = note.userInfo?["peerPublicKey"] as? Data, - note.userInfo?["isKeyUpdate"] is Bool { + let _ = note.userInfo?["isKeyUpdate"] as? Bool { let peerID = PeerID(publicKey: newKey) Task { @MainActor in self.flushOutbox(for: peerID) @@ -190,387 +61,40 @@ final class MessageRouter { // MARK: - Message Sending func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { - let message = QueuedMessage( - content: content, - nickname: recipientNickname, - messageID: messageID, - timestamp: now(), - sendAttempts: 1 - ) - - if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) { - // Even an established Noise session can be stale after the peer - // restarts or replaces its app. Persist before handing the packet - // to the transport so a fast ack cannot race ahead of retention, - // then keep the copy until a delivery/read ack clears it. A - // replacement handshake will retry this same message ID, which - // receivers deduplicate. - enqueue(message, for: peerID) - secureTransmissions.insert(PeerMessageKey(peerID: peerID, messageID: messageID)) - SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) - return - } - - if let transport = connectedTransport(for: peerID) { - // "Connected" without an established secure session is forgeable: - // link bindings heal on signature-verified "direct" announces, but - // directness rides on the unsigned TTL, so a replayed announce can - // bind an absent peer's ID to the replayer's link — where the send - // stalls on a handshake the replayer can never complete. Send now - // (a genuine link finishes the handshake and delivers), but retain - // a copy and hand a sealed copy to couriers so nothing is silently - // lost; receivers dedup resends by message ID. - // - // Deliberate metadata tradeoff: every pre-handshake first DM to a - // connected peer hands nearby verified peers a sealed copy, so - // they learn a DM to this recipient exists (never its content — - // the envelope is opaque). Accepted for delivery robustness; the - // deposit is cleared on ack. Don't "optimize" the courier call - // away. - SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - enqueue(message, for: peerID) - secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) - transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) - attemptCourierDeposit(messageID: messageID, for: peerID) - return - } - if let transport = reachableTransport(for: peerID) { - // Reachability without a connection is a freshness heuristic (e.g. - // the mesh retention window), so the send can silently go nowhere. - // Send now, but retain a copy until a delivery/read ack clears it; - // receivers dedup resends by message ID. - SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - enqueue(message, for: peerID) + SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) - // "Reachable" without prompt delivery means the send only joined - // a queue (Nostr with relays down): also hand a sealed copy to - // any connected couriers rather than waiting for internet that - // may never come. Double delivery is harmless — receivers dedup - // by message ID, and delivered/read acks never downgrade. - if !transport.canDeliverPromptly(to: peerID) { - attemptCourierDeposit(messageID: messageID, for: peerID) - } } else { - var unsent = message - unsent.sendAttempts = 0 - enqueue(unsent, for: peerID) + // Queue for later with timestamp for TTL tracking + if outbox[peerID] == nil { outbox[peerID] = [] } + + let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date()) + outbox[peerID]?.append(message) + + // Enforce per-peer size limit with FIFO eviction + if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer { + let evicted = outbox[peerID]?.removeFirst() + SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session) + } + SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session) - attemptCourierDeposit(messageID: messageID, for: peerID) } } - // MARK: - Couriers - - /// Last resort when no transport can deliver promptly — the peer is - /// unreachable, or only reachable through a send queue waiting on - /// internet: seal the message to their known static key and hand it to - /// connected couriers who may physically encounter them. Mutual favorites - /// are preferred; signature-verified strangers fill remaining slots so a - /// crowd without favorites can still carry mail (envelopes are opaque - /// either way). The queued copy stays retained, so direct delivery still - /// wins if the peer reappears first (receivers dedup by message ID). - private func attemptCourierDeposit(messageID: String, for peerID: PeerID) { - guard let recipientKey = courierDirectory.noiseKey(peerID), - let entry = queuedMessage(messageID, for: peerID) else { return } - // The bridge drop needs no connected courier — only the recipient - // key — so it runs before the courier-slot bookkeeping. - requestBridgeCourierDeposit(entry, for: peerID, recipientKey: recipientKey) - let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count - guard remainingSlots > 0 else { return } - - for transport in transports { - guard let courierTransport = transport as? MeshCourierTransporting else { continue } - let couriers = eligibleCouriers( - on: transport, - recipientKey: recipientKey, - excluding: entry.depositedCourierKeys, - limit: remainingSlots - ) - guard !couriers.isEmpty else { continue } - if courierTransport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) { - SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session) - recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey)) - onMessageCarried?(messageID, peerID) - return - } - } - } - - /// A courier candidate just connected: hand them any queued mail they are - /// not already carrying. This is what turns couriering from "a favorite - /// happened to be around at send time" into eventual spread — deposits - /// retry as eligible peers appear, until each message rides with - /// `maxCouriersPerMessage` distinct couriers or expires. - func courierBecameAvailable(_ peerID: PeerID) { - for transport in transports { - guard let courierTransport = transport as? MeshCourierTransporting else { continue } - guard transport.isPeerConnected(peerID), - let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }), - let courierKey = snapshot.noisePublicKey, - courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue } - - let currentDate = now() - for (recipient, queue) in outbox { - // Mail *to* this peer flushes directly on connect. - guard recipient != peerID, - let recipientKey = courierDirectory.noiseKey(recipient), - recipientKey != courierKey else { continue } - for message in queue { - guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage, - !message.depositedCourierKeys.contains(courierKey), - currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue } - if courierTransport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) { - SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session) - recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey]) - onMessageCarried?(message.messageID, recipient) - } - } - } - return - } - } - - private struct CourierCandidate { - let peerID: PeerID - let noiseKey: Data - } - - private func eligibleCouriers( - on transport: Transport, - recipientKey: Data, - excluding excludedKeys: Set, - limit: Int - ) -> [CourierCandidate] { - guard limit > 0 else { return [] } - let candidates = transport.currentPeerSnapshots().compactMap { snapshot -> (CourierCandidate, isFavorite: Bool)? in - guard snapshot.isConnected, - let key = snapshot.noisePublicKey, - key != recipientKey, - !excludedKeys.contains(key) else { return nil } - let isFavorite = courierDirectory.isTrustedCourier(key) - guard isFavorite || snapshot.isVerified else { return nil } - return (CourierCandidate(peerID: snapshot.peerID, noiseKey: key), isFavorite) - } - return candidates - .sorted { $0.isFavorite && !$1.isFavorite } - .prefix(limit) - .map(\.0) - } - - private func queuedMessage(_ messageID: String, for peerID: PeerID) -> QueuedMessage? { - outbox[peerID]?.first { $0.messageID == messageID } - } - - private func requestBridgeCourierDeposit( - _ message: QueuedMessage, - for peerID: PeerID, - recipientKey: Data - ) { - let inFlightKey = PeerMessageKey(peerID: peerID, messageID: message.messageID) - guard let bridgeCourierDeposit, - bridgeDepositsInFlight.insert(inFlightKey).inserted else { return } - bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in - guard let self else { return } - self.bridgeDepositsInFlight.remove(inFlightKey) - // A direct delivery may have cleared the outbox while the relay - // relay confirmation was in flight; do not regress its UI state. - guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return } - self.onMessageCarried?(message.messageID, peerID) - } - } - - private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) { - metrics?.record(.courierDeposited) - guard var queue = outbox[peerID], - let index = queue.firstIndex(where: { $0.messageID == messageID }) else { return } - queue[index].depositedCourierKeys.formUnion(courierKeys) - outbox[peerID] = queue - persistOutbox() - } - - // MARK: - Outbox Management - - /// A locally trusted delivery transition confirms receipt; stop retaining - /// every copy of the message. Authenticated remote receipts must use the - /// peer-bound overload below instead. - func markDelivered(_ messageID: String) { - clearRetainedMessage(messageID) - } - - /// Stops retaining a message only for the authenticated conversation - /// aliases that produced the accepted receipt. A peer that learns another - /// conversation's message ID cannot use it to clear that conversation's - /// retry state. - func markDelivered(_ messageID: String, from peerIDs: Set) { - guard !peerIDs.isEmpty else { return } - _ = markDelivered(messageID, for: Array(peerIDs)) - } - - private func clearRetainedMessage(_ messageID: String) { - var cleared = false - for (peerID, queue) in outbox { - let filtered = queue.filter { $0.messageID != messageID } - guard filtered.count != queue.count else { continue } - outbox[peerID] = filtered.isEmpty ? nil : filtered - cleared = true - } - let matchingSecureTransmissions = secureTransmissions.filter { - $0.messageID == messageID - } - secureTransmissions.subtract(matchingSecureTransmissions) - // The durable snapshot may still be hidden by protected data. Record - // the ack even when this cold-load view cannot find the message, then - // persist the current view so the store retains a removal tombstone. - outboxStore?.recordRemoval(messageID: messageID) - if cleared { - metrics?.record(.outboxDelivered) - } - persistOutbox() - } - - /// A delivery or read ack authenticated to one account confirms receipt - /// only for that account's transport aliases. A colliding message ID - /// queued for another peer must remain retained. - @discardableResult - func markDelivered(_ messageID: String, for peerAliases: [PeerID]) -> Bool { - let peerIDs = Set(peerAliases) - guard !peerIDs.isEmpty else { return false } - - var cleared = false - for peerID in peerIDs { - guard let queue = outbox[peerID] else { continue } - let filtered = queue.filter { $0.messageID != messageID } - guard filtered.count != queue.count else { continue } - outbox[peerID] = filtered.isEmpty ? nil : filtered - cleared = true - } - for peerID in peerIDs { - secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) - } - // Preserve the scoped ack even when protected data hides the durable - // queue during a cold launch. - outboxStore?.recordRemoval(messageID: messageID, for: peerIDs) - if cleared { - metrics?.record(.outboxDelivered) - } - persistOutbox() - return cleared - } - - private func enqueue(_ message: QueuedMessage, for peerID: PeerID) { - var message = message - var queue = outbox[peerID] ?? [] - // Re-sending an already-queued ID replaces the entry (keeps attempt - // count fresh) but must not forget which couriers already carry it, - // or the replacement re-burns the same courier slots. - if let existing = queue.firstIndex(where: { $0.messageID == message.messageID }) { - message.depositedCourierKeys.formUnion(queue[existing].depositedCourierKeys) - queue.remove(at: existing) - } - queue.append(message) - - // Enforce per-peer size limit with FIFO eviction - if queue.count > Self.maxMessagesPerPeer { - let evicted = queue.removeFirst() - SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session) - dropMessage(evicted.messageID, for: peerID) - } - outbox[peerID] = queue - metrics?.record(.outboxQueued) - persistOutbox() - } - - @discardableResult - private func removeQueuedMessage(_ messageID: String, for peerID: PeerID) -> Bool { - guard let queue = outbox[peerID], - let index = queue.firstIndex(where: { $0.messageID == messageID }) else { - return false - } - var updated = queue - updated.remove(at: index) - outbox[peerID] = updated.isEmpty ? nil : updated - return true - } - - @discardableResult - private func incrementSendAttemptsIfQueued(_ messageID: String, for peerID: PeerID) -> Bool { - guard var queue = outbox[peerID], - let index = queue.firstIndex(where: { $0.messageID == messageID }) else { - // A synchronous delivery/read ack may have cleared the retained - // copy while `sendPrivateMessage` was on the stack. Never - // resurrect it from the flush snapshot. - return false - } - queue[index].sendAttempts += 1 - outbox[peerID] = queue - return true - } - - private func dropMessage(_ messageID: String, for peerID: PeerID) { - secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) - metrics?.record(.outboxDropped) - onMessageDropped?(messageID, peerID) - } - - private func persistOutbox() { - outboxStore?.save(outbox) - } - - /// A cold BLE restoration can launch before protected files are readable. - /// The store initially returns an empty snapshot in that case, then calls - /// back after first unlock. Merge by message ID instead of replacing work - /// accepted during the locked wake, persist the union, and immediately - /// resume normal delivery attempts. - private func mergeRecoveredOutbox(_ recovered: MessageOutboxStore.Snapshot) { - for (peerID, recoveredQueue) in recovered { - var queue = outbox[peerID] ?? [] - for var recoveredMessage in recoveredQueue { - if let index = queue.firstIndex(where: { $0.messageID == recoveredMessage.messageID }) { - recoveredMessage.sendAttempts = max(recoveredMessage.sendAttempts, queue[index].sendAttempts) - recoveredMessage.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys) - queue[index] = recoveredMessage - } else { - queue.append(recoveredMessage) - } - } - queue.sort { $0.timestamp < $1.timestamp } - if queue.count > Self.maxMessagesPerPeer { - let overflow = queue.count - Self.maxMessagesPerPeer - for dropped in queue.prefix(overflow) { - dropMessage(dropped.messageID, for: peerID) - } - queue.removeFirst(overflow) - } - outbox[peerID] = queue - } - persistOutbox() - flushAllOutbox() - retryBridgeCourierDeposits() - } - - /// Panic wipe: forget queued mail on disk and in memory. - func wipeOutbox() { - outbox.removeAll() - secureTransmissions.removeAll() - outboxStore?.wipe() - } - - /// Returns true only when the receipt was handed to a reachable transport. - /// A false result means it was dropped (no route) and must NOT be recorded - /// as sent, or the sender's message would stay unread forever — the receipt - /// is retried on the next read scan (chat open / foreground / reconnect). - @discardableResult - func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session) transport.sendReadReceipt(receipt, to: peerID) - return true } else if !transports.isEmpty { - SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))… — leaving unsent for retry", category: .session) + SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session) + } + } + + func sendDeliveryAck(_ messageID: String, to peerID: PeerID) { + if let transport = reachableTransport(for: peerID) { + SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + transport.sendDeliveryAck(for: messageID, to: peerID) } - return false } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { @@ -581,199 +105,34 @@ final class MessageRouter { } } - /// Retries only messages that the router previously transmitted through - /// an already-established secure session and that still await an ack. - /// - /// A peer restart can leave that local session looking usable until the - /// replacement handshake arrives; the first ciphertext is then - /// undecryptable remotely. Normal pre-handshake sends are intentionally - /// absent from `secureTransmissions` because BLE already queues - /// and drains them when authentication completes. - func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) { - typealias Candidate = ( - peerID: PeerID, - message: QueuedMessage, - aliasOrder: Int, - queueOrder: Int - ) - - var visitedPeerIDs = Set() - var retriedMessageIDs = Set() - var outboxChanged = false - let currentDate = now() - var candidates: [Candidate] = [] - - for (aliasOrder, peerID) in peerIDAliases.enumerated() { - guard visitedPeerIDs.insert(peerID).inserted else { continue } - guard let queued = outbox[peerID], !queued.isEmpty, - let transport = connectedTransport(for: peerID), - transport.canDeliverSecurely(to: peerID) else { - continue - } - - for (queueOrder, message) in queued.enumerated() { - let key = PeerMessageKey(peerID: peerID, messageID: message.messageID) - guard secureTransmissions.contains(key) else { continue } - candidates.append(( - peerID: peerID, - message: message, - aliasOrder: aliasOrder, - queueOrder: queueOrder - )) - } - } - - // Conversation migration can leave retained messages split across the - // ephemeral and stable outbox keys. Merge both queues into one - // chronological stream so callback alias order cannot send newer mail - // ahead of older mail. - candidates.sort { lhs, rhs in - if lhs.message.timestamp != rhs.message.timestamp { - return lhs.message.timestamp < rhs.message.timestamp - } - if lhs.aliasOrder != rhs.aliasOrder { - return lhs.aliasOrder < rhs.aliasOrder - } - if lhs.queueOrder != rhs.queueOrder { - return lhs.queueOrder < rhs.queueOrder - } - return lhs.message.messageID < rhs.message.messageID - } - - for candidate in candidates { - let peerID = candidate.peerID - let message = candidate.message - let key = PeerMessageKey(peerID: peerID, messageID: message.messageID) - guard retriedMessageIDs.insert(message.messageID).inserted, - secureTransmissions.contains(key), - queuedMessage(message.messageID, for: peerID) != nil, - let transport = connectedTransport(for: peerID), - transport.canDeliverSecurely(to: peerID) else { - continue - } - - if currentDate.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { - if removeQueuedMessage(message.messageID, for: peerID) { - dropMessage(message.messageID, for: peerID) - outboxChanged = true - } - continue - } - - guard message.sendAttempts < Self.maxSendAttempts else { - SecureLogger.warning( - "📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) secure attempts", - category: .session - ) - if removeQueuedMessage(message.messageID, for: peerID) { - dropMessage(message.messageID, for: peerID) - outboxChanged = true - } - continue - } - - SecureLogger.debug( - "Auth retry -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", - category: .session - ) - transport.sendPrivateMessage( - message.content, - to: peerID, - recipientNickname: message.nickname, - messageID: message.messageID - ) - metrics?.record(.outboxResent) - outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged - } - - if outboxChanged { - persistOutbox() - } - } + // MARK: - Outbox Management func flushOutbox(for peerID: PeerID) { guard let queued = outbox[peerID], !queued.isEmpty else { return } SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) - let now = now() - var outboxChanged = false + let now = Date() + var remaining: [QueuedMessage] = [] for message in queued { - // A synchronous ack from an earlier send in this flush may have - // removed an entry from the live outbox. The snapshot is only an - // iteration order; never use it to recreate removed messages. - guard queuedMessage(message.messageID, for: peerID) != nil else { continue } - // Skip expired messages (TTL exceeded) if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session) - if removeQueuedMessage(message.messageID, for: peerID) { - dropMessage(message.messageID, for: peerID) - outboxChanged = true - } continue } - if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) { - // A secure session is meaningful enough to retry, but not - // proof that this particular ciphertext reached the peer: the - // remote app may have restarted while our old session still - // looked established. Retain until an ack, while bounding - // actual secure transmissions for peers that never ack. - guard message.sendAttempts < Self.maxSendAttempts else { - SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) - if removeQueuedMessage(message.messageID, for: peerID) { - dropMessage(message.messageID, for: peerID) - outboxChanged = true - } - continue - } - SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) - secureTransmissions.insert( - PeerMessageKey(peerID: peerID, messageID: message.messageID) - ) + if let transport = reachableTransport(for: peerID) { + SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) - metrics?.record(.outboxResent) - outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged - } else if let transport = connectedTransport(for: peerID) { - // "Connected" without a secure session — possibly a stolen - // binding from a replayed announce: send (a genuine link - // finishes the handshake and delivers) but keep retaining - // until an ack clears it. These flushes do NOT count toward - // the attempt-cap drop: the message was transmitted over a - // live link, so a peer whose handshake stalls across - // reconnect flapping must not burn through the cap and lose - // the store-and-forward copy this retention exists to - // preserve. Retention stays bounded by the 24h outbox TTL - // and the per-peer FIFO cap. - SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) - secureTransmissions.remove( - PeerMessageKey(peerID: peerID, messageID: message.messageID) - ) - transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) - metrics?.record(.outboxResent) - } else if let transport = reachableTransport(for: peerID) { - // Reachability without a connection is a freshness heuristic, - // so the send can silently go nowhere: send but keep retaining - // until an ack clears it, bounded by attempt count for peers - // that never ack. - guard message.sendAttempts < Self.maxSendAttempts else { - SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) - if removeQueuedMessage(message.messageID, for: peerID) { - dropMessage(message.messageID, for: peerID) - outboxChanged = true - } - continue - } - SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) - transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) - metrics?.record(.outboxResent) - outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged + } else { + remaining.append(message) } } - if outboxChanged { - persistOutbox() + if remaining.isEmpty { + outbox.removeValue(forKey: peerID) + } else { + outbox[peerID] = remaining } } @@ -783,26 +142,12 @@ final class MessageRouter { /// Periodically clean up expired messages from all outboxes func cleanupExpiredMessages() { - let now = now() - var droppedAny = false + let now = Date() for peerID in Array(outbox.keys) { - var expiredMessageIDs: [String] = [] - outbox[peerID]?.removeAll { message in - guard now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds else { return false } - expiredMessageIDs.append(message.messageID) - return true - } + outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds } if outbox[peerID]?.isEmpty == true { outbox.removeValue(forKey: peerID) } - for messageID in expiredMessageIDs { - SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - dropMessage(messageID, for: peerID) - droppedAny = true - } - } - if droppedAny { - persistOutbox() } } } diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index acc1634b..a9795024 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -25,42 +25,26 @@ extension NostrRelayManager: NetworkActivationRelayControlling {} extension TorURLSession: NetworkActivationProxyControlling {} /// Coordinates when the app is allowed to start Tor and connect to Nostr relays. -/// Policy: permit start when (location permissions are authorized OR there -/// exists at least one mutual favorite) AND the device has a usable network -/// path. When there is provably no network at all we do not bootstrap Tor or -/// spin relay reconnects — that only wastes battery on a mesh-only/offline -/// device. BLE mesh is entirely independent of this gate. +/// Policy: permit start when either location permissions are authorized OR +/// there exists at least one mutual favorite. Otherwise, do not start. @MainActor final class NetworkActivationService: ObservableObject { static let shared = NetworkActivationService() @Published private(set) var activationAllowed: Bool = false @Published private(set) var userTorEnabled: Bool = true - /// Coarse, debounced network reachability. `false` only when the OS reports - /// no usable interface at all. Surfaced for UI ("offline" vs "connecting"). - @Published private(set) var isNetworkReachable: Bool = true private var cancellables = Set() private var started = false - /// Storage key for the Tor preference. Exposed as a `nonisolated` constant - /// so off-main callers can read the preference without hopping to the main - /// actor; see `persistedTorPreference(in:)`. - nonisolated static let torPreferenceKey = "networkActivationService.userTorEnabled" + private let torPreferenceKey = "networkActivationService.userTorEnabled" private var torAutoStartDesired: Bool = false private let storage: UserDefaults private let locationPermissionPublisher: AnyPublisher private let mutualFavoritesPublisher: AnyPublisher, Never> - private let selectedChannelPublisher: AnyPublisher private let permissionProvider: () -> LocationChannelManager.PermissionState private let mutualFavoritesProvider: () -> Set - private let locationChannelSelectedProvider: () -> Bool - private let reachabilityMonitor: NetworkReachabilityMonitoring private let torController: NetworkActivationTorControlling - // Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared - // (via its live dependencies), so capturing NostrRelayManager.shared here would - // re-enter whichever singleton's dispatch_once started first and trap at launch. - private lazy var relayController: NetworkActivationRelayControlling = relayControllerProvider() - private let relayControllerProvider: () -> NetworkActivationRelayControlling + private let relayController: NetworkActivationRelayControlling private let proxyController: NetworkActivationProxyControlling private let notificationCenter: NotificationCenter @@ -68,16 +52,10 @@ final class NetworkActivationService: ObservableObject { storage = .standard locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher() mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher() - selectedChannelPublisher = LocationChannelManager.shared.$selectedChannel.eraseToAnyPublisher() permissionProvider = { LocationChannelManager.shared.permissionState } mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } - locationChannelSelectedProvider = { - if case .location = LocationChannelManager.shared.selectedChannel { return true } - return false - } - reachabilityMonitor = NWPathReachabilityMonitor() torController = TorManager.shared - relayControllerProvider = { NostrRelayManager.shared } + relayController = NostrRelayManager.shared proxyController = TorURLSession.shared notificationCenter = .default } @@ -88,9 +66,6 @@ final class NetworkActivationService: ObservableObject { mutualFavoritesPublisher: AnyPublisher, Never>, permissionProvider: @escaping () -> LocationChannelManager.PermissionState, mutualFavoritesProvider: @escaping () -> Set, - selectedChannelPublisher: AnyPublisher = Empty().eraseToAnyPublisher(), - locationChannelSelectedProvider: @escaping () -> Bool = { false }, - reachabilityMonitor: NetworkReachabilityMonitoring, torController: NetworkActivationTorControlling, relayController: NetworkActivationRelayControlling, proxyController: NetworkActivationProxyControlling, @@ -101,45 +76,24 @@ final class NetworkActivationService: ObservableObject { self.mutualFavoritesPublisher = mutualFavoritesPublisher self.permissionProvider = permissionProvider self.mutualFavoritesProvider = mutualFavoritesProvider - self.selectedChannelPublisher = selectedChannelPublisher - self.locationChannelSelectedProvider = locationChannelSelectedProvider - self.reachabilityMonitor = reachabilityMonitor self.torController = torController - self.relayControllerProvider = { relayController } + self.relayController = relayController self.proxyController = proxyController self.notificationCenter = notificationCenter } - /// Whether Tor routing is switched on, read without main-actor isolation. - /// - /// This is the *preference*, not live Tor readiness. Background work that - /// must decide whether waiting for Tor is even meaningful needs the - /// preference: when someone has deliberately turned Tor off, requests are - /// intended to go direct, so waiting on a client that has been shut down - /// would only burn the bootstrap timeout. When the preference is on, callers - /// must still wait for readiness rather than falling back to clearnet. - nonisolated static func persistedTorPreference( - in defaults: UserDefaults = .standard - ) -> Bool { - defaults.object(forKey: torPreferenceKey) as? Bool ?? true - } - func start() { guard !started else { return } started = true - if let stored = storage.object(forKey: Self.torPreferenceKey) as? Bool { + if let stored = storage.object(forKey: torPreferenceKey) as? Bool { userTorEnabled = stored } else { userTorEnabled = true } - // Begin (idempotent) reachability monitoring and seed initial state. - reachabilityMonitor.start() - isNetworkReachable = reachabilityMonitor.isReachable - // Initial compute - let allowed = effectiveAllowed() + let allowed = basePolicyAllowed() activationAllowed = allowed torAutoStartDesired = allowed && userTorEnabled torController.setAutoStartAllowed(torAutoStartDesired) @@ -165,50 +119,12 @@ final class NetworkActivationService: ObservableObject { self?.reevaluate() } .store(in: &cancellables) - - // React to entering or leaving a location channel, which can flip the - // gate on its own for someone with no location permission and no - // mutual favorites. - selectedChannelPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.reevaluate() - } - .store(in: &cancellables) - - // React to network reachability changes (debounced, unsatisfied-only). - reachabilityMonitor.reachabilityPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] reachable in - guard let self else { return } - guard reachable != self.isNetworkReachable else { return } - self.isNetworkReachable = reachable - SecureLogger.info( - "NetworkActivationService: isNetworkReachable -> \(reachable)", - category: .session - ) - self.reevaluate() - } - .store(in: &cancellables) - } - - /// Stops all internet-facing work at the synchronous panic boundary. - /// `start()` may be called again only after the full wipe commits. - func stopForPanic() { - cancellables.removeAll() - started = false - reachabilityMonitor.stop() - activationAllowed = false - torAutoStartDesired = false - relayController.disconnect() - torController.setAutoStartAllowed(false) - applyTorState(torDesired: false) } func setUserTorEnabled(_ enabled: Bool) { guard enabled != userTorEnabled else { return } userTorEnabled = enabled - storage.set(enabled, forKey: Self.torPreferenceKey) + storage.set(enabled, forKey: torPreferenceKey) notificationCenter.post( name: .TorUserPreferenceChanged, object: nil, @@ -218,8 +134,7 @@ final class NetworkActivationService: ObservableObject { } private func reevaluate() { - guard started else { return } - let allowed = effectiveAllowed() + let allowed = basePolicyAllowed() let torDesired = allowed && userTorEnabled let statusChanged = allowed != activationAllowed let torChanged = torDesired != torAutoStartDesired @@ -244,25 +159,10 @@ final class NetworkActivationService: ObservableObject { } } - /// Base policy: who is allowed to use the network at all (permission or a - /// mutual favorite), ignoring current link state. private func basePolicyAllowed() -> Bool { let permOK = permissionProvider() == .authorized let hasMutual = !mutualFavoritesProvider().isEmpty - // Being in a location channel counts too. Teleporting into a geohash - // needs no location permission, so someone who denied location and has - // no mutual favorites could sit in a channel that never connects: the - // gate suppressed Tor and the relays, and nothing said why. The channel - // is itself an internet feature in active use, which is exactly what - // this gate is meant to detect. - let inLocationChannel = locationChannelSelectedProvider() - return permOK || hasMutual || inLocationChannel - } - - /// Effective gate: base policy AND a usable network path. When there is - /// provably no network, Tor bootstrap and relay reconnects are suppressed. - private func effectiveAllowed() -> Bool { - basePolicyAllowed() && reachabilityMonitor.isReachable + return permOK || hasMutual } private func applyTorState(torDesired: Bool) { diff --git a/bitchat/Services/NetworkReachabilityMonitor.swift b/bitchat/Services/NetworkReachabilityMonitor.swift deleted file mode 100644 index a5ca134e..00000000 --- a/bitchat/Services/NetworkReachabilityMonitor.swift +++ /dev/null @@ -1,181 +0,0 @@ -import Foundation -import Combine -import BitLogger -#if canImport(Network) -import Network -#endif - -/// Coarse, conservative network-reachability signal used to gate Tor bootstrap -/// and Nostr relay connections. -/// -/// Policy (deliberately conservative): -/// - Reports `false` only when the OS says there is *no* usable interface at -/// all (`NWPath.Status.unsatisfied`). A flaky-but-present link stays -/// `true` because Tor tolerates intermittent connectivity, and tearing down -/// on the first hiccup would cost more battery/latency than it saves. -/// - Transitions are debounced (see `ReachabilityDebounce`) so path flapping -/// does not thrash Tor/relay startup. -/// - Starts optimistic (`true`) so nothing is ever suppressed before the first -/// path evaluation arrives. -/// -/// BLE mesh must never consult this monitor — the mesh works fully offline. -@MainActor -protocol NetworkReachabilityMonitoring: AnyObject { - /// Current debounced coarse reachability. - var isReachable: Bool { get } - /// Emits the debounced reachability whenever it changes (main-actor). - var reachabilityPublisher: AnyPublisher { get } - /// Begin monitoring. Idempotent. - func start() - /// Stop monitoring and discard pending debounce work. Idempotent. - func stop() -} - -/// Pure debounce/decision logic for reachability, split out so it can be -/// unit-tested without the Network framework or real timers. -/// -/// A candidate state only becomes the committed state once it has been stable -/// (uninterrupted) for `interval`. Any observation matching the committed state -/// cancels a pending opposite change, which is what makes flapping a no-op. -struct ReachabilityDebounce { - let interval: TimeInterval - private(set) var committed: Bool - private var pending: (value: Bool, since: Date)? - - init(interval: TimeInterval, initial: Bool) { - self.interval = interval - self.committed = initial - } - - /// Whether a change is currently waiting out the debounce window. - var hasPendingChange: Bool { pending != nil } - - /// Time left before the pending change may commit, or `nil` when nothing - /// is pending. Lets callers schedule a flush at the true deadline instead - /// of a full interval from "now" (duplicate observations must not push - /// the deadline out). - func pendingRemaining(at now: Date) -> TimeInterval? { - guard let pending else { return nil } - return max(0, interval - now.timeIntervalSince(pending.since)) - } - - /// Feed a raw observation. Returns the new committed value if it changed, - /// otherwise `nil`. - mutating func observe(reachable: Bool, at now: Date) -> Bool? { - if reachable == committed { - // Already in this state — cancel any pending opposite change. - pending = nil - return nil - } - // Differs from committed: (re)arm the pending change, preserving the - // timestamp if we're already waiting on this same target value. - if pending?.value != reachable { - pending = (reachable, now) - } - return commitIfAged(at: now) - } - - /// Called from a timer to commit a pending change once it has aged past - /// `interval`. Returns the new committed value if it changed, else `nil`. - mutating func flush(at now: Date) -> Bool? { - commitIfAged(at: now) - } - - private mutating func commitIfAged(at now: Date) -> Bool? { - guard let pending else { return nil } - guard now.timeIntervalSince(pending.since) >= interval else { return nil } - committed = pending.value - self.pending = nil - return committed - } -} - -/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the -/// background path callback hops here before touching the debounce. -@MainActor -final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring { - private let subject: CurrentValueSubject - private var debounce: ReachabilityDebounce - private var flushWorkItem: DispatchWorkItem? - private var started = false - private let now: () -> Date - - #if canImport(Network) - private var monitor: NWPathMonitor? - private let monitorQueue = DispatchQueue(label: "chat.bitchat.reachability") - #endif - - init(debounceInterval: TimeInterval = 2.5, now: @escaping () -> Date = Date.init) { - self.now = now - self.debounce = ReachabilityDebounce(interval: debounceInterval, initial: true) - self.subject = CurrentValueSubject(true) - } - - var isReachable: Bool { subject.value } - - var reachabilityPublisher: AnyPublisher { - subject.removeDuplicates().dropFirst().eraseToAnyPublisher() - } - - func start() { - guard !started else { return } - started = true - #if canImport(Network) - let monitor = NWPathMonitor() - self.monitor = monitor - monitor.pathUpdateHandler = { [weak self] path in - // Conservative: only "no interface at all" counts as unreachable. - let reachable = path.status != .unsatisfied - Task { @MainActor in - self?.ingest(reachable: reachable) - } - } - monitor.start(queue: monitorQueue) - #else - // No Network framework: never suppress startup. - #endif - } - - func stop() { - guard started else { return } - started = false - flushWorkItem?.cancel() - flushWorkItem = nil - #if canImport(Network) - monitor?.pathUpdateHandler = nil - monitor?.cancel() - monitor = nil - #endif - } - - /// Feed an observation into the debounce and publish committed changes. - /// Exposed internally so higher layers/tests could drive it if needed. - func ingest(reachable: Bool) { - flushWorkItem?.cancel() - flushWorkItem = nil - if let committed = debounce.observe(reachable: reachable, at: now()) { - publish(committed) - } else if debounce.hasPendingChange { - scheduleFlush() - } - } - - private func scheduleFlush() { - let work = DispatchWorkItem { [weak self] in - guard let self else { return } - if let committed = self.debounce.flush(at: self.now()) { - self.publish(committed) - } - } - flushWorkItem = work - // Fire at the pending change's real deadline (pending.since + interval): - // duplicate path updates re-enter here and must not restart the window. - let delay = debounce.pendingRemaining(at: now()) ?? debounce.interval - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) - } - - private func publish(_ reachable: Bool) { - SecureLogger.info("NWPathReachabilityMonitor: network reachable -> \(reachable)", category: .session) - subject.send(reachable) - } -} diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 5ee7608d..70e43c0d 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -97,7 +97,7 @@ enum EncryptionStatus: Equatable { case noiseHandshaking // Currently establishing case noiseSecured // Established but not verified case noiseVerified // Established and verified - + var icon: String? { // Made optional to hide icon when no handshake switch self { case .none: @@ -153,11 +153,11 @@ enum EncryptionStatus: Equatable { final class NoiseEncryptionService { // Static identity key (persistent across sessions) private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey - let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey + public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey // Ed25519 signing key (persistent across sessions) private let signingKey: Curve25519.Signing.PrivateKey - let signingPublicKey: Curve25519.Signing.PublicKey + public let signingPublicKey: Curve25519.Signing.PublicKey // Session manager private let sessionManager: NoiseSessionManager @@ -165,16 +165,13 @@ final class NoiseEncryptionService { // Peer fingerprints (SHA256 hash of static public key) private var peerFingerprints: [PeerID: String] = [:] private var fingerprintToPeerID: [String: PeerID] = [:] + // Thread safety private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) // Security components private let rateLimiter = NoiseRateLimiter() private let keychain: KeychainManagerProtocol - - // One-time prekeys for forward-secret courier sealing (lazy generation - // inside the store; the batch is minted on first bundle build). - private let localPrekeys: LocalPrekeyStore // Session maintenance private var rekeyTimer: Timer? @@ -182,27 +179,12 @@ final class NoiseEncryptionService { // Callbacks private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication - private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = [] var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake - /// Automatic rekey prepared XX message 1. The transport must claim the - /// exact attempt at its actual BLE handoff; a crossed inbound initiation - /// can invalidate the token before that point. - var onRekeyHandshakeReady: - ((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)? - var onHandshakeRecoveryRequired: - ((_ request: NoiseHandshakeRecoveryRequest) -> Void)? - /// An unauthenticated reconnect attempt failed or timed out and the - /// receive-only rollback session became the active transport again. - /// Transport queues may only be drained for this exact restored - /// generation when the reason is terminal; a restore that owns a pending - /// convergence retry must keep them parked until the retry concludes. - var onSessionRestoredWithGeneration: - ((_ peerID: PeerID, _ generation: UUID, _ reason: NoiseSessionRestoreReason) -> Void)? // Add a handler for peer authentication func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) { - serviceQueue.sync(flags: .barrier) { - onPeerAuthenticatedHandlers.append(handler) + serviceQueue.async(flags: .barrier) { [weak self] in + self?.onPeerAuthenticatedHandlers.append(handler) } } @@ -215,32 +197,9 @@ final class NoiseEncryptionService { } } } - - /// Generation-aware authentication notifications are used by protocols - /// whose state must be bound to one exact Noise transport session. - var onPeerAuthenticatedWithGeneration: ((PeerID, String, UUID) -> Void)? { - get { nil } - set { - guard let handler = newValue else { return } - serviceQueue.sync(flags: .barrier) { - onPeerAuthenticatedWithGenerationHandlers.append(handler) - } - } - } - init( - keychain: KeychainManagerProtocol, - ordinaryHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryHandshakeTimeout, - ordinaryResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, - recentInitiatorCompletionGracePeriod: TimeInterval = - NoiseSecurityConstants.recentInitiatorCompletionGracePeriod, - ordinaryReconnectRollbackCooldown: TimeInterval = - NoiseSecurityConstants.ordinaryReconnectRollbackCooldown - ) { + init(keychain: KeychainManagerProtocol) { self.keychain = keychain - self.localPrekeys = LocalPrekeyStore(keychain: keychain) // BCH-01-009: Load or create static identity key with proper error handling let loadedKey: Curve25519.KeyAgreement.PrivateKey @@ -328,31 +287,11 @@ final class NoiseEncryptionService { self.signingPublicKey = signingKey.publicKey // Initialize session manager - self.sessionManager = NoiseSessionManager( - localStaticKey: staticIdentityKey, - keychain: keychain, - ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, - ordinaryResponderHandshakeTimeout: - ordinaryResponderHandshakeTimeout, - recentInitiatorCompletionGracePeriod: - recentInitiatorCompletionGracePeriod, - ordinaryReconnectRollbackCooldown: - ordinaryReconnectRollbackCooldown - ) + self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) // Set up session callbacks - sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in - self?.handleSessionEstablished( - peerID: peerID, - remoteStaticKey: remoteStaticKey, - sessionGeneration: generation - ) - } - sessionManager.onSessionRestored = { [weak self] peerID, generation, reason in - self?.onSessionRestoredWithGeneration?(peerID, generation, reason) - } - sessionManager.onHandshakeRecoveryRequired = { [weak self] request in - self?.onHandshakeRecoveryRequired?(request) + sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in + self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey) } // Start session maintenance timer @@ -430,154 +369,13 @@ final class NoiseEncryptionService { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? { return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation } - - // MARK: - Courier Envelopes (one-way Noise X) - - /// Domain separation for courier envelopes so X-pattern transcripts can - /// never be confused with interactive XX handshakes. - private static let courierPrologue = Data("bitchat-courier-v1".utf8) - - /// Encrypt a payload to a peer's known static key without an interactive - /// handshake (Noise X pattern). Used for store-and-forward envelopes - /// carried by couriers while the recipient is offline. - /// - Warning: One-way messages have no forward secrecy: a later compromise - /// of the recipient's static key exposes envelopes captured in transit. - /// Use established sessions whenever the peer is reachable. - func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data { - let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey) - let handshake = NoiseHandshakeState( - role: .initiator, - pattern: .X, - keychain: keychain, - localStaticKey: staticIdentityKey, - remoteStaticKey: remoteKey, - prologue: Self.courierPrologue - ) - return try handshake.writeMessage(payload: payload) - } - - /// Decrypt a courier envelope addressed to our static key. Returns the - /// payload and the sender's authenticated static public key (the `ss` - /// DH in the X pattern binds the sender's identity to the ciphertext). - func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) { - let handshake = NoiseHandshakeState( - role: .responder, - pattern: .X, - keychain: keychain, - localStaticKey: staticIdentityKey, - prologue: Self.courierPrologue - ) - let payload = try handshake.readMessage(envelopeCiphertext) - guard let senderKey = handshake.getRemoteStaticPublicKey() else { - throw NoiseError.missingKeys - } - return (payload: payload, senderStaticKey: senderKey.rawRepresentation) - } - - // MARK: - One-Time Prekey Envelopes (forward-secret Noise X) - - /// Domain separation for prekey-sealed envelopes: distinct from both the - /// interactive XX transcripts and static-sealed courier envelopes, and - /// bound to the specific prekey ID so a ciphertext cannot be replayed - /// against a different prekey. - private static let prekeyProloguePrefix = Data("bitchat-prekey-v1".utf8) - - private static func prekeyPrologue(for prekeyID: UInt32) -> Data { - var prologue = prekeyProloguePrefix - var big = prekeyID.bigEndian - withUnsafeBytes(of: &big) { prologue.append(contentsOf: $0) } - return prologue - } - - /// Encrypt a payload to one of the recipient's gossiped one-time prekeys - /// (Noise X where the responder static is the prekey, not the identity - /// key). Unlike `sealCourierPayload`, this is forward secret: once the - /// recipient consumes the prekey and its grace window lapses, the private - /// key is deleted and captured ciphertext becomes undecryptable even if - /// the recipient's identity key is later compromised. The initiator's - /// static still rides inside (encrypted), so the recipient authenticates - /// the sender exactly as with static-sealed envelopes. - func sealPrekeyPayload(_ payload: Data, recipientPrekey: PrekeyBundle.Prekey) throws -> Data { - let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientPrekey.publicKey) - let handshake = NoiseHandshakeState( - role: .initiator, - pattern: .X, - keychain: keychain, - localStaticKey: staticIdentityKey, - remoteStaticKey: remoteKey, - prologue: Self.prekeyPrologue(for: recipientPrekey.id) - ) - return try handshake.writeMessage(payload: payload) - } - - /// Decrypt an envelope sealed to one of our one-time prekeys. On success - /// the prekey is marked consumed (its private key survives a 48h grace - /// window for spray-and-wait redeliveries, then is deleted for good). - /// Returns the payload, the sender's authenticated static key (same - /// contract as `openCourierPayload`), and whether this open actually - /// retired the prekey — false for a redelivery of already-consumed mail — - /// so the caller can re-gossip the shrunken bundle only when it changed. - func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) { - guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else { - throw NoiseEncryptionError.unknownPrekey - } - let handshake = NoiseHandshakeState( - role: .responder, - pattern: .X, - keychain: keychain, - localStaticKey: prekeyPrivate, - prologue: Self.prekeyPrologue(for: prekeyID) - ) - let payload = try handshake.readMessage(envelopeCiphertext) - guard let senderKey = handshake.getRemoteStaticPublicKey() else { - throw NoiseError.missingKeys - } - let consumedPrekey = localPrekeys.markConsumed(prekeyID) - return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey) - } - - /// Current signed prekey bundle for gossip, minting the initial batch on - /// first use. Nil only when signing fails. - func currentPrekeyBundle() -> PrekeyBundle? { - let (prekeys, generatedAt) = localPrekeys.currentBundlePrekeys() - guard !prekeys.isEmpty else { return nil } - let unsigned = PrekeyBundle( - noiseStaticPublicKey: getStaticPublicKeyData(), - prekeys: prekeys, - generatedAt: generatedAt, - signature: Data(count: PrekeyBundle.signatureLength) - ) - guard let signature = signData(unsigned.signableBytes()) else { return nil } - return PrekeyBundle( - noiseStaticPublicKey: unsigned.noiseStaticPublicKey, - prekeys: prekeys, - generatedAt: generatedAt, - signature: signature - ) - } - - /// Verify a peer's bundle signature against their announce-bound Ed25519 - /// signing key. - func verifyPrekeyBundleSignature(_ bundle: PrekeyBundle, signingPublicKey: Data) -> Bool { - verifySignature(bundle.signature, for: bundle.signableBytes(), publicKey: signingPublicKey) - } - - /// Prune dead prekeys and top the batch back up when consumption runs it - /// low. Returns true when the published bundle changed and should be - /// re-gossiped. - @discardableResult - func replenishPrekeysIfNeeded() -> Bool { - localPrekeys.replenishIfNeeded() - } - + /// Clear persistent identity (for panic mode) func clearPersistentIdentity() { // Clear from keychain let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey") let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey") SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning) - // One-time prekey privates go with the identity they were bound to. - localPrekeys.wipe() SecureLogger.warning("Panic mode activated - identity cleared", category: .security) // Stop rekey timer stopRekeyTimer() @@ -630,7 +428,7 @@ final class NoiseEncryptionService { private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data { var out = Data() // context - let context = Data("bitchat-announce-v1".utf8) + let context = "bitchat-announce-v1".data(using: .utf8) ?? Data() out.append(UInt8(min(context.count, 255))) out.append(context.prefix(255)) // peerID (expect 8 bytes; pad/truncate to 8 for canonicalization) @@ -646,7 +444,7 @@ final class NoiseEncryptionService { out.append(ed32) if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) } // nickname length + bytes - let nickData = Data(nickname.utf8) + let nickData = nickname.data(using: .utf8) ?? Data() out.append(UInt8(min(nickData.count, 255))) out.append(nickData.prefix(255)) // timestamp @@ -663,7 +461,7 @@ final class NoiseEncryptionService { guard let packetData = packet.toBinaryDataForSigning() else { return nil } - + // Sign with the noise private key (converted to Ed25519 for signing) guard let signature = signData(packetData) else { return nil @@ -717,105 +515,9 @@ final class NoiseEncryptionService { let handshakeData = try sessionManager.initiateHandshake(with: peerID) return handshakeData } - - /// Atomically admits and prepares one initial ordinary handshake. Returns - /// nil when another discovery callback already created a session. - func initiateHandshakeIfNeeded( - with peerID: PeerID, - retryOnTimeout: Bool = false - ) throws -> NoiseHandshakeInitiation? { - guard peerID.isValid else { - SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) - throw NoiseSecurityError.invalidPeerID - } - - guard let initiation = try sessionManager.initiateHandshakeIfAbsent( - with: peerID, - notifyOnTimeout: retryOnTimeout, - authorize: { [rateLimiter] in - guard rateLimiter.allowHandshake(from: peerID) else { - SecureLogger.warning( - .authenticationFailed(peerID: "Rate limited: \(peerID)") - ) - throw NoiseSecurityError.rateLimitExceeded - } - } - ) else { - return nil - } - SecureLogger.info(.handshakeStarted(peerID: peerID.id)) - return initiation - } - - /// Atomically prepares an ordinary reconnect for a peer whose cached - /// transport belongs to an earlier physical link. Failed authorization or - /// handshake setup preserves the established session. - func initiateReconnectHandshake( - with peerID: PeerID, - retryOnTimeout: Bool = false - ) throws -> NoiseHandshakeInitiation { - guard peerID.isValid else { - SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) - throw NoiseSecurityError.invalidPeerID - } - - return try sessionManager.initiateReconnectHandshake( - with: peerID, - notifyOnTimeout: retryOnTimeout, - authorize: { [rateLimiter] in - guard rateLimiter.allowHandshake(from: peerID) else { - SecureLogger.warning( - .authenticationFailed(peerID: "Rate limited: \(peerID)") - ) - throw NoiseSecurityError.rateLimitExceeded - } - } - ) - } - - func prepareHandshakeRecovery( - _ request: NoiseHandshakeRecoveryRequest - ) throws -> NoiseHandshakeRecoveryPreparation? { - try sessionManager.prepareHandshakeRecovery( - request, - authorizeAttempt: { [rateLimiter] in - guard rateLimiter.allowHandshake(from: request.peerID) else { - SecureLogger.warning( - .authenticationFailed( - peerID: "Rate limited: \(request.peerID)" - ) - ) - throw NoiseSecurityError.rateLimitExceeded - } - } - ) - } - - func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) { - sessionManager.cancelHandshakeRecovery(request) - } - - func claimHandshakeInitiation( - _ initiation: NoiseHandshakeInitiation, - for peerID: PeerID - ) -> Data? { - sessionManager.claimHandshakeInitiation(initiation, for: peerID) - } /// Process an incoming handshake message func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { - try processHandshakeMessageWithResult( - from: peerID, - message: message - ).response - } - - /// Process an incoming handshake message and report whether the exact - /// session that consumed it completed authenticated establishment. - func processHandshakeMessageWithResult( - from peerID: PeerID, - message: Data - ) throws -> NoiseHandshakeProcessingResult { // Validate peer ID guard peerID.isValid else { @@ -837,14 +539,11 @@ final class NoiseEncryptionService { // For handshakes, we process the raw data directly without NoiseMessage wrapper // The Noise protocol handles its own message format - let result = try sessionManager.handleIncomingHandshakeWithResult( - from: peerID, - message: message - ) + let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message) // Return raw response without wrapper - return result + return responsePayload } /// Check if we have an established session with a peer @@ -856,13 +555,6 @@ final class NoiseEncryptionService { func hasSession(with peerID: PeerID) -> Bool { return sessionManager.getSession(for: peerID) != nil } - - /// True while an inbound ordinary XX responder is waiting for message 3. - /// A small amount of immediately-following ciphertext may arrive first - /// over BLE and must be retried only after responder promotion. - func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool { - sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID) - } // MARK: - Encryption/Decryption @@ -887,87 +579,25 @@ final class NoiseEncryptionService { return try sessionManager.encrypt(data, for: peerID) } - - /// Encrypts a finalized private-media packet. Ordinary Noise application - /// messages retain the 64 KiB ceiling; this purpose-specific path permits - /// the bounded `BitchatFilePacket` envelope and refuses every other typed - /// payload so the larger allocation budget cannot become a generic bypass. - func encryptPrivateFilePayload( - _ data: Data, - for peerID: PeerID, - sessionGeneration: UUID? = nil - ) throws -> Data { - guard NoisePayloadType.isPrivateFile(rawValue: data.first), - NoiseSecurityValidator.validatePrivateFileMessageSize(data) else { + + /// Decrypt data from a specific peer + func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { + // Validate message size + guard NoiseSecurityValidator.validateMessageSize(data) else { throw NoiseSecurityError.messageTooLarge } - + + // Check rate limit guard rateLimiter.allowMessage(from: peerID) else { throw NoiseSecurityError.rateLimitExceeded } - + + // Check if we have an established session guard hasEstablishedSession(with: peerID) else { - onHandshakeRequired?(peerID) - throw NoiseEncryptionError.handshakeRequired - } - - // `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed - // nonce/tag overhead, so the result is bounded without a second copy. - if let sessionGeneration { - return try sessionManager.encrypt( - data, - for: peerID, - expectedSessionGeneration: sessionGeneration - ) - } - return try sessionManager.encrypt(data, for: peerID) - } - - /// Decrypt data from a specific peer - func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { - try decryptWithSessionGeneration(data, from: peerID).plaintext - } - - func decryptWithSessionGeneration( - _ data: Data, - from peerID: PeerID, - establishedGenerationIsReady: (UUID) -> Bool = { _ in true } - ) throws -> (plaintext: Data, sessionGeneration: UUID) { - // Standard transport ciphertext has 20 bytes of nonce/tag overhead. - // A larger ciphertext is admitted only up to the framed-file ceiling; - // after authenticated decryption it must prove it is `.privateFile`. - let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data) - let isAdmittedCiphertext = isStandardCiphertext - || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) - - // A quarantined transport is deliberately unavailable for outbound - // state, but remains receive-only until the responder proves identity - // or the bounded rollback restores it. - guard sessionManager.hasReceiveSession(for: peerID) else { throw NoiseEncryptionError.sessionNotEstablished } - let result = try sessionManager.decryptWithSessionGeneration( - data, - from: peerID, - establishedGenerationIsReady: - establishedGenerationIsReady, - authorizeDecrypt: { [rateLimiter] in - guard isAdmittedCiphertext else { - throw NoiseSecurityError.messageTooLarge - } - guard rateLimiter.allowMessage(from: peerID) else { - throw NoiseSecurityError.rateLimitExceeded - } - } - ) - if !isStandardCiphertext { - guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first), - NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else { - throw NoiseSecurityError.messageTooLarge - } - } - return result + return try sessionManager.decrypt(data, from: peerID) } // MARK: - Peer Management @@ -979,25 +609,6 @@ final class NoiseEncryptionService { } } - func sessionGeneration(for peerID: PeerID) -> UUID? { - sessionManager.sessionGeneration(for: peerID) - } - - /// Runs `body` while holding a read lease on the exact session generation. - /// Session insertion, replacement, and removal use the same manager - /// barrier, so they cannot interleave with an authenticated-state commit. - func withCurrentSessionGeneration( - for peerID: PeerID, - expected: UUID, - _ body: () -> Result - ) -> Result? { - sessionManager.withCurrentSessionGeneration( - for: peerID, - expected: expected, - body - ) - } - func clearEphemeralStateForPanic() { sessionManager.removeAllSessions() serviceQueue.sync(flags: .barrier) { @@ -1020,36 +631,24 @@ final class NoiseEncryptionService { // MARK: - Private Helpers - private func handleSessionEstablished( - peerID: PeerID, - remoteStaticKey: Curve25519.KeyAgreement.PublicKey, - sessionGeneration: UUID - ) { + private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { // Calculate fingerprint let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint() - // Registering handlers is synchronous, and this barrier snapshots them - // with the fingerprint update. Invoke the snapshot outside the queue: - // parallel Swift Testing workers must not block behind queued callback - // registration or allow a handler to re-enter serviceQueue. - let handlers: ( - generationAware: [(PeerID, String, UUID) -> Void], - legacy: [(PeerID, String) -> Void] - ) = serviceQueue.sync(flags: .barrier) { + // Store fingerprint mapping + serviceQueue.sync(flags: .barrier) { peerFingerprints[peerID] = fingerprint fingerprintToPeerID[fingerprint] = peerID - return (onPeerAuthenticatedWithGenerationHandlers, onPeerAuthenticatedHandlers) } // Log security event SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) - // Notify all handlers about authentication. - handlers.generationAware.forEach { handler in - handler(peerID, fingerprint, sessionGeneration) - } - handlers.legacy.forEach { handler in - handler(peerID, fingerprint) + // Notify all handlers about authentication + serviceQueue.async { [weak self] in + self?.onPeerAuthenticatedHandlers.forEach { handler in + handler(peerID, fingerprint) + } } } @@ -1070,30 +669,19 @@ final class NoiseEncryptionService { let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey() for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey { + + // Attempt to rekey the session do { - try initiateAutomaticRekey(for: peerID) + try sessionManager.initiateRekey(for: peerID) + SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) + + // Signal that handshake is needed + onHandshakeRequired?(peerID) } catch { SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session) } } } - - private func initiateAutomaticRekey(for peerID: PeerID) throws { - let initiation = try sessionManager.initiateRekey(for: peerID) - SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) - onRekeyHandshakeReady?(peerID, initiation) - onHandshakeRequired?(peerID) - } - - #if DEBUG - func _test_initiateAutomaticRekey(for peerID: PeerID) throws { - try initiateAutomaticRekey(for: peerID) - } - - func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) { - sessionManager._test_fireSuppressedInitiationRecovery(for: peerID) - } - #endif deinit { stopRekeyTimer() @@ -1181,10 +769,4 @@ struct NoiseMessage: Codable { enum NoiseEncryptionError: Error { case handshakeRequired case sessionNotEstablished - /// Manager keys are established or restored, but BLE has not installed - /// generation-bound transport state. No receive nonce was consumed. - case transportGenerationNotReady - /// Envelope references a prekey ID we don't hold (never ours, already - /// deleted after its grace window, or wiped in a panic). - case unknownPrekey } diff --git a/bitchat/Services/NostrProcessedEventStore.swift b/bitchat/Services/NostrProcessedEventStore.swift deleted file mode 100644 index ee70f0e7..00000000 --- a/bitchat/Services/NostrProcessedEventStore.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// NostrProcessedEventStore.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitLogger -import Foundation - -/// Disk persistence for processed private-envelope event IDs. BitChat -/// randomizes envelope timestamps, so DM subscriptions must look back -/// generously (24h) -/// and relays redeliver the same events on every launch — without a -/// cross-launch record, each relaunch reprocesses old PMs and acks -/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise). -/// -/// Contents are event IDs already visible to every relay, so -/// until-first-unlock file protection is the right at-rest posture — the -/// file must also load during a locked-background restoration relaunch. -/// Wiped on panic via the dedup service's clear paths. -final class NostrProcessedEventStore { - private let fileURL: URL? - // All file access is serialized here: appends are read-modify-write, and - // overlapping debounced flushes would otherwise race and drop IDs. - private let ioQueue = DispatchQueue(label: "chat.bitchat.nostr-processed-events", qos: .utility) - - init(fileURL: URL? = nil) { - self.fileURL = fileURL ?? Self.defaultFileURL() - } - - /// Processed event IDs, oldest first (insertion order). - func load() -> [String] { - ioQueue.sync { loadLocked() } - } - - /// Merge new IDs onto the persisted record, oldest-first, trimming from - /// the front past `cap`. Append-merge (not snapshot-overwrite) so the - /// in-memory cache being cleared transiently (channel switches) can - /// never shrink the on-disk record. - func append(_ newIDs: [String], cap: Int) { - guard !newIDs.isEmpty else { return } - ioQueue.async { [self] in - var merged = loadLocked() - var known = Set(merged) - for id in newIDs where !known.contains(id) { - merged.append(id) - known.insert(id) - } - if merged.count > cap { - merged.removeFirst(merged.count - cap) - } - saveLocked(merged) - } - } - - func wipe() { - ioQueue.async { [self] in - guard let fileURL else { return } - try? FileManager.default.removeItem(at: fileURL) - } - } - - private func loadLocked() -> [String] { - guard let fileURL, - let data = try? Data(contentsOf: fileURL), - let ids = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return ids - } - - private func saveLocked(_ eventIDs: [String]) { - guard let fileURL else { return } - guard !eventIDs.isEmpty else { - try? FileManager.default.removeItem(at: fileURL) - return - } - do { - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let data = try JSONEncoder().encode(eventIDs) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtectionUntilFirstUserAuthentication) - #endif - try data.write(to: fileURL, options: options) - } catch { - SecureLogger.error("Failed to persist processed Nostr events: \(error)", category: .session) - } - } - - private static func defaultFileURL() -> URL? { - guard let base = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) else { return nil } - return base - .appendingPathComponent("nostr", isDirectory: true) - .appendingPathComponent("processed-events.json") - } -} diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 2d1c380b..b822a48b 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -13,42 +13,8 @@ final class NostrTransport: Transport, @unchecked Sendable { let currentIdentity: @MainActor () throws -> NostrIdentity? let registerPendingGiftWrap: @MainActor (String) -> Void let sendEvent: @MainActor (NostrEvent) -> Void - /// Emits whether a relay that carries private messages is up - /// (fail-closed behind Tor). A connected geohash/custom relay alone - /// doesn't count: DM sends target the default relay set and would - /// still queue. - let relayConnectivity: @MainActor () -> AnyPublisher - /// Paces outbound acks. Defaults to an isolated pacer so tests don't - /// serialize behind each other; `live` passes the process-wide one. - let ackPacer: AckPacer + let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void - init( - notificationCenter: NotificationCenter, - loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship], - favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?, - favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?, - currentIdentity: @escaping @MainActor () throws -> NostrIdentity?, - registerPendingGiftWrap: @escaping @MainActor (String) -> Void, - sendEvent: @escaping @MainActor (NostrEvent) -> Void, - scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void, - relayConnectivity: @escaping @MainActor () -> AnyPublisher, - ackPacer: AckPacer? = nil - ) { - self.notificationCenter = notificationCenter - self.loadFavorites = loadFavorites - self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey - self.favoriteStatusForPeerID = favoriteStatusForPeerID - self.currentIdentity = currentIdentity - self.registerPendingGiftWrap = registerPendingGiftWrap - self.sendEvent = sendEvent - self.relayConnectivity = relayConnectivity - // Default pacer drives its throttle through the same injected - // scheduler, so tests that step scheduleAfter manually keep - // control of the ack cadence. - self.ackPacer = ackPacer ?? AckPacer(scheduleAfter: scheduleAfter) - } - - @MainActor static func live(idBridge: NostrIdentityBridge) -> Dependencies { Dependencies( notificationCenter: .default, @@ -60,9 +26,7 @@ final class NostrTransport: Transport, @unchecked Sendable { sendEvent: { NostrRelayManager.shared.sendEvent($0) }, scheduleAfter: { delay, action in DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) - }, - relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }, - ackPacer: NostrTransport.sharedAckPacer + } ) } } @@ -70,81 +34,31 @@ final class NostrTransport: Transport, @unchecked Sendable { // Provide BLE short peer ID for BitChat embedding var senderPeerID = PeerID(str: "") - // Throttle outbound acks — READ receipts and DELIVERED acks, direct and - // geohash — to avoid relay rate limits. Reconnect redelivery produces a - // burst of acks at once: 8 DELIVERED in under a second tripped damus's - // "noting too much" during July 2026 device testing. - private enum QueuedAck { - case readDirect(ReadReceipt, PeerID) - case deliveredDirect(messageID: String, peerID: PeerID) - case deliveredGeohash(messageID: String, recipientHex: String, identity: NostrIdentity) - case readGeohash(messageID: String, recipientHex: String, identity: NostrIdentity) + // Throttle READ receipts to avoid relay rate limits + private struct QueuedRead { + let receipt: ReadReceipt + let peerID: PeerID } - - /// Ack pacing shared across transport instances. Geohash acks are sent - /// through short-lived transports created per ack - /// (`makeGeohashNostrTransport()`), so a per-instance queue would only - /// ever hold one item and never pace a burst (flagged by Codex on - /// #1398). Production wires `sharedAckPacer` via `Dependencies.live`; - /// tests get an isolated instance per `Dependencies` by default. - /// @unchecked Sendable: all mutable state (`pending`, `isSending`) is - /// confined to the serial `queue`; the class is only touched via - /// `enqueue` and the scheduler callback, both of which hop onto it. - final class AckPacer: @unchecked Sendable { - typealias Scheduler = @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void - - private let queue = DispatchQueue(label: "chat.bitchat.nostr-ack-pacer") - private var pending: [() -> Void] = [] - private var isSending = false - private let interval: TimeInterval = TransportConfig.nostrReadAckInterval - private let scheduleAfter: Scheduler - - init(scheduleAfter: @escaping Scheduler = { delay, action in - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: action) - }) { - self.scheduleAfter = scheduleAfter - } - - func enqueue(_ send: @escaping () -> Void) { - queue.async { - self.pending.append(send) - self.processNext() - } - } - - /// Must be called on `queue`. - private func processNext() { - guard !isSending, !pending.isEmpty else { return } - isSending = true - let send = pending.removeFirst() - send() - scheduleAfter(interval) { [weak self] in - guard let self else { return } - self.queue.async { - self.isSending = false - self.processNext() - } - } - } - } - static let sharedAckPacer = AckPacer() + private var readQueue: [QueuedRead] = [] + private var isSendingReadAcks = false + private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval + private let keychain: KeychainManagerProtocol + private let idBridge: NostrIdentityBridge private let dependencies: Dependencies private var favoriteStatusObserver: NSObjectProtocol? // Reachability Cache (thread-safe) private var reachablePeers: Set = [] - // Mirror of the relay manager's connection state, cached here because - // canDeliverPromptly is called synchronously off the main actor. - private var relaysConnected = false - private var relayConnectivityCancellable: AnyCancellable? private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent) @MainActor init( - keychain _: KeychainManagerProtocol, + keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, dependencies: Dependencies? = nil ) { + self.keychain = keychain + self.idBridge = idBridge self.dependencies = dependencies ?? .live(idBridge: idBridge) setupObservers() @@ -158,12 +72,6 @@ final class NostrTransport: Transport, @unchecked Sendable { queue.sync(flags: .barrier) { self.reachablePeers = Set(reachable) } - - relayConnectivityCancellable = self.dependencies.relayConnectivity() - .sink { [weak self] connected in - guard let self else { return } - self.queue.async(flags: .barrier) { self.relaysConnected = connected } - } } deinit { @@ -198,9 +106,11 @@ final class NostrTransport: Transport, @unchecked Sendable { // MARK: - Transport Protocol Conformance weak var delegate: BitchatDelegate? - weak var eventDelegate: TransportEventDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? + var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { + Just([]).eraseToAnyPublisher() + } func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] } var myPeerID: PeerID { senderPeerID } @@ -214,39 +124,34 @@ final class NostrTransport: Transport, @unchecked Sendable { func isPeerConnected(_ peerID: PeerID) -> Bool { false } func isPeerReachable(_ peerID: PeerID) -> Bool { - // Callers address peers by either the short 16-hex ID or the full - // 64-hex noise key (offline favorites), so compare in short form. - let short = peerID.toShort() - return queue.sync { + queue.sync { + // Check if exact match if reachablePeers.contains(peerID) { return true } - return reachablePeers.contains(where: { $0.toShort() == short }) + // Check for short ID match + if peerID.isShort { + return reachablePeers.contains(where: { $0.toShort() == peerID }) + } + return false } } - - func canDeliverPromptly(to peerID: PeerID) -> Bool { - // A known npub makes a peer "reachable", but with no relay - // connection a send only joins the local queue. Answering honestly - // here lets the router hand a sealed copy to a courier in parallel - // instead of waiting for internet that may never come. - isPeerReachable(peerID) && queue.sync { relaysConnected } - } - - func canDeliverSecurely(to peerID: PeerID) -> Bool { - // Nostr has no link bindings to forge; a known recipient key plus a - // connected relay is the strongest delivery signal it has. The router - // already retains + couriers for Nostr sends, so keep that behavior. - canDeliverPromptly(to: peerID) - } - + func peerNickname(peerID: PeerID) -> String? { nil } - func getPeerNicknames() -> [PeerID: String] { [:] } + func getPeerNicknames() -> [PeerID : String] { [:] } func getFingerprint(for peerID: PeerID) -> String? { nil } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func triggerHandshake(with peerID: PeerID) { /* no-op */ } - - // Nostr does not use Noise sessions here; the inert Transport defaults - // for the noise* identity hooks apply. + + // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation + private static var cachedNoiseService: NoiseEncryptionService? + func getNoiseService() -> NoiseEncryptionService { + if let noiseService = Self.cachedNoiseService { + return noiseService + } + let noiseService = NoiseEncryptionService(keychain: keychain) + Self.cachedNoiseService = noiseService + return noiseService + } // Public broadcast not supported over Nostr here func sendMessage(_ content: String, mentions: [String]) { /* no-op */ } @@ -266,14 +171,12 @@ final class NostrTransport: Transport, @unchecked Sendable { } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { - enqueueAck(.readDirect(receipt, peerID)) - } - - /// Enqueue an ack for paced sending. Captures self strongly on purpose: - /// geohash acks ride throwaway transport instances that must stay alive - /// until their ack leaves the queue. - private func enqueueAck(_ ack: QueuedAck) { - dependencies.ackPacer.enqueue { self.sendAckItem(ack) } + // Enqueue and process with throttling to avoid relay rate limits + // Use barrier to synchronize access to readQueue + queue.async(flags: .barrier) { [weak self] in + self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) + self?.processReadQueueIfNeeded() + } } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { @@ -293,7 +196,17 @@ final class NostrTransport: Transport, @unchecked Sendable { func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendDeliveryAck(for messageID: String, to peerID: PeerID) { - enqueueAck(.deliveredDirect(messageID: messageID, peerID: peerID)) + Task { @MainActor in + guard let recipientNpub = resolveRecipientNpub(for: peerID), + let recipientHex = npubToHex(recipientNpub), + let senderIdentity = try? dependencies.currentIdentity() else { return } + SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session) + guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { + SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) + return + } + sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) + } } } @@ -303,11 +216,19 @@ extension NostrTransport { // MARK: Geohash ACK helpers func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - enqueueAck(.deliveredGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity)) + Task { @MainActor in + SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session) + guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } + sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) + } } func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - enqueueAck(.readGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity)) + Task { @MainActor in + SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session) + guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } + sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) + } } // MARK: Geohash DMs (per-geohash identity) @@ -353,42 +274,36 @@ extension NostrTransport { dependencies.sendEvent(event) } + /// Must be called within a barrier on `queue` + private func processReadQueueIfNeeded() { + guard !isSendingReadAcks else { return } + guard !readQueue.isEmpty else { return } + isSendingReadAcks = true + let item = readQueue.removeFirst() + sendReadAckItem(item) + } - /// Sends a single ack item (invoked by the pacer, one per interval) - private func sendAckItem(_ item: QueuedAck) { + /// Sends a single read ack item (called after extraction from queue within barrier) + private func sendReadAckItem(_ item: QueuedRead) { Task { @MainActor in - switch item { - case .readDirect(let receipt, let peerID): - guard let recipientNpub = resolveRecipientNpub(for: peerID), - let recipientHex = npubToHex(recipientNpub), - let senderIdentity = try? dependencies.currentIdentity() else { return } - SecureLogger.debug("NostrTransport: preparing READ ack id=\(receipt.originalMessageID.prefix(8))…", category: .session) - guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: receipt.originalMessageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { - SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) - return - } - sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) + defer { scheduleNextReadAck() } + guard let recipientNpub = resolveRecipientNpub(for: item.peerID), + let recipientHex = npubToHex(recipientNpub), + let senderIdentity = try? dependencies.currentIdentity() else { return } + SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session) + guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { + SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) + return + } + sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) + } + } - case .deliveredDirect(let messageID, let peerID): - guard let recipientNpub = resolveRecipientNpub(for: peerID), - let recipientHex = npubToHex(recipientNpub), - let senderIdentity = try? dependencies.currentIdentity() else { return } - SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session) - guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { - SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) - return - } - sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) - - case .deliveredGeohash(let messageID, let recipientHex, let identity): - SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session) - guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } - sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) - - case .readGeohash(let messageID, let recipientHex, let identity): - SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session) - guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } - sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) + private func scheduleNextReadAck() { + dependencies.scheduleAfter(readAckInterval) { [weak self] in + self?.queue.async(flags: .barrier) { [weak self] in + self?.isSendingReadAcks = false + self?.processReadQueueIfNeeded() } } } diff --git a/bitchat/Services/NotificationPrivacySettings.swift b/bitchat/Services/NotificationPrivacySettings.swift deleted file mode 100644 index da67c30b..00000000 --- a/bitchat/Services/NotificationPrivacySettings.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// NotificationPrivacySettings.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -/// Controls how much a delivered notification says while the device is locked. -/// -/// Notification content is rendered by the system on the lock screen, so it is -/// readable by anyone holding the phone without unlocking it. With previews -/// hidden, alerts still say that something arrived and stay tappable, but the -/// message body, the sender's nickname, and the geohash are withheld until the -/// app is opened. -/// -/// Defaults to hidden: a locked phone lying on a table or taken at a protest -/// should not narrate conversations, and someone who wants previews can say so. -enum NotificationPrivacySettings { - private static let hidePreviewsKey = "notifications.hideMessagePreviews" - - static var hideMessagePreviews: Bool { - get { hideMessagePreviews(in: .standard) } - set { setHideMessagePreviews(newValue, in: .standard) } - } - - /// Store-injecting forms, so tests can assert the default and both settings - /// without touching the shared preferences other tests read. - static func hideMessagePreviews(in defaults: UserDefaults) -> Bool { - defaults.object(forKey: hidePreviewsKey) as? Bool ?? true - } - - static func setHideMessagePreviews(_ hide: Bool, in defaults: UserDefaults) { - defaults.set(hide, forKey: hidePreviewsKey) - } - - /// Panic-wipe hook. Removing the key restores the hidden default, so a - /// wiped device cannot come back louder than a fresh install. - static func reset(in defaults: UserDefaults = .standard) { - defaults.removeObject(forKey: hidePreviewsKey) - } -} diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index e50d4da5..d51efa86 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -26,10 +26,6 @@ protocol NotificationRequestDelivering { func add(_ request: UNNotificationRequest) } -protocol NotificationCategoryRegistering { - func setCategories(_ categories: Set) -} - private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing { private let center: UNUserNotificationCenter @@ -59,18 +55,6 @@ private final class NotificationCenterRequestDelivererAdapter: NotificationReque } } -private final class NotificationCenterCategoryRegistrarAdapter: NotificationCategoryRegistering { - private let center: UNUserNotificationCenter - - init(center: UNUserNotificationCenter) { - self.center = center - } - - func setCategories(_ categories: Set) { - center.setNotificationCategories(categories) - } -} - private struct NoopNotificationAuthorizer: NotificationAuthorizing { func requestAuthorization( options: UNAuthorizationOptions, @@ -84,50 +68,12 @@ private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering { func add(_ request: UNNotificationRequest) {} } -private struct NoopNotificationCategoryRegistrar: NotificationCategoryRegistering { - func setCategories(_ categories: Set) {} -} - final class NotificationService { static let shared = NotificationService() - /// Category for the "bitchatters nearby" notification, carrying the wave quick action. - static let nearbyCategoryID = "chat.bitchat.category.nearby" - static let waveActionID = "chat.bitchat.action.wave" - - /// Copy used when `NotificationPrivacySettings.hideMessagePreviews` is on. - /// These say that something arrived without naming who sent it, quoting it, - /// or disclosing which geohash it came from. - private enum Redacted { - static var directMessageTitle: String { - String(localized: "notification.redacted.dm.title", defaultValue: "🔒 new dm", comment: "Lock-screen notification title for a received direct message when message previews are hidden; deliberately names neither the sender nor the content") - } - static var mentionTitle: String { - String(localized: "notification.redacted.mention.title", defaultValue: "🫵 you were mentioned", comment: "Lock-screen notification title telling someone they were mentioned when message previews are hidden; deliberately omits who mentioned them") - } - static var geohashActivityTitle: String { - String(localized: "notification.redacted.geohash.title", defaultValue: "📍 new activity nearby", comment: "Lock-screen notification title for activity in a location channel when message previews are hidden; deliberately omits the geohash") - } - static var body: String { - String(localized: "notification.redacted.body", defaultValue: "open bitchat to read", comment: "Lock-screen notification body shown in place of the message text when message previews are hidden") - } - } - - /// Whether delivered alerts must withhold sender, content, and geohash. - /// - /// Injected rather than read from the preference directly so tests state - /// which behavior they are asserting instead of inheriting whatever the - /// shared preference happens to hold when they run. - private let hidePreviewsProvider: () -> Bool - - private var hidePreviews: Bool { - hidePreviewsProvider() - } - private let isRunningTestsProvider: () -> Bool private let authorizer: NotificationAuthorizing private let requestDeliverer: NotificationRequestDelivering - private let categoryRegistrar: NotificationCategoryRegistering /// Returns true if running in test environment (XCTest, Swift Testing, or CI) private var isRunningTests: Bool { @@ -135,7 +81,6 @@ final class NotificationService { } private init() { - self.hidePreviewsProvider = { NotificationPrivacySettings.hideMessagePreviews } self.isRunningTestsProvider = { let env = ProcessInfo.processInfo.environment return NSClassFromString("XCTestCase") != nil || @@ -147,33 +92,26 @@ final class NotificationService { if isRunningTestsProvider() { self.authorizer = NoopNotificationAuthorizer() self.requestDeliverer = NoopNotificationRequestDeliverer() - self.categoryRegistrar = NoopNotificationCategoryRegistrar() } else { let center = UNUserNotificationCenter.current() self.authorizer = NotificationCenterAuthorizerAdapter(center: center) self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center) - self.categoryRegistrar = NotificationCenterCategoryRegistrarAdapter(center: center) } } internal init( isRunningTestsProvider: @escaping () -> Bool, authorizer: NotificationAuthorizing, - requestDeliverer: NotificationRequestDelivering, - categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar(), - hidePreviewsProvider: @escaping () -> Bool = { NotificationPrivacySettings.hideMessagePreviews } + requestDeliverer: NotificationRequestDelivering ) { self.isRunningTestsProvider = isRunningTestsProvider self.authorizer = authorizer self.requestDeliverer = requestDeliverer - self.categoryRegistrar = categoryRegistrar - self.hidePreviewsProvider = hidePreviewsProvider } func requestAuthorization() { guard !isRunningTests else { return } - registerCategories() - authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in if granted { // Permission granted } else { @@ -181,29 +119,13 @@ final class NotificationService { } } } - - private func registerCategories() { - let wave = UNNotificationAction( - identifier: Self.waveActionID, - title: String(localized: "notification.action.wave", comment: "Title of the notification action button that sends a friendly wave back to a nearby person"), - options: [] - ) - let nearby = UNNotificationCategory( - identifier: Self.nearbyCategoryID, - actions: [wave], - intentIdentifiers: [], - options: [] - ) - categoryRegistrar.setCategories([nearby]) - } func sendLocalNotification( title: String, body: String, identifier: String, userInfo: [String: Any]? = nil, - interruptionLevel: UNNotificationInterruptionLevel = .active, - categoryIdentifier: String? = nil + interruptionLevel: UNNotificationInterruptionLevel = .active ) { guard !isRunningTests else { return } let content = UNMutableNotificationContent() @@ -211,9 +133,6 @@ final class NotificationService { content.body = body content.sound = .default content.interruptionLevel = interruptionLevel - if let categoryIdentifier = categoryIdentifier { - content.categoryIdentifier = categoryIdentifier - } if let userInfo = userInfo { content.userInfo = userInfo @@ -229,41 +148,34 @@ final class NotificationService { } func sendMentionNotification(from sender: String, message: String) { - let title = hidePreviews ? Redacted.mentionTitle : "🫵 you were mentioned by \(sender)" - let body = hidePreviews ? Redacted.body : message + let title = "🫵 you were mentioned by \(sender)" + let body = message let identifier = "mention-\(UUID().uuidString)" - + sendLocalNotification(title: title, body: body, identifier: identifier) } - + func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) { - let title = hidePreviews ? Redacted.directMessageTitle : "🔒 DM from \(sender)" - let body = hidePreviews ? Redacted.body : message + let title = "🔒 DM from \(sender)" + let body = message let identifier = "private-\(UUID().uuidString)" - // Routing payload, not display copy: `userInfo` never reaches the lock - // screen, and the conversation to open still has to be identifiable. let userInfo = ["peerID": peerID.id, "senderName": sender] - + sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) } - + // Geohash public chat notification with deep link to a specific geohash func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) { - // The geohash itself is location data, so hiding previews withholds it - // from the alert while leaving the deep link intact for the tap. - let title = hidePreviews ? Redacted.geohashActivityTitle : "\(titlePrefix)\(geohash)" - let body = hidePreviews ? Redacted.body : bodyPreview + let title = "\(titlePrefix)\(geohash)" let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)" let deeplink = "bitchat://geohash/\(geohash)" let userInfo: [String: Any] = ["deeplink": deeplink] - sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) + sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo) } func sendNetworkAvailableNotification(peerCount: Int) { - let title = String(localized: "notification.nearby.title", defaultValue: "👥 bitchatters nearby!", comment: "Title of the local notification when mesh peers come into range") - let body = peerCount == 1 - ? String(localized: "notification.nearby.body_one", defaultValue: "1 person around", comment: "Body of the nearby notification for exactly one peer") - : String(format: String(localized: "notification.nearby.body_many", defaultValue: "%lld people around", comment: "Body of the nearby notification; placeholder is the peer count"), locale: .current, peerCount) + let title = "👥 bitchatters nearby!" + let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" // Fixed identifier so iOS updates the existing notification instead of creating new ones let identifier = "network-available" @@ -271,8 +183,7 @@ final class NotificationService { title: title, body: body, identifier: identifier, - interruptionLevel: .timeSensitive, - categoryIdentifier: Self.nearbyCategoryID + interruptionLevel: .timeSensitive ) } } diff --git a/bitchat/Services/NotificationStreamAssembler.swift b/bitchat/Services/NotificationStreamAssembler.swift index c4ebb25d..cbef4506 100644 --- a/bitchat/Services/NotificationStreamAssembler.swift +++ b/bitchat/Services/NotificationStreamAssembler.swift @@ -7,7 +7,6 @@ // import BitLogger -import BitFoundation import Foundation struct NotificationStreamAssembler { @@ -21,19 +20,6 @@ struct NotificationStreamAssembler { pendingFrameExpectedLength = 0 } - private mutating func discardLeadingPaddingIfPresent() -> Bool { - guard let first = buffer.first else { return false } - guard first != 1 && first != 2 else { return false } - let paddingLength = Int(first) - guard paddingLength > 0, paddingLength <= buffer.count else { return false } - guard buffer.prefix(paddingLength).allSatisfy({ $0 == first }) else { return false } - - buffer.removeFirst(paddingLength) - pendingFrameStartedAt = nil - pendingFrameExpectedLength = 0 - return true - } - mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) { guard !chunk.isEmpty else { return ([], [], false) } @@ -55,9 +41,6 @@ struct NotificationStreamAssembler { while buffer.count >= minimumFramePrefix { guard let version = buffer.first else { break } guard version == 1 || version == 2 else { - if discardLeadingPaddingIfPresent() { - continue - } dropped.append(buffer.removeFirst()) pendingFrameStartedAt = nil pendingFrameExpectedLength = 0 @@ -149,11 +132,6 @@ struct NotificationStreamAssembler { let frame = Data(buffer.prefix(frameLength)) frames.append(frame) buffer.removeFirst(frameLength) - _ = discardLeadingPaddingIfPresent() - } - - if discardLeadingPaddingIfPresent() { - return (frames, dropped, didReset) } if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) { diff --git a/bitchat/Services/Prekeys/LocalPrekeyStore.swift b/bitchat/Services/Prekeys/LocalPrekeyStore.swift deleted file mode 100644 index 26f9ec67..00000000 --- a/bitchat/Services/Prekeys/LocalPrekeyStore.swift +++ /dev/null @@ -1,228 +0,0 @@ -// -// LocalPrekeyStore.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import BitLogger -import CryptoKit -import Foundation - -/// Owns this device's one-time Curve25519 prekey private keys. -/// -/// Privates persist in the Keychain (single blob, same protection class as -/// the identity keys). A batch of `batchSize` unconsumed prekeys backs the -/// gossiped bundle; when consumption drops the unconsumed count below -/// `replenishThreshold`, the batch tops back up and the bundle's -/// `generatedAt` bumps so peers replace their cached copy. -/// -/// Redelivery grace: spray-and-wait means the same prekey-sealed ciphertext -/// (or a re-seal of the same message to the same prekey ID) can arrive via -/// several couriers days apart. A consumed prekey's private key is therefore -/// retained for `consumedGraceSeconds` after first use and only then deleted. -/// Tradeoff: during the grace window a compromise of the device still exposes -/// mail sealed to that prekey — the forward-secrecy clock starts at deletion, -/// not at first open. Refusing new ciphertexts while accepting redeliveries -/// is not possible (the recipient cannot distinguish them), so the window is -/// kept short and fixed. -final class LocalPrekeyStore { - struct Record: Codable { - let id: UInt32 - let privateKey: Data - let createdAt: Date - var consumedAt: Date? - } - - private struct Persisted: Codable { - var records: [Record] - var nextID: UInt32 - var generatedAt: UInt64 - } - - enum Policy { - static let batchSize = PrekeyBundle.maxPrekeys - static let replenishThreshold = 3 - /// How long a consumed prekey private survives for duplicate courier - /// deliveries of mail sealed to it. - static let consumedGraceSeconds: TimeInterval = 48 * 60 * 60 - /// Unconsumed prekeys older than this are rotated out: no honest - /// sender seals to a bundle that stale (see - /// `PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds`). - static let unconsumedRetentionSeconds: TimeInterval = 30 * 24 * 60 * 60 - } - - private static let keychainKey = "prekeysV1" - - private let keychain: KeychainManagerProtocol - private let now: () -> Date - private let queue = DispatchQueue(label: "chat.bitchat.prekeys.local") - - // Guarded by `queue`. - private var records: [Record] = [] - private var nextID: UInt32 = 0 - private var generatedAt: UInt64 = 0 - private var loaded = false - - init(keychain: KeychainManagerProtocol, now: @escaping () -> Date = Date.init) { - self.keychain = keychain - self.now = now - } - - // MARK: - Bundle contents (public prekeys) - - /// Unconsumed public prekeys for the gossiped bundle, generating the - /// initial batch on first use. Sorted by ID for canonical signing bytes. - func currentBundlePrekeys() -> (prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) { - queue.sync { - loadLocked() - _ = replenishLocked() - let prekeys = records - .filter { $0.consumedAt == nil } - .sorted { $0.id < $1.id } - .compactMap { record -> PrekeyBundle.Prekey? in - guard let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) else { return nil } - return PrekeyBundle.Prekey(id: record.id, publicKey: key.publicKey.rawRepresentation) - } - return (prekeys, generatedAt) - } - } - - // MARK: - Opening (private prekeys) - - /// Private key for a prekey ID: unconsumed, or consumed within the - /// redelivery grace window. - func privateKey(for id: UInt32) -> Curve25519.KeyAgreement.PrivateKey? { - queue.sync { - loadLocked() - let date = now() - guard let record = records.first(where: { $0.id == id }) else { return nil } - if let consumedAt = record.consumedAt, - date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds { - return nil - } - return try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) - } - } - - /// Marks a prekey consumed (starts its grace clock). Idempotent: a - /// redelivery within the grace window does not restart the clock. - /// - /// Returns true when this call actually retired a prekey, i.e. the - /// published bundle shrank. Consuming a prekey drops it from - /// `currentBundlePrekeys()`, so `generatedAt` must advance strictly too: - /// otherwise peers that cached the old bundle reject the same-`generatedAt` - /// replacement in `PrekeyBundleStore.ingest`, keep assigning the consumed - /// ID, and their mail starts failing `unknownPrekey` once the 48h grace - /// lapses. The caller re-gossips on a true result. - @discardableResult - func markConsumed(_ id: UInt32) -> Bool { - queue.sync { - loadLocked() - guard let index = records.firstIndex(where: { $0.id == id }), - records[index].consumedAt == nil else { return false } - records[index].consumedAt = now() - advanceGeneratedAtLocked() - persistLocked() - return true - } - } - - /// Prunes dead prekeys and tops the unconsumed batch back up when it runs - /// low. Returns true when the published bundle changed (caller should - /// re-gossip). - @discardableResult - func replenishIfNeeded() -> Bool { - queue.sync { - loadLocked() - return replenishLocked() - } - } - - var unconsumedCount: Int { - queue.sync { - loadLocked() - return records.filter { $0.consumedAt == nil }.count - } - } - - /// Panic wipe: drop all prekey privates from memory and the Keychain. - func wipe() { - queue.sync { - records.removeAll() - nextID = 0 - generatedAt = 0 - loaded = true - _ = keychain.deleteIdentityKey(forKey: Self.keychainKey) - } - } - - // MARK: - Internals (call only on `queue`) - - private func replenishLocked() -> Bool { - let date = now() - - // Consumed prekeys past the grace window are gone for good; stale - // unconsumed ones rotate out (their bundle is too old to seal to). - let recordsBefore = records.count - let unconsumedBefore = records.filter { $0.consumedAt == nil }.count - records.removeAll { record in - if let consumedAt = record.consumedAt { - return date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds - } - return date.timeIntervalSince(record.createdAt) > Policy.unconsumedRetentionSeconds - } - // Only a change to the *unconsumed* set alters the published bundle; - // grace-expired consumed keys were never in it. - let unconsumed = records.filter { $0.consumedAt == nil }.count - var bundleChanged = unconsumed != unconsumedBefore - - if unconsumed < Policy.replenishThreshold { - for _ in unconsumed.. -// - -import BitFoundation -import BitLogger -import Foundation - -/// Signature-verified one-time prekey bundles received from other peers. -/// -/// One bundle per Noise static key: a newer `generatedAt` replaces the cached -/// copy, keeping the IDs we already sealed with marked used so a prekey is -/// never reused across messages. Assignments are remembered per message ID so -/// deposit retries of the same message re-use its prekey (and its budget) -/// instead of burning a fresh one per courier. -/// -/// Only public key material lives here; it persists to disk so a sender can -/// prekey-seal for recipients met long ago. Included in the panic wipe. -final class PrekeyBundleStore { - struct StoredBundle: Codable { - // noiseKey is read in loadFromDisk (dictionary keying), but the - // Periphery indexer intermittently misses that read and flaked CI - // with "assign-only" — even past its baselined USR. Covered - // deterministically by retain_codable_properties in .periphery.yml - // (an in-source ignore can't work: strict mode flags it as - // superfluous on the runs where the indexer gets it right). - let noiseKey: Data - var generatedAt: UInt64 - var prekeyIDs: [UInt32] - var prekeyPublicKeys: [Data] - /// IDs this device already sealed with (never reused). - var usedIDs: Set - /// messageID → prekey ID, so re-deposits of one message share one prekey. - var assignments: [String: UInt32] - var updatedAt: Date - } - - enum Limits { - static let maxPeers = 200 - /// Don't seal to bundles older than this: the owner may have rotated - /// the unconsumed keys out (see `LocalPrekeyStore.Policy`). - static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60 - } - - static let shared = PrekeyBundleStore() - - private var bundles: [Data: StoredBundle] = [:] - private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles") - private let fileURL: URL? - private let maxPeers: Int - private let now: () -> Date - - /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored - /// when `persistsToDisk` is false. - init( - persistsToDisk: Bool = true, - fileURL: URL? = nil, - maxPeers: Int = Limits.maxPeers, - now: @escaping () -> Date = Date.init - ) { - self.now = now - self.maxPeers = maxPeers - self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil - loadFromDisk() - } - - // MARK: - Ingest - - /// Stores a bundle whose signature the caller has already verified - /// against the owner's announce-bound signing key. Returns false when an - /// equal-or-newer bundle is already cached (nothing changed). - @discardableResult - func ingest(_ bundle: PrekeyBundle) -> Bool { - guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength, - !bundle.prekeys.isEmpty else { return false } - return queue.sync { - if let existing = bundles[bundle.noiseStaticPublicKey], - existing.generatedAt >= bundle.generatedAt { - return false - } - let previous = bundles[bundle.noiseStaticPublicKey] - let newIDs = Set(bundle.prekeys.map(\.id)) - // Keep consumption state for IDs the fresh bundle still offers - // (a top-up keeps the owner's unconsumed keys); drop the rest. - let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs) - let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) } - bundles[bundle.noiseStaticPublicKey] = StoredBundle( - noiseKey: bundle.noiseStaticPublicKey, - generatedAt: bundle.generatedAt, - prekeyIDs: bundle.prekeys.map(\.id), - prekeyPublicKeys: bundle.prekeys.map(\.publicKey), - usedIDs: carriedUsed, - assignments: carriedAssignments, - updatedAt: now() - ) - enforceCapLocked() - persistLocked() - return true - } - } - - // MARK: - Sealing support - - /// Whether an unexpired bundle with sealable prekeys is cached for a peer. - func hasUsableBundle(for noiseKey: Data) -> Bool { - queue.sync { - guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false } - return bundle.usedIDs.count < bundle.prekeyIDs.count - } - } - - /// The prekey to seal a message with: the message's existing assignment if - /// any (re-deposits reuse it), else the lowest unused ID, which is then - /// marked used. Nil when no fresh bundle is cached or all its prekeys are - /// spent — callers fall back to static sealing. - func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? { - queue.sync { - guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil } - - if let assigned = bundle.assignments[messageID], - let index = bundle.prekeyIDs.firstIndex(of: assigned) { - return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index]) - } - - guard let index = bundle.prekeyIDs.indices - .filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) }) - .min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else { - return nil - } - let id = bundle.prekeyIDs[index] - bundle.usedIDs.insert(id) - bundle.assignments[messageID] = id - bundle.updatedAt = now() - bundles[recipientNoiseKey] = bundle - persistLocked() - return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index]) - } - } - - // MARK: - Maintenance - - /// Panic wipe: drop all cached bundles from memory and disk. - func wipe() { - queue.sync { - bundles.removeAll() - if let fileURL { - try? FileManager.default.removeItem(at: fileURL) - } - } - } - - // MARK: - Internals (call only on `queue`) - - private func isFreshLocked(_ bundle: StoredBundle) -> Bool { - let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000 - return ageSeconds <= Limits.maxBundleAgeForSealingSeconds - } - - private func enforceCapLocked() { - while bundles.count > maxPeers { - guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return } - bundles.removeValue(forKey: victim.key) - } - } - - private func persistLocked() { - guard let fileURL else { return } - do { - if bundles.isEmpty { - try? FileManager.default.removeItem(at: fileURL) - return - } - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let data = try JSONEncoder().encode(Array(bundles.values)) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtection) - #endif - try data.write(to: fileURL, options: options) - } catch { - SecureLogger.error("Failed to persist prekey bundle store: \(error)", category: .security) - } - } - - private func loadFromDisk() { - guard let fileURL else { return } - queue.sync { - guard let data = try? Data(contentsOf: fileURL), - let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else { - return - } - for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count { - bundles[bundle.noiseKey] = bundle - } - } - } - - private static func defaultFileURL() -> URL? { - guard let base = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) else { return nil } - return base - .appendingPathComponent("prekeys", isDirectory: true) - .appendingPathComponent("bundles.json") - } -} diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index dcf4631b..3e920e12 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -8,26 +8,16 @@ import BitLogger import BitFoundation -import Combine import Foundation import SwiftUI -/// Manages private chat session policy (selection, read receipts, -/// consolidation). Message storage lives in the single-writer -/// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the -/// `privateChats` / `unreadMessages` properties below are read-only views -/// derived from it. -@MainActor +/// Manages all private chat functionality final class PrivateChatManager: ObservableObject { - /// Read-only mirror of `ConversationStore.selectedPrivatePeerID` — the - /// store is the sole owner of conversation selection. Kept `@Published` - /// so existing observers (`objectWillChange` forwarding into - /// `ChatViewModel`) keep firing on selection changes. Mutate via - /// `startChat(with:)` / `endChat()`, which route through the store's - /// `setSelectedPrivatePeer` intent. - @Published private(set) var selectedPeer: PeerID? = nil - private var selectedPeerMirrorCancellable: AnyCancellable? = nil + @Published var privateChats: [PeerID: [BitchatMessage]] = [:] + @Published var selectedPeer: PeerID? = nil + @Published var unreadMessages: Set = [] + private var selectedPeerFingerprint: String? = nil var sentReadReceipts: Set = [] // Made accessible for ChatViewModel weak var meshService: Transport? @@ -35,51 +25,13 @@ final class PrivateChatManager: ObservableObject { weak var messageRouter: MessageRouter? // Peer service for looking up peer info during consolidation weak var unifiedPeerService: UnifiedPeerService? - /// Single source of truth for message and selection state; injected by - /// the bootstrapper (`wireServiceGraph`). - var conversationStore: ConversationStore? { - didSet { bindSelectionMirror() } - } - init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) { + init(meshService: Transport? = nil) { self.meshService = meshService - self.conversationStore = conversationStore - bindSelectionMirror() // didSet does not fire during init } - /// Keeps `selectedPeer` in lock-step with the store's selection axis - /// (including store-internal handoffs such as conversation migration). - private func bindSelectionMirror() { - guard let store = conversationStore else { - selectedPeerMirrorCancellable = nil - return - } - selectedPeerMirrorCancellable = store.$selectedPrivatePeerID - .sink { [weak self] peerID in - guard let self, self.selectedPeer != peerID else { return } - self.selectedPeer = peerID - } - } - - // MARK: - Derived message state (read-only compat views) - - /// All private chats keyed by routing peer ID, derived from the store. - /// Mutations go through the store's intent API only. - @MainActor - var privateChats: [PeerID: [BitchatMessage]] { - conversationStore?.directMessagesByRoutingPeerID() ?? [:] - } - - /// Unread chats, derived from the store's unread state. - @MainActor - var unreadMessages: Set { - conversationStore?.unreadDirectRoutingPeerIDs() ?? [] - } - - @MainActor - private func messages(for peerID: PeerID) -> [BitchatMessage] { - conversationStore?.conversationsByID[.directPeer(peerID)]?.messages ?? [] - } + // Cap for messages stored per private chat + private let privateChatCap = TransportConfig.privateChatCap // MARK: - Message Consolidation @@ -92,51 +44,57 @@ final class PrivateChatManager: ObservableObject { /// - Returns: True if any unread messages were found during consolidation @MainActor func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set) -> Bool { - guard let meshService = meshService, let store = conversationStore else { return false } + guard let meshService = meshService else { return false } var hasUnreadMessages = false // 1. Consolidate from stable Noise key (64-char hex) if let peer = unifiedPeerService?.getPeer(by: peerID) { let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - let nostrMessages = messages(for: noiseKeyHex) - if noiseKeyHex != peerID, !nostrMessages.isEmpty { + if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty { + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + + let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) for message in nostrMessages { - // Update senderPeerID for correct read receipts - let updatedMessage = BitchatMessage( - id: message.id, - sender: message.sender, - content: message.content, - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: message.isPrivate, - recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, - mentions: message.mentions, - deliveryStatus: message.deliveryStatus - ) - // Store append dedups by message ID (skips ones the - // target chat already has). - guard store.append(updatedMessage, to: .directPeer(peerID)) else { continue } + if !existingMessageIds.contains(message.id) { + // Update senderPeerID for correct read receipts + let updatedMessage = BitchatMessage( + id: message.id, + sender: message.sender, + content: message.content, + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, + mentions: message.mentions, + deliveryStatus: message.deliveryStatus + ) + privateChats[peerID]?.append(updatedMessage) - // Check for recent unread messages (< 60s, not sent by us, not already read) - // Use persistedReadReceipts to correctly identify already-read messages after app restart - if message.senderPeerID != meshService.myPeerID { - let messageAge = Date().timeIntervalSince(message.timestamp) - if messageAge < 60 && !persistedReadReceipts.contains(message.id) { - hasUnreadMessages = true + // Check for recent unread messages (< 60s, not sent by us, not already read) + // Use persistedReadReceipts to correctly identify already-read messages after app restart + if message.senderPeerID != meshService.myPeerID { + let messageAge = Date().timeIntervalSince(message.timestamp) + if messageAge < 60 && !persistedReadReceipts.contains(message.id) { + hasUnreadMessages = true + } } } } + privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + if hasUnreadMessages { - store.markUnread(.directPeer(peerID)) - } else { - store.markRead(.directPeer(noiseKeyHex)) + unreadMessages.insert(peerID) + } else if unreadMessages.contains(noiseKeyHex) { + unreadMessages.remove(noiseKeyHex) } - store.removeConversation(.directPeer(noiseKeyHex)) + privateChats.removeValue(forKey: noiseKeyHex) } } @@ -154,43 +112,52 @@ final class PrivateChatManager: ObservableObject { } if !tempPeerIDsToConsolidate.isEmpty { + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + + let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) var consolidatedCount = 0 var hadUnreadTemp = false - let unreadPeerIDs = unreadMessages for tempPeerID in tempPeerIDsToConsolidate { - if unreadPeerIDs.contains(tempPeerID) { + if unreadMessages.contains(tempPeerID) { hadUnreadTemp = true } - for message in messages(for: tempPeerID) { - let updatedMessage = BitchatMessage( - id: message.id, - sender: message.sender, - content: message.content, - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: message.isPrivate, - recipientNickname: message.recipientNickname, - senderPeerID: peerID, - mentions: message.mentions, - deliveryStatus: message.deliveryStatus - ) - if store.append(updatedMessage, to: .directPeer(peerID)) { - consolidatedCount += 1 + if let tempMessages = privateChats[tempPeerID] { + for message in tempMessages { + if !existingMessageIds.contains(message.id) { + let updatedMessage = BitchatMessage( + id: message.id, + sender: message.sender, + content: message.content, + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: peerID, + mentions: message.mentions, + deliveryStatus: message.deliveryStatus + ) + privateChats[peerID]?.append(updatedMessage) + consolidatedCount += 1 + } } + privateChats.removeValue(forKey: tempPeerID) + unreadMessages.remove(tempPeerID) } - store.removeConversation(.directPeer(tempPeerID)) } if hadUnreadTemp { - store.markUnread(.directPeer(peerID)) + unreadMessages.insert(peerID) hasUnreadMessages = true SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session) } if consolidatedCount > 0 { + privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session) } } @@ -201,84 +168,110 @@ final class PrivateChatManager: ObservableObject { /// Syncs the read receipt tracking between manager and view model for sent messages @MainActor func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set) { - for message in messages(for: peerID) { + guard let messages = privateChats[peerID] else { return } + + for message in messages { if message.sender == nickname { - switch message.deliveryStatus { - case .read, .delivered: - externalReceipts.insert(message.id) - sentReadReceipts.insert(message.id) - case .notSentYet, .failed, .partiallyDelivered, .sending, .sent, .carried: - break + if let status = message.deliveryStatus { + switch status { + case .read, .delivered: + externalReceipts.insert(message.id) + sentReadReceipts.insert(message.id) + case .failed, .partiallyDelivered, .sending, .sent: + break + } } } } } - - /// Start a private chat with a peer. Selection is mutated through the - /// store's intent (the store owns it); the manager keeps its side - /// effects (read receipts, unread clearing). - @MainActor + + /// Start a private chat with a peer func startChat(with peerID: PeerID) { - // Also creates the conversation if needed and updates the derived - // `selectedConversationID`; `selectedPeer` mirrors the change. - conversationStore?.setSelectedPrivatePeer(peerID) - + selectedPeer = peerID + + // Store fingerprint for persistence across reconnections + if let fingerprint = meshService?.getFingerprint(for: peerID) { + selectedPeerFingerprint = fingerprint + } + // Mark messages as read markAsRead(from: peerID) + + // Initialize chat if needed + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } } - - /// End the current private chat (selection returns to the active public - /// channel's conversation). + + /// End the current private chat func endChat() { - conversationStore?.setSelectedPrivatePeer(nil) + selectedPeer = nil + selectedPeerFingerprint = nil } - /// Mark messages from a peer as read - @MainActor - func markAsRead(from peerID: PeerID) { - conversationStore?.markRead(.directPeer(peerID)) + /// Remove duplicate messages by ID and keep chronological order + func sanitizeChat(for peerID: PeerID) { + guard let arr = privateChats[peerID] else { return } + if arr.count <= 1 { + return + } + var indexByID: [String: Int] = [:] + indexByID.reserveCapacity(arr.count) + var deduped: [BitchatMessage] = [] + deduped.reserveCapacity(arr.count) + + for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) { + if let existing = indexByID[msg.id] { + deduped[existing] = msg + } else { + indexByID[msg.id] = deduped.count + deduped.append(msg) + } + } + + privateChats[peerID] = deduped + } + + /// Mark messages from a peer as read + func markAsRead(from peerID: PeerID) { + unreadMessages.remove(peerID) + // Send read receipts for unread messages that haven't been sent yet - for message in messages(for: peerID) { - if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) { - sendReadReceipt(for: message) + if let messages = privateChats[peerID] { + for message in messages { + if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) { + sendReadReceipt(for: message) + } } } } - + // MARK: - Private Methods - + private func sendReadReceipt(for message: BitchatMessage) { guard !sentReadReceipts.contains(message.id), let senderPeerID = message.senderPeerID else { return } - + + sentReadReceipts.insert(message.id) + // Create read receipt using the simplified method let receipt = ReadReceipt( originalMessageID: message.id, readerID: meshService?.myPeerID ?? PeerID(str: ""), readerNickname: meshService?.myNickname ?? "" ) - + // Route via MessageRouter to avoid handshakeRequired spam when session isn't established if let router = messageRouter { SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session) - let messageID = message.id - // Claim the receipt synchronously so a second read scan in the - // same runloop pass (chat open triggers two) can't route a - // duplicate; release the claim on a failed route (no reachable - // transport) so a later read scan retries instead of permanently - // losing the receipt. - sentReadReceipts.insert(messageID) - Task { @MainActor [weak self] in - if !router.sendReadReceipt(receipt, to: senderPeerID) { - self?.sentReadReceipts.remove(messageID) - } + Task { @MainActor in + router.sendReadReceipt(receipt, to: senderPeerID) } } else { - // Fallback: preserve previous behavior (best-effort mesh send). - sentReadReceipts.insert(message.id) + // Fallback: preserve previous behavior meshService?.sendReadReceipt(receipt, to: senderPeerID) } } diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index 7dbcf4ab..e7593b44 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -11,28 +11,18 @@ struct RelayDecision { struct RelayController { static func decide(ttl: UInt8, senderIsSelf: Bool, - recipientIsSelf: Bool = false, - isEncrypted _: Bool, + isEncrypted: Bool, isDirectedEncrypted: Bool, isFragment: Bool, isDirectedFragment: Bool, isHandshake: Bool, isAnnounce: Bool, - isRequestSync: Bool = false, - isUrgentBoardPost: Bool = false, - isVoiceFrame: Bool = false, degree: Int, highDegreeThreshold: Int) -> RelayDecision { let ttlCap = min(ttl, TransportConfig.messageTTLDefault) - // REQUEST_SYNC is link-local: never relay it, even when a peer crafts - // one with TTL headroom to turn every reachable node into a responder. - if isRequestSync { - return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) - } - // Suppress obvious non-relays - if ttlCap <= 1 || senderIsSelf || recipientIsSelf { + if ttlCap <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) } @@ -47,17 +37,8 @@ struct RelayController { return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) } - // Live voice floods with the fragment policy: the dense clamp - // contains the sustained ~15 pkt/s per-talker stream, and the tight - // jitter window keeps per-hop latency inside the receiver's ~350 ms - // jitter buffer across multi-hop paths. - if isFragment || isVoiceFrame { - // Dense graphs clamp harder to contain full-fanout fragment floods; - // sparse graphs get full depth so media reaches as far as text. - let fragmentCap = degree >= highDegreeThreshold - ? TransportConfig.bleFragmentRelayTtlCapDense - : TransportConfig.bleFragmentRelayTtlCap - let ttlLimit = min(ttlCap, fragmentCap) + if isFragment { + let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap) guard ttlLimit > 1 else { return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0) } @@ -68,17 +49,12 @@ struct RelayController { // TTL clamping for broadcast // - Dense graphs: keep lower but still allow multi-hop bridging - // - Thin chains (degree <= 2): every hop counts and flood cost is - // minimal, so relay at full incoming depth - // - Announces (and urgent board posts) get a bit more headroom + // - Announces get a bit more headroom let ttlLimit: UInt8 = { if degree >= highDegreeThreshold { return max(UInt8(2), min(ttlCap, UInt8(5))) } - if degree <= 2 { - return ttlCap - } - let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6) + let preferred = UInt8(isAnnounce ? 7 : 6) return max(UInt8(2), min(ttlCap, preferred)) }() let newTTL = ttlLimit &- 1 diff --git a/bitchat/Services/SharedContentHandoff.swift b/bitchat/Services/SharedContentHandoff.swift deleted file mode 100644 index 0e3dce83..00000000 --- a/bitchat/Services/SharedContentHandoff.swift +++ /dev/null @@ -1,212 +0,0 @@ -import Foundation - -enum SharedContentKind: String, Codable, Sendable, Equatable { - case text - case url -} - -/// The single, bounded payload handed from the share extension to the app. -/// -/// The app-group store intentionally contains at most one envelope. A newer -/// share replaces an older one, which prevents unbounded shared-container -/// growth while still surviving suspension and a later app launch. -struct SharedContentPayload: Codable, Sendable, Equatable, Identifiable { - static let currentVersion = 1 - static let maxContentBytes = 16_000 - static let maxTitleBytes = 512 - static let maxEnvelopeBytes = 24_000 - static let retentionSeconds: TimeInterval = 24 * 60 * 60 - static let allowedFutureSkewSeconds: TimeInterval = 5 * 60 - - let version: Int - let id: UUID - let kind: SharedContentKind - let content: String - let title: String? - let createdAt: Date - - init( - version: Int = Self.currentVersion, - id: UUID = UUID(), - kind: SharedContentKind, - content: String, - title: String? = nil, - createdAt: Date = Date() - ) { - self.version = version - self.id = id - self.kind = kind - self.content = content - self.title = title - self.createdAt = createdAt - } - - static func text(_ content: String, createdAt: Date = Date()) -> SharedContentPayload { - SharedContentPayload(kind: .text, content: content, createdAt: createdAt) - } - - var composerText: String { content } - - var preview: String { - let normalized = content - .replacingOccurrences(of: "\r\n", with: "\n") - .replacingOccurrences(of: "\r", with: "\n") - guard normalized.count > 240 else { return normalized } - return String(normalized.prefix(240)) + "…" - } - - func validate(now: Date = Date()) throws { - guard version == Self.currentVersion else { - throw SharedContentHandoffError.unsupportedVersion - } - - let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - throw SharedContentHandoffError.emptyContent - } - guard content.utf8.count <= Self.maxContentBytes else { - throw SharedContentHandoffError.contentTooLarge - } - if let title { - guard title.utf8.count <= Self.maxTitleBytes else { - throw SharedContentHandoffError.titleTooLarge - } - guard !Self.containsDisallowedControl(in: title, allowsTextLayout: false) else { - throw SharedContentHandoffError.invalidCharacters - } - } - - let age = now.timeIntervalSince(createdAt) - guard age >= -Self.allowedFutureSkewSeconds, - age <= Self.retentionSeconds else { - throw SharedContentHandoffError.expired - } - - switch kind { - case .text: - guard !Self.containsDisallowedControl(in: content, allowsTextLayout: true) else { - throw SharedContentHandoffError.invalidCharacters - } - case .url: - guard !Self.containsDisallowedControl(in: content, allowsTextLayout: false), - let components = URLComponents(string: content), - let scheme = components.scheme?.lowercased(), - scheme == "http" || scheme == "https", - components.host?.isEmpty == false else { - throw SharedContentHandoffError.unsupportedURL - } - } - } - - private static func containsDisallowedControl( - in value: String, - allowsTextLayout: Bool - ) -> Bool { - value.unicodeScalars.contains { scalar in - guard CharacterSet.controlCharacters.contains(scalar) else { return false } - if allowsTextLayout, scalar == "\n" || scalar == "\r" || scalar == "\t" { - return false - } - return true - } - } -} - -enum SharedContentHandoffError: Error, Equatable { - case unsupportedVersion - case emptyContent - case contentTooLarge - case titleTooLarge - case invalidCharacters - case expired - case unsupportedURL - case envelopeTooLarge - case encodingFailed -} - -/// Durable, single-item app-group storage used by both the extension and app. -final class SharedContentStore { - static let storageKey = "sharedContentEnvelopeV1" - - private static let legacyKeys = [ - "sharedContent", - "sharedContentType", - "sharedContentDate" - ] - - private let defaults: UserDefaults - private let encoder: JSONEncoder - private let decoder: JSONDecoder - - init(defaults: UserDefaults) { - self.defaults = defaults - self.encoder = JSONEncoder() - self.decoder = JSONDecoder() - } - - /// Replaces any older pending share with a validated, bounded envelope. - func stage(_ payload: SharedContentPayload, now: Date = Date()) throws { - try payload.validate(now: now) - guard let encoded = try? encoder.encode(payload) else { - throw SharedContentHandoffError.encodingFailed - } - guard encoded.count <= SharedContentPayload.maxEnvelopeBytes else { - throw SharedContentHandoffError.envelopeTooLarge - } - - defaults.set(encoded, forKey: Self.storageKey) - clearLegacyKeys() - } - - /// Reads the pending share without consuming it. Invalid and expired data - /// is removed immediately so malformed app-group state cannot linger. - func pending(now: Date = Date()) -> SharedContentPayload? { - clearLegacyKeys() - - guard let encoded = defaults.data(forKey: Self.storageKey) else { return nil } - guard encoded.count <= SharedContentPayload.maxEnvelopeBytes, - let payload = try? decoder.decode(SharedContentPayload.self, from: encoded) else { - defaults.removeObject(forKey: Self.storageKey) - return nil - } - - do { - try payload.validate(now: now) - return payload - } catch { - defaults.removeObject(forKey: Self.storageKey) - return nil - } - } - - /// Consumes only the envelope the user actually reviewed. If a newer share - /// already replaced it, the newer content remains pending. - func consume(id: UUID, now: Date = Date()) -> SharedContentPayload? { - guard let payload = pending(now: now), payload.id == id else { return nil } - defaults.removeObject(forKey: Self.storageKey) - return payload - } - - /// Explicit cancellation has the same identity guard as consumption so it - /// can never discard a newer share that arrived while a prompt was open. - func discard(id: UUID) { - guard let encoded = defaults.data(forKey: Self.storageKey), - encoded.count <= SharedContentPayload.maxEnvelopeBytes, - let payload = try? decoder.decode(SharedContentPayload.self, from: encoded), - payload.id == id else { - return - } - defaults.removeObject(forKey: Self.storageKey) - } - - func discardAll() { - defaults.removeObject(forKey: Self.storageKey) - clearLegacyKeys() - } - - private func clearLegacyKeys() { - for key in Self.legacyKeys { - defaults.removeObject(forKey: key) - } - } -} diff --git a/bitchat/Services/TestEnvironment.swift b/bitchat/Services/TestEnvironment.swift deleted file mode 100644 index 86854a6c..00000000 --- a/bitchat/Services/TestEnvironment.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// TestEnvironment.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -/// Process-level test-environment detection for singletons that must swap a -/// real OS-backed dependency (keychain, persistent defaults, notifications) -/// for an in-memory one under test. Mirrors the detection already used by -/// `NotificationService` and `LocationStateManager`. -enum TestEnvironment { - /// True when running under XCTest / Swift Testing or in CI. - static let isRunningTests: Bool = { - let env = ProcessInfo.processInfo.environment - return NSClassFromString("XCTestCase") != nil || - env["XCTestConfigurationFilePath"] != nil || - env["XCTestBundlePath"] != nil || - env["GITHUB_ACTIONS"] != nil || - env["CI"] != nil - }() -} diff --git a/bitchat/Services/TransferProgressManager.swift b/bitchat/Services/TransferProgressManager.swift index 13c4c254..3ab721ba 100644 --- a/bitchat/Services/TransferProgressManager.swift +++ b/bitchat/Services/TransferProgressManager.swift @@ -11,7 +11,6 @@ final class TransferProgressManager { case updated(id: String, sentFragments: Int, totalFragments: Int) case completed(id: String, totalFragments: Int) case cancelled(id: String, sentFragments: Int, totalFragments: Int) - case rejected(id: String, reason: String) } private let subject = PassthroughSubject() @@ -50,14 +49,9 @@ final class TransferProgressManager { } } - /// Fails a preflight check while keeping the outgoing placeholder visible - /// with an actionable reason instead of treating policy/size rejection as - /// a user cancellation. - func rejectBeforeStart(id: String, reason: String) { + func reset(id: String) { queue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - self.states.removeValue(forKey: id) - self.subject.send(.rejected(id: id, reason: reason)) + self?.states.removeValue(forKey: id) } } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 3bb6db11..8f7fd03d 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -1,7 +1,6 @@ import BitFoundation import Foundation import Combine -import CoreBluetooth /// Abstract transport interface used by ChatViewModel and services. /// BLEService implements this protocol; a future Nostr transport can too. @@ -11,133 +10,16 @@ struct TransportPeerSnapshot: Equatable, Hashable { let isConnected: Bool let noisePublicKey: Data? let lastSeen: Date - /// Whether the peer's announce was signature-verified (courier tier gate). - let isVerified: Bool - - init( - peerID: PeerID, - nickname: String, - isConnected: Bool, - noisePublicKey: Data?, - lastSeen: Date, - isVerified: Bool = false - ) { - self.peerID = peerID - self.nickname = nickname - self.isConnected = isConnected - self.noisePublicKey = noisePublicKey - self.lastSeen = lastSeen - self.isVerified = isVerified - } -} - -/// Outcome of a `/ping` probe over the mesh. -struct MeshPingResult: Equatable { - /// Round-trip time in milliseconds. - let rttMs: Int - /// Total hops to the peer (1 = directly connected), derived from the - /// pong's TTL decrements; nil when the reply carried inconsistent TTLs. - let hops: Int? -} - -/// Undirected mesh link between two peers, normalized so `(a, b)` and -/// `(b, a)` collapse to one edge. -struct MeshTopologyEdge: Hashable { - let a: PeerID - let b: PeerID - - init(_ first: PeerID, _ second: PeerID) { - if first < second { - a = first - b = second - } else { - a = second - b = first - } - } -} - -/// Point-in-time view of the mesh graph learned from gossiped announces -/// (each announce carries up to 10 `directNeighbors`). -struct MeshTopologySnapshot: Equatable { - let localPeerID: PeerID - let nodes: [PeerID] - let edges: [MeshTopologyEdge] -} - -enum TransportEvent: @unchecked Sendable { - case messageReceived(BitchatMessage) - case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) - case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) - /// Encrypted group broadcast (MessageType 0x25). Opaque here — the group - /// coordinator decrypts and authenticates against the roster. - case groupMessageReceived(payload: Data, timestamp: Date) - /// Public live-voice burst packet (MessageType 0x29), already - /// signature-verified against the claimed sender. - case publicVoiceFrameReceived(peerID: PeerID, nickname: String, payload: Data, timestamp: Date) - case peerConnected(PeerID) - case peerDisconnected(PeerID) - case peerListUpdated([PeerID]) - case peerSnapshotsUpdated([TransportPeerSnapshot]) - case messageDeliveryStatusUpdated(messageID: String, status: DeliveryStatus) - case bluetoothStateUpdated(CBManagerState) -} - -/// Downgrade-safe decision for a private-media recipient. Callers ask before -/// prompting, and BLEService checks again when it consumes any one-shot -/// legacy consent. -enum PrivateMediaSendPolicy: Equatable { - case encrypted - /// A public announce hinted at encrypted media (or a prior authenticated - /// pin exists), but this exact Noise session has not yet supplied its - /// authenticated peer-state proof. Callers wait boundedly; they must not - /// pre-queue encrypted bytes or silently select the legacy path. - case awaitingCapabilityProof - case legacyRequiresConsent - case blockedDowngrade -} - -/// Receiver-only persistence surface for explicit private-media deletion. -/// Kept separate from `Transport` so sender retry branches can rebase without -/// inheriting or implementing receiver storage concerns. -protocol PrivateMediaDeletionPersisting: AnyObject { - @MainActor - func persistDeletedPrivateMedia( - messageIDs: [String], - payloadRelativePaths: [String: String], - protectedPayloadRelativePaths: Set, - completion: @escaping @MainActor (Bool) -> Void - ) - - /// Gated unlink for a LEGACY (non-stable-ID) incoming payload whose - /// bubble was explicitly removed. Implementations delete the file only - /// when its path is not pending delivery and not reserved by any receipt - /// or in-flight deletion transaction; otherwise the file stays for - /// bounded quota cleanup. - @MainActor - func removeLegacyPrivateMediaPayload(relativePath: String) -} - -protocol TransportEventDelegate: AnyObject { - @MainActor func didReceiveTransportEvent(_ event: TransportEvent) -} - -/// Optional typed-event contract for sinks that can synchronously decide -/// whether an inbound message was accepted. -protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate { - @MainActor - func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool } protocol Transport: AnyObject { // Event sink var delegate: BitchatDelegate? { get set } - // Typed event sink for transport-domain events. Prefer this over BitchatDelegate for new code. - var eventDelegate: TransportEventDelegate? { get set } // Peer events (preferred over publishers for UI) var peerEventsDelegate: TransportPeerEventsDelegate? { get set } // Peer snapshots (for non-UI services) + var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get } func currentPeerSnapshots() -> [TransportPeerSnapshot] // Identity @@ -153,19 +35,6 @@ protocol Transport: AnyObject { // Connectivity and peers func isPeerConnected(_ peerID: PeerID) -> Bool func isPeerReachable(_ peerID: PeerID) -> Bool - /// Whether a send to this peer is likely to leave the device promptly. - /// Distinct from reachability: Nostr claims any favorite with a known - /// npub as reachable even with no relay connection, where a send only - /// joins a queue waiting for internet that may never come. - func canDeliverPromptly(to peerID: PeerID) -> Bool - /// Whether a send to this peer can complete an end-to-end encrypted - /// delivery right now (e.g. an established Noise session). Distinct from - /// connectivity: a "connected" link binding alone is forgeable — link - /// bindings heal on signature-verified "direct" announces, but directness - /// rides on the unsigned TTL, so a replayed announce can wear an absent - /// peer's ID on the replayer's link. Routers must not trust a connected - /// link outright without this. - func canDeliverSecurely(to peerID: PeerID) -> Bool func peerNickname(peerID: PeerID) -> String? func getPeerNicknames() -> [PeerID: String] @@ -173,27 +42,7 @@ protocol Transport: AnyObject { func getFingerprint(for peerID: PeerID) -> String? func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState func triggerHandshake(with peerID: PeerID) - - // Noise identity/session access. Narrow, purpose-named wrappers so the - // underlying NoiseEncryptionService (and its peer-binding/session - // orchestration) is never exposed outside the transport. - /// The remote static public key of the Noise session with `peerID`, if established. - func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? - /// Fingerprint of our own Noise static identity key. - func noiseIdentityFingerprint() -> String - /// Our Noise static public key (Curve25519 key agreement). - func noiseStaticPublicKeyData() -> Data - /// Our Noise signing public key (Ed25519). - func noiseSigningPublicKeyData() -> Data - /// Signs `data` with our Noise signing key. - func noiseSignData(_ data: Data) -> Data? - /// Verifies an Ed25519 `signature` over `data` against `publicKey`. - func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool - /// Registers session-lifecycle callbacks (peer authenticated / handshake required). - func installNoiseSessionCallbacks( - onPeerAuthenticated: @escaping (PeerID, String) -> Void, - onHandshakeRequired: @escaping (PeerID) -> Void - ) + func getNoiseService() -> NoiseEncryptionService // Messaging func sendMessage(_ content: String, mentions: [String]) @@ -203,116 +52,36 @@ protocol Transport: AnyObject { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendBroadcastAnnounce() func sendDeliveryAck(for messageID: String, to peerID: PeerID) - /// Capabilities the peer advertised in its last verified announce; - /// empty for peers that predate the capabilities TLV. - func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities - /// Appends a peer-authenticated observer. Unlike - /// `installNoiseSessionCallbacks` this never touches the (single-slot) - /// handshake-required callback, so secondary features can observe - /// session establishment without disturbing the primary registration. - func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) -} + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) + func cancelTransfer(_ transferId: String) -/// A carried public mesh message from the store-and-forward window, decoded -/// for display. `packetIdHex` is stable across launches so echo rows keep a -/// deterministic message ID. -struct ArchivedPublicMessage { - let packetIdHex: String - let senderPeerID: PeerID - let senderNickname: String - let content: String - let timestamp: Date -} + // QR verification (optional for transports) + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) -extension BitchatMessage { - /// Echo rows are minted locally with this prefix (packet-id derived, so - /// stable across launches); the timeline dims them. - static let archivedEchoIDPrefix = "echo-" - - var isArchivedEcho: Bool { - id.hasPrefix(Self.archivedEchoIDPrefix) - } + // Pending file management (BCH-01-002: files held in memory until user accepts) + func acceptPendingFile(id: String) -> URL? + func declinePendingFile(id: String) } extension Transport { - // Reachability implies prompt delivery for transports that hand packets - // straight to the radio; queue-backed transports override this. - func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) } - - // Transports without a forgeable link-binding layer (everything but the - // BLE mesh) have no stronger delivery signal than prompt delivery. - func canDeliverSecurely(to peerID: PeerID) -> Bool { canDeliverPromptly(to: peerID) } - - // Noise identity hooks default to inert for transports that do not carry - // Noise sessions (e.g. NostrTransport). - func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil } - func noiseIdentityFingerprint() -> String { "" } - func noiseStaticPublicKeyData() -> Data { Data() } - func noiseSigningPublicKeyData() -> Data { Data() } - func noiseSignData(_ data: Data) -> Data? { nil } - func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { false } - func installNoiseSessionCallbacks( - onPeerAuthenticated: @escaping (PeerID, String) -> Void, - onHandshakeRequired: @escaping (PeerID) -> Void - ) {} - - func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } - func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} + func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} + func cancelTransfer(_ transferId: String) {} func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { sendMessage(content, mentions: mentions) } + + func acceptPendingFile(id: String) -> URL? { nil } + func declinePendingFile(id: String) {} } protocol TransportPeerEventsDelegate: AnyObject { - @MainActor func didUpdatePeerSnapshots(_: [TransportPeerSnapshot]) -} - -extension BitchatDelegate { - @MainActor - func receiveTransportEvent(_ event: TransportEvent) { - switch event { - case .messageReceived(let message): - didReceiveMessage(message) - case let .publicMessageReceived(peerID, nickname, content, timestamp, messageID): - didReceivePublicMessage( - from: peerID, - nickname: nickname, - content: content, - timestamp: timestamp, - messageID: messageID - ) - case let .noisePayloadReceived(peerID, type, payload, timestamp): - didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp) - case let .groupMessageReceived(payload, timestamp): - didReceiveGroupMessage(payload: payload, timestamp: timestamp) - case let .publicVoiceFrameReceived(peerID, nickname, payload, timestamp): - didReceivePublicVoiceFrame(from: peerID, nickname: nickname, payload: payload, timestamp: timestamp) - case .peerConnected(let peerID): - didConnectToPeer(peerID) - case .peerDisconnected(let peerID): - didDisconnectFromPeer(peerID) - case .peerListUpdated(let peers): - didUpdatePeerList(peers) - case .peerSnapshotsUpdated: - break - case let .messageDeliveryStatusUpdated(messageID, status): - didUpdateMessageDeliveryStatus(messageID, status: status) - case .bluetoothStateUpdated(let state): - didUpdateBluetoothState(state) - } - } + @MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) } extension BLEService: Transport {} -extension BLEService: MeshFileTransferring {} -extension BLEService: MeshVoiceStreaming {} -extension BLEService: MeshCourierTransporting {} -extension BLEService: MeshGroupMessaging {} -extension BLEService: MeshBoardBroadcasting {} -extension BLEService: MeshDiagnosing {} -extension BLEService: MeshVerifying {} -extension BLEService: MeshPublicArchiving {} -extension BLEService: BluetoothStateReporting {} -extension BLEService: PanicResettingTransport {} -extension BLEService: MeshBridgingTransport {} diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index b1e19701..5265af59 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -9,59 +9,15 @@ enum TransportConfig { static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends - // Bounded wait for the session-authenticated capability proof used by - // private-media migration. Expiry never auto-sends clear bytes; it only - // resolves to the existing one-shot consent or downgrade-blocked path. - static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5 - static let privateMediaCapabilityProofPendingPeerCap: Int = 64 - static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16 - /// Accepted private-media receipts and explicit-deletion tombstones each - /// receive this independent capacity. - static let privateMediaReceivedLedgerCapacity: Int = 4_096 - /// A bounded retry horizon prevents stable receipt state from growing into - /// permanent application history. - static let privateMediaReceivedLedgerTTLSeconds: TimeInterval = - 7 * 24 * 60 * 60 static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays - // Fragment relay TTL in sparse graphs; matches messageTTLDefault so media - // reaches as far as text. Dense graphs clamp harder in RelayController. - static let bleFragmentRelayTtlCap: UInt8 = 7 - static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs - - // Live voice (push-to-talk) - // Burst-content budget per voice packet. Sized so the Noise ciphertext - // (content + 1 type byte + 16 tag bytes) stays within MessagePadding's - // 256-byte bucket and the whole directed packet (16 header + 8 sender + - // 8 recipient + 256 payload = 288 bytes) rides one BLE frame — live audio - // must never enter the fragment scheduler, which caps concurrent - // transfers at 2 and would let voice starve file sends. - static let pttMaxBurstContentBytes: Int = 210 - static let pttJitterBufferSeconds: TimeInterval = 0.35 // buffered audio before live playback starts - static let pttJitterDeadlineSeconds: TimeInterval = 0.5 // start anyway after this wall-clock wait - static let pttBurstEndTimeoutSeconds: TimeInterval = 3.0 // no frames -> burst considered ended - static let pttMaxConcurrentAssemblies: Int = 8 // concurrent inbound bursts cap - static let pttMaxBurstBytes: Int = 384 * 1024 // 120s at ~2KB/s + generous slack - static let pttFinishedBurstRegistrySeconds: TimeInterval = 600 // window to absorb the finalized note - // Inbound flood guard: a real burst arrives at ~2KB/s; allow 3x plus a - // small settling allowance before dropping a sender's frames. - static let pttInboundMaxBytesPerSecond: Int = 6_000 - // Public bursts are live-only traffic: frames older than this are relay - // stragglers or replays, not audio anyone should start hearing. - static let pttPublicFrameMaxAgeSeconds: TimeInterval = 30 - - // Mesh diagnostics (/ping) - static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window - static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)... - static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification) + static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods // UI / Storage Caps static let privateChatCap: Int = 1337 static let meshTimelineCap: Int = 1337 static let geoTimelineCap: Int = 1337 - static let geoNicknameParticipantsCap: Int = 1337 static let contentLRUCap: Int = 2000 - static let geoSamplingEventLRUCap: Int = 2000 // Timers static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes @@ -84,101 +40,57 @@ enum TransportConfig { static let blePendingNotificationsCapCount: Int = 128 static let bleNotificationRetryDelayMs: Int = 25 static let bleNotificationRetryMaxAttempts: Int = 80 - // Sample interval for notification backpressure logs (fire per fragment - // during media transfers). - static let bleBackpressureLogInterval: Int = 25 // Nostr static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second - static let nostrInboundEventDedupCap: Int = 4096 - static let nostrInboundEventDedupTrimTarget: Int = 3072 - static let nostrDuplicateEventLogInterval: Int = 50 - // Sample interval for per-event debug logs on the inbound hot path. - static let nostrInboundEventLogInterval: Int = 100 - // Reject oversized/untrusted relay frames before JSON parse / store. - static let nostrMaxInboundMessageBytes: Int = 256 * 1024 - static let nostrMaxEventTags: Int = 64 - static let nostrMaxEventTagValues: Int = 16 - static let nostrMaxEventTagValueBytes: Int = 1024 - // Bounded per-relay inbound frame buffer. Each relay connection owns its - // own serial verify pipeline; if a relay floods faster than its Schnorr - // verification drains, the oldest buffered frames for THAT relay are - // dropped (bufferingNewest) so one relay cannot stall other relays. - // Nostr inbound is already best-effort (relays are redundant and events - // replay), so dropping a flooding relay's backlog is safe. Together with - // nostrInboundMaxFrameBytes this caps buffered inbound bytes at - // cap × maxFrameBytes (128 MiB) per hostile relay — bounded, not zero. - static let nostrInboundPerRelayBufferCap: Int = 256 - // Hard per-frame byte bound, applied as URLSessionWebSocketTask - // .maximumMessageSize (oversized frames fail the receive instead of - // buffering). BitChat's legitimate Nostr traffic is small: geohash chat / - // presence events (kind 20000/20001), kind-1 notes, and NIP-17 - // gift-wrapped DMs carrying text payloads or receipts are all a few KiB, - // and most public relays reject events beyond ~64–256 KiB anyway. 512 KiB - // leaves an order-of-magnitude margin over anything we produce or expect - // while halving the URLSession default (1 MiB), so a hostile relay's - // worst-case buffered pile-up per connection is - // nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB. - static let nostrInboundMaxFrameBytes: Int = 512 * 1024 - - // Conversation store diagnostics (field observability) - // Sample interval for the periodic store-audit "OK" heartbeat line - // (first + every Nth audit); violations always log at error level. - static let conversationStoreAuditLogInterval: Int = 10 - // Sample interval for the mirrored-republish debug line in the ID-only - // delivery fan-out (first + every Nth republish). - static let conversationStoreMirroredRepublishLogInterval: Int = 25 // UI thresholds + static let uiLateInsertThreshold: TimeInterval = 15.0 + // Geohash public chats are more sensitive to ordering; use a tighter threshold + static let uiLateInsertThresholdGeo: TimeInterval = 0.0 static let uiProcessedNostrEventsCap: Int = 2000 - + static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60 + // UI rate limiters (token buckets) static let uiSenderRateBucketCapacity: Double = 5 static let uiSenderRateBucketRefillPerSec: Double = 1.0 static let uiContentRateBucketCapacity: Double = 3 static let uiContentRateBucketRefillPerSec: Double = 0.5 - // Bound attacker-keyed bucket maps (sender IDs / content digests). - static let uiSenderRateBucketMaxEntries: Int = 2000 - static let uiContentRateBucketMaxEntries: Int = 2000 - static let uiRateBucketIdleTTL: TimeInterval = 10 * 60 - // Cap teleported-participant markers so remote events cannot grow the set. - static let geoTeleportedParticipantsCap: Int = 1337 // UI sleeps/delays static let uiStartupInitialDelaySeconds: TimeInterval = 1.0 + static let uiStartupShortSleepNs: UInt64 = 200_000_000 static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0 + static let uiAsyncShortSleepNs: UInt64 = 100_000_000 + static let uiAsyncMediumSleepNs: UInt64 = 500_000_000 static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1 static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5 static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15 static let uiScrollThrottleSeconds: TimeInterval = 0.5 + static let uiAnimationShortSeconds: TimeInterval = 0.15 static let uiAnimationMediumSeconds: TimeInterval = 0.2 + static let uiAnimationSidebarSeconds: TimeInterval = 0.25 static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60 static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0 // BLE maintenance & thresholds static let bleMaintenanceInterval: TimeInterval = 5.0 static let bleMaintenanceLeewaySeconds: Int = 1 - static let bleIsolationRelaxThresholdSeconds: TimeInterval = 30 - // Isolated nodes accept the weakest usable links — a fringe connection - // beats no connection. Relaxed floor sits at CoreBluetooth's practical - // reporting limit so prolonged isolation gates on nothing but decode. - static let bleRSSIIsolatedBase: Int = -95 - static let bleRSSIIsolatedRelaxed: Int = -100 + static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60 + static let bleRecentTimeoutWindowSeconds: TimeInterval = 60 + static let bleRecentTimeoutCountThreshold: Int = 3 + static let bleRSSIIsolatedBase: Int = -90 + static let bleRSSIIsolatedRelaxed: Int = -92 static let bleRSSIConnectedThreshold: Int = -85 + static let bleRSSIHighTimeoutThreshold: Int = -80 // How long without seeing traffic before we sanity-check the direct link // Lowered to make connected→reachable icon changes react faster when walking out of range static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0 - // How long to retain a peer as "reachable" (not directly connected) since lastSeen. - // Must comfortably exceed the worst-case dense announce interval (38s) plus a - // missed cycle, so duty-cycled nodes don't forget peers between announces. - static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 60.0 // verified/favorites - static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 45.0 // unknown/unverified + // How long to retain a peer as "reachable" (not directly connected) since lastSeen + static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites + static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified static let bleFragmentLifetimeSeconds: TimeInterval = 30.0 static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0 - // At most one rotation rebind per link per window: TTL is not signed, so - // a replayed announce can forge "direct", and without a cooldown two - // identities could fight over a link in a rebind flip-flop. - static let bleLinkRebindCooldownSeconds: TimeInterval = 60.0 static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0 static let bleRecentPacketWindowSeconds: TimeInterval = 30.0 static let bleRecentPacketWindowMaxCount: Int = 100 @@ -216,21 +128,13 @@ enum TransportConfig { static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300 static let nostrGeohashSampleLimit: Int = 100 static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400 - // Tolerated clock skew for the client-side rumor-timestamp window on - // inbound Nostr DMs (senders stamp the inner rumor with real time; only - // the outer gift wrap is randomized per NIP-17). - static let nostrDMMaxClockSkewSeconds: TimeInterval = 900 - // A sampled chat message this recent means "a conversation is happening - // there" for the empty-timeline nearby-activity hint. - static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900 - // Startup delay before reading the gossip archive for "heard here - // earlier" echoes; covers the archive's async disk restore. - static let uiArchivedEchoLoadDelaySeconds: TimeInterval = 1.5 - // Dead drops: location notes left via /drop expire after this long. - static let locationDropExpirySeconds: TimeInterval = 24 * 60 * 60 - // Poll cadence while geo notes wait for a relay connection (Tor warming - // up); re-subscribes as soon as one comes up. - static let uiGeoNotesConnectivityRetrySeconds: TimeInterval = 3.0 + + // Nostr helpers + static let nostrShortKeyDisplayLength: Int = 8 + static let nostrConvKeyPrefixLength: Int = 16 + + // Compression + static let compressionThresholdBytes: Int = 100 // Message deduplication static let messageDedupMaxAgeSeconds: TimeInterval = 300 @@ -244,30 +148,7 @@ enum TransportConfig { static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0 static let nostrRelayBackoffMultiplier: Double = 2.0 static let nostrRelayMaxReconnectAttempts: Int = 10 - // Reconnect delays get ±20% random jitter so relays that dropped together - // (e.g. a network blip) don't thundering-herd the same reconnect instant. - static let nostrRelayBackoffJitterRatio: Double = 0.2 static let nostrRelayDefaultFetchLimit: Int = 100 - // How many consecutive Tor-readiness waits (each bounded by TorManager's - // bootstrap deadline) to attempt before unblocking pending EOSE callers. - static let nostrTorReadyMaxWaitAttempts: Int = 3 - static let nostrPendingSendQueueCap: Int = 200 - // Sample interval for the send-queue overflow warning (first + every Nth - // dropped event). Drops are ephemeral presence/geo traffic — log-only. - static let nostrPendingSendDropLogInterval: Int = 10 - // Pending (not-yet-flushed) REQs are bounded per relay: oldest-by-insertion - // eviction at the cap, plus an age sweep on connect attempts. Durable - // subscription intent survives in subscriptionRequestState either way. - static let nostrPendingSubscriptionsPerRelayCap: Int = 64 - static let nostrPendingSubscriptionTTLSeconds: TimeInterval = 600.0 - // Fallback deadline for treating a subscription's initial fetch as complete - // when a relay never sends EOSE (generous to cover Tor circuit setup). - static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0 - // A bridge drop is durable only after NIP-01 `OK`. Relays that omit OK must - // not pin the router's in-flight state indefinitely. - static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0 - // After this long, a relay marked permanently failed gets another chance. - static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0 // Geo relay directory static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24 @@ -291,54 +172,17 @@ enum TransportConfig { static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown - // Source routing (v2 directed packets) - // Longest path we will originate, in intermediate hops between us and the - // recipient. Keep small: every hop must be a fresh, confirmed, v2-capable - // node, and long stale paths fail more often than floods. - static let bleSourceRouteMaxIntermediateHops: Int = 4 - // A routed send with no inbound traffic from the recipient within this - // window counts as a route failure. - static let bleSourceRouteConfirmationWindowSeconds: TimeInterval = 10.0 - // After a route failure, directed sends to that recipient flood instead - // of routing until this lapses. - static let bleSourceRouteSuppressionSeconds: TimeInterval = 60.0 - - // Targeted fragment resync (REQUEST_SYNC fragmentIdFilter) - // A broadcast reassembly with no new fragment for this long is stalled - // and triggers a targeted REQUEST_SYNC naming its fragment stream. - static let bleFragmentResyncStallSeconds: TimeInterval = 5.0 - // Minimum spacing between targeted resync requests for the same stream. - static let bleFragmentResyncRetrySeconds: TimeInterval = 10.0 - - // Store-and-forward for directed packets at relays. Spooled packets retry - // on each maintenance flush until the window lapses; a longer window lets - // brief link gaps (walking between rooms, reconnect churn) heal themselves. - static let bleDirectedSpoolWindowSeconds: TimeInterval = 60.0 + // Store-and-forward for directed packets at relays + static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0 // Log/UI debounce windows // Shorter debounce so UI reacts faster while still suppressing duplicate callbacks static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9 static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0 - // Background wake-on-proximity (iOS). Pending connects issued on - // backgrounding never expire at the OS level: the Bluetooth controller - // completes them whenever the peer reappears in range and relaunches the - // app via state restoration. Entries older than the BLE address-rotation - // window no longer map to a reachable address, so the cache prunes them. - static let bleRecentPeripheralCacheCap: Int = 16 - static let bleRecentPeripheralMaxAgeSeconds: TimeInterval = 15 * 60 - // Central slots kept free for connects driven by live background discovery - static let bleBackgroundPendingConnectSlotReserve: Int = 2 - // Weak-link cooldown after connection timeouts static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0 static let bleWeakLinkRSSICutoff: Int = -90 - // Rediscovery ignore windows after a failed link, by failure kind: - // a connect attempt that timed out means the peer likely isn't reachable, - // so back off; a dropped established connection (walked out of range) - // usually returns, so only pause long enough for CoreBluetooth to settle. - static let bleTimeoutDiscoveryIgnoreSeconds: TimeInterval = 15.0 - static let bleDisconnectDiscoveryIgnoreSeconds: TimeInterval = 3.0 // Content hashing / formatting static let contentKeyPrefixLength: Int = 256 @@ -346,7 +190,12 @@ enum TransportConfig { static let uiVeryLongTokenThreshold: Int = 512 static let uiLongMessageLineLimit: Int = 30 static let uiFingerprintSampleCount: Int = 3 - + + // UI swipe/gesture thresholds + static let uiBackSwipeTranslationLarge: CGFloat = 50 + static let uiBackSwipeTranslationSmall: CGFloat = 30 + static let uiBackSwipeVelocityThreshold: CGFloat = 300 + // UI color tuning static let uiColorHueAvoidanceDelta: Double = 0.05 static let uiColorHueOffset: Double = 0.12 @@ -362,20 +211,14 @@ enum TransportConfig { // Share extension static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0 + static let uiShareAcceptWindowSeconds: TimeInterval = 30.0 static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 // Gossip Sync Configuration static let syncSeenCapacity: Int = 1000 static let syncGCSMaxBytes: Int = 400 static let syncGCSTargetFpr: Double = 0.01 - // Fragments and file transfers keep the short window; whole public - // messages get hours so a phone walking between partitions carries the - // room's recent history with it (see syncPublicMessageMaxAgeSeconds). static let syncMaxMessageAgeSeconds: TimeInterval = 900 - // How far back public broadcast messages stay sync-able. Must not exceed - // the receive-side acceptance window (BLEPublicMessagePolicy uses this - // same constant) or served packets would be dropped as stale. - static let syncPublicMessageMaxAgeSeconds: TimeInterval = 6 * 60 * 60 static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0 static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0 static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0 @@ -384,30 +227,4 @@ enum TransportConfig { static let syncFragmentIntervalSeconds: TimeInterval = 30.0 static let syncFileTransferIntervalSeconds: TimeInterval = 60.0 static let syncMessageIntervalSeconds: TimeInterval = 15.0 - static let syncResponseRateLimitMaxResponses: Int = 8 - static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0 - - // Courier store-and-forward - // Initial spray-and-wait budget per deposited envelope: each courier may - // hand half its remaining copies to another courier on encounter, so a - // message diffuses through a moving crowd instead of riding one person. - static let courierInitialCopies: UInt8 = 4 - // Cooldown between speculative multi-hop handovers of the same envelope - // toward a recipient heard only via relayed announces. - static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60 - // Recently opened courier inner message IDs kept for receiver-side dedup - // (redundant copies ride distinct seals, so only the inner ID matches). - static let courierOpenedMessageIDCap: Int = 512 - - // One-time prekey bundles (forward-secret courier sealing) - // Own gossip-sync round for bundles: modest cadence, bounded peer count, - // and a long freshness window so bundles persist mesh-wide while their - // owners are away. - static let syncPrekeyBundleCapacity: Int = 200 - static let syncPrekeyBundleIntervalSeconds: TimeInterval = 60.0 - static let syncPrekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60 - // Unforced re-broadcasts of our own (unchanged) bundle, piggybacked on - // announces, keep it alive in peers' gossip stores; changed bundles are - // sent immediately. - static let prekeyBundleRebroadcastSeconds: TimeInterval = 60 * 60 } diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index f1e4f8fa..3d180c68 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -70,7 +70,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { } // TransportPeerEventsDelegate - func didUpdatePeerSnapshots(_: [TransportPeerSnapshot]) { + func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) { updatePeers() } @@ -86,44 +86,45 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { var enrichedPeers: [BitchatPeer] = [] var connected: Set = [] var addedPeerIDs: Set = [] - var meshNoiseKeys: Set = [] - + // Phase 1: Add all mesh peers (connected and reachable) for peerInfo in meshPeers { let peerID = peerInfo.peerID guard peerID != meshService.myPeerID else { continue } // Never add self - + let peer = buildPeerFromMesh( peerInfo: peerInfo, favorites: favorites, meshAttached: hasAnyConnected ) - + enrichedPeers.append(peer) if peer.isConnected { connected.insert(peerID) } addedPeerIDs.insert(peerID) - + // Update fingerprint cache if let publicKey = peerInfo.noisePublicKey { - meshNoiseKeys.insert(publicKey) fingerprintCache[peerID] = publicKey.sha256Fingerprint() } } - - // Phase 2: Add offline favorites that we actively favorite. - // Mesh rows use the short 16-hex peer ID while favorites are keyed by - // the full 32-byte noise key, so dedup must compare noise keys — a - // PeerID comparison between the two forms can never match. + + // Phase 2: Add offline favorites that we actively favorite for (favoriteKey, favorite) in favorites where favorite.isFavorite { - if meshNoiseKeys.contains(favoriteKey) { continue } - let peerID = PeerID(hexData: favoriteKey) + + // Skip if already added (connected peer) if addedPeerIDs.contains(peerID) { continue } - + + // Skip if connected under different ID but same nickname + let isConnectedByNickname = enrichedPeers.contains { + $0.nickname == favorite.peerNickname && $0.isConnected + } + if isConnectedByNickname { continue } + let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID) enrichedPeers.append(peer) addedPeerIDs.insert(peerID) - + // Update fingerprint cache fingerprintCache[peerID] = favoriteKey.sha256Fingerprint() } @@ -195,8 +196,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { nickname: peerInfo.nickname, lastSeen: peerInfo.lastSeen, isConnected: peerInfo.isConnected, - isReachable: isReachable, - localPetname: localPetname(forFingerprint: fingerprint) + isReachable: isReachable ) // Check for favorite status @@ -219,8 +219,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { nickname: favorite.peerNickname, lastSeen: favorite.lastUpdated, isConnected: false, - isReachable: false, - localPetname: localPetname(forFingerprint: favorite.peerNoisePublicKey.sha256Fingerprint()) + isReachable: false ) peer.favoriteStatus = favorite @@ -229,21 +228,6 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { return peer } - /// Rebuild peer rows after a social-identity write (local alias, etc.) so - /// display names update without waiting for a mesh event. - func refreshPeers() { - updatePeers() - } - - private func localPetname(forFingerprint fingerprint: String?) -> String? { - guard let fingerprint, - let petname = identityManager.getSocialIdentity(for: fingerprint)?.localPetname, - !petname.isEmpty else { - return nil - } - return petname - } - // MARK: - Public Methods /// Get peer by ID @@ -253,11 +237,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { /// Get peer ID for nickname func getPeerID(for nickname: String) -> PeerID? { - // Normalize both sides: the query may come from typed content and - // stored names may predate NFC-at-ingest (e.g. persisted favorites). - let target = nickname.normalizedNickname for peer in peers { - if peer.displayName.normalizedNickname == target || peer.nickname.normalizedNickname == target { + if peer.displayName == nickname || peer.nickname == nickname { return peer.peerID } } @@ -276,35 +257,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { return false } - - /// Block or unblock a mesh peer by its stable Noise identity. - /// - /// The block is keyed by the peer's fingerprint, resolved from `peerID` - /// (cache / mesh session / known-peer Noise key). This works even when the - /// peer is offline — including offline favorites — so the exact tapped peer - /// is (un)blocked unambiguously instead of being re-resolved by a - /// display-name string that two peers could share. - /// - Returns: the resolved fingerprint, or `nil` if the identity is unknown. - @discardableResult - func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? { - guard let fingerprint = getFingerprint(for: peerID) else { - SecureLogger.warning( - "⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)", - category: .session - ) - return nil - } - identityManager.setBlocked(fingerprint, isBlocked: blocked) - if blocked { - // Purge while the fingerprint↔peerID mapping is still known: the - // archived-echo seed filter can't resolve offline strangers, so - // scrub their carried messages now rather than at relaunch. - (meshService as? MeshPublicArchiving)?.purgeArchivedPublicMessages(from: peerID) - } - updatePeers() - return fingerprint - } - + /// Toggle favorite status func toggleFavorite(_ peerID: PeerID) { guard let peer = getPeer(by: peerID) else { @@ -393,7 +346,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { } // MARK: - Compatibility Methods (for easy migration) - + + var allPeers: [BitchatPeer] { peers } + var connectedPeers: Set { connectedPeerIDs } + var favoritePeers: Set { + Set(favorites.compactMap { getFingerprint(for: $0.peerID) }) + } var blockedUsers: Set { Set(peers.compactMap { peer in isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil diff --git a/bitchat/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift index 26247379..cf3ab4de 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -4,11 +4,9 @@ import Foundation final class VerificationService { static let shared = VerificationService() - // Injected running transport (do NOT create new BLEService). Noise - // identity operations go through the transport's narrow noise* wrappers - // so the raw NoiseEncryptionService is never exposed. - private var transport: Transport? - func configure(with transport: Transport) { self.transport = transport } + // Injected Noise service from the running transport (do NOT create new BLEService) + private var noise: NoiseEncryptionService? + func configure(with noise: NoiseEncryptionService) { self.noise = noise } /// Encapsulates the data encoded into a verification QR struct VerificationQR: Codable { @@ -79,17 +77,16 @@ final class VerificationService { if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 { return c.value } - guard let transport = transport else { return nil } - let noiseKey = transport.noiseStaticPublicKeyData().hexEncodedString() - let signKey = transport.noiseSigningPublicKeyData().hexEncodedString() + guard let noise = noise else { return nil } + let noiseKey = noise.getStaticPublicKeyData().hexEncodedString() + let signKey = noise.getSigningPublicKeyData().hexEncodedString() let ts = Int64(Date().timeIntervalSince1970) var nonce = Data(count: 16) - let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } - guard status == errSecSuccess else { return nil } + _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "") let msg = payload.canonicalBytes() - guard let sig = transport.noiseSignData(msg) else { return nil } + guard let sig = noise.signData(msg) else { return nil } let signed = VerificationQR(v: payload.v, noiseKeyHex: payload.noiseKeyHex, signKeyHex: payload.signKeyHex, @@ -106,14 +103,13 @@ final class VerificationService { /// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks) func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? { guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil } - // Freshness, in both directions: a future-dated timestamp must not - // buy a QR a longer validity window than a fresh one gets. + // Freshness let now = Date().timeIntervalSince1970 - if abs(now - Double(qr.ts)) > maxAge { return nil } + if now - Double(qr.ts) > maxAge { return nil } // Verify signature using embedded ed25519 signKey guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil } - guard let transport = transport else { return nil } - let ok = transport.noiseVerifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey) + guard let noise = noise else { return nil } + let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey) return ok ? qr : nil } @@ -137,7 +133,7 @@ final class VerificationService { let nk = noiseKeyHex.data(using: .utf8) ?? Data() msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255)) msg.append(nonceA) - guard let transport = transport, let sig = transport.noiseSignData(msg) else { return nil } + guard let noise = noise, let sig = noise.signData(msg) else { return nil } var tlv = Data() tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255)) tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255)) @@ -182,7 +178,7 @@ final class VerificationService { let nk = noiseKeyHex.data(using: .utf8) ?? Data() msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255)) msg.append(nonceA) - guard let transport = transport, let pub = Data(hexString: signerPublicKeyHex) else { return false } - return transport.noiseVerifySignature(signature, for: msg, publicKey: pub) + guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false } + return noise.verifySignature(signature, for: msg, publicKey: pub) } } diff --git a/bitchat/Sync/GCSFilter.swift b/bitchat/Sync/GCSFilter.swift index 8b36b376..d3c4d9fb 100644 --- a/bitchat/Sync/GCSFilter.swift +++ b/bitchat/Sync/GCSFilter.swift @@ -10,18 +10,7 @@ import CryptoKit // - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1< Int { @@ -41,46 +30,42 @@ enum GCSFilter { static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params { let p = deriveP(targetFpr: targetFpr) guard !ids.isEmpty else { - return Params(p: p, m: 1, data: Data(), includedCount: 0) + return Params(p: p, m: 1, data: Data()) } let cap = estimateMaxElements(sizeBytes: maxBytes, p: p) - // Modulus is fixed to the initial candidate count so `m` stays stable - // as the tail is trimmed to fit the byte budget below. - let range = max(1, hashRange(count: min(ids.count, cap), p: p)) + let selected = Array(ids.prefix(cap)) + let range = max(1, hashRange(count: selected.count, p: p)) let modulo = UInt64(range) - // Encode the first `count` inputs (input order). The caller passes IDs - // newest-first, so trimming from the tail drops the oldest — which is - // what lets a since-cursor stay exact: the surviving set is always a - // contiguous newest-prefix, never a hash-order-arbitrary subset. - func encodeFirst(_ count: Int) -> Data { - var mapped = ids.prefix(count) - .map { h64($0) } - .map { mapHash($0, modulo: modulo) } - .sorted() - mapped = normalizeMappedValues(mapped, modulo: modulo) - return mapped.isEmpty ? Data() : encode(sorted: mapped, p: p) + var mapped = selected + .map { h64($0) } + .map { mapHash($0, modulo: modulo) } + .sorted() + mapped = normalizeMappedValues(mapped, modulo: modulo) + + if mapped.isEmpty { + return Params(p: p, m: range, data: Data()) } - var count = min(ids.count, cap) - var encoded = encodeFirst(count) - while encoded.count > maxBytes && count > 1 { - count = max(1, (count * 9) / 10) - encoded = encodeFirst(count) - } - // A single element that still overflows can't be represented. - if encoded.count > maxBytes { - return Params(p: p, m: range, data: Data(), includedCount: 0) + var encoded = encode(sorted: mapped, p: p) + var trimmedCount = mapped.count + + while encoded.count > maxBytes && trimmedCount > 0 { + if trimmedCount == 1 { + mapped.removeAll() + encoded = Data() + break + } + trimmedCount = max(1, (trimmedCount * 9) / 10) + mapped = Array(mapped.prefix(trimmedCount)) + encoded = encode(sorted: mapped, p: p) } - return Params(p: p, m: range, data: encoded, includedCount: encoded.isEmpty ? 0 : count) + return Params(p: p, m: range, data: encoded) } static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] { - // Reject out-of-range parameters rather than decoding garbage: callers - // treat the result as "peer has nothing" and fall back to sending data. - guard p >= 1, p <= maxP, m > 1 else { return [] } var values: [UInt64] = [] let reader = BitReader(data) var acc: UInt64 = 0 diff --git a/bitchat/Sync/GossipMessageArchive.swift b/bitchat/Sync/GossipMessageArchive.swift deleted file mode 100644 index dd81983d..00000000 --- a/bitchat/Sync/GossipMessageArchive.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// GossipMessageArchive.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitLogger -import Foundation - -/// Disk persistence for the gossip-sync public message store, so the recent -/// public history a device carries survives app restarts. This is what lets -/// a phone act as a town crier: walk between two mesh partitions (or relaunch -/// hours later) and sync the room's backlog to whoever missed it. -/// -/// Contents are signed public broadcasts — already visible to anyone in radio -/// range — so file protection (no additional sealing) is the right at-rest -/// posture. Wiped on panic. -final class GossipMessageArchive { - private let fileURL: URL? - - init(fileURL: URL? = nil) { - self.fileURL = fileURL ?? Self.defaultFileURL() - } - - /// Raw binary packets, decoded and freshness-filtered by the caller. - func load() -> [Data] { - guard let fileURL, - let data = try? Data(contentsOf: fileURL), - let packets = try? JSONDecoder().decode([Data].self, from: data) else { - return [] - } - return packets - } - - func save(_ packets: [Data]) { - guard let fileURL else { return } - guard !packets.isEmpty else { - try? FileManager.default.removeItem(at: fileURL) - return - } - do { - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let data = try JSONEncoder().encode(packets) - var options: Data.WritingOptions = [.atomic] - #if os(iOS) - options.insert(.completeFileProtection) - #endif - try data.write(to: fileURL, options: options) - } catch { - SecureLogger.error("Failed to persist gossip archive: \(error)", category: .sync) - } - } - - func wipe() { - guard let fileURL else { return } - try? FileManager.default.removeItem(at: fileURL) - } - - /// Panic-wipe hook for callers that don't hold the live instance. - static func wipeDefault() { - GossipMessageArchive().wipe() - } - - private static func defaultFileURL() -> URL? { - guard let base = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) else { return nil } - return base - .appendingPathComponent("sync", isDirectory: true) - .appendingPathComponent("public-messages.json") - } -} diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index eeb1ff97..1b65f9f9 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -64,82 +64,41 @@ final class GossipSyncManager { var seenCapacity: Int = 1000 // max packets per sync (cap across types) var gcsMaxBytes: Int = 400 // filter size budget (128..1024) var gcsTargetFpr: Double = 0.01 // 1% - var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - fragments/files/announces - // Whole public messages stay sync-able much longer so devices carry - // the room's recent history between partitions and across restarts. - var publicMessageMaxAgeSeconds: TimeInterval = 900 + var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages var maintenanceIntervalSeconds: TimeInterval = 30.0 var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0 var stalePeerTimeoutSeconds: TimeInterval = 60.0 var fragmentCapacity: Int = 600 var fileTransferCapacity: Int = 200 - var groupMessageCapacity: Int = 200 var fragmentSyncIntervalSeconds: TimeInterval = 30.0 var fileTransferSyncIntervalSeconds: TimeInterval = 60.0 var messageSyncIntervalSeconds: TimeInterval = 15.0 - // Board posts are few but long-lived (days, until each post's own - // expiry), so they get a slow round with their own capacity instead - // of competing with the 15-minute message window. - var boardCapacity: Int = 200 - var boardSyncIntervalSeconds: TimeInterval = 60.0 - var responseRateLimitMaxResponses: Int = 8 - var responseRateLimitWindowSeconds: TimeInterval = 30.0 - // Prekey bundles: one per peer, own sync round, long freshness so - // bundles persist mesh-wide while their owners are offline. - var prekeyBundleCapacity: Int = 200 - var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0 - var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60 } private let myPeerID: PeerID private let config: Config private let requestSyncManager: RequestSyncManager - private let archive: GossipMessageArchive? weak var delegate: Delegate? - /// Source of raw signed board packets (posts + tombstones). The board - /// store is the single owner of board retention (expiry, tombstones, - /// caps, persistence), so sync rounds query it instead of keeping a - /// second copy here. Must be thread-safe; set before `start()`. - var boardPacketsProvider: (() -> [BitchatPacket])? - // Storage: broadcast packets by type, and latest announce per sender private var messages = PacketStore() private var fragments = PacketStore() private var fileTransfers = PacketStore() - private var groupMessages = PacketStore() - private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:] - // Latest verified prekey bundle per owner. Unlike announces, bundles are - // NOT dropped on leave/stale peer: their whole purpose is reaching a - // sender while the owner is away. - private var latestPrekeyBundleByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] - private var archiveDirty = false + private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] // Timer private var periodicTimer: DispatchSourceTimer? private let queue = DispatchQueue(label: "mesh.sync", qos: .utility) private var lastStalePeerCleanup: Date = .distantPast private var syncSchedules: [SyncSchedule] = [] - private var responseRateLimiter: SyncResponseRateLimiter - init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager, archive: GossipMessageArchive? = nil) { + init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) { self.myPeerID = myPeerID self.config = config self.requestSyncManager = requestSyncManager - self.archive = archive - self.responseRateLimiter = SyncResponseRateLimiter( - maxResponses: config.responseRateLimitMaxResponses, - window: config.responseRateLimitWindowSeconds - ) var schedules: [SyncSchedule] = [] if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 { - // Group messages ride the public-message cadence; old clients - // ignore the extended bit and answer with announces/messages only. - var messageTypes: SyncTypeFlags = .publicMessages - if config.groupMessageCapacity > 0 { - messageTypes.formUnion(.groupMessage) - } - schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) + schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) } if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast)) @@ -147,19 +106,7 @@ final class GossipSyncManager { if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast)) } - if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 { - schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast)) - } - if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 { - schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast)) - } syncSchedules = schedules - - if archive != nil { - queue.async { [weak self] in - self?.restoreArchivedMessages() - } - } } func start() { @@ -181,24 +128,17 @@ final class GossipSyncManager { func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) { queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in guard let self = self else { return } - - var types: SyncTypeFlags = .publicMessages - if self.config.groupMessageCapacity > 0 { - types.formUnion(.groupMessage) - } + self.sendRequestSync(to: peerID, types: .publicMessages) if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 { - types.formUnion(.fragment) + self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.sendRequestSync(to: peerID, types: .fragment) + } } if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 { - types.formUnion(.fileTransfer) + self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.sendRequestSync(to: peerID, types: .fileTransfer) + } } - if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 { - types.formUnion(.prekeyBundle) - } - if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil { - types.formUnion(.board) - } - self.sendRequestSync(to: peerID, types: types) } } @@ -208,23 +148,10 @@ final class GossipSyncManager { } } - // Helper to check if a packet is within the age threshold. Whole public - // messages get the long town-crier window; fragments, file transfers and - // announces keep the short one. + // Helper to check if a packet is within the age threshold private func isPacketFresh(_ packet: BitchatPacket) -> Bool { - // Group messages share the whole-message window: members off the mesh - // for a while should backfill their crew's history like public chat. - let maxAgeSeconds: TimeInterval - switch packet.type { - case MessageType.message.rawValue, MessageType.groupMessage.rawValue: - maxAgeSeconds = config.publicMessageMaxAgeSeconds - case MessageType.prekeyBundle.rawValue: - maxAgeSeconds = config.prekeyBundleMaxAgeSeconds - default: - maxAgeSeconds = config.maxMessageAgeSeconds - } let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - let ageThresholdMs = UInt64(maxAgeSeconds * 1000) + let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000) // If current time is less than threshold, accept all (handle clock issues gracefully) guard nowMs >= ageThresholdMs else { return true } @@ -257,14 +184,14 @@ final class GossipSyncManager { removeState(for: sender) return } + let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let sender = PeerID(hexData: packet.senderID) - latestAnnouncementByPeer[sender] = packet + latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) case .message: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity)) - archiveDirty = true case .fragment: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } @@ -275,36 +202,6 @@ final class GossipSyncManager { guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity)) - case .groupMessage: - // Opaque ciphertext to non-members; carried and served like any - // other broadcast so members get backfill from any relay. - guard isBroadcastRecipient else { return } - guard isPacketFresh(packet) else { return } - let idHex = PacketIdUtil.computeId(packet).hexEncodedString() - groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity)) - case .prekeyBundle: - // Callers only feed verified bundles here (own bundles at send - // time, peers' after signature verification), so gossip never - // spreads a bundle this node couldn't attribute. - guard isBroadcastRecipient else { return } - guard isPacketFresh(packet) else { return } - // Key by the bundle's authenticated identity (its noise static key), - // NOT the unauthenticated packet senderID. Otherwise one valid - // bundle re-broadcast under many fabricated sender IDs would create - // one cache entry each and exhaust the per-owner cap, starving - // legitimate bundles. One owner ⇒ at most one entry. - guard let bundle = PrekeyBundle.decode(packet.payload) else { return } - let owner = PeerID(publicKey: bundle.noiseStaticPublicKey) - if let existing = latestPrekeyBundleByPeer[owner], - existing.packet.timestamp >= packet.timestamp { - return - } - // Bounded owner count; replacing a known owner's bundle is always - // allowed so the cap can't block refreshes. - guard latestPrekeyBundleByPeer[owner] != nil - || latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return } - let idHex = PacketIdUtil.computeId(packet).hexEncodedString() - latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet) default: break } @@ -313,7 +210,7 @@ final class GossipSyncManager { private func sendPeriodicSync(for types: SyncTypeFlags) { // Unicast sync to connected peers to allow RSR attribution if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty { - SecureLogger.debug("Sending periodic sync (\(types.logDescription)) to \(connectedPeers.count) connected peers", category: .sync) + SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync) for peerID in connectedPeers { sendRequestSync(to: peerID, types: types) } @@ -338,29 +235,11 @@ final class GossipSyncManager { delegate?.sendPacket(signed) } - /// Targeted fragment recovery: ask connected peers for the specific - /// fragment streams whose reassembly has stalled, instead of waiting on - /// the next periodic GCS fragment round to cover them. - func requestMissingFragments(fragmentIDs: [Data]) { - queue.async { [weak self] in - self?._requestMissingFragments(fragmentIDs) - } - } - - private func _requestMissingFragments(_ fragmentIDs: [Data]) { - guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return } - guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return } - SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync) - for peerID in connectedPeers { - sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter) - } - } - - private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags, fragmentIdFilter: String? = nil) { + private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) { // Register the request for RSR validation requestSyncManager.registerRequest(to: peerID) - - let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter) + + let payload = buildGcsPayload(for: types) var recipient = Data() var temp = peerID.id while temp.count >= 2 && recipient.count < 8 { @@ -388,17 +267,7 @@ final class GossipSyncManager { } private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) { - // A response can replay the whole store, so bound how often one peer - // can trigger a diff pass regardless of how fast it asks. - guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else { - SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync) - return - } let requestedTypes = (request.types ?? .publicMessages) - // The requester's filter only covers packets at or after this cursor; - // older packets are outside the filter but not missing, and without - // the cursor they would be re-sent every round. - let since = request.sinceTimestamp // Decode GCS into sorted set and prepare membership checker let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) func mightContain(_ id: Data) -> Bool { @@ -406,13 +275,11 @@ final class GossipSyncManager { return GCSFilter.contains(sortedValues: sorted, candidate: bucket) } - // Announces are exempt from the since-cursor: they carry the signing - // keys needed to verify everything else, and there is at most one per - // peer, so the resend cost is negligible. if requestedTypes.contains(.announce) { - for (_, pkt) in latestAnnouncementByPeer { + for (_, pair) in latestAnnouncementByPeer { + let (idHex, pkt) = pair guard isPacketFresh(pkt) else { continue } - let idBytes = PacketIdUtil.computeId(pkt) + let idBytes = Data(hexString: idHex) ?? Data() if !mightContain(idBytes) { var toSend = pkt toSend.ttl = 0 @@ -425,7 +292,6 @@ final class GossipSyncManager { if requestedTypes.contains(.message) { let toSendMsgs = messages.allPackets(isFresh: isPacketFresh) for pkt in toSendMsgs { - if let since, pkt.timestamp < since { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -437,19 +303,8 @@ final class GossipSyncManager { } if requestedTypes.contains(.fragment) { - // A fragment-ID filter narrows the diff to exactly the named - // fragment streams (targeted resync for stalled reassemblies) - // and bypasses the since-cursor for them; the GCS filter still - // excludes the pieces the requester already holds. Fragment - // payloads start with the 8-byte stream ID. - let fragmentIdFilter = RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter) let frags = fragments.allPackets(isFresh: isPacketFresh) for pkt in frags { - if let fragmentIdFilter { - guard fragmentIdFilter.contains(Data(pkt.payload.prefix(8))) else { continue } - } else if let since, pkt.timestamp < since { - continue - } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -463,53 +318,6 @@ final class GossipSyncManager { if requestedTypes.contains(.fileTransfer) { let files = fileTransfers.allPackets(isFresh: isPacketFresh) for pkt in files { - if let since, pkt.timestamp < since { continue } - let idBytes = PacketIdUtil.computeId(pkt) - if !mightContain(idBytes) { - var toSend = pkt - toSend.ttl = 0 - toSend.isRSR = true // Mark as solicited response - delegate?.sendPacket(to: peerID, packet: toSend) - } - } - } - - if requestedTypes.contains(.groupMessage) { - let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh) - for pkt in groupPkts { - if let since, pkt.timestamp < since { continue } - let idBytes = PacketIdUtil.computeId(pkt) - if !mightContain(idBytes) { - var toSend = pkt - toSend.ttl = 0 - toSend.isRSR = true // Mark as solicited response - delegate?.sendPacket(to: peerID, packet: toSend) - } - } - } - // Like announces, prekey bundles are exempt from the since-cursor: - // there is at most one per owner (newer replaces older), so the - // resend cost is bounded and a joining peer must be able to learn - // bundles generated long before it arrived. - if requestedTypes.contains(.prekeyBundle) { - for (_, pair) in latestPrekeyBundleByPeer { - let (idHex, pkt) = pair - guard isPacketFresh(pkt) else { continue } - let idBytes = Data(hexString: idHex) ?? Data() - if !mightContain(idBytes) { - var toSend = pkt - toSend.ttl = 0 - toSend.isRSR = true // Mark as solicited response - delegate?.sendPacket(to: peerID, packet: toSend) - } - } - } - if requestedTypes.contains(.boardPost) { - // The board store already filters to live posts and tombstones; - // no freshness window applies (posts sync until their own expiry). - let boardPackets = boardPacketsProvider?() ?? [] - for pkt in boardPackets { - if let since, pkt.timestamp < since { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -522,11 +330,11 @@ final class GossipSyncManager { } // Build REQUEST_SYNC payload using current candidates and GCS params - private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data { + private func buildGcsPayload(for types: SyncTypeFlags) -> Data { var candidates: [BitchatPacket] = [] if types.contains(.announce) { - for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) { - candidates.append(pkt) + for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) { + candidates.append(pair.packet) } } if types.contains(.message) { @@ -538,20 +346,9 @@ final class GossipSyncManager { if types.contains(.fileTransfer) { candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh)) } - if types.contains(.groupMessage) { - candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh)) - } - if types.contains(.prekeyBundle) { - for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) { - candidates.append(pair.packet) - } - } - if types.contains(.boardPost) { - candidates.append(contentsOf: boardPacketsProvider?() ?? []) - } if candidates.isEmpty { let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr) - let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter) + let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) return req.encode() } @@ -565,121 +362,39 @@ final class GossipSyncManager { cap = max(1, config.fragmentCapacity) } else if types == .fileTransfer { cap = max(1, config.fileTransferCapacity) - } else if types == .prekeyBundle { - cap = max(1, config.prekeyBundleCapacity) - } else if types == .board { - cap = max(1, config.boardCapacity) } else { cap = max(1, config.seenCapacity) } let takeN = min(candidates.count, min(nMax, cap)) if takeN <= 0 { - let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter) + let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) return req.encode() } - let included = Array(candidates.prefix(takeN)) - let ids: [Data] = included.map { PacketIdUtil.computeId($0) } + let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) } let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr) - // When the filter can't cover every candidate — either the store - // exceeds `takeN` or the encoder trimmed the tail to fit the byte - // budget — tell the responder how far back the filter actually - // reaches. `includedCount` counts inputs in newest-first order, so the - // covered set is a contiguous newest-prefix and the oldest included - // timestamp is an exact cursor. Packets older than it are outside the - // filter but not missing; without the cursor the responder would - // re-send that entire tail every round. - let covered = params.includedCount - let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0) - ? included[covered - 1].timestamp - : nil - let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter) + let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types) return req.encode() } // Periodic cleanup of expired messages and announcements private func cleanupExpiredMessages() { // Remove expired announcements - latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pkt in - isPacketFresh(pkt) - } - - let messageCountBefore = messages.packets.count - messages.removeExpired(isFresh: isPacketFresh) - if messages.packets.count != messageCountBefore { - archiveDirty = true - } - fragments.removeExpired(isFresh: isPacketFresh) - fileTransfers.removeExpired(isFresh: isPacketFresh) - groupMessages.removeExpired(isFresh: isPacketFresh) - latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in + latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in isPacketFresh(pair.packet) } - } - // MARK: - Archive (public message persistence) - - /// Rebuild the public message store from disk on launch, dropping - /// anything that aged out while the app was dead. - private func restoreArchivedMessages() { - guard let archive else { return } - var restored = 0 - for data in archive.load() { - guard let packet = BitchatPacket.from(data), - packet.type == MessageType.message.rawValue, - isPacketFresh(packet) else { continue } - let idHex = PacketIdUtil.computeId(packet).hexEncodedString() - messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity)) - restored += 1 - } - if restored > 0 { - SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync) - archiveDirty = true - } - } - - private func persistArchiveIfDirty() { - guard archiveDirty, let archive else { return } - archiveDirty = false - let packets = messages.allPackets(isFresh: isPacketFresh) - .compactMap { $0.toBinaryData(padding: false) } - archive.save(packets) - } - - /// Flush the archive outside the maintenance cadence (app backgrounding). - func persistNow() { - queue.async { [weak self] in - self?.persistArchiveIfDirty() - } - } - - /// Snapshot of the carried public-message packets (fresh window only), - /// for the "heard here earlier" timeline echoes. Completion runs on the - /// sync queue. - func collectPublicMessagePackets(completion: @escaping ([BitchatPacket]) -> Void) { - queue.async { [weak self] in - guard let self else { - completion([]) - return - } - completion(self.messages.allPackets(isFresh: self.isPacketFresh)) - } + messages.removeExpired(isFresh: isPacketFresh) + fragments.removeExpired(isFresh: isPacketFresh) + fileTransfers.removeExpired(isFresh: isPacketFresh) } private func performPeriodicMaintenance(now: Date = Date()) { cleanupExpiredMessages() cleanupStaleAnnouncementsIfNeeded(now: now) - persistArchiveIfDirty() requestSyncManager.cleanup() // Cleanup expired sync requests - responseRateLimiter.prune(now: now) - // One request per due schedule rather than a union filter: each type - // group gets the full GCS capacity and its own since-cursor, so heavy - // fragment traffic can't crowd messages out of the filter. for index in syncSchedules.indices { guard syncSchedules[index].interval > 0 else { continue } - // No board source wired up means nothing to offer or store; - // skip the round entirely. - if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue } if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { syncSchedules[index].lastSent = now sendPeriodicSync(for: syncSchedules[index].types) @@ -700,8 +415,8 @@ final class GossipSyncManager { let nowMs = UInt64(now.timeIntervalSince1970 * 1000) guard nowMs >= timeoutMs else { return } let cutoff = nowMs - timeoutMs - let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pkt in - pkt.timestamp < cutoff ? peerID : nil + let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in + pair.packet.timestamp < cutoff ? peerID : nil } guard !stalePeerIDs.isEmpty else { return } for peerKey in stalePeerIDs { @@ -716,55 +431,11 @@ final class GossipSyncManager { } } - /// Block-time hygiene: drop the carried public messages from a blocked - /// sender and persist immediately, so nothing of theirs can resurface as - /// an archived echo on the next launch. Narrower than `removeState(for:)` - /// — the peer's announcement and in-flight fragments are untouched. - func removePublicMessages(from peerID: PeerID) { - queue.async { [weak self] in - guard let self else { return } - let countBefore = self.messages.packets.count - self.messages.remove { PeerID(hexData: $0.senderID) == peerID } - guard self.messages.packets.count != countBefore else { return } - self.archiveDirty = true - // Persist now rather than waiting for maintenance: a relaunch in - // the gap would restore the purged messages from disk. - self.persistArchiveIfDirty() - } - } - - /// Drop every carried public message and clear the archive on disk. - /// - /// Used when someone clears the mesh timeline: the watermark already stops - /// cleared messages from being shown again, so anything left in the - /// archive is retained purely to serve other peers — and a person who - /// clears a timeline reasonably reads that as "this is gone from my - /// phone". The cost is that this device stops offering the recent public - /// backlog to peers until it hears fresh traffic. - func removeAllPublicMessages() { - queue.async { [weak self] in - guard let self else { return } - self.messages.remove { _ in true } - self.archiveDirty = true - // Persist now rather than waiting for maintenance: a relaunch in - // the gap would restore the purged messages from disk. - self.persistArchiveIfDirty() - self.archive?.wipe() - } - } - private func removeState(for peerID: PeerID) { - // Deliberately keeps the peer's prekey bundle: bundles exist to reach - // owners who left the mesh, and they age out on their own schedule. _ = latestAnnouncementByPeer.removeValue(forKey: peerID) - let messageCountBefore = messages.packets.count messages.remove { PeerID(hexData: $0.senderID) == peerID } - if messages.packets.count != messageCountBefore { - archiveDirty = true - } fragments.remove { PeerID(hexData: $0.senderID) == peerID } fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID } - groupMessages.remove { PeerID(hexData: $0.senderID) == peerID } } } @@ -782,12 +453,6 @@ extension GossipSyncManager { } } - func _hasPrekeyBundle(for peerID: PeerID) -> Bool { - queue.sync { - latestPrekeyBundleByPeer[peerID] != nil - } - } - func _messageCount(for peerID: PeerID) -> Int { queue.sync { messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count diff --git a/bitchat/Sync/PacketIdUtil.swift b/bitchat/Sync/PacketIdUtil.swift index 3c6c0bdc..08b7910d 100644 --- a/bitchat/Sync/PacketIdUtil.swift +++ b/bitchat/Sync/PacketIdUtil.swift @@ -1,4 +1,3 @@ -import struct BitFoundation.BitchatPacket import Foundation import CryptoKit diff --git a/bitchat/Sync/SyncResponseRateLimiter.swift b/bitchat/Sync/SyncResponseRateLimiter.swift deleted file mode 100644 index a6ffc6a1..00000000 --- a/bitchat/Sync/SyncResponseRateLimiter.swift +++ /dev/null @@ -1,42 +0,0 @@ -import BitFoundation -import Foundation - -/// Sliding-window limiter for REQUEST_SYNC responses. -/// -/// A single sync response can replay the entire gossip store, so a peer that -/// requests in a tight loop must not be able to drain the airtime and battery -/// of everyone in radio range. Legitimate peers send at most a few requests -/// per maintenance tick (one per type schedule, plus the initial sync). -struct SyncResponseRateLimiter { - private let maxResponses: Int - private let window: TimeInterval - private var history: [PeerID: [Date]] = [:] - - init(maxResponses: Int, window: TimeInterval) { - self.maxResponses = max(1, maxResponses) - self.window = max(0, window) - } - - /// Returns true (and records the response) if the peer is under its - /// response budget for the current window. - mutating func shouldRespond(to peerID: PeerID, now: Date) -> Bool { - let cutoff = now.addingTimeInterval(-window) - var recent = (history[peerID] ?? []).filter { $0 >= cutoff } - guard recent.count < maxResponses else { - history[peerID] = recent - return false - } - recent.append(now) - history[peerID] = recent - return true - } - - /// Drops history outside the window so departed peers don't accumulate. - mutating func prune(now: Date) { - let cutoff = now.addingTimeInterval(-window) - history = history.compactMapValues { dates in - let recent = dates.filter { $0 >= cutoff } - return recent.isEmpty ? nil : recent - } - } -} diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 4dbbb53a..15e22d89 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -1,4 +1,3 @@ -import BitFoundation import Foundation /// Bitfield describing which message types are covered by a REQUEST_SYNC round. @@ -7,25 +6,9 @@ struct SyncTypeFlags: OptionSet { let rawValue: UInt64 init(rawValue: UInt64) { - // Drop any bit that doesn't map to a known message type. Wire data can - // carry up to 8 bytes of flags; without this mask, bits with no type - // (a truncated/garbled field, or a type a newer peer added) would live - // in the set as phantom membership that no `contains` check matches and - // `toData` re-serializes — a meaningless "accepted but does nothing" - // state. Masking here keeps every instance normalized at the source. - self.rawValue = rawValue & SyncTypeFlags.knownTypeMask + self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes } - /// Union of every bit that maps to a message type. Derived from the - /// bit↔type table so it tracks automatically when a type is added. - private static let knownTypeMask: UInt64 = { - var mask: UInt64 = 0 - for bit in 0..<64 where SyncTypeFlags.type(forBit: bit) != nil { - mask |= (1 << UInt64(bit)) - } - return mask - }() - private static func bitIndex(for type: MessageType) -> Int? { switch type { case .announce: return 0 @@ -36,37 +19,6 @@ struct SyncTypeFlags: OptionSet { case .fragment: return 5 case .requestSync: return 6 case .fileTransfer: return 7 - case .boardPost: return 8 - // Extended bits are compat-safe by construction: `toData()` encodes - // the bitfield little-endian with trailing zero bytes trimmed (bit 10 - // widens the wire form from 1 to 2 bytes inside the length-prefixed - // REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while - // `type(forBit:)` maps unknown bits to nil — so old clients simply - // ignore the group bit and answer with the types they know. - case .groupMessage: return 10 - // Courier envelopes are directed deposits between trusted peers and - // must never spread via gossip sync. - case .courierEnvelope: return nil - // Ping/pong are ephemeral directed probes; replaying them via gossip - // sync would only produce stale, unanswerable echoes. - case .ping, .pong: return nil - // Gateway carriers are ephemeral live traffic (uplinks are directed, - // downlinks are rate-budgeted rebroadcasts); replaying them via sync - // would waste airtime and extend their lifetime. - case .nostrCarrier: return nil - // Live voice is only useful now; replaying stale audio frames via - // sync would waste airtime (receivers drop them as stale anyway). - case .voiceFrame: return nil - // Rotating-ID presence is valid only inside its epoch, and gossiping it - // would defeat the point: a synced announce would let a device that was - // never in radio range collect tag blocks, turning a local presence - // beacon into a network-wide one. - case .announceV2: return nil - // Prekey bundles gossip like board posts. The bitfield is a - // wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits - // ignored by `type(forBit:)`), so bits 8+ need no format change: old - // clients decode the wider flags and simply never match the new bits. - case .prekeyBundle: return 9 } } @@ -80,12 +32,6 @@ struct SyncTypeFlags: OptionSet { case 5: return .fragment case 6: return .requestSync case 7: return .fileTransfer - // Bit 8 spills the encoded bitfield into a second byte. Decoders since - // type-aware sync (#853) accept 1-8 bytes and map unknown bits to no - // known type, so old clients ignore board rounds instead of choking. - case 8: return .boardPost - case 9: return .prekeyBundle - case 10: return .groupMessage default: return nil } @@ -95,9 +41,6 @@ struct SyncTypeFlags: OptionSet { static let message = SyncTypeFlags(messageTypes: [.message]) static let fragment = SyncTypeFlags(messageTypes: [.fragment]) static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer]) - static let board = SyncTypeFlags(messageTypes: [.boardPost]) - static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle]) - static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage]) static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message]) @@ -123,15 +66,6 @@ struct SyncTypeFlags: OptionSet { SyncTypeFlags(rawValue: rawValue & other.rawValue) } - /// Compact form for logs, e.g. "message+fragment". Without this, the - /// per-schedule periodic sync rounds log identical lines and read as - /// duplicated sends (misdiagnosed twice during July 2026 device testing). - var logDescription: String { - let types = toMessageTypes() - guard !types.isEmpty else { return "none" } - return types.map { String(describing: $0) }.joined(separator: "+") - } - func toMessageTypes() -> [MessageType] { guard rawValue != 0 else { return [] } var types: [MessageType] = [] diff --git a/bitchat/Utils/AppLanguageSettings.swift b/bitchat/Utils/AppLanguageSettings.swift deleted file mode 100644 index 0bde5377..00000000 --- a/bitchat/Utils/AppLanguageSettings.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -/// In-app override for the UI language, on top of the system per-app -/// language. Apple resolves localization from the AppleLanguages default at -/// process start, so a new choice takes effect on the next launch — callers -/// surface a "restart to apply" note after changing it. -enum AppLanguageSettings { - /// "" means no override: follow the device (or per-app system) language. - static let overrideKey = "app.languageOverride" - private static let appleLanguagesKey = "AppleLanguages" - - /// Language codes the app ships translations for, straight from the - /// built bundle so this never drifts from the string catalog. - static var availableLanguages: [String] { - Bundle.main.localizations - .filter { $0 != "Base" } - .sorted { endonym(for: $0).localizedCaseInsensitiveCompare(endonym(for: $1)) == .orderedAscending } - } - - /// The language's name in that language ("فارسی", "한국어") so every user - /// can find their own entry regardless of the current UI language. - static func endonym(for code: String) -> String { - let locale = Locale(identifier: code) - let name = locale.localizedString(forIdentifier: code) ?? code - return name.lowercased(with: locale) - } - - /// Persists the override (nil clears it). AppleLanguages drives the - /// actual localization lookup on next launch. - static func setOverride(_ code: String?) { - let defaults = UserDefaults.standard - if let code, !code.isEmpty { - defaults.set(code, forKey: overrideKey) - defaults.set([code], forKey: appleLanguagesKey) - } else { - defaults.removeObject(forKey: overrideKey) - defaults.removeObject(forKey: appleLanguagesKey) - } - } -} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/CompressionUtil.swift b/bitchat/Utils/CompressionUtil.swift similarity index 95% rename from localPackages/BitFoundation/Sources/BitFoundation/CompressionUtil.swift rename to bitchat/Utils/CompressionUtil.swift index 5bd7bd2b..0e238ced 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/CompressionUtil.swift +++ b/bitchat/Utils/CompressionUtil.swift @@ -6,12 +6,12 @@ // For more information, see // -import struct Foundation.Data -private import Compression +import Foundation +import Compression struct CompressionUtil { // Compression threshold - don't compress if data is smaller than this - static let compressionThreshold = Constants.compressionThresholdBytes // bytes + static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes // Compress data using zlib algorithm (most compatible) static func compress(_ data: Data) -> Data? { diff --git a/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift b/bitchat/Utils/FileTransferLimits.swift similarity index 72% rename from localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift rename to bitchat/Utils/FileTransferLimits.swift index 4c54f79c..e7fd7d27 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift +++ b/bitchat/Utils/FileTransferLimits.swift @@ -1,13 +1,15 @@ +import Foundation + /// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios. -public enum FileTransferLimits { +enum FileTransferLimits { /// Absolute ceiling enforced for any file payload (voice, image, other). - public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB + static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB /// Voice notes stay small for low-latency relays. - public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB + static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB /// Compressed images after downscaling should comfortably fit under this budget. - public static let maxImageBytes: Int = 512 * 1024 // 512 KiB + static let maxImageBytes: Int = 512 * 1024 // 512 KiB /// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads. - public static let maxFramedFileBytes: Int = { + static let maxFramedFileBytes: Int = { let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize @@ -17,7 +19,7 @@ public enum FileTransferLimits { return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead }() - public static func isValidPayload(_ size: Int) -> Bool { + static func isValidPayload(_ size: Int) -> Bool { size <= maxPayloadBytes } } diff --git a/bitchat/Utils/InputValidator.swift b/bitchat/Utils/InputValidator.swift index 929cfee5..e9c86868 100644 --- a/bitchat/Utils/InputValidator.swift +++ b/bitchat/Utils/InputValidator.swift @@ -39,10 +39,9 @@ struct InputValidator { return trimmed } - /// Validates nickname and returns it in canonical (NFC) form so - /// visually identical names always compare equal. + /// Validates nickname static func validateNickname(_ nickname: String) -> String? { - return validateUserString(nickname, maxLength: Limits.maxNicknameLength)?.normalizedNickname + return validateUserString(nickname, maxLength: Limits.maxNicknameLength) } // MARK: - Protocol Field Validation diff --git a/bitchat/Utils/MessageDeduplicator.swift b/bitchat/Utils/MessageDeduplicator.swift index 215384fa..00ea0eb5 100644 --- a/bitchat/Utils/MessageDeduplicator.swift +++ b/bitchat/Utils/MessageDeduplicator.swift @@ -52,6 +52,18 @@ final class MessageDeduplicator { return false } + /// Record an ID with a specific timestamp (for content key tracking) + func record(_ id: String, timestamp: Date) { + lock.lock() + defer { lock.unlock() } + + if lookup[id] == nil { + entries.append(Entry(id: id, timestamp: timestamp)) + } + lookup[id] = timestamp + trimIfNeeded() + } + /// Add an ID without checking (for announce-back tracking) func markProcessed(_ id: String) { lock.lock() @@ -71,6 +83,13 @@ final class MessageDeduplicator { return lookup[id] != nil } + /// Get timestamp for an ID (for content deduplication time-window checks) + func timestampFor(_ id: String) -> Date? { + lock.lock() + defer { lock.unlock() } + return lookup[id] + } + private func trimIfNeeded() { let activeCount = entries.count - head guard activeCount > maxCount else { return } diff --git a/bitchat/Utils/PeerDisplayNameResolver.swift b/bitchat/Utils/PeerDisplayNameResolver.swift index a92953d3..77a89ec5 100644 --- a/bitchat/Utils/PeerDisplayNameResolver.swift +++ b/bitchat/Utils/PeerDisplayNameResolver.swift @@ -27,3 +27,4 @@ struct PeerDisplayNameResolver { return result } } + diff --git a/bitchat/Utils/QuickJoinRegions.swift b/bitchat/Utils/QuickJoinRegions.swift deleted file mode 100644 index 149aa8d3..00000000 --- a/bitchat/Utils/QuickJoinRegions.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Foundation - -/// The one-tap "quick join" suggestion in the channels sheet: the -/// region-level geohash channel around the device region's main population -/// center. Derived from the device locale — no location access, no GPS; the -/// tap reuses the same path as typing the geohash and teleporting. -/// -/// This replaced an earlier curated list of heavily censored countries. A -/// hand-picked roster invites disputes over who is on it and goes stale -/// with every political shift; deriving the suggestion from the locale -/// gives every country the same treatment under one rule. -/// -/// Quick join is channel discovery, not protection: region cells are -/// public, well known, and trivially enumerable, so the suggested channel -/// must be assumed watched. Joining it hides nothing and bypasses nothing. -struct QuickJoinSuggestion { - let regionCode: String - let geohash: String - - /// Regional-indicator flag emoji derived from the ISO code. - var flag: String { - regionCode.unicodeScalars.reduce(into: "") { result, scalar in - if let indicator = Unicode.Scalar(127397 + scalar.value) { - result.unicodeScalars.append(indicator) - } - } - } - - var localizedName: String { - Locale.current.localizedString(forRegionCode: regionCode) ?? regionCode - } - - /// The suggestion for the device's region, or nil when the region is - /// unknown or unmapped (the section is hidden then). - static func current(for locale: Locale = .current) -> QuickJoinSuggestion? { - guard let code = locale.region?.identifier.uppercased(), - let geohash = regionCells[code] else { return nil } - return QuickJoinSuggestion(regionCode: code, geohash: geohash) - } - - /// ISO 3166-1 alpha-2 region → the 2-character geohash cell over the - /// country's main population center — the largest metro, not always the - /// capital (US → New York, TR → Istanbul, MM → Yangon), because that is - /// the cell where a country's channel actually forms. Someone elsewhere - /// in the country lands in this cell too; the caption in the sheet says - /// so rather than calling it "your country's channel". - /// - /// Coverage is every UN member state plus inhabited territories a - /// device locale plausibly reports; a missing code just hides the row. - /// Cells are 11.25° × 5.625°, so city-level coordinates are ample. The - /// table is generated by geohashing each center's coordinates; the - /// twelve entries the earlier roster shipped were independently - /// verified in review and reproduce unchanged, and entries near a cell - /// boundary were checked by hand. Distinct countries can legitimately - /// share a cell (Seoul and Pyongyang are both "wy") — cells are big. - private static let regionCells: [String: String] = [ - "AD": "sp", "AE": "th", "AF": "tw", "AG": "de", - "AL": "sr", "AM": "sz", "AO": "kq", "AR": "69", - "AT": "u2", "AU": "r3", "AW": "d6", "AZ": "tp", - "BA": "sr", "BB": "dd", "BD": "wh", "BE": "u1", - "BF": "ef", "BG": "sx", "BH": "th", "BI": "kx", - "BJ": "s1", "BM": "dt", "BN": "w8", "BO": "6s", - "BR": "6g", "BS": "dk", "BT": "tu", "BW": "ke", - "BY": "u9", "BZ": "d5", "CA": "dp", "CD": "kr", - "CF": "s2", "CG": "kr", "CH": "u0", "CI": "eb", - "CL": "66", "CM": "s0", "CN": "wx", "CO": "d2", - "CR": "d1", "CU": "dh", "CV": "e6", "CW": "d6", - "CY": "sw", "CZ": "u2", "DE": "u3", "DJ": "sf", - "DK": "u3", "DM": "dd", "DO": "d7", "DZ": "sn", - "EC": "6p", "EE": "ud", "EG": "st", "ER": "sf", - "ES": "ez", "ET": "sc", "FI": "ud", "FJ": "ru", - "FM": "x9", "FO": "gg", "FR": "u0", "GA": "s0", - "GB": "gc", "GD": "dd", "GE": "sz", "GF": "db", - "GG": "gb", "GH": "eb", "GI": "ey", "GL": "fg", - "GM": "ed", "GN": "e9", "GP": "dd", "GQ": "s0", - "GR": "sw", "GT": "9f", "GU": "x4", "GW": "ed", - "GY": "d9", "HK": "we", "HN": "d4", "HR": "u2", - "HT": "d7", "HU": "u2", "ID": "qq", "IE": "gc", - "IL": "sv", "IM": "gc", "IN": "tt", "IQ": "sv", - "IR": "tn", "IS": "ge", "IT": "sr", "JE": "gb", - "JM": "d7", "JO": "sv", "JP": "xn", "KE": "kz", - "KG": "tx", "KH": "w6", "KI": "xb", "KM": "kv", - "KN": "de", "KP": "wy", "KR": "wy", "KW": "tj", - "KY": "d5", "KZ": "tx", "LA": "w7", "LB": "sy", - "LC": "dd", "LI": "u0", "LK": "tc", "LR": "ec", - "LS": "kd", "LT": "u9", "LU": "u0", "LV": "ud", - "LY": "sm", "MA": "ev", "MC": "sp", "MD": "u8", - "ME": "sr", "MG": "mh", "MH": "xc", "MK": "sr", - "ML": "ef", "MM": "w4", "MN": "y2", "MO": "we", - "MQ": "dd", "MR": "ee", "MT": "sq", "MU": "mk", - "MV": "t8", "MW": "kv", "MX": "9g", "MY": "w2", - "MZ": "ke", "NA": "k7", "NC": "rs", "NE": "s4", - "NG": "s1", "NI": "d4", "NL": "u1", "NO": "u4", - "NP": "tu", "NR": "rx", "NZ": "rc", "OM": "tk", - "PA": "d1", "PE": "6m", "PF": "2s", "PG": "rq", - "PH": "wd", "PK": "tk", "PL": "u3", "PR": "de", - "PS": "sv", "PT": "ey", "PW": "wc", "PY": "6e", - "QA": "th", "RE": "mh", "RO": "sx", "RS": "sr", - "RU": "uc", "RW": "kx", "SA": "th", "SB": "rw", - "SC": "mp", "SD": "sd", "SE": "u6", "SG": "w2", - "SI": "u2", "SK": "u2", "SL": "e9", "SM": "sr", - "SN": "ed", "SO": "t0", "SR": "dc", "SS": "s8", - "ST": "s0", "SV": "d4", "SY": "sv", "SZ": "ke", - "TD": "s6", "TG": "s1", "TH": "w4", "TJ": "tw", - "TL": "qy", "TM": "tq", "TN": "sn", "TO": "2h", - "TR": "sx", "TT": "d9", "TV": "ry", "TW": "ws", - "TZ": "ky", "UA": "u8", "UG": "s8", "US": "dr", - "UY": "6c", "UZ": "tx", "VA": "sr", "VC": "dd", - "VE": "d9", "VI": "de", "VN": "w7", "VU": "rs", - "WS": "2j", "XK": "sr", "YE": "sf", "YT": "mj", - "ZA": "ke", "ZM": "kt", "ZW": "ks" - ] -} diff --git a/bitchat/Utils/SafeRegex.swift b/bitchat/Utils/SafeRegex.swift deleted file mode 100644 index 125f2f80..00000000 --- a/bitchat/Utils/SafeRegex.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// SafeRegex.swift -// bitchat -// -// Non-trapping construction for the app's compiled-in regex patterns. -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitLogger -import Foundation - -enum SafeRegex { - /// Compiles a bundled pattern. On failure it logs and returns a regex - /// that can never match, so a bad pattern degrades that one feature - /// instead of crashing at startup. - static func compile(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression { - do { - return try NSRegularExpression(pattern: pattern, options: options) - } catch { - SecureLogger.error("Regex pattern failed to compile, matching disabled: \(pattern) (\(error))", category: .session) - return neverMatching - } - } - - /// `(?!)` — an empty negative lookahead — always compiles and can never match. - private static let neverMatching: NSRegularExpression = { - if let regex = try? NSRegularExpression(pattern: "(?!)", options: []) { - return regex - } - // Unreachable: "(?!)" is a valid ICU pattern. The inherited plain - // initializer (empty pattern) is the least-bad non-trapping fallback - // if ICU itself were ever broken. - return NSRegularExpression() - }() -} diff --git a/bitchat/Utils/String+Nickname.swift b/bitchat/Utils/String+Nickname.swift index 9a652667..b586aa66 100644 --- a/bitchat/Utils/String+Nickname.swift +++ b/bitchat/Utils/String+Nickname.swift @@ -9,14 +9,6 @@ import Foundation extension String { - /// Canonical form for nickname storage and comparison (Unicode NFC). - /// "café" typed with a combining accent and "café" typed precomposed - /// must resolve to the same user wherever nicknames are stored or - /// matched (mentions, DM resolution, autocomplete, geo presence). - var normalizedNickname: String { - precomposedStringWithCanonicalMapping - } - /// Split a nickname into base and a '#abcd' suffix if present func splitSuffix() -> (String, String) { let name = self.replacingOccurrences(of: "@", with: "") diff --git a/bitchat/Utils/SystemSettings.swift b/bitchat/Utils/SystemSettings.swift index 2a739ea9..ab552e07 100644 --- a/bitchat/Utils/SystemSettings.swift +++ b/bitchat/Utils/SystemSettings.swift @@ -14,22 +14,17 @@ import AppKit enum SystemSettings { case bluetooth - /// The radio power toggle, distinct from the `.bluetooth` privacy - /// permission anchor: an already-authorized person whose radio is - /// switched off can't fix anything from the privacy pane. - case bluetoothPower case location case microphone #if os(macOS) private static let baseURL = "x-apple.systempreferences:com.apple.preference.security" - private var macURLString: String { + private var macPrivacyAnchor: String { switch self { - case .bluetooth: "\(Self.baseURL)?Privacy_Bluetooth" - case .bluetoothPower: "x-apple.systempreferences:com.apple.BluetoothSettings" - case .location: "\(Self.baseURL)?Privacy_LocationServices" - case .microphone: "\(Self.baseURL)?Privacy_Microphone" + case .bluetooth: "Privacy_Bluetooth" + case .location: "Privacy_LocationServices" + case .microphone: "Privacy_Microphone" } } #endif @@ -40,7 +35,8 @@ enum SystemSettings { UIApplication.shared.open(url) } #elseif os(macOS) - if let url = URL(string: macURLString) { + let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)" + if let url = URL(string: urlString) { NSWorkspace.shared.open(url) } #endif diff --git a/bitchat/Utils/Theme.swift b/bitchat/Utils/Theme.swift deleted file mode 100644 index ec0283e0..00000000 --- a/bitchat/Utils/Theme.swift +++ /dev/null @@ -1,339 +0,0 @@ -// -// Theme.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI - -/// A user-selectable app-wide visual theme. Persisted by raw value. -enum AppTheme: String, CaseIterable, Identifiable { - case matrix - case liquidGlass - - var id: String { rawValue } - - /// UserDefaults key backing the theme selection. - static let storageKey = "appTheme" - - var displayNameKey: LocalizedStringKey { - switch self { - case .matrix: return "app_info.appearance.matrix" - case .liquidGlass: return "app_info.appearance.liquid_glass" - } - } - - /// Font design used for themed text. Matrix keeps the terminal monospace; - /// liquid glass uses the system default. - var bodyFontDesign: Font.Design { - switch self { - case .matrix: return .monospaced - case .liquidGlass: return .default - } - } - - /// Whether chrome surfaces (header/composer bars, input field) render as - /// translucent glass/material instead of the flat matrix background. - var usesGlassChrome: Bool { - self == .liquidGlass - } - - /// Discriminator mixed into per-message formatting caches so cached - /// AttributedStrings from one theme are never served under another. - /// Empty for matrix to keep its historical cache keys. - var formatCacheVariant: String { - switch self { - case .matrix: return "" - case .liquidGlass: return "lg:" - } - } - - /// Resolves the semantic color palette for this theme under the given color scheme. - func palette(for colorScheme: ColorScheme) -> ThemePalette { - switch self { - case .matrix: - return .matrix(colorScheme) - case .liquidGlass: - return .liquidGlass(colorScheme) - } - } -} - -/// Semantic colors for the active theme, resolved against the current color scheme. -/// Views should consume these via `@ThemedPalette` rather than computing colors inline. -struct ThemePalette { - /// Primary window/sheet background. - let background: Color - /// Primary text color. - let primary: Color - /// De-emphasized text (timestamps, hints, captions). - let secondary: Color - /// Interactive tint (buttons, toggles, selection). - let accent: Color - /// Location/geohash channel accent (badges, counts, subtitles). - let locationAccent: Color - /// Informational accent (links, read receipts, teleport markers). - let accentBlue: Color - /// Destructive/error accent. - let alertRed: Color - /// Hairline separators. - let divider: Color - - static func matrix(_ colorScheme: ColorScheme) -> ThemePalette { - let isDark = colorScheme == .dark - let green = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - return ThemePalette( - background: isDark ? Color.black : Color.white, - primary: green, - secondary: green.opacity(0.8), - accent: green, - locationAccent: green, - accentBlue: Color(red: 0.0, green: 0.478, blue: 1.0), - alertRed: Color(red: 0.75, green: 0.1, blue: 0.1), - divider: isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08) - ) - } - - static func liquidGlass(_: ColorScheme) -> ThemePalette { - ThemePalette( - background: systemBackground, - primary: .primary, - secondary: .secondary, - accent: .blue, - locationAccent: .green, - accentBlue: .blue, - alertRed: .red, - divider: separator - ) - } - - private static var systemBackground: Color { - #if os(iOS) - Color(UIColor.systemBackground) - #else - Color(NSColor.windowBackgroundColor) - #endif - } - - private static var separator: Color { - #if os(iOS) - Color(UIColor.separator) - #else - Color(NSColor.separatorColor) - #endif - } -} - -private struct AppThemeKey: EnvironmentKey { - static let defaultValue: AppTheme = .matrix -} - -extension EnvironmentValues { - var appTheme: AppTheme { - get { self[AppThemeKey.self] } - set { self[AppThemeKey.self] = newValue } - } -} - -/// Resolves the active theme's palette against the view's color scheme. -/// -/// @ThemedPalette private var palette -/// var body: some View { Text("hi").foregroundColor(palette.primary) } -@propertyWrapper -struct ThemedPalette: DynamicProperty { - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - - var wrappedValue: ThemePalette { theme.palette(for: colorScheme) } -} - -// MARK: - Themed view helpers - -/// Themed replacement for `.font(.bitchatSystem(size:weight:design: .monospaced))`: -/// monospaced under matrix, system default under liquid glass. -private struct ThemedFontModifier: ViewModifier { - @Environment(\.appTheme) private var theme - let size: CGFloat - let weight: Font.Weight - - func body(content: Content) -> some View { - content.font(.bitchatSystem(size: size, weight: weight, design: theme.bodyFontDesign)) - } -} - -/// Root backdrop. Matrix gets its flat background; glass gets a subtle static -/// gradient with a soft tinted glow — glass panels need visual texture behind -/// them to refract, and collapse to flat gray over a solid color. -struct ThemedRootBackground: View { - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - @ThemedPalette private var palette - - var body: some View { - if theme.usesGlassChrome { - let isDark = colorScheme == .dark - ZStack { - LinearGradient( - colors: isDark - ? [Color(red: 0.09, green: 0.10, blue: 0.15), Color(red: 0.04, green: 0.04, blue: 0.07)] - : [Color(red: 0.93, green: 0.95, blue: 1.0), Color(red: 0.98, green: 0.97, blue: 0.99)], - startPoint: .top, - endPoint: .bottom - ) - RadialGradient( - colors: [Color.blue.opacity(isDark ? 0.22 : 0.12), .clear], - center: .topLeading, - startRadius: 0, - endRadius: 600 - ) - RadialGradient( - colors: [Color.purple.opacity(isDark ? 0.14 : 0.08), .clear], - center: .bottomTrailing, - startRadius: 0, - endRadius: 500 - ) - } - .ignoresSafeArea() - } else { - palette.background - } - } -} - -/// Wraps glass-shape content in real Liquid Glass on OS 26+, with a material -/// fallback below that keeps the frosted look. -private struct GlassPanel: ViewModifier { - let shape: S - - @ViewBuilder - func body(content: Content) -> some View { - #if compiler(>=6.2) - if #available(iOS 26.0, macOS 26.0, *) { - content.glassEffect(.regular, in: shape) - } else { - materialFallback(content) - } - #else - materialFallback(content) - #endif - } - - private func materialFallback(_ content: Content) -> some View { - content - .background(shape.fill(.ultraThinMaterial)) - .overlay(shape.stroke(Color.white.opacity(0.15), lineWidth: 0.5)) - } -} - -/// Chrome surface for the header and composer. Matrix keeps the original flat -/// edge-to-edge wash; glass floats the content as an inset Liquid Glass panel -/// (content is expected to scroll underneath via safe-area insets). -private struct ThemedChromePanelModifier: ViewModifier { - @Environment(\.appTheme) private var theme - @ThemedPalette private var palette - let edge: VerticalEdge - - @ViewBuilder - func body(content: Content) -> some View { - if theme.usesGlassChrome { - content - .modifier(GlassPanel(shape: RoundedRectangle(cornerRadius: 18, style: .continuous))) - .padding(.horizontal, 8) - .padding(edge == .top ? .top : .bottom, 4) - } else { - content.background(palette.background.opacity(0.95)) - } - } -} - -/// Background for the composer input field. Matrix keeps its translucent fill; -/// glass leaves it clear — the field sits inside the composer's glass panel, -/// and glass cannot sample other glass. -private struct ThemedInputBackgroundModifier: ViewModifier { - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - - private var shape: RoundedRectangle { - RoundedRectangle(cornerRadius: 14, style: .continuous) - } - - @ViewBuilder - func body(content: Content) -> some View { - if theme.usesGlassChrome { - content - } else { - content.background( - shape.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7)) - ) - } - } -} - -extension View { - func bitchatFont(size: CGFloat, weight: Font.Weight = .regular) -> some View { - modifier(ThemedFontModifier(size: size, weight: weight)) - } - - func themedChromePanel(edge: VerticalEdge) -> some View { - modifier(ThemedChromePanelModifier(edge: edge)) - } - - func themedInputBackground() -> some View { - modifier(ThemedInputBackgroundModifier()) - } - - /// Floating surface for popover-style boxes (autocomplete, command - /// suggestions): glass panel under liquid glass, the original flat - /// background + hairline stroke under matrix. - func themedOverlayPanel() -> some View { - modifier(ThemedOverlayPanelModifier()) - } - - /// Root background for sheets — same backdrop as the main window so every - /// surface speaks one visual language. - func themedSheetBackground() -> some View { - background(ThemedRootBackground()) - } - - /// Flat background wash for bars/headers inside sheets. Matrix keeps its - /// opaque wash; glass goes transparent so the backdrop gradient shows. - func themedSurface(opacity: Double = 1.0) -> some View { - modifier(ThemedSurfaceModifier(opacity: opacity)) - } -} - -private struct ThemedSurfaceModifier: ViewModifier { - @Environment(\.appTheme) private var theme - @ThemedPalette private var palette - let opacity: Double - - @ViewBuilder - func body(content: Content) -> some View { - if theme.usesGlassChrome { - content - } else { - content.background(palette.background.opacity(opacity)) - } - } -} - -private struct ThemedOverlayPanelModifier: ViewModifier { - @Environment(\.appTheme) private var theme - @ThemedPalette private var palette - - @ViewBuilder - func body(content: Content) -> some View { - if theme.usesGlassChrome { - content.modifier(GlassPanel(shape: RoundedRectangle(cornerRadius: 12, style: .continuous))) - } else { - content - .background(palette.background) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(palette.secondary.opacity(0.3), lineWidth: 1) - ) - } - } -} diff --git a/bitchat/ViewModels/ChatBluetoothAlertPolicy.swift b/bitchat/ViewModels/ChatBluetoothAlertPolicy.swift deleted file mode 100644 index 0e7168e4..00000000 --- a/bitchat/ViewModels/ChatBluetoothAlertPolicy.swift +++ /dev/null @@ -1,35 +0,0 @@ -import CoreBluetooth -import Foundation - -struct ChatBluetoothAlertUpdate: Equatable { - let isPresented: Bool - let message: String? -} - -enum ChatBluetoothAlertPolicy { - static func update(for state: CBManagerState) -> ChatBluetoothAlertUpdate { - switch state { - case .poweredOff: - ChatBluetoothAlertUpdate( - isPresented: true, - message: String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off") - ) - case .unauthorized: - ChatBluetoothAlertUpdate( - isPresented: true, - message: String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing") - ) - case .unsupported: - ChatBluetoothAlertUpdate( - isPresented: true, - message: String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support") - ) - case .poweredOn: - ChatBluetoothAlertUpdate(isPresented: false, message: "") - case .unknown, .resetting: - ChatBluetoothAlertUpdate(isPresented: false, message: nil) - @unknown default: - ChatBluetoothAlertUpdate(isPresented: false, message: nil) - } - } -} diff --git a/bitchat/ViewModels/ChatComposerCoordinator.swift b/bitchat/ViewModels/ChatComposerCoordinator.swift deleted file mode 100644 index d0d347f2..00000000 --- a/bitchat/ViewModels/ChatComposerCoordinator.swift +++ /dev/null @@ -1,167 +0,0 @@ -import BitFoundation -import Foundation - -/// The narrow surface `ChatComposerCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatComposerCoordinatorContextTests`) and makes its true -/// dependencies explicit. -@MainActor -protocol ChatComposerContext: AnyObject { - // MARK: Autocomplete UI state - var autocompleteSuggestions: [String] { get set } - var autocompleteRange: NSRange? { get set } - var showAutocomplete: Bool { get set } - var selectedAutocompleteIndex: Int { get set } - /// Computes mention suggestions for the text up to the cursor. - func autocompleteQuery( - for text: String, - peers: [String], - cursorPosition: Int - ) -> (suggestions: [String], range: NSRange?) - /// Replaces the matched range in `text` with the chosen suggestion. - func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String - - // MARK: Identity & channel state - var nickname: String { get } - var myPeerID: PeerID { get } - var activeChannel: ChannelID { get } - /// The transport's own nickname (excluded from autocomplete candidates). - var meshNickname: String { get } - func meshPeerNicknames() -> [PeerID: String] - /// True when this mesh nickname belongs to a blocked peer. - func isMeshNicknameBlocked(_ nickname: String) -> Bool - /// True when this geohash pubkey is blocked for location chats. - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool - - // MARK: Geohash identity (shared with the other contexts) - var geoNicknames: [String: String] { get } - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity -} - -extension ChatViewModel: ChatComposerContext { - // `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`, - // `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`, - // `geoNicknames`, `meshPeerNicknames()`, `isNostrBlocked(pubkeyHexLowercased:)`, - // and `deriveNostrIdentity(forGeohash:)` are shared requirements with the - // other contexts or satisfied by existing `ChatViewModel` members. The - // members below flatten nested service accesses into intent-named calls. - - func autocompleteQuery( - for text: String, - peers: [String], - cursorPosition: Int - ) -> (suggestions: [String], range: NSRange?) { - autocompleteService.getSuggestions(for: text, peers: peers, cursorPosition: cursorPosition) - } - - func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String { - autocompleteService.applySuggestion(suggestion, to: text, range: range) - } - - var meshNickname: String { - meshService.myNickname - } - - func isMeshNicknameBlocked(_ nickname: String) -> Bool { - for (peerID, nick) in meshService.getPeerNicknames() where nick == nickname { - if isPeerBlocked(peerID) { return true } - } - return false - } -} - -@MainActor -final class ChatComposerCoordinator { - private unowned let context: any ChatComposerContext - - init(context: any ChatComposerContext) { - self.context = context - } - - func updateAutocomplete(for text: String, cursorPosition: Int) { - let peerCandidates = autocompleteCandidates() - let (suggestions, range) = context.autocompleteQuery( - for: text, - peers: peerCandidates, - cursorPosition: cursorPosition - ) - - if !suggestions.isEmpty { - context.autocompleteSuggestions = suggestions - context.autocompleteRange = range - context.showAutocomplete = true - context.selectedAutocompleteIndex = 0 - } else { - context.autocompleteSuggestions = [] - context.autocompleteRange = nil - context.showAutocomplete = false - context.selectedAutocompleteIndex = 0 - } - } - - func completeNickname(_ nickname: String, in text: inout String) -> Int { - guard let range = context.autocompleteRange else { return text.count } - - text = context.applyAutocompleteSuggestion(nickname, to: text, range: range) - - context.showAutocomplete = false - context.autocompleteSuggestions = [] - context.autocompleteRange = nil - context.selectedAutocompleteIndex = 0 - - return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2) - } - - func parseMentions(from content: String) -> [String] { - let regex = ChatViewModel.Patterns.mention - let nsContent = content as NSString - let matches = regex.matches( - in: content, - options: [], - range: NSRange(location: 0, length: nsContent.length) - ) - - let peerNicknames = context.meshPeerNicknames() - var validTokens = Set(peerNicknames.values) - validTokens.insert(context.nickname) - validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4))) - - var mentions: [String] = [] - for match in matches { - guard let range = Range(match.range(at: 1), in: content) else { continue } - let mentionedName = String(content[range]) - if validTokens.contains(mentionedName) { - mentions.append(mentionedName) - } - } - - return Array(Set(mentions)) - } -} - -private extension ChatComposerCoordinator { - func autocompleteCandidates() -> [String] { - switch context.activeChannel { - case .mesh: - let values = context.meshPeerNicknames().values - return Array(values.filter { nick in - nick != context.meshNickname && !context.isMeshNicknameBlocked(nick) - }) - - case .location(let channel): - var tokens = Set() - for (pubkey, nick) in context.geoNicknames { - guard !context.isNostrBlocked(pubkeyHexLowercased: pubkey) else { continue } - tokens.insert("\(nick)#\(pubkey.suffix(4))") - } - if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - let myToken = context.nickname + "#" + String(identity.publicKeyHex.suffix(4)) - tokens.remove(myToken) - } - return Array(tokens) - } - } -} diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift deleted file mode 100644 index dcaf95e6..00000000 --- a/bitchat/ViewModels/ChatDeliveryCoordinator.swift +++ /dev/null @@ -1,217 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The narrow surface `ChatDeliveryCoordinator` needs from its owner. -/// -/// Coordinators should depend on the minimal context they actually use rather -/// than holding an `unowned` back-reference to the whole `ChatViewModel`. This -/// keeps the coordinator independently testable (see -/// `ChatDeliveryCoordinatorContextTests`) and makes its true dependencies -/// explicit. This protocol is the exemplar for migrating the other -/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs. -@MainActor -protocol ChatDeliveryContext: AnyObject { - var isStartupPhase: Bool { get } - /// Applies a delivery status to every copy of the message across - /// conversations (`ConversationStore` intent, ID-only: the store's - /// message-ID → conversation map resolves which conversations hold the - /// message, including mirrored ephemeral/stable private copies). The - /// no-downgrade rule is enforced in the store. Returns `false` when the - /// message is unknown or no copy changed. - @discardableResult - func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool - /// Applies an authenticated receipt only to the direct conversations - /// represented by the supplied peer aliases. - @discardableResult - func setDeliveryStatus( - _ status: DeliveryStatus, - forMessageID messageID: String, - inDirectPeerAliases peerIDs: Set - ) -> Bool - /// Current delivery status of the message in whichever conversation holds it. - func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? - /// Message IDs across all direct conversations (read-receipt pruning). - func privateMessageIDs() -> Set - /// Drops every recorded read receipt whose message ID is not in `validMessageIDs`. - /// Returns the number of receipts removed. (Single mutation path for the - /// owner's `sentReadReceipts`; this coordinator never reads the raw set.) - func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int - /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). - func notifyUIChanged() - /// Confirms receipt so the message router stops retaining the message for resend. - func markMessageDelivered(_ messageID: String) - /// Peer-bound form for authenticated remote receipts. Only the supplied - /// conversation aliases may have retained state terminalized. This is the - /// router-side clear only: it is safe without a conversation lookup - /// because the router scopes removal to the acking peer's own queues. - func markMessageDelivered(_ messageID: String, from peerIDs: Set) - /// Releases the media reconnect retry for a private transfer. Unlike the - /// router clear above this is keyed only by the stable media message ID, - /// so callers must first bind the receipt to one of our outgoing - /// conversations for the acking peer. - func confirmPrivateMediaDelivery(_ messageID: String) - /// Returns true only when `messageID` is one of our outgoing messages in - /// at least one of the authenticated peer's direct-conversation aliases. - func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool -} - -extension ChatViewModel: ChatDeliveryContext { - @discardableResult - func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { - conversations.setDeliveryStatus(status, forMessageID: messageID) - } - - @discardableResult - func setDeliveryStatus( - _ status: DeliveryStatus, - forMessageID messageID: String, - inDirectPeerAliases peerIDs: Set - ) -> Bool { - conversations.setDeliveryStatus( - status, - forMessageID: messageID, - inDirectPeerAliases: peerIDs - ) - } - - func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { - conversations.deliveryStatus(forMessageID: messageID) - } - - func privateMessageIDs() -> Set { - conversations.directMessageIDs() - } - - func notifyUIChanged() { - objectWillChange.send() - } - - func markMessageDelivered(_ messageID: String) { - messageRouter.markDelivered(messageID) - mediaTransferCoordinator.confirmPrivateMediaDelivery( - messageID: messageID - ) - } - - func markMessageDelivered(_ messageID: String, from peerIDs: Set) { - messageRouter.markDelivered(messageID, from: peerIDs) - } - - func confirmPrivateMediaDelivery(_ messageID: String) { - mediaTransferCoordinator.confirmPrivateMediaDelivery( - messageID: messageID - ) - } - - func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool { - peerIDs.contains { peerID in - privateMessages(for: peerID).contains { message in - message.id == messageID && message.senderPeerID == myPeerID - } - } - } -} - -/// Thin mapper from delivery events (read receipts, transport delivery -/// callbacks) onto `ConversationStore` delivery intents, plus read-receipt -/// retention cleanup. The store's message-ID → conversation map replaces the -/// positional `messageLocationIndex` this coordinator used to maintain. -final class ChatDeliveryCoordinator { - private unowned let context: any ChatDeliveryContext - - init(context: any ChatDeliveryContext) { - self.context = context - } - - @MainActor - func cleanupOldReadReceipts() { - guard !context.isStartupPhase else { return } - let validMessageIDs = context.privateMessageIDs() - guard !validMessageIDs.isEmpty else { return } - - let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs) - if removedCount > 0 { - SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session) - } - } - - @MainActor - func didReceiveReadReceipt(_ receipt: ReadReceipt) { - updateAcknowledgedMessageDeliveryStatus( - receipt.originalMessageID, - status: .read(by: receipt.readerNickname, at: receipt.timestamp), - from: [receipt.readerID] - ) - } - - @MainActor - func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { - updateMessageDeliveryStatus(messageID, status: status) - } - - @MainActor - func deliveryStatus(for messageID: String) -> DeliveryStatus? { - context.deliveryStatus(forMessageID: messageID) - } - - @MainActor - @discardableResult - func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { - guard context.setDeliveryStatus(status, forMessageID: messageID) else { - return false - } - switch status { - case .delivered, .read: - // Terminalize only after the store accepted the transition. - context.markMessageDelivered(messageID) - default: - break - } - context.notifyUIChanged() - return true - } - - /// Applies an authenticated remote delivery/read receipt. The durable - /// retry state is always released for the acking peer's own aliases - /// (`markMessageDelivered(_:from:)` is peer-scoped on the router side, so - /// a receipt can only terminalize messages queued for that peer). Only the - /// UI status transition and the media-retry release are gated on the - /// in-memory conversation holding the message as one of ours: after a - /// force-quit → relaunch the durable outbox is restored - /// while the conversation may not be, and discarding the ack there would - /// re-send an already-delivered message on every flush/auth event until - /// the attempt cap marks it failed. Mirrors the Nostr path - /// (`ChatPrivateConversationCoordinator.handleDelivered`), which clears - /// retained state unconditionally. - @MainActor - @discardableResult - func updateAcknowledgedMessageDeliveryStatus( - _ messageID: String, - status: DeliveryStatus, - from peerIDAliases: Set - ) -> Bool { - switch status { - case .delivered, .read: - break - default: - return false - } - guard !peerIDAliases.isEmpty else { return false } - context.markMessageDelivered(messageID, from: peerIDAliases) - guard context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases), - context.setDeliveryStatus( - status, - forMessageID: messageID, - inDirectPeerAliases: peerIDAliases - ) else { - return false - } - // The receipt is now bound to one of our outgoing conversations for - // the acking peer; only then release the media reconnect retry, whose - // stable message ID is not peer-scoped. - context.confirmPrivateMediaDelivery(messageID) - context.notifyUIChanged() - return true - } -} diff --git a/bitchat/ViewModels/ChatFavoriteTogglePolicy.swift b/bitchat/ViewModels/ChatFavoriteTogglePolicy.swift deleted file mode 100644 index 2cded1f2..00000000 --- a/bitchat/ViewModels/ChatFavoriteTogglePolicy.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation - -struct ChatFavoriteStatusSnapshot: Equatable { - let peerNickname: String - let peerNostrPublicKey: String? - let isFavorite: Bool - let theyFavoritedUs: Bool - - init( - peerNickname: String, - peerNostrPublicKey: String?, - isFavorite: Bool, - theyFavoritedUs: Bool - ) { - self.peerNickname = peerNickname - self.peerNostrPublicKey = peerNostrPublicKey - self.isFavorite = isFavorite - self.theyFavoritedUs = theyFavoritedUs - } - - init(_ relationship: FavoritesPersistenceService.FavoriteRelationship) { - self.peerNickname = relationship.peerNickname - self.peerNostrPublicKey = relationship.peerNostrPublicKey - self.isFavorite = relationship.isFavorite - self.theyFavoritedUs = relationship.theyFavoritedUs - } -} - -enum ChatFavoritePersistenceAction: Equatable { - case add(nickname: String, nostrKey: String?) - case remove -} - -enum ChatFavoriteNotificationDecision: Equatable { - case none - case send(isFavorite: Bool) -} - -struct ChatFavoriteTogglePlan: Equatable { - let persistenceAction: ChatFavoritePersistenceAction - let notification: ChatFavoriteNotificationDecision -} - -enum ChatFavoriteTogglePolicy { - static func plan( - currentStatus: ChatFavoriteStatusSnapshot?, - fallbackNickname: String?, - bridgedNostrKey: String? - ) -> ChatFavoriteTogglePlan { - let wasFavorite = currentStatus?.isFavorite ?? false - - if wasFavorite { - return ChatFavoriteTogglePlan( - persistenceAction: .remove, - notification: .send(isFavorite: false) - ) - } - - return ChatFavoriteTogglePlan( - persistenceAction: .add( - nickname: currentStatus?.peerNickname ?? fallbackNickname ?? "Unknown", - nostrKey: currentStatus?.peerNostrPublicKey ?? bridgedNostrKey - ), - notification: currentStatus?.theyFavoritedUs == true ? .send(isFavorite: true) : .none - ) - } -} diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift deleted file mode 100644 index b8a2b2a6..00000000 --- a/bitchat/ViewModels/ChatGroupCoordinator.swift +++ /dev/null @@ -1,610 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The narrow surface `ChatGroupCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. Group chats are keyed like direct chats -/// (virtual "group_" peer IDs), so the conversation intents below reuse the -/// private-chat store operations. -@MainActor -protocol ChatGroupContext: AnyObject { - // MARK: Identity & state - var nickname: String { get } - var myPeerID: PeerID { get } - var selectedPrivateChatPeer: PeerID? { get } - var groupStore: GroupStore { get } - - /// Fingerprint of our own Noise static identity key. - func myNoiseFingerprint() -> String - /// Our Ed25519 signing public key. - func mySigningPublicKey() -> Data - /// Signs `data` with our Noise signing key. - func signWithNoiseKey(_ data: Data) -> Data? - - // MARK: Peers - func getPeerIDForNickname(_ nickname: String) -> PeerID? - func isPeerConnected(_ peerID: PeerID) -> Bool - func peerNickname(for peerID: PeerID) -> String? - /// The peer's Noise fingerprint from the live session/registry. - func meshFingerprint(for peerID: PeerID) -> String? - /// The peer's persisted crypto identity (fingerprint + signing key), if - /// the identity store has a signature-verified announce for them. - func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? - /// The connected short peer ID whose fingerprint matches, if any. - func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? - /// Whether the user has blocked the identity with this Noise fingerprint. - func isFingerprintBlocked(_ fingerprint: String) -> Bool - - // MARK: Transport - func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) - func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) - func broadcastGroupMessagePayload(_ payload: Data) - - // MARK: Conversation intents (group chats are direct-keyed) - @discardableResult - func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool - func markPrivateChatUnread(_ peerID: PeerID) - func removePrivateChat(_ peerID: PeerID) - func startPrivateChat(with peerID: PeerID) - func endPrivateChat() - func addSystemMessage(_ content: String) - func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) - func notifyUIChanged() - func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) -} - -extension ChatViewModel: ChatGroupContext { - // `nickname`, `myPeerID`, `selectedPrivateChatPeer`, `groupStore`, - // `getPeerIDForNickname(_:)`, `isPeerConnected(_:)`, `peerNickname(for:)`, - // `appendPrivateMessage(_:to:)`, `markPrivateChatUnread(_:)`, - // `removePrivateChat(_:)`, `startPrivateChat(with:)`, - // `addSystemMessage(_:)`, `addLocalPrivateSystemMessage(_:to:)`, - // `notifyUIChanged()`, and `notifyPrivateMessage(from:message:peerID:)` - // are shared requirements with the other contexts or satisfied by - // existing `ChatViewModel` members. The members below flatten nested - // service accesses into intent-named calls. - - func myNoiseFingerprint() -> String { - meshService.noiseIdentityFingerprint() - } - - func mySigningPublicKey() -> Data { - meshService.noiseSigningPublicKeyData() - } - - func signWithNoiseKey(_ data: Data) -> Data? { - meshService.noiseSignData(data) - } - - func meshFingerprint(for peerID: PeerID) -> String? { - meshService.getFingerprint(for: peerID) - } - - /// The persisted, signature-verified identity behind a short mesh peer - /// ID. Cross-checked against the live session fingerprint so a roster - /// entry can never be pinned to a signing key from a different identity. - func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? { - guard let fingerprint = meshService.getFingerprint(for: peerID) else { return nil } - let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) - guard let identity = candidates.first(where: { $0.fingerprint == fingerprint }), - let signingKey = identity.signingPublicKey else { return nil } - return (fingerprint, signingKey) - } - - /// Short mesh peer IDs are the fingerprint's first 16 hex chars, so the - /// connected peer for a roster fingerprint is a direct derivation. - func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? { - let shortID = PeerID(str: String(fingerprint.prefix(16))) - return meshService.isPeerConnected(shortID) ? shortID : nil - } - - func isFingerprintBlocked(_ fingerprint: String) -> Bool { - identityManager.isBlocked(fingerprint: fingerprint) - } - - /// Group state rides the mesh's Noise sessions only. - private var groupTransport: MeshGroupMessaging? { meshService as? MeshGroupMessaging } - - func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) { - groupTransport?.sendGroupInvite(payload, to: peerID) - } - - func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) { - groupTransport?.sendGroupKeyUpdate(payload, to: peerID) - } - - func broadcastGroupMessagePayload(_ payload: Data) { - groupTransport?.broadcastGroupMessage(payload) - } - - // MARK: CommandContextProvider group commands (parsed by CommandProcessor) - - func groupCreate(named name: String) -> CommandResult { - groupCoordinator.createGroup(named: name) - } - - func groupInvite(nickname: String) -> CommandResult { - groupCoordinator.inviteMember(nickname: nickname) - } - - func groupRemove(nickname: String) -> CommandResult { - groupCoordinator.removeMember(nickname: nickname) - } - - func groupLeave() -> CommandResult { - groupCoordinator.leaveGroup() - } - - func groupList() -> CommandResult { - groupCoordinator.listGroups() - } -} - -/// Owns the private-groups feature: creating groups, creator-managed invites -/// and key rotation over Noise, and sealing/opening group message broadcasts. -/// Delivery is fire-and-flood like public chat — no per-member acks in v1 — -/// with gossip-sync backfill as the only offline catch-up. -@MainActor -final class ChatGroupCoordinator { - private unowned let context: any ChatGroupContext - - private static let maxGroupNameLength = 40 - - init(context: any ChatGroupContext) { - self.context = context - } - - // MARK: - Commands - - func createGroup(named rawName: String) -> CommandResult { - let name = rawName.trimmed - guard !name.isEmpty else { - return .error(message: String(localized: "system.group.usage_create", comment: "Usage hint for /group create")) - } - guard name.count <= Self.maxGroupNameLength else { - return .error(message: String(localized: "system.group.name_too_long", comment: "Error when a group name exceeds the length cap")) - } - - let myFingerprint = context.myNoiseFingerprint() - let mySigningKey = context.mySigningPublicKey() - guard !myFingerprint.isEmpty, mySigningKey.count == 32 else { - return .error(message: String(localized: "system.group.identity_unavailable", comment: "Error when the local identity is not ready for group operations")) - } - - let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname) - guard let group = context.groupStore.createGroup(named: name, creator: creator) else { - return .error(message: String(localized: "system.group.create_failed", comment: "Error when group creation fails")) - } - - context.startPrivateChat(with: group.peerID) - return .success(message: String( - format: String(localized: "system.group.created", comment: "System message after creating a group; placeholder is the group name"), - locale: .current, - name - )) - } - - func inviteMember(nickname rawNickname: String) -> CommandResult { - let nickname = normalizedNickname(rawNickname) - guard !nickname.isEmpty else { - return .error(message: String(localized: "system.group.usage_invite", comment: "Usage hint for /group invite")) - } - guard let group = selectedGroup() else { - return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) - } - guard group.creatorFingerprint == context.myNoiseFingerprint() else { - return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action")) - } - guard let peerID = context.getPeerIDForNickname(nickname) else { - return .error(message: String( - format: String(localized: "system.group.peer_not_found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"), - locale: .current, - nickname - )) - } - guard context.isPeerConnected(peerID) else { - return .error(message: String( - format: String(localized: "system.group.peer_not_connected", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"), - locale: .current, - nickname - )) - } - guard let identity = context.cryptoIdentity(for: peerID) else { - return .error(message: String( - format: String(localized: "system.group.peer_identity_unknown", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"), - locale: .current, - nickname - )) - } - guard !group.isMember(fingerprint: identity.fingerprint) else { - return .error(message: String( - format: String(localized: "system.group.already_member", comment: "Error when the invitee is already a member; placeholder is the nickname"), - locale: .current, - nickname - )) - } - guard group.members.count < BitchatGroup.maxMembers else { - return .error(message: String( - format: String(localized: "system.group.full", comment: "Error when the group is at the member cap; placeholder is the cap"), - locale: .current, - "\(BitchatGroup.maxMembers)" - )) - } - - let newMember = GroupMember( - fingerprint: identity.fingerprint, - signingKey: identity.signingKey, - nickname: context.peerNickname(for: peerID) ?? nickname - ) - // Rotate the key (epoch + 1) on every roster change, not just removals. - // A monotonically increasing epoch per roster gives the receiver a - // strict ordering: two out-of-order invite states can no longer share - // an epoch and last-writer-wins a just-added member back out. - let members = group.members + [newMember] - guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members), - let payload = signedStatePayload(for: updated, key: key) else { - return .error(message: String(localized: "system.group.invite_failed", comment: "Error when building or signing a group invite fails")) - } - - context.sendGroupInvitePayload(payload, to: peerID) - distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate) - - return .success(message: String( - format: String(localized: "system.group.invited", comment: "System message after inviting someone; placeholders are the nickname and the group name"), - locale: .current, - nickname, - updated.name - )) - } - - /// Creator-side removal: rotates the group key (epoch + 1) and sends the - /// new state to every remaining member so the removed member's key stops - /// decrypting future traffic. - func removeMember(nickname rawNickname: String) -> CommandResult { - let nickname = normalizedNickname(rawNickname) - guard !nickname.isEmpty else { - return .error(message: String(localized: "system.group.usage_remove", comment: "Usage hint for /group remove")) - } - guard let group = selectedGroup() else { - return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) - } - guard group.creatorFingerprint == context.myNoiseFingerprint() else { - return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action")) - } - guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else { - return .error(message: String( - format: String(localized: "system.group.member_not_found", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"), - locale: .current, - nickname - )) - } - guard member.fingerprint != group.creatorFingerprint else { - return .error(message: String(localized: "system.group.cannot_remove_creator", comment: "Error when the creator tries to remove themselves")) - } - - let remaining = group.members.filter { $0.fingerprint != member.fingerprint } - guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining), - let payload = signedStatePayload(for: rotated, key: newKey) else { - return .error(message: String(localized: "system.group.rotate_failed", comment: "Error when rotating the group key fails")) - } - - distributeState(payload, group: rotated, excluding: [], type: .keyUpdate) - notifyRemovedMember(member, rotated: rotated) - - return .success(message: String( - format: String(localized: "system.group.removed_member", comment: "System message after removing a member and rotating the key; placeholder is the nickname"), - locale: .current, - member.nickname - )) - } - - func leaveGroup() -> CommandResult { - guard let group = selectedGroup() else { - return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) - } - // Close the chat window first so the confirmation message doesn't - // resurrect the conversation we're about to remove. - context.endPrivateChat() - context.removePrivateChat(group.peerID) - context.groupStore.removeGroup(withID: group.groupID) - context.notifyUIChanged() - return .success(message: String( - format: String(localized: "system.group.left", comment: "System message after leaving a group; placeholder is the group name"), - locale: .current, - group.name - )) - } - - func listGroups() -> CommandResult { - let groups = context.groupStore.groups - guard !groups.isEmpty else { - return .success(message: String(localized: "system.group.none", comment: "System message when the user is in no groups")) - } - let myFingerprint = context.myNoiseFingerprint() - let lines = groups.map { group -> String in - let role = group.creatorFingerprint == myFingerprint ? " (creator)" : "" - return "#\(group.name)\(role) — \(group.members.count)/\(BitchatGroup.maxMembers)" - } - return .success(message: String(localized: "system.group.list_header", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n")) - } - - // MARK: - Sending - - /// Fire-and-flood send: local echo goes straight to `.sent` because group - /// messages have no per-member acknowledgments in v1. - func sendGroupMessage(_ content: String, to groupPeerID: PeerID) { - guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return } - guard let group = context.groupStore.group(for: groupPeerID), - let key = context.groupStore.key(forGroupID: group.groupID) else { - // The person is inside the group thread; the error belongs there, - // not on the active public timeline. - context.addLocalPrivateSystemMessage( - String(localized: "system.group.unknown", comment: "System message when sending into an unknown group"), - to: groupPeerID - ) - return - } - - let messageID = UUID().uuidString - let timestamp = Date() - let payload: Data - do { - payload = try GroupCrypto.sealMessage( - content: content, - messageID: messageID, - senderNickname: context.nickname, - senderSigningKey: context.mySigningPublicKey(), - timestampMs: UInt64(timestamp.timeIntervalSince1970 * 1000), - groupID: group.groupID, - epoch: group.epoch, - key: key, - sign: { [weak context] data in context?.signWithNoiseKey(data) } - ) - } catch { - SecureLogger.error("Failed to seal group message: \(error)", category: .encryption) - context.addLocalPrivateSystemMessage( - String(localized: "system.group.send_failed", comment: "System message when sealing a group message fails"), - to: groupPeerID - ) - return - } - - let message = BitchatMessage( - id: messageID, - sender: context.nickname, - content: content, - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: group.name, - senderPeerID: context.myPeerID, - mentions: nil, - deliveryStatus: .sent - ) - context.appendPrivateMessage(message, to: groupPeerID) - context.broadcastGroupMessagePayload(payload) - context.notifyUIChanged() - } - - // MARK: - Receiving - - /// Decrypt-verify path for an incoming 0x25 broadcast. Drops silently for - /// unknown groups (non-members relay but never read), wrong epochs, bad - /// sender signatures, and senders missing from the pinned roster. - func handleGroupMessagePayload(_ payload: Data, timestamp _: Date) { - guard let envelope = GroupMessageEnvelope.decode(payload) else { return } - guard let group = context.groupStore.group(withID: envelope.groupID) else { return } - guard envelope.epoch == group.epoch else { - SecureLogger.debug("Dropping group message with epoch \(envelope.epoch) (current \(group.epoch))", category: .encryption) - return - } - guard let key = context.groupStore.key(forGroupID: group.groupID) else { return } - - let plaintext: GroupMessagePlaintext - do { - plaintext = try GroupCrypto.openMessage(envelope, key: key) - } catch { - SecureLogger.debug("Failed to open group message: \(error)", category: .encryption) - return - } - - // Sender must be pinned in the creator-signed roster; key possession - // alone is not authorship. - guard let member = group.member(withSigningKey: plaintext.senderSigningKey) else { - SecureLogger.warning("Dropping group message from non-roster sender", category: .security) - return - } - // Our own broadcast echoed back via relay or sync replay. - guard plaintext.senderSigningKey != context.mySigningPublicKey() else { return } - // Honor /block inside groups too: drop display + notification for a - // blocked member, consistent with every other inbound path. - guard !context.isFingerprintBlocked(member.fingerprint) else { - SecureLogger.debug("Dropping group message from blocked member", category: .security) - return - } - - let groupPeerID = group.peerID - // Trust the authenticated inner timestamp (clamped so a future-dated - // message cannot pin itself to the bottom of the timeline). - let messageDate = min(Date(timeIntervalSince1970: TimeInterval(plaintext.timestampMs) / 1000), Date()) - let senderName = member.nickname.isEmpty ? plaintext.senderNickname : member.nickname - let senderPeerID = PeerID(str: String(member.fingerprint.prefix(16))) - let message = BitchatMessage( - id: plaintext.messageID, - sender: senderName, - content: plaintext.content, - timestamp: messageDate, - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: group.name, - senderPeerID: senderPeerID, - mentions: nil - ) - - guard context.appendPrivateMessage(message, to: groupPeerID) else { return } - - let isViewing = context.selectedPrivateChatPeer == groupPeerID - if !isViewing { - context.markPrivateChatUnread(groupPeerID) - let isRecent = Date().timeIntervalSince(messageDate) < 30 - if isRecent { - context.notifyPrivateMessage( - from: "\(senderName) @ \(group.name)", - message: plaintext.content, - peerID: groupPeerID - ) - } - } - context.notifyUIChanged() - } - - /// Accepts creator-signed group state arriving as an invite. The Noise - /// session peer must BE the creator, the signature must verify against - /// the creator key pinned in the roster, and we must be in the roster. - func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { - applyGroupState(from: peerID, payload: payload, isInvite: true) - } - - /// Accepts creator-signed state updates (rotation/roster). A state whose - /// roster no longer includes us means we were removed: drop the group. - func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { - applyGroupState(from: peerID, payload: payload, isInvite: false) - } -} - -private extension ChatGroupCoordinator { - enum StateSendType { - case invite - case keyUpdate - } - - func normalizedNickname(_ raw: String) -> String { - let trimmed = raw.trimmed - return trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed - } - - func selectedGroup() -> BitchatGroup? { - guard let selected = context.selectedPrivateChatPeer, selected.isGroup else { return nil } - return context.groupStore.group(for: selected) - } - - func signedStatePayload(for group: BitchatGroup, key: Data) -> Data? { - GroupStatePayload.makeSigned(group: group, key: key) { [weak context] data in - context?.signWithNoiseKey(data) - }?.encode() - } - - /// Sends the state payload to every connected roster member except us and - /// the excluded fingerprints. Offline members catch up the next time the - /// creator sends them state (v1 limitation, documented in the PR). - func distributeState(_ payload: Data, group: BitchatGroup, excluding excludedFingerprints: Set, type: StateSendType) { - let myFingerprint = context.myNoiseFingerprint() - for member in group.members { - guard member.fingerprint != myFingerprint, - !excludedFingerprints.contains(member.fingerprint), - let peerID = context.connectedPeerID(forFingerprint: member.fingerprint) else { continue } - switch type { - case .invite: - context.sendGroupInvitePayload(payload, to: peerID) - case .keyUpdate: - context.sendGroupKeyUpdatePayload(payload, to: peerID) - } - } - } - - /// Tells a just-removed member they're out so their client can deactivate - /// the group instead of silently going dark (dropping every message under - /// the epoch it no longer has the key for). The notice is a creator-signed - /// state whose roster excludes the removee — their `applyGroupState` - /// removal branch fires on the missing-self roster and surfaces the - /// "removed from group" system message. - /// - /// It carries a throwaway all-zero key, never the rotated key, so the - /// removee cannot decrypt post-removal traffic. State is sent 1:1 over - /// authenticated Noise, so no remaining member ever receives this blob - /// (and even if one did, its own missing-self check would not match). - /// If the removee is offline the notice can't be delivered — same v1 - /// limitation as any other missed key update, documented in the PR. - func notifyRemovedMember(_ removed: GroupMember, rotated: BitchatGroup) { - guard let peerID = context.connectedPeerID(forFingerprint: removed.fingerprint) else { return } - let throwawayKey = Data(count: BitchatGroup.keyLength) - guard let payload = signedStatePayload(for: rotated, key: throwawayKey) else { return } - context.sendGroupKeyUpdatePayload(payload, to: peerID) - } - - func applyGroupState(from peerID: PeerID, payload: Data, isInvite: Bool) { - guard let state = GroupStatePayload.decode(payload) else { - SecureLogger.warning("Malformed group state payload from \(peerID.id.prefix(8))…", category: .security) - return - } - // The Noise session already authenticated `peerID`; require that the - // authenticated peer IS the creator whose key signed the state, so a - // member can't re-invite or rotate on the creator's behalf. - guard let senderFingerprint = context.meshFingerprint(for: peerID), - senderFingerprint == state.creatorFingerprint else { - SecureLogger.warning("Dropping group state from non-creator \(peerID.id.prefix(8))…", category: .security) - return - } - guard state.verifyCreatorSignature() else { - SecureLogger.warning("Dropping group state with invalid creator signature", category: .security) - return - } - - let myFingerprint = context.myNoiseFingerprint() - let existing = context.groupStore.group(withID: state.groupID) - - // A creator-signed roster that no longer includes us is a removal. - guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else { - if let existing { - if context.selectedPrivateChatPeer == existing.peerID { - context.endPrivateChat() - } - context.removePrivateChat(existing.peerID) - context.groupStore.removeGroup(withID: existing.groupID) - context.addSystemMessage(String( - format: String(localized: "system.group.removed_from", comment: "System message when removed from a group; placeholder is the group name"), - locale: .current, - existing.name - )) - context.notifyUIChanged() - } - return - } - - // Never regress the epoch: state travels over live Noise sessions, - // so an older epoch here is a stale (or misbehaving) creator device. - if let existing, state.epoch < existing.epoch { - SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security) - return - } - - let isNewMembership = existing == nil - guard context.groupStore.upsert(state.asGroup, key: state.key) else { - SecureLogger.error("Failed to store group state for \(state.name)", category: .session) - return - } - - if isNewMembership { - let inviter = state.members.first { $0.fingerprint == state.creatorFingerprint }?.nickname - ?? context.peerNickname(for: peerID) - ?? "?" - let notice = String( - format: String(localized: "system.group.joined", comment: "System message when added to a group; placeholders are the group name and the inviter"), - locale: .current, - state.name, - inviter - ) - context.addSystemMessage(notice) - context.markPrivateChatUnread(state.asGroup.peerID) - context.notifyPrivateMessage(from: inviter, message: notice, peerID: state.asGroup.peerID) - } else if isInvite == false, let existing, state.epoch > existing.epoch { - SecureLogger.info("Group '\(state.name)' rotated to epoch \(state.epoch)", category: .session) - } - context.notifyUIChanged() - } -} diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift deleted file mode 100644 index ea90e692..00000000 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ /dev/null @@ -1,331 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The narrow surface `ChatLifecycleCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatLifecycleCoordinatorContextTests`) and makes its true -/// dependencies explicit. -@MainActor -protocol ChatLifecycleContext: AnyObject { - // MARK: Chat & receipt state - var messages: [BitchatMessage] { get } - /// A single private chat's timeline (store-direct lookup on - /// `ChatViewModel`; no `privateChats` dictionary build). - func privateMessages(for peerID: PeerID) -> [BitchatMessage] - var unreadPrivateMessages: Set { get } - var selectedPrivateChatPeer: PeerID? { get } - /// Appends a private message via the single-writer store intent. - @discardableResult - func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool - /// Clears the peer's unread flag (store unread state only). - func markPrivateChatRead(_ peerID: PeerID) - var sentReadReceipts: Set { get } - var nickname: String { get } - var myPeerID: PeerID { get } - var activeChannel: ChannelID { get } - var nostrKeyMapping: [PeerID: String] { get } - /// Records that a read receipt is being sent for `messageID`. - /// Returns `false` when one was already recorded — the caller must skip sending. - @discardableResult - func markReadReceiptSent(_ messageID: String) -> Bool - /// The owner-level read pass (chat manager + receipts); used for the - /// delayed re-run after the app becomes active. - func markPrivateMessagesAsRead(from peerID: PeerID) - /// Marks the chat read in the private chat manager (sends pending mesh READ acks). - func markChatAsRead(from peerID: PeerID) - /// Schedules main-actor work after a UI-timing delay. Injected so tests - /// can run the work synchronously instead of polling wall-clock queues. - func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) - - // MARK: Peers & sessions - func peerNickname(for peerID: PeerID) -> String? - /// The peer's current entry in the unified peer service, if known. - func unifiedPeer(for peerID: PeerID) -> BitchatPeer? - func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState - func stopMeshServices() - /// Re-reads the transport's current Bluetooth state and updates the alert UI. - func refreshBluetoothState() - - // MARK: Routing & receipts - func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) - @discardableResult - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool - func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) - - // MARK: Nostr & geohash - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity - - // MARK: Favorites (shared with `ChatPrivateConversationContext`) - /// The persisted favorite relationship for the peer's Noise static key, if any. - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? - - // MARK: Identity persistence - /// Forces the identity manager to persist its state now. - func forceSaveIdentity() - /// Confirms the Noise identity key is still present in the keychain. - @discardableResult - func verifyIdentityKeyExists() -> Bool -} - -extension ChatViewModel: ChatLifecycleContext { - // `messages`, `privateMessages(for:)`, `unreadPrivateMessages`, - // `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`, - // `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`, - // `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`, - // `markPrivateChatRead(_:)`, - // `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`, - // the routing/ack members, - // `deriveNostrIdentity(forGeohash:)`, - // and `favoriteRelationship(forNoiseKey:)` - // are shared requirements with the other contexts or satisfied by - // existing `ChatViewModel` members. The members below flatten nested - // service accesses into intent-named calls. - - func markChatAsRead(from peerID: PeerID) { - privateChatManager.markAsRead(from: peerID) - } - - func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - Task { @MainActor in - work() - } - } - } - - func stopMeshServices() { - meshService.stopServices() - } - - func refreshBluetoothState() { - if let radio = meshService as? BluetoothStateReporting { - updateBluetoothState(radio.getCurrentBluetoothState()) - } - } - - func forceSaveIdentity() { - identityManager.forceSave() - } - - @discardableResult - func verifyIdentityKeyExists() -> Bool { - keychain.verifyIdentityKeyExists() - } -} - -@MainActor -final class ChatLifecycleCoordinator { - private unowned let context: any ChatLifecycleContext - - init(context: any ChatLifecycleContext) { - self.context = context - } - - func handleDidBecomeActive() { - context.refreshBluetoothState() - - guard let peerID = context.selectedPrivateChatPeer else { return } - - markPrivateMessagesAsRead(from: peerID) - - let context = self.context - context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in - context?.markPrivateMessagesAsRead(from: peerID) - } - } - - func handleScreenshotCaptured() { - // Public channels never announce screenshots. The old broadcast told - // everyone in radio range — and, on geohash channels, public Nostr - // relays, permanently — that this nickname was present and active - // here right now. Documenting something (or someone) is a core use - // of a protest app; it must not out the person doing it. Screenshot - // notices remain a DM-only courtesy between the two people involved. - guard let peerID = context.selectedPrivateChatPeer else { return } - - let screenshotMessage = "* \(context.nickname) took a screenshot *" - // Only echo "you took a screenshot" when the peer was actually - // notified — the unconditional echo used to imply a notice that - // frequently was never sent (no established session). - if sendPrivateScreenshotNotificationIfPossible(screenshotMessage, to: peerID) { - appendPrivateScreenshotNotice(for: peerID) - } - } - - func saveIdentityState() { - context.forceSaveIdentity() - context.verifyIdentityKeyExists() - } - - func applicationWillTerminate() { - context.stopMeshServices() - saveIdentityState() - } - - func markPrivateMessagesAsRead(from peerID: PeerID) { - context.markChatAsRead(from: peerID) - - // Group chats are keyed under a virtual group_ peerID; no member IS the - // conversation peer, so the receipt loops below (which gate on - // senderPeerID == peerID) must never emit a read/delivered receipt for - // one. This guard makes that explicit so a future refactor of the - // receipt matching can't silently start leaking receipts into groups. - guard !peerID.isGroup else { return } - - if peerID.isGeoDM, - let recipientHex = context.nostrKeyMapping[peerID], - case .location(let channel) = context.activeChannel, - let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - let messages = context.privateMessages(for: peerID) - for message in messages where message.senderPeerID == peerID && !message.isRelay { - guard !context.sentReadReceipts.contains(message.id) else { continue } - - SecureLogger.debug( - "GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", - category: .session - ) - context.sendGeohashReadReceipt( - message.id, - toRecipientHex: recipientHex, - from: identity - ) - context.markReadReceiptSent(message.id) - } - return - } - - var noiseKeyHex: PeerID? - - if let noiseKey = Data(hexString: peerID.id), - context.favoriteRelationship(forNoiseKey: noiseKey) != nil { - noiseKeyHex = peerID - } else if let peer = context.unifiedPeer(for: peerID) { - noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - - if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) { - context.markPrivateChatRead(noiseKeyHex) - } - } - - // No Nostr-key gate here: the router picks whatever transport can - // reach the peer (mesh included), so read receipts must flow for - // non-favorite mesh peers too. `sentReadReceipts` dedups against the - // PrivateChatManager path; the router drops receipts it can't route. - - for message in getPrivateChatMessages(for: peerID) { - guard (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay else { - continue - } - - guard !context.sentReadReceipts.contains(message.id) else { continue } - - let receipt = ReadReceipt( - originalMessageID: message.id, - readerID: context.myPeerID, - readerNickname: context.nickname - ) - let recipientPeerID = peerID.isHex - ? peerID - : (context.unifiedPeer(for: peerID)?.peerID ?? peerID) - - // Only record the receipt as sent when it actually left via a - // reachable transport; a dropped receipt stays unmarked so the - // next read scan retries it instead of burning it forever. - if context.routeReadReceipt(receipt, to: recipientPeerID) { - context.markReadReceiptSent(message.id) - } - } - } - - func getMessages(for peerID: PeerID?) -> [BitchatMessage] { - guard let peerID else { return context.messages } - return getPrivateChatMessages(for: peerID) - } - - func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { - var combined: [BitchatMessage] = [] - - combined.append(contentsOf: context.privateMessages(for: peerID)) - - if let peer = context.unifiedPeer(for: peerID) { - let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - if noiseKeyHex != peerID { - combined.append(contentsOf: context.privateMessages(for: noiseKeyHex)) - } - } - - var bestByID: [String: BitchatMessage] = [:] - for message in combined { - if let existing = bestByID[message.id] { - let existingRank = deliveryStatusRank(existing.deliveryStatus) - let candidateRank = deliveryStatusRank(message.deliveryStatus) - if candidateRank > existingRank || (candidateRank == existingRank && message.timestamp > existing.timestamp) { - bestByID[message.id] = message - } - } else { - bestByID[message.id] = message - } - } - - return bestByID.values.sorted { $0.timestamp < $1.timestamp } - } -} - -private extension ChatLifecycleCoordinator { - /// Returns whether the notice actually went out, so the caller can keep - /// the local echo honest. - func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) -> Bool { - guard let peerNickname = context.peerNickname(for: peerID) else { return false } - - let sessionState = context.noiseSessionState(for: peerID) - switch sessionState { - case .established: - context.routePrivateMessage( - message, - to: peerID, - recipientNickname: peerNickname, - messageID: UUID().uuidString - ) - return true - - case .none, .failed, .handshakeQueued, .handshaking: - SecureLogger.debug( - "Skipping screenshot notification to \(peerID) - no established session", - category: .security - ) - return false - } - } - - func appendPrivateScreenshotNotice(for peerID: PeerID) { - let notice = BitchatMessage( - sender: "system", - content: String(localized: "system.screenshot.you", defaultValue: "you took a screenshot", comment: "Local system line after your screenshot notice was sent to the DM peer"), - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: context.peerNickname(for: peerID), - senderPeerID: context.myPeerID - ) - - context.appendPrivateMessage(notice, to: peerID) - } - - func deliveryStatusRank(_ status: DeliveryStatus) -> Int { - switch status { - case .notSentYet: return 0 - case .failed: return 1 - case .sending: return 2 - case .sent: return 3 - case .carried: return 4 - case .partiallyDelivered: return 5 - case .delivered: return 6 - case .read: return 7 - } - } -} diff --git a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift deleted file mode 100644 index 78fa83e1..00000000 --- a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift +++ /dev/null @@ -1,715 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The narrow surface `ChatLiveVoiceCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`, keeping it independently testable. -@MainActor -protocol ChatLiveVoiceContext: AnyObject { - var nickname: String { get } - var myPeerID: PeerID { get } - var selectedPrivateChatPeer: PeerID? { get } - /// Whether the public mesh timeline is what's on screen (autoplay gate - /// for public bursts). - var isViewingPublicMeshTimeline: Bool { get } - func isPeerBlocked(_ peerID: PeerID) -> Bool - func resolveNickname(for peerID: PeerID) -> String - /// Routes an inbound private message through the full pipeline - /// (store append, unread state, notification, read receipt). - func handlePrivateMessage(_ message: BitchatMessage) - /// Appends directly to the public mesh timeline, bypassing the batched - /// public pipeline: a live bubble must be removable when its burst is - /// canceled or empty, which a pipeline-buffered entry is not (it would - /// re-commit at the next flush). - func appendPublicMeshMessage(_ message: BitchatMessage) - /// Replace-or-append by message ID via the single-writer store intent. - func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) - /// Replace-or-append by message ID in the public mesh timeline. - func upsertPublicMeshMessage(_ message: BitchatMessage) - @discardableResult - func removePrivateMessage(withID messageID: String) -> BitchatMessage? - /// Records and sends the finalized note's read receipt after a live - /// bubble adopts its wire-derivable message ID. - func hasSentReadReceipt(_ messageID: String) -> Bool - @discardableResult - func markReadReceiptSent(_ messageID: String) -> Bool - func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) - /// Removes a message from whichever conversation holds it. - func removeMessage(withID messageID: String, cleanupFile: Bool) - /// Publishes who is currently talking live in the public mesh channel - /// (floor-courtesy indicator on the composer mic), nil when nobody is. - func setActivePublicVoiceTalker(_ nickname: String?) - func notifyUIChanged() -} - -extension ChatViewModel: ChatLiveVoiceContext { - var isViewingPublicMeshTimeline: Bool { - selectedPrivateChatPeer == nil && activeChannel == .mesh - } - - func appendPublicMeshMessage(_ message: BitchatMessage) { - _ = appendPublicMessage(message, to: ConversationID(channelID: .mesh)) - } - - func upsertPublicMeshMessage(_ message: BitchatMessage) { - conversations.upsertByID(message, in: ConversationID(channelID: .mesh)) - } - - func setActivePublicVoiceTalker(_ nickname: String?) { - if activePublicVoiceTalker != nickname { - activePublicVoiceTalker = nickname - } - } -} - -/// Where a live voice burst lives: a Noise DM or the public mesh timeline. -enum VoiceBurstScope: Hashable { - case directMessage - case publicMesh -} - -/// Assembles inbound live push-to-talk bursts (`NoisePayloadType.voiceFrame`): -/// orders packets behind a jitter window, persists frames progressively as an -/// ADTS `.aac` so even a partial burst is a replayable voice-note bubble, -/// optionally plays the stream live, and absorbs the sender's finalized -/// `.m4a` voice note (matched by the burst ID in its file name) into the same -/// bubble so nobody sees a duplicate. -@MainActor -final class ChatLiveVoiceCoordinator { - /// Burst IDs are sender-chosen, so they only identify a burst *within* - /// an authenticated (peer, scope) pair: keying assemblies by the full - /// triple stops an attacker who observed a public burst ID from racing - /// a START to capture the real talker's frames. - private struct AssemblyKey: Hashable { - let peerID: PeerID - let scope: VoiceBurstScope - let burstID: Data - } - - private final class Assembly { - let burstID: Data - let peerID: PeerID - let scope: VoiceBurstScope - let nickname: String - let message: BitchatMessage - var messageID: String { message.id } - var messageTimestamp: Date { message.timestamp } - let fileURL: URL - var fileHandle: FileHandle? - /// Data packets buffered ahead of `nextSeq` (seq -> frames). - var buffered: [UInt16: [Data]] = [:] - /// Next data-packet seq to deliver (seq 0 is START). - var nextSeq: UInt16 = 1 - var deliveredFrames = 0 - var receivedBytes = 0 - let firstPacketAt: Date - var endInfo: (totalDataPackets: UInt16, durationMs: UInt32)? - /// When a seq gap was first observed; after - /// `ChatLiveVoiceCoordinator.gapSkipSeconds` the gap is skipped. - var gapSince: Date? - var player: PTTBurstPlayer? - var idleTimeout: Task? - var gapRedrain: Task? - - var key: AssemblyKey { AssemblyKey(peerID: peerID, scope: scope, burstID: burstID) } - - init(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, message: BitchatMessage, fileURL: URL, fileHandle: FileHandle) { - self.burstID = burstID - self.peerID = peerID - self.scope = scope - self.nickname = nickname - self.message = message - self.fileURL = fileURL - self.fileHandle = fileHandle - self.firstPacketAt = Date() - } - } - - private struct FinishedBurst { - let messageID: String - let peerID: PeerID - let scope: VoiceBurstScope - let fileURL: URL - let messageTimestamp: Date - let expiresAt: Date - } - - /// How long a missing packet stalls delivery before being skipped. - private static let gapSkipSeconds: TimeInterval = 0.5 - private static let finishedBurstsCap = 32 - - private unowned let context: any ChatLiveVoiceContext - private let fileStore: BLEIncomingFileStore - /// Captures live in the store's directories, so file operations go - /// through the store's (injectable) file manager. - private var fileManager: FileManager { fileStore.fileManager } - private var assemblies: [AssemblyKey: Assembly] = [:] - private var finishedBursts: [AssemblyKey: FinishedBurst] = [:] - /// Players still draining after their assembly — the sole strong owner — - /// was discarded on burst END. Without this hold the player deallocates - /// mid-tail (or mid session-acquire, killing the whole burst's audio) - /// and its registered session token would only be reclaimed by the - /// deinit backstop. Entries remove themselves via `onStopped`. - private var drainingPlayers: [ObjectIdentifier: PTTBurstPlayer] = [:] - - /// `sweepsOnInit` exists for tests whose coordinator shares the real - /// application-support directory: they pass `false` so parallel test - /// runs never sweep each other's in-flight capture files. - init(context: any ChatLiveVoiceContext, fileStore: BLEIncomingFileStore = BLEIncomingFileStore(), sweepsOnInit: Bool = true) { - self.context = context - self.fileStore = fileStore - // Orphaned partial captures from a previous session (live-only bursts - // whose finalized note never arrived) are dead weight the quota can - // never reclaim (eviction skips voice_live_* by design) — sweep them - // on startup. - if sweepsOnInit { - sweepStaleLiveCaptures() - } - } - - // MARK: - Inbound frames - - /// Inbound DM burst packet (`NoisePayloadType.voiceFrame`). - func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) { - handle(payload, from: peerID, scope: .directMessage, nickname: context.resolveNickname(for: peerID), timestamp: timestamp) - } - - /// Inbound public burst packet (`MessageType.voiceFrame`), already - /// signature-verified by the transport, which resolved the nickname. - func handlePublicVoiceFramePayload(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) { - handle(payload, from: peerID, scope: .publicMesh, nickname: nickname, timestamp: timestamp) - } - - private func handle(_ payload: Data, from peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) { - // Live voice off means classic-notes-only in both directions: no live - // bubble, no partial file, no early notification — the finalized - // voice note still arrives through the normal pipeline. - guard PTTSettings.liveVoiceEnabled else { - SecureLogger.debug("PTT: dropping inbound voice frame — live voice is toggled off", category: .session) - return - } - guard let packet = VoiceBurstPacket.decode(payload) else { - SecureLogger.warning("PTT: undecodable voice frame from \(peerID.id.prefix(8))… (\(payload.count) bytes: \(payload.prefix(16).hexEncodedString())…)", category: .session) - return - } - guard !context.isPeerBlocked(peerID) else { - SecureLogger.debug("PTT: dropping voice frame from blocked peer \(peerID.id.prefix(8))…", category: .session) - return - } - - // The sender is authenticated (Noise session or packet signature), - // and the key binds the burst ID to that (peer, scope): a colliding - // START from another peer opens its own assembly instead of - // capturing this one's frames. - let key = AssemblyKey(peerID: peerID, scope: scope, burstID: packet.burstID) - if let assembly = assemblies[key] { - apply(packet, to: assembly) - return - } - - switch packet.kind { - case .start, .frames: - // A data packet with no prior START (lost or mid-burst join) - // still opens the assembly with the default codec. - guard assemblies.count < TransportConfig.pttMaxConcurrentAssemblies else { - SecureLogger.debug("PTT: dropping burst from \(peerID.id.prefix(8))… — assembly cap reached", category: .session) - return - } - guard let assembly = makeAssembly(burstID: packet.burstID, peerID: peerID, scope: scope, nickname: nickname, timestamp: timestamp) else { return } - assemblies[key] = assembly - updatePublicTalkerIndicator() - apply(packet, to: assembly) - case .end, .canceled: - // Control packet for a burst we never saw — nothing to do. - break - } - } - - /// Whether this message is the bubble of a burst still streaming in. - func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool { - assemblies.values.contains { $0.messageID == message.id } - } - - /// Stop every live file handle/player before the panic media directory is - /// removed. This prevents an in-flight assembly from continuing to write - /// through an unlinked file after the wipe returns. - func resetForPanic() { - for assembly in Array(assemblies.values) { - cancelAssembly(assembly) - } - for player in drainingPlayers.values { - player.stop() - } - drainingPlayers.removeAll(keepingCapacity: false) - finishedBursts.removeAll(keepingCapacity: false) - updatePublicTalkerIndicator() - } - - /// Called for every inbound private message: when it is the finalized - /// voice note of a burst we assembled (matched by burst ID in the file - /// name), swap it into the existing live bubble and report `true` so the - /// caller skips normal handling — no duplicate row, no second - /// notification. - func absorbFinalizedVoiceNote(_ message: BitchatMessage) -> Bool { - let prefix = MimeType.Category.audio.messagePrefix - guard message.content.hasPrefix(prefix), - let burstID = Self.burstID(fromVoiceFileName: String(message.content.dropFirst(prefix.count))) - else { return false } - - // Bind the note to the burst's authenticated sender and scope: an - // attacker's burst reusing the same ID lives under its own key and - // never matches the real sender's note (registry is capped at - // `finishedBurstsCap`, so the linear scan is cheap). - func matches(_ key: AssemblyKey) -> Bool { - key.burstID == burstID - && (message.senderPeerID == nil || key.peerID == message.senderPeerID) - && message.isPrivate == (key.scope == .directMessage) - } - - // The note usually lands after END, but a lost END or a fast transfer - // can beat it — close out the live assembly first. - if let assembly = assemblies.first(where: { matches($0.key) })?.value { - finalize(assembly) - } - - pruneFinishedBursts() - guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false } - let finished = entry.value - - // A DM live bubble starts before the finalized file exists and - // therefore has a receiver-local random ID. Adopt the finalized - // message's deterministic ID so delivery/read ACKs address the same - // row as the sender's media placeholder. Public notes retain their - // live-bubble ID because public transfers have no private receipts. - let replacementID = finished.scope == .directMessage - ? message.id - : finished.messageID - let replacement = BitchatMessage( - id: replacementID, - sender: message.sender, - content: message.content, - timestamp: finished.messageTimestamp, - isRelay: false, - originalSender: nil, - isPrivate: finished.scope == .directMessage, - recipientNickname: finished.scope == .directMessage ? context.nickname : nil, - senderPeerID: finished.peerID, - mentions: nil, - deliveryStatus: message.deliveryStatus - ) - switch finished.scope { - case .directMessage: - // Capture read state before rekeying. The user may have read the - // live bubble and navigated away before the finalized .m4a lands. - let shouldSendAdoptedReadReceipt = - context.hasSentReadReceipt(finished.messageID) - || context.selectedPrivateChatPeer == finished.peerID - - // Insert first so replacing the only row in a DM never - // transiently deletes its conversation, unread state, or current - // selection. Then remove the receiver-local live-bubble alias. - context.upsertPrivateMessage(replacement, in: finished.peerID) - if replacementID != finished.messageID { - context.removePrivateMessage(withID: finished.messageID) - } - // The live bubble may already have emitted a receiver-local READ - // before the sender created its finalized media row. Re-emit once - // for the adopted stable ID now that the file has arrived. - if shouldSendAdoptedReadReceipt, - context.markReadReceiptSent(replacementID) { - let receipt = ReadReceipt( - originalMessageID: replacementID, - readerID: context.myPeerID, - readerNickname: context.nickname - ) - context.sendMeshReadReceipt(receipt, to: finished.peerID) - } - case .publicMesh: - context.upsertPublicMeshMessage(replacement) - } - - // The complete .m4a replaces the partial live capture. - WaveformCache.shared.purge(url: finished.fileURL) - try? fileManager.removeItem(at: finished.fileURL) - finishedBursts.removeValue(forKey: entry.key) - - context.notifyUIChanged() - SecureLogger.debug("PTT: absorbed finalized note for burst \(burstID.hexEncodedString())", category: .session) - return true - } - - // MARK: - Assembly lifecycle - - private func makeAssembly(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) -> Assembly? { - guard let fileURL = makeIncomingURL(burstID: burstID, peerID: peerID, scope: scope) else { - SecureLogger.error("PTT: cannot resolve incoming media directory for burst \(burstID.hexEncodedString())", category: .session) - return nil - } - // BCH-01-002: live captures share the incoming-media quota with - // finalized transfers; reserve the burst's worst case up front. - // Eviction skips voice_live_* names, so partials still streaming in - // are safe no matter which caller triggers enforcement. - fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes) - fileManager.createFile( - atPath: fileURL.path, - contents: nil, - attributes: BLEIncomingFileStore.mediaProtectionAttributes - ) - guard let handle = try? FileHandle(forWritingTo: fileURL) else { - SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session) - try? fileManager.removeItem(at: fileURL) - return nil - } - - let isPrivate = scope == .directMessage - let message = BitchatMessage( - sender: nickname, - content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)", - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: isPrivate, - recipientNickname: isPrivate ? context.nickname : nil, - senderPeerID: peerID - ) - - let assembly = Assembly( - burstID: burstID, - peerID: peerID, - scope: scope, - nickname: nickname, - message: message, - fileURL: fileURL, - fileHandle: handle - ) - - // DM bubbles ride the full inbound pipeline (store append, unread, - // notification). Public bubbles append directly to the store: the - // batched public pipeline can't purge a buffered entry if the burst - // is canceled before the flush. - switch scope { - case .directMessage: - context.handlePrivateMessage(message) - case .publicMesh: - context.appendPublicMeshMessage(message) - } - - // Live playback only when the user is looking at this conversation - // with the app frontmost and live voice enabled. - let isViewing = switch scope { - case .directMessage: context.selectedPrivateChatPeer == peerID - case .publicMesh: context.isViewingPublicMeshTimeline - } - if PTTSettings.liveVoiceEnabled, PTTSettings.isAppActive, isViewing { - assembly.player = PTTBurstPlayer() - } - - SecureLogger.debug("PTT: burst \(burstID.hexEncodedString()) started from \(peerID.id.prefix(8))…", category: .session) - return assembly - } - - /// Keeps the composer's floor-courtesy indicator pointing at whoever is - /// currently talking live in the public mesh channel. - private func updatePublicTalkerIndicator() { - let talker = assemblies.values.first { $0.scope == .publicMesh }?.nickname - context.setActivePublicVoiceTalker(talker) - } - - private func apply(_ packet: VoiceBurstPacket, to assembly: Assembly) { - assembly.receivedBytes += packet.encode().count - let elapsed = Date().timeIntervalSince(assembly.firstPacketAt) - // Flood guards: a real burst arrives at ~2 KB/s. - guard assembly.receivedBytes <= TransportConfig.pttInboundMaxBytesPerSecond * Int(elapsed + 2), - assembly.receivedBytes <= TransportConfig.pttMaxBurstBytes - else { - SecureLogger.warning("PTT: burst from \(assembly.peerID.id.prefix(8))… exceeded rate/size caps — finalizing", category: .security) - finalize(assembly) - return - } - - rescheduleIdleTimeout(for: assembly) - - switch packet.kind { - case .start(let codec): - guard codec == .aacLC16kMono else { - // Codec we can't decode: drop the burst; the finalized note - // (whose MIME/magic the file handler validates) still arrives. - cancelAssembly(assembly) - return - } - case .frames(let frames): - guard packet.seq >= assembly.nextSeq, assembly.buffered[packet.seq] == nil else { return } - assembly.buffered[packet.seq] = frames - drainInOrder(assembly) - case .end(let totalDataPackets, let durationMs): - assembly.endInfo = (totalDataPackets, durationMs) - drainInOrder(assembly) - finalizeIfComplete(assembly) - case .canceled: - cancelAssembly(assembly) - } - } - - private func drainInOrder(_ assembly: Assembly) { - while true { - if let frames = assembly.buffered.removeValue(forKey: assembly.nextSeq) { - deliver(frames, to: assembly) - assembly.nextSeq &+= 1 - assembly.gapSince = nil - continue - } - guard !assembly.buffered.isEmpty else { - assembly.gapSince = nil - return - } - // Packets buffered ahead of a hole. - if let since = assembly.gapSince { - guard Date().timeIntervalSince(since) >= Self.gapSkipSeconds, - let smallest = assembly.buffered.keys.min() - else { return } - // Give up on the missing packet(s); playback underrun already - // covered the audible gap. - assembly.nextSeq = smallest - assembly.gapSince = nil - } else { - assembly.gapSince = Date() - scheduleGapRedrain(for: assembly) - return - } - } - } - - private func deliver(_ frames: [Data], to assembly: Assembly) { - for frame in frames { - do { - try assembly.fileHandle?.write(contentsOf: ADTSFramer.frame(frame)) - } catch { - SecureLogger.error("PTT: incoming burst write failed: \(error)", category: .session) - assembly.fileHandle = nil - } - } - assembly.deliveredFrames += frames.count - assembly.player?.enqueue(frames) - } - - private func finalizeIfComplete(_ assembly: Assembly) { - guard let end = assembly.endInfo else { return } - // All data packets delivered when nextSeq passed the last one - // (data seqs are 1...totalDataPackets). Otherwise stragglers may - // still arrive; the gap-redrain or idle timeout closes the burst. - if assembly.nextSeq > end.totalDataPackets { - finalize(assembly) - } - } - - private func finalize(_ assembly: Assembly) { - assembly.idleTimeout?.cancel() - assembly.gapRedrain?.cancel() - // Deliver whatever is decodable past any remaining holes. - while !assembly.buffered.isEmpty, let smallest = assembly.buffered.keys.min() { - assembly.nextSeq = smallest - if let frames = assembly.buffered.removeValue(forKey: smallest) { - deliver(frames, to: assembly) - assembly.nextSeq &+= 1 - } - } - try? assembly.fileHandle?.close() - assembly.fileHandle = nil - assemblies.removeValue(forKey: assembly.key) - updatePublicTalkerIndicator() - - guard assembly.deliveredFrames > 0 else { - // Nothing audible ever arrived — drop the empty bubble. - removeBubble(of: assembly) - try? fileManager.removeItem(at: assembly.fileURL) - context.notifyUIChanged() - return - } - - if let player = assembly.player, !player.stopped { - // Park the draining player: this method just dropped the - // assembly, and nothing else holds the player strongly. It may - // still be playing out its tail — or still acquiring the audio - // session off-main — so it must stay alive until it stops. - let id = ObjectIdentifier(player) - drainingPlayers[id] = player - player.onStopped = { [weak self] in - self?.drainingPlayers.removeValue(forKey: id) - } - player.finishAfterDrain() - } - // The bubble's waveform may have been computed from a partial file. - WaveformCache.shared.purge(url: assembly.fileURL) - // The capture is the bubble's replayable audio from here on (unless a - // finalized note arrives to swap in): move it off its voice_live_ - // name so only genuinely in-flight files match the startup sweep and - // the quota's live-capture guard — a kept fallback is never swept, - // and it ages out of the quota like any finalized media. - let fileURL = promoteToFallback(assembly.fileURL) - // Republish so the row re-renders without its LIVE treatment — and - // points at the promoted file — even if no note ever arrives. - republishBubble(of: assembly, fileURL: fileURL) - - pruneFinishedBursts() - finishedBursts[assembly.key] = FinishedBurst( - messageID: assembly.messageID, - peerID: assembly.peerID, - scope: assembly.scope, - fileURL: fileURL, - messageTimestamp: assembly.messageTimestamp, - expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds) - ) - context.notifyUIChanged() - SecureLogger.debug("PTT: burst \(assembly.burstID.hexEncodedString()) finalized (\(assembly.deliveredFrames) frames)", category: .session) - } - - private func removeBubble(of assembly: Assembly) { - switch assembly.scope { - case .directMessage: - context.removePrivateMessage(withID: assembly.messageID) - case .publicMesh: - context.removeMessage(withID: assembly.messageID, cleanupFile: false) - } - } - - private func republishBubble(of assembly: Assembly, fileURL: URL) { - // Same row (same ID), content re-pointed at `fileURL`; the delivery - // status is carried over because the inbound pipeline may have - // updated it on the shared original. - let message = BitchatMessage( - id: assembly.messageID, - sender: assembly.nickname, - content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)", - timestamp: assembly.messageTimestamp, - isRelay: false, - isPrivate: assembly.scope == .directMessage, - recipientNickname: assembly.scope == .directMessage ? context.nickname : nil, - senderPeerID: assembly.peerID, - deliveryStatus: assembly.message.deliveryStatus - ) - switch assembly.scope { - case .directMessage: - context.upsertPrivateMessage(message, in: assembly.peerID) - case .publicMesh: - context.upsertPublicMeshMessage(message) - } - } - - /// Moves a finished capture off its `voice_live_` prefix (onto plain - /// `voice_`), so the in-flight patterns — the startup sweep and the - /// quota's eviction guard — only ever match captures still streaming in. - /// On failure the live name is kept: worst case the file is reclaimed at - /// the next startup, exactly the pre-promotion behavior. - private func promoteToFallback(_ liveURL: URL) -> URL { - let liveName = liveURL.lastPathComponent - guard liveName.hasPrefix(BLEIncomingFileStore.liveCapturePrefix) else { return liveURL } - let fallbackName = "voice_" + liveName.dropFirst(BLEIncomingFileStore.liveCapturePrefix.count) - let destination = liveURL.deletingLastPathComponent().appendingPathComponent(fallbackName) - do { - // A leftover from an earlier burst that reused the same - // (peer, scope, burstID) triple would block the move. - try? fileManager.removeItem(at: destination) - try fileManager.moveItem(at: liveURL, to: destination) - return destination - } catch { - SecureLogger.warning("PTT: keeping live-capture name for finished burst — promotion failed: \(error)", category: .session) - return liveURL - } - } - - private func cancelAssembly(_ assembly: Assembly) { - assembly.idleTimeout?.cancel() - assembly.gapRedrain?.cancel() - assembly.player?.stop() - try? assembly.fileHandle?.close() - assembly.fileHandle = nil - assemblies.removeValue(forKey: assembly.key) - updatePublicTalkerIndicator() - removeBubble(of: assembly) - WaveformCache.shared.purge(url: assembly.fileURL) - try? fileManager.removeItem(at: assembly.fileURL) - context.notifyUIChanged() - } - - // MARK: - Timers - - private func rescheduleIdleTimeout(for assembly: Assembly) { - assembly.idleTimeout?.cancel() - let key = assembly.key - assembly.idleTimeout = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttBurstEndTimeoutSeconds * 1_000_000_000)) - guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return } - // Talker went silent/out of range without an END. - self.finalize(assembly) - } - } - - private func scheduleGapRedrain(for assembly: Assembly) { - assembly.gapRedrain?.cancel() - let key = assembly.key - assembly.gapRedrain = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: UInt64((Self.gapSkipSeconds + 0.05) * 1_000_000_000)) - guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return } - self.drainInOrder(assembly) - self.finalizeIfComplete(assembly) - } - } - - // MARK: - Helpers - - private func pruneFinishedBursts() { - let now = Date() - finishedBursts = finishedBursts.filter { $0.value.expiresAt > now } - while finishedBursts.count >= Self.finishedBurstsCap { - guard let oldest = finishedBursts.min(by: { $0.value.expiresAt < $1.value.expiresAt }) else { break } - finishedBursts.removeValue(forKey: oldest.key) - } - } - - /// Extracts the 8-byte burst ID from a finalized note's file name - /// (`voice_<16 hex>.m4a`, possibly uniquified by the incoming file store). - static func burstID(fromVoiceFileName fileName: String) -> Data? { - guard fileName.hasPrefix("voice_") else { return nil } - let afterPrefix = fileName.dropFirst("voice_".count) - let hex = String(afterPrefix.prefix(16)) - guard hex.count == 16, hex.allSatisfy(\.isHexDigit) else { return nil } - return Data(hexString: hex) - } - - private static let incomingSubdirectory = "\(MimeType.Category.audio.mediaDir)/incoming" - - /// The peer ID and scope in the name mirror the assembly key: colliding - /// burst IDs from different senders — or from the same sender across DM - /// and public — land on distinct files instead of truncating each other. - /// `burstID(fromVoiceFileName:)` still rejects every `voice_live_*` name, - /// so live captures can never absorb a note. - private func makeIncomingURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope) -> URL? { - guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory) else { return nil } - let scopeTag = scope == .directMessage ? "dm" : "mesh" - return directory.appendingPathComponent("\(BLEIncomingFileStore.liveCapturePrefix)\(burstID.hexEncodedString())_\(peerID.id)_\(scopeTag).aac") - } - - /// Deletes partial live captures left behind by a previous session. - /// In-session cleanup needs no sweep: absorb, cancel, and empty-finalize - /// delete their capture file, and finalize promotes keepers off the - /// `voice_live_` name. So anything the sweep matches was orphaned by a - /// crash mid-burst — safe to delete, because chat rows are in-memory - /// only (ConversationStore never persists; the gossip archive replays - /// `MessageType.message` packets only), so no row from a previous - /// process can reference the file. - private func sweepStaleLiveCaptures() { - guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory), - let contents = try? fileManager.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) - else { return } - for url in contents where url.lastPathComponent.hasPrefix(BLEIncomingFileStore.liveCapturePrefix) && url.pathExtension == "aac" { - try? fileManager.removeItem(at: url) - } - } -} diff --git a/bitchat/ViewModels/ChatMediaPreparation.swift b/bitchat/ViewModels/ChatMediaPreparation.swift deleted file mode 100644 index 6852d397..00000000 --- a/bitchat/ViewModels/ChatMediaPreparation.swift +++ /dev/null @@ -1,57 +0,0 @@ -import BitFoundation -import Foundation - -enum ChatMediaPreparationError: Error, Equatable { - case encodingFailed - case voiceNoteTooLarge(bytes: Int) - case imageTooLarge(bytes: Int) -} - -struct ChatPreparedImage { - let outputURL: URL - let packet: BitchatFilePacket -} - -enum ChatMediaPreparation { - static func prepareVoiceNotePacket(at url: URL) throws -> BitchatFilePacket { - let attributes = try FileManager.default.attributesOfItem(atPath: url.path) - guard let fileSize = attributes[.size] as? Int else { - throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 0) - } - guard fileSize <= FileTransferLimits.maxVoiceNoteBytes else { - throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: fileSize) - } - - let data = try Data(contentsOf: url) - let packet = BitchatFilePacket( - fileName: url.lastPathComponent, - fileSize: UInt64(data.count), - mimeType: "audio/mp4", - content: data - ) - guard packet.encode() != nil else { throw ChatMediaPreparationError.encodingFailed } - return packet - } - - static func prepareImagePacket(from sourceURL: URL) throws -> ChatPreparedImage { - let outputURL = try ImageUtils.processImage(at: sourceURL) - do { - let data = try Data(contentsOf: outputURL) - guard data.count <= FileTransferLimits.maxImageBytes else { - throw ChatMediaPreparationError.imageTooLarge(bytes: data.count) - } - - let packet = BitchatFilePacket( - fileName: outputURL.lastPathComponent, - fileSize: UInt64(data.count), - mimeType: "image/jpeg", - content: data - ) - guard packet.encode() != nil else { throw ChatMediaPreparationError.encodingFailed } - return ChatPreparedImage(outputURL: outputURL, packet: packet) - } catch { - try? FileManager.default.removeItem(at: outputURL) - throw error - } - } -} diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift deleted file mode 100644 index 548e48b0..00000000 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ /dev/null @@ -1,1905 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -#if os(iOS) -import UIKit -#endif - -struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable { - let id: UUID - let peerID: PeerID - let peerName: String - let transferId: String - let messageID: String -} - -struct PendingLegacyPrivateMediaConsent { - let request: LegacyPrivateMediaConsentRequest - let completion: @MainActor (Bool) -> Void -} - -struct PrivateMediaReconnectRetryLimits: Equatable { - var maxRetainedPackets = 8 - var maxRetainedBytes = 4 * 1024 * 1024 - var maxRetriesPerMessage = 2 - var retentionSeconds: TimeInterval = 120 - var maxRetriesPerReconnect = 2 -} - -private struct PrivateMediaReconnectRetryRecord { - let messageID: String - let peerID: PeerID - let packet: BitchatFilePacket - var receiptSessionGeneration: UUID - var createdAt: Date - var retryCount: Int - var activeTransferID: String? - var retryAfterCompletion: Bool - var idleOutcome: PrivateMediaReconnectRetryIdleOutcome - var deferredTerminalFailureReason: String? - var expiryToken: UUID? - - var retainedBytes: Int { - packet.content.count - } -} - -private enum PrivateMediaReconnectRetryIdleOutcome { - case none - case locallyCompleted - case cancelled - case rejected(reason: String) -} - -private struct PrivateMediaReconnectRetryCandidate { - let messageID: String - /// The receipt-capable Noise generation that owned this record when the - /// reconnect/authentication event captured it. - let receiptSessionGeneration: UUID -} - -/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatMediaTransferCoordinatorContextTests`) and makes its -/// true dependencies explicit. -@MainActor -protocol ChatMediaTransferContext: AnyObject { - // MARK: Composition state - var canSendMediaInCurrentContext: Bool { get } - var selectedPrivateChatPeer: PeerID? { get } - var nickname: String { get } - var myPeerID: PeerID { get } - var activeChannel: ChannelID { get } - func nicknameForPeer(_ peerID: PeerID) -> String - func currentPublicSender() -> (name: String, peerID: PeerID) - - // MARK: Message state - /// Appends a private message via the single-writer store intent. - @discardableResult - func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool - /// Appends a public message via the single-writer store intent - /// (immediate: outgoing media placeholders must render without batching). - @discardableResult - func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool - func removeMessage(withID messageID: String, cleanupFile: Bool) - /// Removes a media bubble with direction-scoped cleanup instead of the - /// broad compatibility cleanup path. - func removeUntombstonedMediaMessage(withID messageID: String) - func removeOutgoingMediaMessage(withID messageID: String) - func addSystemMessage(_ content: String) - /// Surfaces a refused explicit media deletion in the affected chat so a - /// wedged delete never looks like success. - func notifyMediaDeletionRefused(messageID: String) - /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). - func notifyUIChanged() - - // MARK: Delivery status & dedup - func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) - func normalizedContentKey(_ content: String) -> String - func recordContentKey(_ key: String, timestamp: Date) - - // MARK: Mesh file transfer - func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy - func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID? - func resolvePrivateMediaSendPolicy( - to peerID: PeerID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) - func requestLegacyPrivateMediaConsent( - for peerID: PeerID, - transferId: String, - messageID: String, - completion: @escaping @MainActor (Bool) -> Void - ) - func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) - func sendFilePrivate( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool - ) - func sendFilePrivateReceiptRetry( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String - ) - func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) - func cancelTransfer(_ transferId: String) - /// Receiver-side stable-ID deletion commit. Implementations must invoke - /// completion only after the entire batch is durably tombstoned. - func persistDeletedPrivateMedia( - messageIDs: [String], - completion: @escaping @MainActor (Bool) -> Void - ) - /// Whether any current private-chat copy of this stable ID came from a - /// remote peer and therefore requires a receiver tombstone. - func requiresPrivateMediaTombstone(messageID: String) -> Bool -} - -extension ChatViewModel: ChatMediaTransferContext { - // `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`, - // `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`, - // `currentPublicSender()`, - // `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`, - // `addSystemMessage(_:)`, `notifyUIChanged()`, - // `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`, - // and `recordContentKey(_:timestamp:)` are shared requirements with the - // other contexts or satisfied by existing `ChatViewModel` members. The - // members below flatten mesh service accesses. - - /// File transfer rides the mesh only. Without that capability the - /// policy degrades to the safe floor (blocked), matching the old - /// inert protocol defaults. - private var fileTransport: MeshFileTransferring? { meshService as? MeshFileTransferring } - - func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { - fileTransport?.privateMediaSendPolicy(to: peerID) ?? .blockedDowngrade - } - - func authenticatedPrivateMediaReceiptSessionGeneration( - to peerID: PeerID - ) -> UUID? { - fileTransport?.authenticatedPrivateMediaReceiptSessionGeneration( - to: peerID - ) - } - - func resolvePrivateMediaSendPolicy( - to peerID: PeerID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) { - guard let fileTransport else { - Task { @MainActor in completion(.blockedDowngrade) } - return - } - fileTransport.resolvePrivateMediaSendPolicy(to: peerID, completion: completion) - } - - func requestLegacyPrivateMediaConsent( - for peerID: PeerID, - transferId: String, - messageID: String, - completion: @escaping @MainActor (Bool) -> Void - ) { - enqueueLegacyPrivateMediaConsent( - for: peerID, - transferId: transferId, - messageID: messageID, - completion: completion - ) - } - - func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) { - invalidateLegacyPrivateMediaConsent( - transferId: transferId, - messageID: messageID - ) - } - - func sendFilePrivate( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool - ) { - fileTransport?.sendFilePrivate( - packet, - to: peerID, - transferId: transferId, - allowLegacyFallback: allowLegacyFallback - ) - } - - func sendFilePrivateReceiptRetry( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String - ) { - fileTransport?.sendFilePrivateReceiptRetry( - packet, - to: peerID, - transferId: transferId - ) - } - - func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { - fileTransport?.sendFileBroadcast(packet, transferId: transferId) - } - - func cancelTransfer(_ transferId: String) { - fileTransport?.cancelTransfer(transferId) - } - - func removeUntombstonedMediaMessage(withID messageID: String) { - let message = conversations.conversationsByID.values.lazy - .flatMap(\.messages) - .first { $0.id == messageID } - if let message, !isIncomingPrivateMessage(message) { - mediaTransferCoordinator.cleanupOutgoingLocalFile( - forMessage: message - ) - } - removeMessage(withID: messageID, cleanupFile: false) - if let message { - cleanupLegacyIncomingMediaPayloads(for: [message]) - } - } - - /// Explicitly deleted LEGACY (non-stable-ID) incoming media has no - /// durable ID-to-file ownership, so the actual unlink is delegated to - /// the transport's gated cleanup: a basename that is pending delivery or - /// reserved by a receipt/deletion transaction stays on disk for bounded - /// quota cleanup instead. Must run after the bubbles were removed; a - /// surviving reference in any conversation keeps the payload. - func cleanupLegacyIncomingMediaPayloads(for messages: [BitchatMessage]) { - guard let cleanup = - meshService as? any PrivateMediaDeletionPersisting else { - return - } - let legacyPaths = Set(messages.compactMap { message -> String? in - guard !PrivateMediaMessageIdentity.isStableID(message.id), - isIncomingPrivateMessage(message) else { - return nil - } - return incomingMediaRelativePath(for: message) - }) - guard !legacyPaths.isEmpty else { return } - let survivingPaths = Set( - conversations.conversationsByID.values.lazy - .flatMap(\.messages) - .compactMap { message -> String? in - guard self.isIncomingPrivateMessage(message) else { - return nil - } - return self.incomingMediaRelativePath(for: message) - } - ) - for relativePath in legacyPaths.subtracting(survivingPaths).sorted() { - cleanup.removeLegacyPrivateMediaPayload( - relativePath: relativePath - ) - } - } - - func removeOutgoingMediaMessage(withID messageID: String) { - let message = conversations.conversationsByID.values.lazy - .flatMap(\.messages) - .first { $0.id == messageID } - if let message { - mediaTransferCoordinator.cleanupOutgoingLocalFile( - forMessage: message - ) - } - removeMessage(withID: messageID, cleanupFile: false) - } - - func persistDeletedPrivateMedia( - messageIDs: [String], - completion: @escaping @MainActor (Bool) -> Void - ) { - guard !messageIDs.isEmpty else { - completion(true) - return - } - guard let persistence = - meshService as? any PrivateMediaDeletionPersisting else { - completion(false) - return - } - let requestedIDs = Set(messageIDs) - let incomingPathReferences = Array( - conversations.conversationsByID.values - .lazy - .flatMap(\.messages) - .compactMap { message -> ( - messageID: String, - path: String - )? in - guard self.isIncomingPrivateMessage(message), - let path = self.incomingMediaRelativePath( - for: message - ) else { - return nil - } - return (message.id, path) - } - ) - let ownerIDsByPath = Dictionary( - grouping: incomingPathReferences, - by: { $0.path } - ).mapValues { Set($0.map(\.messageID)) } - let protectedPayloadRelativePaths = Set( - ownerIDsByPath.compactMap { path, ownerIDs in - ownerIDs.isSubset(of: requestedIDs) ? nil : path - } - ) - var payloadRelativePaths: [String: String] = [:] - for reference in incomingPathReferences - where requestedIDs.contains(reference.messageID) - && ownerIDsByPath[reference.path, default: []] - .isSubset(of: requestedIDs) { - payloadRelativePaths[reference.messageID] = reference.path - } - persistence.persistDeletedPrivateMedia( - messageIDs: messageIDs, - payloadRelativePaths: payloadRelativePaths, - protectedPayloadRelativePaths: - protectedPayloadRelativePaths, - completion: completion - ) - } - - func requiresPrivateMediaTombstone(messageID: String) -> Bool { - guard PrivateMediaMessageIdentity.isStableID(messageID) else { - return false - } - return privateChats.values.lazy.flatMap { $0 }.contains { message in - message.id == messageID && isIncomingPrivateMessage(message) - } - } - - func notifyMediaDeletionRefused(messageID: String) { - let owningPeerID = privateChats.first { _, messages in - messages.contains { $0.id == messageID } - }?.key - notifyPrivateMediaDeletionRefused(peerID: owningPeerID) - } - - /// A refused deletion/clear previously surfaced only in SecureLogger, so - /// a wedged /clear looked like success. Tell the affected chat that its - /// bubbles and payloads were intentionally kept. - func notifyPrivateMediaDeletionRefused(peerID: PeerID?) { - let copy = String( - localized: "content.system.media_delete_refused", - comment: "System message when an explicit media delete or /clear was refused and bubbles/files were kept" - ) - if let peerID = peerID ?? selectedPrivateChatPeer { - addLocalPrivateSystemMessage(copy, to: peerID) - } else { - addSystemMessage(copy) - } - } - - private func isIncomingPrivateMessage( - _ message: BitchatMessage - ) -> Bool { - if let senderPeerID = message.senderPeerID { - return senderPeerID.toShort() != meshService.myPeerID.toShort() - } - return message.sender != nickname - && !message.sender.hasPrefix(nickname + "#") - } - - private func incomingMediaRelativePath( - for message: BitchatMessage - ) -> String? { - let categories: [MimeType.Category] = [.audio, .image, .file] - guard let category = categories.first(where: { - message.content.hasPrefix($0.messagePrefix) - }), - let rawFilename = String( - message.content.dropFirst(category.messagePrefix.count) - ).trimmedOrNilIfEmpty, - let safeFilename = - (rawFilename as NSString).lastPathComponent.nilIfEmpty, - safeFilename != ".", - safeFilename != ".." else { - return nil - } - return "\(category.mediaDir)/incoming/\(safeFilename)" - } -} - -/// Synchronous boundary between detached image writers and panic deletion. -/// -/// Invalidation closes admission before waiting for writers that already -/// entered. Those writers never need the main actor while inside the boundary, -/// so a synchronous panic transaction can safely join them and then delete -/// every output before reporting completion. -private final class ImagePreparationBarrier: @unchecked Sendable { - private let condition = NSCondition() - private var generation: UInt64 = 0 - private var activeOperations = 0 - - var currentGeneration: UInt64 { - condition.lock() - defer { condition.unlock() } - return generation - } - - func isCurrent(_ candidate: UInt64) -> Bool { - condition.lock() - defer { condition.unlock() } - return generation == candidate - } - - func performIfCurrent( - generation candidate: UInt64, - operation: () throws -> T - ) rethrows -> T? { - condition.lock() - guard generation == candidate else { - condition.unlock() - return nil - } - activeOperations += 1 - condition.unlock() - - defer { - condition.lock() - activeOperations -= 1 - if activeOperations == 0 { - condition.broadcast() - } - condition.unlock() - } - return try operation() - } - - func invalidateAndWait() { - condition.lock() - generation &+= 1 - while activeOperations > 0 { - condition.wait() - } - condition.unlock() - } -} - -/// Runs synchronous media encoding and file I/O on a dispatch worker. -/// -/// These operations can legitimately block. Keeping them off Swift's -/// cooperative executor preserves forward progress when several transfers or -/// test doubles wait at the same time. -private func runBlockingMediaPreparation( - _ operation: @escaping @Sendable () throws -> T -) async throws -> T { - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - do { - continuation.resume(returning: try operation()) - } catch { - continuation.resume(throwing: error) - } - } - } -} - -@MainActor -final class ChatMediaTransferCoordinator { - private unowned let context: any ChatMediaTransferContext - private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage - private let imagePreparationBarrier = ImagePreparationBarrier() - private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket - private let reconnectRetryLimits: PrivateMediaReconnectRetryLimits - private let now: () -> Date - private let transferIDFactory: (String) -> String - - private(set) var transferIdToMessageIDs: [String: [String]] = [:] - private(set) var messageIDToTransferId: [String: String] = [:] - private var deletionGeneration: UInt64 = 0 - private var reconnectRetryRecords: [ - String: PrivateMediaReconnectRetryRecord - ] = [:] - /// A newly authenticated session supersedes any raw-connect policy - /// resolution still in flight for that peer. - private var peersResolvingReconnectRetry: [ - PeerID: ( - id: UUID, - replacingActiveTransfer: Bool, - candidates: [PrivateMediaReconnectRetryCandidate] - ) - ] = [:] - private var reconnectRetryExpiryTasks: [String: Task] = [:] - - var retainedReconnectRetryCount: Int { - reconnectRetryRecords.count - } - - var retainedReconnectRetryBytes: Int { - reconnectRetryRecords.values.reduce(0) { $0 + $1.retainedBytes } - } - - init( - context: any ChatMediaTransferContext, - prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = { - try ChatMediaPreparation.prepareImagePacket(from: $0) - }, - prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = { - try ChatMediaPreparation.prepareVoiceNotePacket(at: $0) - }, - reconnectRetryLimits: PrivateMediaReconnectRetryLimits = - PrivateMediaReconnectRetryLimits(), - now: @escaping () -> Date = Date.init, - transferIDFactory: @escaping (String) -> String = { - "\($0)-\(UUID().uuidString)" - } - ) { - self.context = context - self.prepareImagePacket = prepareImagePacket - self.prepareVoiceNotePacket = prepareVoiceNotePacket - self.reconnectRetryLimits = reconnectRetryLimits - self.now = now - self.transferIDFactory = transferIDFactory - } - - func sendVoiceNote(at url: URL) { - guard context.canSendMediaInCurrentContext else { - SecureLogger.info("Voice note blocked outside mesh/private context", category: .session) - try? FileManager.default.removeItem(at: url) - context.addSystemMessage(String(localized: "Voice notes are only available in mesh chats.", comment: "System message when a voice note is attempted outside mesh chats")) - return - } - - let targetPeer = context.selectedPrivateChatPeer - let privateMessageID = targetPeer.flatMap { peerID in - PrivateMediaMessageIdentity.stableID( - senderPeerID: context.myPeerID, - recipientPeerID: peerID, - fileName: url.lastPathComponent - ) - } - let message = enqueueMediaMessage( - content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", - targetPeer: targetPeer, - messageID: privateMessageID - ) - let messageID = message.id - let transferId = makeTransferID(messageID: messageID) - // Own the transfer before detached preparation begins. Cancel/delete - // must be able to invalidate this exact invocation even while file I/O - // is still running off the main actor. - registerTransfer(transferId: transferId, messageID: messageID) - let prepareVoiceNotePacket = self.prepareVoiceNotePacket - let barrier = imagePreparationBarrier - let generation = barrier.currentGeneration - - Task.detached(priority: .userInitiated) { [weak self, barrier] in - do { - let packet = try await runBlockingMediaPreparation { - try prepareVoiceNotePacket(url) - } - - await MainActor.run { [weak self, barrier] in - guard let self, - barrier.isCurrent(generation), - self.isRegisteredTransfer(transferId, messageID: messageID) else { - return - } - if let peerID = targetPeer { - self.beginPrivateMediaSend( - packet, - to: peerID, - transferId: transferId, - messageID: messageID - ) - } else { - self.context.sendFileBroadcast(packet, transferId: transferId) - } - } - } catch ChatMediaPreparationError.voiceNoteTooLarge(let size) { - SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) - try? FileManager.default.removeItem(at: url) - await MainActor.run { [weak self, barrier] in - guard let self, - barrier.isCurrent(generation), - self.isRegisteredTransfer(transferId, messageID: messageID) else { - return - } - self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit")) - } - } catch { - SecureLogger.error("Voice note send failed: \(error)", category: .session) - await MainActor.run { [weak self, barrier] in - guard let self, - barrier.isCurrent(generation), - self.isRegisteredTransfer(transferId, messageID: messageID) else { - return - } - self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent")) - } - } - } - } - - #if os(iOS) - func processThenSendImage(_ image: UIImage?) { - guard let image else { return } - let generation = imagePreparationBarrier.currentGeneration - let barrier = imagePreparationBarrier - Task.detached(priority: .userInitiated) { [weak self, barrier] in - do { - let processedURL = try await runBlockingMediaPreparation { - try barrier.performIfCurrent( - generation: generation, - operation: { - try ImageUtils.processImage(image) - } - ) - } - guard let processedURL else { - return - } - await MainActor.run { [weak self, barrier] in - guard let self, - barrier.isCurrent(generation) else { - try? FileManager.default.removeItem(at: processedURL) - return - } - self.sendImage(from: processedURL) - } - } catch { - SecureLogger.error("Image processing failed: \(error)", category: .session) - } - } - } - #elseif os(macOS) - func processThenSendImage(from url: URL?) { - guard let url else { return } - let generation = imagePreparationBarrier.currentGeneration - let barrier = imagePreparationBarrier - Task.detached(priority: .userInitiated) { [weak self, barrier] in - do { - let processedURL = try await runBlockingMediaPreparation { - try barrier.performIfCurrent( - generation: generation, - operation: { - try ImageUtils.processImage(at: url) - } - ) - } - guard let processedURL else { - return - } - await MainActor.run { [weak self, barrier] in - guard let self, - barrier.isCurrent(generation) else { - try? FileManager.default.removeItem(at: processedURL) - return - } - self.sendImage(from: processedURL) - } - } catch { - SecureLogger.error("Image processing failed: \(error)", category: .session) - } - } - } - #endif - - func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { - guard context.canSendMediaInCurrentContext else { - SecureLogger.info("Image send blocked outside mesh/private context", category: .session) - cleanup?() - context.addSystemMessage(String(localized: "Images are only available in mesh chats.", comment: "System message when an image send is attempted outside mesh chats")) - return - } - - let targetPeer = context.selectedPrivateChatPeer - let generation = imagePreparationBarrier.currentGeneration - - do { - try ImageUtils.validateImageSource(at: sourceURL) - } catch { - SecureLogger.error("Image send preparation failed: \(error)", category: .session) - context.addSystemMessage("Failed to prepare image for sending.") - return - } - - let prepareImagePacket = self.prepareImagePacket - let barrier = imagePreparationBarrier - Task.detached(priority: .userInitiated) { [weak self, barrier] in - do { - let prepared = try await runBlockingMediaPreparation { - try barrier.performIfCurrent( - generation: generation, - operation: { - try prepareImagePacket(sourceURL) - } - ) - } - guard let prepared else { - return - } - - await MainActor.run { [weak self, barrier] in - guard let self, - barrier.isCurrent(generation) else { - try? FileManager.default.removeItem(at: prepared.outputURL) - return - } - let privateMessageID = targetPeer.flatMap { peerID in - PrivateMediaMessageIdentity.stableID( - for: prepared.packet, - senderPeerID: self.context.myPeerID, - recipientPeerID: peerID - ) - } - let message = self.enqueueMediaMessage( - content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)", - targetPeer: targetPeer, - messageID: privateMessageID - ) - let messageID = message.id - let transferId = self.makeTransferID(messageID: messageID) - self.registerTransfer(transferId: transferId, messageID: messageID) - if let peerID = targetPeer { - self.beginPrivateMediaSend( - prepared.packet, - to: peerID, - transferId: transferId, - messageID: messageID - ) - } else { - self.context.sendFileBroadcast(prepared.packet, transferId: transferId) - } - } - } catch ChatMediaPreparationError.imageTooLarge(let size) { - SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session) - await MainActor.run { [weak self, barrier] in - guard let self, - barrier.isCurrent(generation) else { - return - } - self.context.addSystemMessage("Image is too large to send.") - } - } catch { - SecureLogger.error("Image send preparation failed: \(error)", category: .session) - await MainActor.run { [weak self, barrier] in - guard let self, - barrier.isCurrent(generation) else { - return - } - self.context.addSystemMessage("Failed to prepare image for sending.") - } - } - } - } - - func enqueueMediaMessage( - content: String, - targetPeer: PeerID?, - messageID: String? = nil - ) -> BitchatMessage { - let timestamp = Date() - let message: BitchatMessage - - if let peerID = targetPeer { - message = BitchatMessage( - id: messageID, - sender: context.nickname, - content: content, - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: context.nicknameForPeer(peerID), - senderPeerID: context.myPeerID, - deliveryStatus: .sending - ) - context.appendPrivateMessage(message, to: peerID) - } else { - let (displayName, senderPeerID) = context.currentPublicSender() - message = BitchatMessage( - sender: displayName, - content: content, - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: senderPeerID, - deliveryStatus: .sending - ) - context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel)) - } - - let key = context.normalizedContentKey(message.content) - context.recordContentKey(key, timestamp: timestamp) - context.notifyUIChanged() - return message - } - - private func beginPrivateMediaSend( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - messageID: String - ) { - continuePrivateMediaSend( - packet, - to: peerID, - transferId: transferId, - messageID: messageID, - policy: context.privateMediaSendPolicy(to: peerID) - ) - } - - private func continuePrivateMediaSend( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - messageID: String, - policy: PrivateMediaSendPolicy - ) { - switch policy { - case .encrypted: - retainForReconnectRetryIfEligible( - packet, - peerID: peerID, - messageID: messageID, - activeTransferID: transferId - ) - context.sendFilePrivate( - packet, - to: peerID, - transferId: transferId, - allowLegacyFallback: false - ) - - case .awaitingCapabilityProof: - context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in - guard let self, - self.isRegisteredTransfer(transferId, messageID: messageID) else { - return - } - guard resolvedPolicy != .awaitingCapabilityProof else { - self.handleMediaSendFailure( - messageID: messageID, - reason: String( - localized: "content.delivery.reason.private_media_capability_unresolved", - defaultValue: "Could not confirm encrypted media support", - comment: "Failure reason when private-media capability negotiation did not resolve" - ) - ) - return - } - self.continuePrivateMediaSend( - packet, - to: peerID, - transferId: transferId, - messageID: messageID, - policy: resolvedPolicy - ) - } - - case .legacyRequiresConsent: - context.requestLegacyPrivateMediaConsent( - for: peerID, - transferId: transferId, - messageID: messageID - ) { [weak self] approved in - guard let self else { return } - // Consent belongs to this exact placeholder/transfer. A late - // dialog callback after cancel/delete must never resurrect it. - guard self.messageIDToTransferId[messageID] == transferId, - self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else { - return - } - guard approved else { - self.handleMediaSendFailure( - messageID: messageID, - reason: String( - localized: "content.delivery.reason.legacy_media_declined", - defaultValue: "Not sent without end-to-end encryption", - comment: "Failure reason after declining the warning for a legacy clear private-media send" - ) - ) - return - } - self.context.sendFilePrivate( - packet, - to: peerID, - transferId: transferId, - allowLegacyFallback: true - ) - } - - case .blockedDowngrade: - handleMediaSendFailure( - messageID: messageID, - reason: String( - localized: "content.delivery.reason.private_media_downgrade_blocked", - defaultValue: "Encrypted media required; ask this contact to upgrade", - comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade" - ) - ) - } - } - - func registerTransfer(transferId: String, messageID: String) { - transferIdToMessageIDs[transferId, default: []].append(messageID) - messageIDToTransferId[messageID] = transferId - } - - private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool { - messageIDToTransferId[messageID] == transferId - && transferIdToMessageIDs[transferId]?.contains(messageID) == true - } - - func makeTransferID(messageID: String) -> String { - transferIDFactory(messageID) - } - - func clearTransferMapping(for messageID: String) { - guard let transferId = messageIDToTransferId[messageID] else { return } - clearTransferMapping( - transferID: transferId, - messageID: messageID, - clearCurrentOwner: true - ) - } - - private func clearTransferMapping( - transferID: String, - messageID: String, - clearCurrentOwner: Bool - ) { - if clearCurrentOwner, - messageIDToTransferId[messageID] == transferID { - messageIDToTransferId.removeValue(forKey: messageID) - } - context.cancelLegacyPrivateMediaConsent( - transferId: transferID, - messageID: messageID - ) - guard var queue = transferIdToMessageIDs[transferID] else { return } - - if !queue.isEmpty { - if queue.first == messageID { - queue.removeFirst() - } else if let index = queue.firstIndex(of: messageID) { - queue.remove(at: index) - } - } - - transferIdToMessageIDs[transferID] = queue.isEmpty ? nil : queue - } - - /// Returns the message still owned by this exact transfer. Replacement - /// retries can receive late callbacks from the cancelled predecessor; - /// those callbacks may clear only their stale queue entry. - private func currentMessageID(forTransferID transferID: String) -> String? { - guard let messageID = transferIdToMessageIDs[transferID]?.first else { - return nil - } - guard messageIDToTransferId[messageID] == transferID else { - clearTransferMapping( - transferID: transferID, - messageID: messageID, - clearCurrentOwner: false - ) - return nil - } - return messageID - } - - func handleMediaSendFailure(messageID: String, reason: String) { - discardReconnectRetry( - messageID: messageID, - cancelActiveTransfer: false - ) - context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) - clearTransferMapping(for: messageID) - } - - func handleTransferEvent(_ event: TransferProgressManager.Event) { - switch event { - case .started(let id, let total): - guard let messageID = currentMessageID(forTransferID: id) else { - return - } - if isReconnectRetryTransfer(id, messageID: messageID) { - return - } - context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) - - case .updated(let id, let sent, let total): - guard let messageID = currentMessageID(forTransferID: id) else { - return - } - if isReconnectRetryTransfer(id, messageID: messageID) { - return - } - context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) - - case .completed(let id, _): - guard let messageID = currentMessageID(forTransferID: id) else { - return - } - let ownsRetainedRecord = - reconnectRetryRecords[messageID]?.activeTransferID == id - let retryAfterCompletion = ownsRetainedRecord - && reconnectRetryRecords[messageID]?.retryAfterCompletion == true - let deferredTerminalReason = ownsRetainedRecord - ? reconnectRetryRecords[messageID]? - .deferredTerminalFailureReason - : nil - if ownsRetainedRecord { - reconnectRetryRecords[messageID]?.activeTransferID = nil - reconnectRetryRecords[messageID]?.retryAfterCompletion = false - reconnectRetryRecords[messageID]?.idleOutcome = - .locallyCompleted - reconnectRetryRecords[messageID]?.createdAt = now() - } - context.updateMessageDeliveryStatus(messageID, status: .sent) - clearTransferMapping(for: messageID) - if let deferredTerminalReason { - terminalizeReconnectRetry( - messageID: messageID, - reason: deferredTerminalReason - ) - } else if retryAfterCompletion { - startReconnectRetry(messageID: messageID) - } else if ownsRetainedRecord { - scheduleReconnectRetryExpiry(messageID: messageID) - } - - case .cancelled(let id, _, _): - guard let messageID = currentMessageID(forTransferID: id) else { - return - } - if isRetainedPrivateMediaTransfer(id, messageID: messageID) { - finishRetainedTransfer( - id, - messageID: messageID, - outcome: .cancelled, - rejectionReason: nil - ) - return - } - discardReconnectRetry( - messageID: messageID, - cancelActiveTransfer: false - ) - clearTransferMapping(for: messageID) - context.removeOutgoingMediaMessage(withID: messageID) - case .rejected(let id, let reason): - guard let messageID = currentMessageID(forTransferID: id) else { - return - } - if isRetainedPrivateMediaTransfer(id, messageID: messageID) { - finishRetainedTransfer( - id, - messageID: messageID, - outcome: .rejected(reason: reason), - rejectionReason: reason - ) - return - } - handleMediaSendFailure(messageID: messageID, reason: reason) - } - } - - func cleanupLocalFile(forMessage message: BitchatMessage) { - cleanupLocalFile( - forMessage: message, - directions: ["outgoing", "incoming"], - searchAllCategories: true - ) - } - - /// `/clear` may cancel an outgoing message before receiver tombstones are - /// committed. Restrict cleanup to that message's outgoing directory so a - /// same-name incoming payload cannot be removed prematurely. - func cleanupOutgoingLocalFile(forMessage message: BitchatMessage) { - cleanupLocalFile( - forMessage: message, - directions: ["outgoing"], - searchAllCategories: false - ) - } - - /// Receiver cleanup runs only after any required tombstone commit. Keep it - /// scoped to the parsed media category and incoming directory so unrelated - /// outgoing or cross-category payloads with the same basename survive. - func cleanupIncomingLocalFile(forMessage message: BitchatMessage) { - cleanupLocalFile( - forMessage: message, - directions: ["incoming"], - searchAllCategories: false - ) - } - - private func cleanupLocalFile( - forMessage message: BitchatMessage, - directions: [String], - searchAllCategories: Bool - ) { - let categories: [MimeType.Category] = [.audio, .image, .file] - guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }), - let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty, - let base = try? applicationFilesDirectory(), - let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty, - safeFilename != ".", - safeFilename != ".." else { - return - } - - let targetCategories = searchAllCategories ? categories : [category] - let subdirs = targetCategories.flatMap { category in - directions.map { "\(category.mediaDir)/\($0)" } - } - for subdir in subdirs { - let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) - guard target.path.hasPrefix(base.path) else { continue } - - guard FileManager.default.fileExists(atPath: target.path) else { - continue - } - guard let values = try? target.resourceValues( - forKeys: [.isRegularFileKey] - ), - values.isRegularFile == true else { - SecureLogger.warning( - "Refusing to cleanup non-file media target \(safeFilename)", - category: .session - ) - continue - } - do { - try FileManager.default.removeItem(at: target) - } catch CocoaError.fileNoSuchFile { - continue - } catch { - SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session) - } - } - } - - func cancelMediaSend(messageID: String) { - cancelAllMediaSendOwners(messageID: messageID) - context.removeOutgoingMediaMessage(withID: messageID) - } - - /// Lets `/clear` cancel send ownership without implicitly deciding which - /// bubbles/files its deletion transaction may remove. - func cancelMediaTransferForConversationClear(messageID: String) { - cancelAllMediaSendOwners(messageID: messageID) - } - - func deleteMediaMessage(messageID: String) { - // Stop every exact sender owner before the durable receiver commit. - // Otherwise a retained retry or admitted legacy send could transmit - // after the bubble and payload have been deleted. - cancelAllMediaSendOwners(messageID: messageID) - - guard context.requiresPrivateMediaTombstone( - messageID: messageID - ) else { - finishMediaDeletion( - messageID: messageID, - receiverJournalOwnsPayload: false - ) - return - } - - let generation = deletionGeneration - context.persistDeletedPrivateMedia( - messageIDs: [messageID] - ) { [weak self] persisted in - guard let self, - self.deletionGeneration == generation else { - return - } - guard persisted else { - SecureLogger.error( - "Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…", - category: .session - ) - self.context.notifyMediaDeletionRefused( - messageID: messageID - ) - return - } - self.finishMediaDeletion( - messageID: messageID, - receiverJournalOwnsPayload: true - ) - } - } - - private func finishMediaDeletion( - messageID: String, - receiverJournalOwnsPayload: Bool - ) { - if receiverJournalOwnsPayload { - // The journal already owns the exact path. A basename cleanup here - // could delete a different arrival that reused it after unlink. - context.removeMessage(withID: messageID, cleanupFile: false) - } else { - context.removeUntombstonedMediaMessage(withID: messageID) - } - } - - private func cancelAllMediaSendOwners(messageID: String) { - // This releases the retained packet and expiry/retry owner. When its - // exact active transfer still owns the mapping, it cancels that owner - // before any deletion persistence or UI mutation can proceed. - discardReconnectRetry( - messageID: messageID, - cancelActiveTransfer: true - ) - // In particular, an approved legacy send may still be waiting on - // BLEService.messageQueue. Its admission must be canceled before the - // mapping/consent owner is released. - if let transferId = messageIDToTransferId[messageID], - transferIdToMessageIDs[transferId]?.first == messageID { - context.cancelTransfer(transferId) - } - clearTransferMapping(for: messageID) - } - - /// A raw link callback can arrive before the replacement Noise session - /// proves its capabilities. Resolve against the exact session before - /// releasing any retained bytes into a whole-file retry. - func peerDidReconnect(_ peerID: PeerID) { - resolveReconnectRetries( - for: peerID, - replacingActiveTransfer: false - ) - } - - /// Authentication supersedes a raw-connect resolution that may still - /// refer to the cached generation and replaces only stale active sends. - func peerDidAuthenticate(_ peerID: PeerID) { - resolveReconnectRetries( - for: peerID, - replacingActiveTransfer: true - ) - } - - /// A policy resolution's completion can be dropped entirely when the - /// transport tears down mid-flight (BLEService's queue guards on a - /// deallocated self), which would leave the pending entry blocking every - /// future resolution for this peer. Disconnection invalidates the - /// resolution's premise anyway, so drop it; retained records stay and the - /// next reconnect starts a fresh resolution. - func peerDidDisconnect(_ peerID: PeerID) { - peersResolvingReconnectRetry.removeValue(forKey: peerID.toShort()) - } - - /// Local fragment completion is not proof that the recipient reconstructed - /// the file. Only a remote delivery/read receipt releases retry ownership. - func confirmPrivateMediaDelivery(messageID: String) { - guard PrivateMediaMessageIdentity.isStableID(messageID) else { - return - } - discardReconnectRetry( - messageID: messageID, - cancelActiveTransfer: true - ) - } - - /// Deterministic clock seam for focused tests. Production records also own - /// wall-clock expiry tasks. - func _test_expireReconnectRetries() { - pruneExpiredReconnectRetries() - } - - /// Invalidates detached preparation work and cancels every transfer that - /// reached the transport. Closing image-preparation admission and joining - /// active synchronous writers ensures the following panic media deletion - /// is the last filesystem mutation before the transaction can complete. - func resetForPanic() { - imagePreparationBarrier.invalidateAndWait() - deletionGeneration &+= 1 - peersResolvingReconnectRetry.removeAll(keepingCapacity: false) - for task in reconnectRetryExpiryTasks.values { - task.cancel() - } - reconnectRetryExpiryTasks.removeAll(keepingCapacity: false) - reconnectRetryRecords.removeAll(keepingCapacity: false) - let transferIDs = Set(transferIdToMessageIDs.keys) - transferIdToMessageIDs.removeAll(keepingCapacity: false) - messageIDToTransferId.removeAll(keepingCapacity: false) - for transferID in transferIDs { - context.cancelTransfer(transferID) - } - } -} - -private extension ChatMediaTransferCoordinator { - func reconnectRetryCandidates( - for peerID: PeerID, - limit: Int? - ) -> [PrivateMediaReconnectRetryCandidate] { - let records = reconnectRetryRecords.values - .filter { - $0.peerID == peerID - && $0.retryCount - < reconnectRetryLimits.maxRetriesPerMessage - } - .sorted { - if $0.createdAt == $1.createdAt { - return $0.messageID < $1.messageID - } - return $0.createdAt < $1.createdAt - } - let selected: ArraySlice - if let limit { - selected = records.prefix(max(0, limit)) - } else { - selected = records[...] - } - return selected.map { - PrivateMediaReconnectRetryCandidate( - messageID: $0.messageID, - receiptSessionGeneration: $0.receiptSessionGeneration - ) - } - } - - func resolveReconnectRetries( - for peerID: PeerID, - replacingActiveTransfer: Bool - ) { - pruneExpiredReconnectRetries() - let normalizedPeerID = peerID.toShort() - let candidates: [PrivateMediaReconnectRetryCandidate] - if let pending = peersResolvingReconnectRetry[normalizedPeerID] { - // Authentication is the only event that may supersede a raw-link - // resolution; duplicate callbacks add no new proof. - guard replacingActiveTransfer, - !pending.replacingActiveTransfer else { - return - } - candidates = reconnectRetryCandidates( - for: normalizedPeerID, - limit: nil - ) - } else { - candidates = reconnectRetryCandidates( - for: normalizedPeerID, - limit: replacingActiveTransfer - ? nil - : max( - 0, - reconnectRetryLimits.maxRetriesPerReconnect - ) - ) - } - guard !candidates.isEmpty else { return } - - let resolutionID = UUID() - peersResolvingReconnectRetry[normalizedPeerID] = ( - id: resolutionID, - replacingActiveTransfer: replacingActiveTransfer, - candidates: candidates - ) - context.resolvePrivateMediaSendPolicy( - to: normalizedPeerID - ) { [weak self] policy in - guard let self, - let pending = - self.peersResolvingReconnectRetry[normalizedPeerID], - pending.id == resolutionID else { - return - } - self.peersResolvingReconnectRetry.removeValue( - forKey: normalizedPeerID - ) - guard policy == .encrypted, - let provenGeneration = self.context - .authenticatedPrivateMediaReceiptSessionGeneration( - to: normalizedPeerID - ) else { - self.terminalizeUnavailableCapabilityProof( - pending.candidates, - for: normalizedPeerID - ) - return - } - self.scheduleReconnectRetries( - pending.candidates, - for: normalizedPeerID, - replacingActiveTransfer: - pending.replacingActiveTransfer, - provenGeneration: provenGeneration - ) - } - } - - func retainForReconnectRetryIfEligible( - _ packet: BitchatFilePacket, - peerID: PeerID, - messageID: String, - activeTransferID: String - ) { - let normalizedPeerID = peerID.toShort() - guard let receiptSessionGeneration = context - .authenticatedPrivateMediaReceiptSessionGeneration( - to: normalizedPeerID - ), - reconnectRetryLimits.maxRetainedPackets > 0, - reconnectRetryLimits.maxRetainedBytes > 0, - reconnectRetryLimits.maxRetriesPerMessage > 0, - packet.content.count - <= reconnectRetryLimits.maxRetainedBytes, - PrivateMediaMessageIdentity.isStableID(messageID), - PrivateMediaMessageIdentity.stableID( - for: packet, - senderPeerID: context.myPeerID, - recipientPeerID: normalizedPeerID - ) == messageID else { - return - } - - pruneExpiredReconnectRetries() - if var existing = reconnectRetryRecords[messageID] { - cancelReconnectRetryExpiry(messageID: messageID) - existing.receiptSessionGeneration = receiptSessionGeneration - existing.createdAt = now() - existing.activeTransferID = activeTransferID - existing.retryAfterCompletion = false - existing.idleOutcome = .none - existing.deferredTerminalFailureReason = nil - existing.expiryToken = nil - reconnectRetryRecords[messageID] = existing - return - } - - makeReconnectRetryCapacity(for: packet.content.count) - guard reconnectRetryRecords.count - < reconnectRetryLimits.maxRetainedPackets, - retainedReconnectRetryBytes + packet.content.count - <= reconnectRetryLimits.maxRetainedBytes else { - SecureLogger.debug( - "Private media retry retention full; sending once id=\(messageID.prefix(12))…", - category: .session - ) - return - } - - reconnectRetryRecords[messageID] = - PrivateMediaReconnectRetryRecord( - messageID: messageID, - peerID: normalizedPeerID, - packet: packet, - receiptSessionGeneration: receiptSessionGeneration, - createdAt: now(), - retryCount: 0, - activeTransferID: activeTransferID, - retryAfterCompletion: false, - idleOutcome: .none, - deferredTerminalFailureReason: nil, - expiryToken: nil - ) - } - - func terminalizeUnavailableCapabilityProof( - _ candidates: [PrivateMediaReconnectRetryCandidate], - for peerID: PeerID - ) { - let reason = privateMediaCapabilityUnresolvedReason - for candidate in candidates { - guard var record = - reconnectRetryRecords[candidate.messageID], - record.peerID == peerID, - record.receiptSessionGeneration - == candidate.receiptSessionGeneration else { - continue - } - if record.activeTransferID != nil { - // The original transport owner can still produce a valid - // remote receipt. Defer failure until it releases ownership. - record.deferredTerminalFailureReason = reason - reconnectRetryRecords[candidate.messageID] = record - } else { - terminalizeReconnectRetry( - messageID: candidate.messageID, - reason: reason - ) - } - } - } - - func scheduleReconnectRetries( - _ candidates: [PrivateMediaReconnectRetryCandidate], - for peerID: PeerID, - replacingActiveTransfer: Bool, - provenGeneration: UUID - ) { - pruneExpiredReconnectRetries() - - var scheduledCount = 0 - let limit = max( - 0, - reconnectRetryLimits.maxRetriesPerReconnect - ) - for candidate in candidates { - guard scheduledCount < limit else { break } - let messageID = candidate.messageID - guard var record = reconnectRetryRecords[messageID], - record.peerID == peerID, - record.receiptSessionGeneration - == candidate.receiptSessionGeneration, - record.retryCount - < reconnectRetryLimits.maxRetriesPerMessage else { - continue - } - if record.deferredTerminalFailureReason != nil { - record.deferredTerminalFailureReason = nil - reconnectRetryRecords[messageID] = record - } - if replacingActiveTransfer, - candidate.receiptSessionGeneration == provenGeneration { - continue - } - if record.activeTransferID != nil { - if replacingActiveTransfer { - if replaceActiveTransferAfterAuthentication( - messageID: messageID - ) { - scheduledCount += 1 - } - continue - } - // Arm one retry after the current transfer drains. Duplicate - // reconnect callbacks cannot chain more work. - if record.retryCount == 0 { - record.retryAfterCompletion = true - reconnectRetryRecords[messageID] = record - scheduledCount += 1 - } - } else if startReconnectRetry(messageID: messageID) { - scheduledCount += 1 - } - } - } - - @discardableResult - func replaceActiveTransferAfterAuthentication( - messageID: String - ) -> Bool { - guard var record = reconnectRetryRecords[messageID], - let staleTransferID = record.activeTransferID, - record.retryCount - < reconnectRetryLimits.maxRetriesPerMessage else { - return false - } - record.activeTransferID = nil - record.retryAfterCompletion = false - record.createdAt = now() - reconnectRetryRecords[messageID] = record - - if messageIDToTransferId[messageID] == staleTransferID { - clearTransferMapping(for: messageID) - } - context.cancelTransfer(staleTransferID) - return startReconnectRetry(messageID: messageID) - } - - @discardableResult - func startReconnectRetry(messageID: String) -> Bool { - pruneExpiredReconnectRetries() - guard var record = reconnectRetryRecords[messageID], - record.activeTransferID == nil, - record.retryCount - < reconnectRetryLimits.maxRetriesPerMessage else { - return false - } - guard context.privateMediaSendPolicy(to: record.peerID) - == .encrypted, - let receiptSessionGeneration = context - .authenticatedPrivateMediaReceiptSessionGeneration( - to: record.peerID - ) else { - terminalizeReconnectRetry( - messageID: messageID, - reason: privateMediaCapabilityUnresolvedReason - ) - return false - } - - cancelReconnectRetryExpiry(messageID: messageID) - let transferID = makeTransferID(messageID: messageID) - record.retryCount += 1 - record.receiptSessionGeneration = receiptSessionGeneration - record.activeTransferID = transferID - record.retryAfterCompletion = false - record.idleOutcome = .none - record.deferredTerminalFailureReason = nil - record.expiryToken = nil - reconnectRetryRecords[messageID] = record - registerTransfer( - transferId: transferID, - messageID: messageID - ) - - SecureLogger.debug( - "🔄 Retrying private media after reconnect id=\(messageID.prefix(12))… attempt=\(record.retryCount)", - category: .session - ) - context.sendFilePrivateReceiptRetry( - record.packet, - to: record.peerID, - transferId: transferID - ) - return true - } - - func finishRetainedTransfer( - _ transferID: String, - messageID: String, - outcome: PrivateMediaReconnectRetryIdleOutcome, - rejectionReason: String? - ) { - guard var record = reconnectRetryRecords[messageID], - record.activeTransferID == transferID else { - return - } - let retryAfterCompletion = record.retryAfterCompletion - let deferredTerminalReason = - record.deferredTerminalFailureReason - record.activeTransferID = nil - record.retryAfterCompletion = false - record.idleOutcome = outcome - record.createdAt = now() - reconnectRetryRecords[messageID] = record - clearTransferMapping(for: messageID) - - if let deferredTerminalReason { - terminalizeReconnectRetry( - messageID: messageID, - reason: deferredTerminalReason - ) - } else if retryAfterCompletion { - startReconnectRetry(messageID: messageID) - } else if record.retryCount - >= reconnectRetryLimits.maxRetriesPerMessage { - terminalizeReconnectRetry( - messageID: messageID, - reason: rejectionReason - ?? privateMediaNotDeliveredReason - ) - } else { - scheduleReconnectRetryExpiry(messageID: messageID) - } - - if let rejectionReason { - SecureLogger.debug( - "Private media retry rejected id=\(messageID.prefix(12))…: \(rejectionReason)", - category: .session - ) - } - } - - func isReconnectRetryTransfer( - _ transferID: String, - messageID: String - ) -> Bool { - guard let record = reconnectRetryRecords[messageID] else { - return false - } - return record.retryCount > 0 - && record.activeTransferID == transferID - } - - func isRetainedPrivateMediaTransfer( - _ transferID: String, - messageID: String - ) -> Bool { - reconnectRetryRecords[messageID]?.activeTransferID - == transferID - } - - func discardReconnectRetry( - messageID: String, - cancelActiveTransfer: Bool - ) { - cancelReconnectRetryExpiry(messageID: messageID) - guard let record = - reconnectRetryRecords.removeValue(forKey: messageID) else { - return - } - guard cancelActiveTransfer, - let transferID = record.activeTransferID, - messageIDToTransferId[messageID] == transferID else { - return - } - // Release ownership before cancellation so its late callback cannot - // remove a remotely confirmed row. - clearTransferMapping(for: messageID) - context.cancelTransfer(transferID) - } - - func pruneExpiredReconnectRetries() { - let current = now() - let lifetime = max( - 0, - reconnectRetryLimits.retentionSeconds - ) - let expiredMessageIDs: [String] = - reconnectRetryRecords.values.compactMap { record in - guard record.activeTransferID == nil, - current.timeIntervalSince(record.createdAt) - >= lifetime else { - return nil - } - return record.messageID - } - for messageID in expiredMessageIDs { - guard let record = reconnectRetryRecords[messageID], - record.activeTransferID == nil else { - continue - } - terminalizeReconnectRetry( - messageID: messageID, - reason: expiryFailureReason(for: record) - ) - } - } - - func makeReconnectRetryCapacity(for incomingBytes: Int) { - while reconnectRetryRecords.count - >= reconnectRetryLimits.maxRetainedPackets - || retainedReconnectRetryBytes + incomingBytes - > reconnectRetryLimits.maxRetainedBytes { - guard let victim = reconnectRetryRecords.values - .filter({ $0.activeTransferID == nil }) - .min(by: { - if $0.createdAt == $1.createdAt { - return $0.messageID < $1.messageID - } - return $0.createdAt < $1.createdAt - }) else { - return - } - terminalizeReconnectRetry( - messageID: victim.messageID, - reason: expiryFailureReason(for: victim) - ) - } - } - - var privateMediaNotDeliveredReason: String { - String( - localized: "content.delivery.reason.not_delivered", - defaultValue: "Not delivered", - comment: "Failure reason shown when a private media transfer could not finish" - ) - } - - var privateMediaCapabilityUnresolvedReason: String { - String( - localized: - "content.delivery.reason.private_media_capability_unresolved", - defaultValue: "Could not confirm encrypted media support", - comment: "Failure reason when private-media capability negotiation did not resolve" - ) - } - - var privateMediaDeliveryUnconfirmedReason: String { - String( - localized: - "content.delivery.reason.private_media_delivery_unconfirmed", - defaultValue: "Delivery could not be confirmed", - comment: "Failure reason when private media left this device but no delivery receipt arrived" - ) - } - - func expiryFailureReason( - for record: PrivateMediaReconnectRetryRecord - ) -> String { - switch record.idleOutcome { - case .locallyCompleted: - return privateMediaDeliveryUnconfirmedReason - case .rejected(let reason): - return reason - case .none, .cancelled: - return privateMediaNotDeliveredReason - } - } - - func terminalizeReconnectRetry( - messageID: String, - reason: String - ) { - guard let record = reconnectRetryRecords[messageID], - record.activeTransferID == nil else { - return - } - cancelReconnectRetryExpiry(messageID: messageID) - guard reconnectRetryRecords.removeValue(forKey: messageID) != nil else { - return - } - context.updateMessageDeliveryStatus( - messageID, - status: .failed(reason: reason) - ) - } - - func scheduleReconnectRetryExpiry(messageID: String) { - guard var record = reconnectRetryRecords[messageID], - record.activeTransferID == nil else { - return - } - cancelReconnectRetryExpiry(messageID: messageID) - - let lifetime = max( - 0, - reconnectRetryLimits.retentionSeconds - ) - let elapsed = max( - 0, - now().timeIntervalSince(record.createdAt) - ) - let delay = max(0, lifetime - elapsed) - let token = UUID() - record.expiryToken = token - reconnectRetryRecords[messageID] = record - - let nanoseconds = UInt64( - min( - delay, - TimeInterval(UInt64.max) / 1_000_000_000 - ) * 1_000_000_000 - ) - reconnectRetryExpiryTasks[messageID] = Task { @MainActor [weak self] in - if nanoseconds > 0 { - try? await Task.sleep(nanoseconds: nanoseconds) - } - guard !Task.isCancelled, - let self, - let current = - self.reconnectRetryRecords[messageID], - current.activeTransferID == nil, - current.expiryToken == token else { - return - } - self.terminalizeReconnectRetry( - messageID: messageID, - reason: self.expiryFailureReason(for: current) - ) - } - } - - func cancelReconnectRetryExpiry(messageID: String) { - reconnectRetryExpiryTasks.removeValue( - forKey: messageID - )?.cancel() - if reconnectRetryRecords[messageID]?.expiryToken != nil { - reconnectRetryRecords[messageID]?.expiryToken = nil - } - } - - func applicationFilesDirectory() throws -> URL { - let base = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let filesDirectory = base.appendingPathComponent("files", isDirectory: true) - try FileManager.default.createDirectory( - at: filesDirectory, - withIntermediateDirectories: true, - attributes: BLEIncomingFileStore.mediaProtectionAttributes - ) - return filesDirectory - } -} diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift deleted file mode 100644 index 1a00315b..00000000 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ /dev/null @@ -1,537 +0,0 @@ -import BitFoundation -import Foundation -import SwiftUI - -@MainActor -final class ChatMessageFormatter { - typealias Patterns = MessageFormattingEngine.Patterns - - private unowned let viewModel: ChatViewModel - private let meshPalette = MinimalDistancePalette(config: .mesh) - private let nostrPalette = MinimalDistancePalette(config: .nostr) - - init(viewModel: ChatViewModel) { - self.viewModel = viewModel - } - - func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString { - let design = theme.bodyFontDesign - let isSelf: Bool = { - if let spid = message.senderPeerID { - if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat { - let myGeo: NostrIdentity? = { - if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash { - return cached.identity - } - if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { - viewModel.cachedGeohashIdentity = (channel.geohash, identity) - return identity - } - return nil - }() - if let myGeo { - return spid == PeerID(nostr: myGeo.publicKeyHex) - } - } - return spid == viewModel.meshService.myPeerID - } - if message.sender == viewModel.nickname { return true } - if message.sender.hasPrefix(viewModel.nickname + "#") { return true } - return false - }() - - let isDark = colorScheme == .dark - let isVerifiedSender = !isSelf && isVerifiedSender(of: message) - let cacheVariant = theme.formatCacheVariant + (isVerifiedSender ? "-vf" : "") - if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: cacheVariant) { - return cachedText - } - - var result = AttributedString() - let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) - - if message.sender != "system" { - let (baseName, suffix) = message.sender.splitSuffix() - var senderStyle = AttributeContainer() - senderStyle.foregroundColor = baseColor - let fontWeight: Font.Weight = isSelf ? .bold : .medium - senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: design) - if let spid = message.senderPeerID, - let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") { - senderStyle.link = url - } - - result.append(AttributedString("<@").mergingAttributes(senderStyle)) - result.append(AttributedString(baseName).mergingAttributes(senderStyle)) - if !suffix.isEmpty { - var suffixStyle = senderStyle - suffixStyle.foregroundColor = baseColor.opacity(0.6) - result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) - } - // Private rows render a filled SF Symbol seal beside the lock - // (TextMessageView / MediaMessageView); skip the in-string ✓ there - // so verified DMs don't show two markers. - if isVerifiedSender, !message.isPrivate { - appendVerifiedSeal(to: &result, baseColor: baseColor, design: design) - } - result.append(AttributedString("> ").mergingAttributes(senderStyle)) - - let content = message.content - let nsContent = content as NSString - let nsLen = nsContent.length - - if content.isOversizedForRichFormatting() { - var plainStyle = AttributeContainer() - plainStyle.foregroundColor = baseColor - plainStyle.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: design) - : .bitchatSystem(size: 14, design: design) - result.append(AttributedString(content).mergingAttributes(plainStyle)) - } else { - let hashtagRegex = Patterns.hashtag - let mentionRegex = Patterns.mention - let cashuRegex = Patterns.cashu - let bolt11Regex = Patterns.bolt11 - let lnurlRegex = Patterns.lnurl - let lightningSchemeRegex = Patterns.lightningScheme - let detector = Patterns.linkDetector - let hasMentionsHint = content.contains("@") - let hasHashtagsHint = content.contains("#") - let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http") - let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:") - let hasCashuHint = content.lowercased().contains("cashu") - - let hashtagMatches = hasHashtagsHint - ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) - : [] - let mentionMatches = hasMentionsHint - ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) - : [] - let urlMatches = hasURLHint - ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) - : [] - let cashuMatches = hasCashuHint - ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) - : [] - let lightningMatches = hasLightningHint - ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) - : [] - let bolt11Matches = hasLightningHint - ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) - : [] - let lnurlMatches = hasLightningHint - ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) - : [] - - let mentionRanges = mentionMatches.map { $0.range(at: 0) } - func overlapsMention(_ range: NSRange) -> Bool { - for mentionRange in mentionRanges where NSIntersectionRange(range, mentionRange).length > 0 { - return true - } - return false - } - - func attachedToMention(_ range: NSRange) -> Bool { - if let swiftRange = Range(range, in: content), swiftRange.lowerBound > content.startIndex { - var index = content.index(before: swiftRange.lowerBound) - while true { - let character = content[index] - if character.isWhitespace || character.isNewline { break } - if character == "@" { return true } - if index == content.startIndex { break } - index = content.index(before: index) - } - } - return false - } - - func isStandaloneHashtag(_ range: NSRange) -> Bool { - guard let swiftRange = Range(range, in: content) else { return false } - if swiftRange.lowerBound == content.startIndex { return true } - let previous = content.index(before: swiftRange.lowerBound) - return content[previous].isWhitespace || content[previous].isNewline - } - - var allMatches: [(range: NSRange, type: String)] = [] - for match in hashtagMatches - where !overlapsMention(match.range(at: 0)) - && !attachedToMention(match.range(at: 0)) - && isStandaloneHashtag(match.range(at: 0)) { - allMatches.append((match.range(at: 0), "hashtag")) - } - for match in mentionMatches { - allMatches.append((match.range(at: 0), "mention")) - } - for match in urlMatches where !overlapsMention(match.range) { - allMatches.append((match.range, "url")) - } - for match in cashuMatches where !overlapsMention(match.range(at: 0)) { - allMatches.append((match.range(at: 0), "cashu")) - } - for match in lightningMatches where !overlapsMention(match.range(at: 0)) { - allMatches.append((match.range(at: 0), "lightning")) - } - - let occupied = urlMatches.map(\.range) + lightningMatches.map { $0.range(at: 0) } - func overlapsOccupied(_ range: NSRange) -> Bool { - for occupiedRange in occupied where NSIntersectionRange(range, occupiedRange).length > 0 { - return true - } - return false - } - - for match in bolt11Matches - where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { - allMatches.append((match.range(at: 0), "bolt11")) - } - for match in lnurlMatches - where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { - allMatches.append((match.range(at: 0), "lnurl")) - } - allMatches.sort { $0.range.location < $1.range.location } - - var lastEnd = content.startIndex - let myNickname = viewModel.nickname.normalizedNickname - let isMentioned = message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false - - for (range, type) in allMatches { - guard let swiftRange = Range(range, in: content) else { continue } - - if lastEnd < swiftRange.lowerBound { - let beforeText = String(content[lastEnd.. content.startIndex { - var index = content.index(before: swiftRange.lowerBound) - while true { - let character = content[index] - if character.isWhitespace || character.isNewline { break } - if character == "@" { return true } - if index == content.startIndex { break } - index = content.index(before: index) - } - } - return false - }() - let standalone: Bool = { - if swiftRange.lowerBound == content.startIndex { return true } - let previous = content.index(before: swiftRange.lowerBound) - return content[previous].isWhitespace || content[previous].isNewline - }() - - var tagStyle = AttributeContainer() - tagStyle.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: design) - : .bitchatSystem(size: 14, design: design) - tagStyle.foregroundColor = baseColor - if isGeohash && !attachedToMentionToken && standalone, - let url = URL(string: "bitchat://geohash/\(token)") { - tagStyle.link = url - tagStyle.underlineStyle = .single - } - result.append(AttributedString(matchText).mergingAttributes(tagStyle)) - } else if type == "cashu" || type == "lightning" || type == "bolt11" || type == "lnurl" { - var spacer = AttributeContainer() - spacer.foregroundColor = baseColor - spacer.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: design) - : .bitchatSystem(size: 14, design: design) - result.append(AttributedString(" ").mergingAttributes(spacer)) - } else { - var matchStyle = AttributeContainer() - matchStyle.font = .bitchatSystem( - size: 14, - weight: isSelf ? .bold : .semibold, - design: design - ) - if type == "url" { - matchStyle.foregroundColor = isSelf ? .orange : .blue - matchStyle.underlineStyle = .single - if let url = URL(string: matchText) { - matchStyle.link = url - } - } - result.append(AttributedString(matchText).mergingAttributes(matchStyle)) - } - - if lastEnd < swiftRange.upperBound { - lastEnd = swiftRange.upperBound - } - } - - if lastEnd < content.endIndex { - let remainingText = String(content[lastEnd...]) - var remainingStyle = AttributeContainer() - remainingStyle.foregroundColor = baseColor - remainingStyle.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: design) - : .bitchatSystem(size: 14, design: design) - if isMentioned { - remainingStyle.font = remainingStyle.font?.bold() - } - result.append(AttributedString(remainingText).mergingAttributes(remainingStyle)) - } - } - - let timestamp = AttributedString(" [\(message.formattedTimestamp)]") - var timestampStyle = AttributeContainer() - timestampStyle.foregroundColor = Color.gray.opacity(0.7) - timestampStyle.font = .bitchatSystem(size: 10, design: design) - result.append(timestamp.mergingAttributes(timestampStyle)) - } else { - var contentStyle = AttributeContainer() - contentStyle.foregroundColor = Color.gray - let content = AttributedString("* \(message.content) *") - contentStyle.font = .bitchatSystem(size: 12, design: design).italic() - result.append(content.mergingAttributes(contentStyle)) - - let timestamp = AttributedString(" [\(message.formattedTimestamp)]") - var timestampStyle = AttributeContainer() - timestampStyle.foregroundColor = Color.gray.opacity(0.5) - timestampStyle.font = .bitchatSystem(size: 10, design: design) - result.append(timestamp.mergingAttributes(timestampStyle)) - } - - message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: cacheVariant) - return result - } - - func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString { - let design = theme.bodyFontDesign - let isSelf: Bool = { - if let spid = message.senderPeerID { - if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"), - let myGeo = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { - return spid == PeerID(nostr: myGeo.publicKeyHex) - } - return spid == viewModel.meshService.myPeerID - } - if message.sender == viewModel.nickname { return true } - if message.sender.hasPrefix(viewModel.nickname + "#") { return true } - return false - }() - - let isDark = colorScheme == .dark - let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) - let isVerifiedSender = !isSelf && isVerifiedSender(of: message) - - if message.sender == "system" { - var style = AttributeContainer() - style.foregroundColor = baseColor - style.font = .bitchatSystem(size: 14, weight: .medium, design: design) - return AttributedString(message.sender).mergingAttributes(style) - } - - var result = AttributedString() - let (baseName, suffix) = message.sender.splitSuffix() - var senderStyle = AttributeContainer() - senderStyle.foregroundColor = baseColor - senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: design) - if let spid = message.senderPeerID, - let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") { - senderStyle.link = url - } - - result.append(AttributedString("<@").mergingAttributes(senderStyle)) - result.append(AttributedString(baseName).mergingAttributes(senderStyle)) - if !suffix.isEmpty { - var suffixStyle = senderStyle - suffixStyle.foregroundColor = baseColor.opacity(0.6) - result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) - } - if isVerifiedSender, !message.isPrivate { - appendVerifiedSeal(to: &result, baseColor: baseColor, design: design) - } - result.append(AttributedString("> ").mergingAttributes(senderStyle)) - return result - } - - func isSelfMessage(_ message: BitchatMessage) -> Bool { - if let spid = message.senderPeerID { - if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat { - let myGeo: NostrIdentity? = { - if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash { - return cached.identity - } - if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { - viewModel.cachedGeohashIdentity = (channel.geohash, identity) - return identity - } - return nil - }() - if let myGeo { - return spid == PeerID(nostr: myGeo.publicKeyHex) - } - } - return spid == viewModel.meshService.myPeerID - } - if message.sender == viewModel.nickname { return true } - if message.sender.hasPrefix(viewModel.nickname + "#") { return true } - return false - } - - func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { - peerColor(for: message, isDark: isDark) - } - - func peerURL(for peerID: PeerID) -> URL? { - URL(string: "bitchat://user/\(peerID.toPercentEncoded())") - } - - func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { - getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark) - } - - func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color { - getPeerPaletteColor(for: peerID, isDark: isDark) - } -} - -private extension ChatMessageFormatter { - /// Whether the message sender has a fingerprint the user has verified. - /// Used for the in-chat seal next to `<@name>` so verification is visible - /// without opening the fingerprint sheet (#1439). - func isVerifiedSender(of message: BitchatMessage) -> Bool { - guard let peerID = message.senderPeerID, - let fingerprint = viewModel.getFingerprint(for: peerID) else { - return false - } - return viewModel.peerIdentityStore.isVerified(fingerprint) - } - - func appendVerifiedSeal( - to result: inout AttributedString, - baseColor: Color, - design: Font.Design - ) { - var sealStyle = AttributeContainer() - // Match the peer-list verified seal: filled checkmark in the sender tint. - sealStyle.foregroundColor = baseColor - sealStyle.font = .bitchatSystem(size: 11, weight: .semibold, design: design) - result.append(AttributedString(" ✓").mergingAttributes(sealStyle)) - } - - func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { - if let spid = message.senderPeerID { - if spid.isGeoChat || spid.isGeoDM { - let full = viewModel.nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased() - return getNostrPaletteColor(for: full, isDark: isDark) - } else if spid.id.count == 16 { - return getPeerPaletteColor(for: spid, isDark: isDark) - } else { - return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark) - } - } - return Color(peerSeed: message.sender.lowercased(), isDark: isDark) - } - - func meshSeed(for peerID: PeerID) -> String { - if let full = viewModel.cachedStablePeerID(for: peerID)?.id.lowercased() { - return "noise:" + full - } - return peerID.id.lowercased() - } - - func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color { - if peerID == viewModel.meshService.myPeerID { - return .orange - } - - meshPalette.ensurePalette(for: currentMeshPaletteSeeds()) - if let color = meshPalette.color(for: peerID.id, isDark: isDark) { - return color - } - return Color(peerSeed: meshSeed(for: peerID), isDark: isDark) - } - - func currentMeshPaletteSeeds() -> [String: String] { - let myID = viewModel.meshService.myPeerID - var seeds: [String: String] = [:] - for peer in viewModel.allPeers where peer.peerID != myID { - seeds[peer.peerID.id] = meshSeed(for: peer.peerID) - } - return seeds - } - - func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color { - let myHex = currentGeohashIdentityHex() - if let myHex, pubkeyHexLowercased == myHex { - return .orange - } - - nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex)) - if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) { - return color - } - return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark) - } - - func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] { - var seeds: [String: String] = [:] - let excluded = myHex ?? "" - for person in viewModel.visibleGeohashPeople() where person.id != excluded { - seeds[person.id] = "nostr:" + person.id - } - return seeds - } - - func currentGeohashIdentityHex() -> String? { - if case .location(let channel) = viewModel.activeChannel, - let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { - return identity.publicKeyHex.lowercased() - } - return nil - } -} diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift deleted file mode 100644 index ae54966d..00000000 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ /dev/null @@ -1,144 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The surface `ChatNostrCoordinator` needs from its owner. -/// -/// Inherits the component contexts (`GeohashSubscriptionContext`, -/// `NostrInboundPipelineContext`, `GeoPresenceContext`) so a single object — -/// `ChatViewModel` in production, one mock in tests — can back the whole -/// Nostr stack. The members declared here are only the residual -/// favorites/ack glue the slimmed coordinator still owns. -@MainActor -protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineContext, GeoPresenceContext { - var selectedPrivateChatPeer: PeerID? { get } - var nostrKeyMapping: [PeerID: String] { get } - func startPrivateChat(with peerID: PeerID) - func visibleGeohashPeople() -> [GeoPerson] - - // MARK: Routing & acknowledgements (shared with `ChatPrivateConversationContext`) - func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) - func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) - func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) - - // MARK: Favorites (shared with the other contexts) - /// The persisted favorite relationship for the peer's Noise static key, if any. - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? -} - -extension ChatViewModel: ChatNostrContext { - // All requirements — including the component-context witnesses declared - // in `GeohashSubscriptionManager.swift`, `NostrInboundPipeline.swift`, - // `GeoPresenceTracker.swift`, and the favorites/notification witnesses in - // `ChatPrivateConversationCoordinator.swift`, - // `ChatPeerIdentityCoordinator.swift`, and - // `ChatVerificationCoordinator.swift` — already exist on `ChatViewModel`. -} - -/// Thin facade over the Nostr stack: owns and wires the three components and -/// keeps the residual favorites/ack glue that fits none of them. -/// -/// - `subscriptions`: relay lifecycle and subscription IDs -/// (`GeohashSubscriptionManager`) -/// - `inbound`: the hot event -> message/payload pipeline -/// (`NostrInboundPipeline`) -/// - `presence`: teleport marking, sampling dedup, notification cooldown -/// (`GeoPresenceTracker`) -final class ChatNostrCoordinator { - private weak var context: (any ChatNostrContext)? - let presence: GeoPresenceTracker - let inbound: NostrInboundPipeline - let subscriptions: GeohashSubscriptionManager - - init(context: any ChatNostrContext) { - self.context = context - let presence = GeoPresenceTracker(context: context) - let inbound = NostrInboundPipeline(context: context, presence: presence) - self.presence = presence - self.inbound = inbound - self.subscriptions = GeohashSubscriptionManager(context: context, inbound: inbound, presence: presence) - } - - @MainActor - func sendDeliveryAckViaNostrEmbedded( - _ message: BitchatMessage, - wasReadBefore: Bool, - senderPubkey: String, - key: Data? - ) { - guard let context else { return } - if key != nil { - if let identity = context.currentNostrIdentity() { - context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity) - } - } else if let identity = context.currentNostrIdentity() { - context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity) - SecureLogger.debug( - "Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", - category: .session - ) - } - - if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID { - if key != nil { - if let identity = context.currentNostrIdentity() { - context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) - } - } else if let identity = context.currentNostrIdentity() { - context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) - SecureLogger.debug( - "Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", - category: .session - ) - } - } - } - - @MainActor - func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { - guard let context else { return } - guard let relationship = context.favoriteRelationship(forNoiseKey: noisePublicKey), - relationship.peerNostrPublicKey != nil else { - SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session) - return - } - - let peerID = PeerID(hexData: noisePublicKey) - context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite) - } - - @MainActor - func nostrPubkeyForDisplayName(_ name: String) -> String? { - guard let context else { return nil } - for person in context.visibleGeohashPeople() where person.displayName == name { - return person.id - } - for (pub, nick) in context.geoNicknames where nick == name { - return pub - } - return nil - } - - @MainActor - func startGeohashDM(withPubkeyHex hex: String) { - guard let context else { return } - let convKey = PeerID(nostr_: hex) - context.registerNostrKeyMapping(hex, for: convKey) - context.startPrivateChat(with: convKey) - } - - @MainActor - func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? { - guard let context else { return nil } - return context.nostrKeyMapping[senderID] - } - - @MainActor - func geohashDisplayName(for convKey: PeerID) -> String { - guard let context else { return convKey.bare } - guard let full = context.nostrKeyMapping[convKey] else { - return convKey.bare - } - return context.displayNameForNostrPubkey(full) - } -} diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift deleted file mode 100644 index 1bb3e213..00000000 --- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift +++ /dev/null @@ -1,238 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The narrow surface `ChatOutgoingCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatOutgoingCoordinatorContextTests`) and makes its true -/// dependencies explicit. -@MainActor -protocol ChatOutgoingContext: AnyObject { - // MARK: Identity & channel state - var nickname: String { get } - var myPeerID: PeerID { get } - var activeChannel: ChannelID { get } - var selectedPrivateChatPeer: PeerID? { get } - var isTeleported: Bool { get } - - // MARK: Commands & private messages - func handleCommand(_ command: String) - func updatePrivateChatPeerIfNeeded() - func sendPrivateMessage(_ content: String, to peerID: PeerID) - - // MARK: Public timeline (local echo) - func parseMentions(from content: String) -> [String] - /// Appends a public message via the single-writer store intent - /// (immediate: the local echo must render without batching). - @discardableResult - func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool - func addSystemMessage(_ content: String) - - // MARK: Content dedup - func normalizedContentKey(_ content: String) -> String - func recordContentKey(_ key: String, timestamp: Date) - - // MARK: Outbound routing - /// Stamps "now" as the channel's last public activity (background nudges). - /// (Single mutation path for the owner's `lastPublicActivityAt`; this - /// coordinator never reads it.) - func recordPublicActivity(forChannelKey key: String) - func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) - func sendGeohash(context: ChatViewModel.GeoOutgoingContext) - /// Ships the bridged (rendezvous) copy of a just-sent public mesh - /// message; no-op when the bridge is off or the send is nearby-only. - /// Takes the origin coordinates (sender + wire timestamp) — the bridge - /// derives the cross-device-stable mesh message ID from them, not from - /// our local timeline UUID (which no other device can recompute). - func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) - - // MARK: Geohash identity (shared with the other contexts) - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity -} - -extension ChatViewModel: ChatOutgoingContext { - // `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`, - // `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`, - // `sendPrivateMessage(_:to:)`, `parseMentions(from:)`, - // `appendPublicMessage(_:to:)`, `addSystemMessage(_:)`, - // `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`, - // `sendMeshMessage(_:mentions:messageID:timestamp:)`, - // `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are - // shared requirements with the other contexts or satisfied by existing - // `ChatViewModel` members. The single-writer intent op below lives next to - // its backing state's owner. - - func recordPublicActivity(forChannelKey key: String) { - lastPublicActivityAt[key] = Date() - } - - func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) { - BridgeService.shared.bridgeOutgoing(content: content, senderPeerID: senderPeerID, timestamp: timestamp) - } -} - -@MainActor -final class ChatOutgoingCoordinator { - private unowned let context: any ChatOutgoingContext - - /// In-flight NIP-13 mining for the most recent geohash send. A newer send - /// (or leaving the channel) cancels it, which only expedites the mining — - /// the message still goes out at the difficulty already reached. - /// (Read access is internal so tests can await the send's completion.) - private(set) var geohashMiningTask: Task? - - init(context: any ChatOutgoingContext) { - self.context = context - } - - /// Finish any in-flight geohash PoW mining early (the pending message - /// still sends, at whatever committed difficulty it reached). - func expeditePendingGeohashMining() { - geohashMiningTask?.cancel() - } - - func sendMessage(_ content: String) { - guard let trimmed = content.trimmedOrNilIfEmpty else { return } - - if content.hasPrefix("/") { - Task { @MainActor [weak context = self.context] in - context?.handleCommand(content) - } - return - } - - if context.selectedPrivateChatPeer != nil { - context.updatePrivateChatPeerIfNeeded() - - if let selectedPeer = context.selectedPrivateChatPeer { - context.sendPrivateMessage(content, to: selectedPeer) - } - return - } - - let mentions = context.parseMentions(from: content) - - switch context.activeChannel { - case .mesh: - sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions) - case .location(let channel): - sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel) - } - } - - /// Broadcasts a wave on the mesh channel regardless of the active channel — - /// used by the "bitchatters nearby" notification quick action, which always - /// refers to mesh peers. - func sendMeshWave() { - sendMeshPublicMessage(originalContent: "👋", trimmed: "👋", mentions: []) - } -} - -private extension ChatOutgoingCoordinator { - func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) { - let message = BitchatMessage( - sender: context.nickname, - content: trimmed, - timestamp: Date(), - isRelay: false, - senderPeerID: context.myPeerID, - mentions: mentions.isEmpty ? nil : mentions - ) - - appendLocalEcho(message, to: .mesh) - context.recordPublicActivity(forChannelKey: "mesh") - context.sendMeshMessage( - originalContent, - mentions: mentions, - messageID: message.id, - timestamp: message.timestamp - ) - context.bridgeOutgoingPublicMessage(trimmed, senderPeerID: context.myPeerID, timestamp: message.timestamp) - } - - /// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see - /// `NostrPoW`), so the whole echo-and-send runs in a task once the signed - /// event — whose ID is also the local message ID — exists. Typical mining - /// at the default target is well under 100 ms and hard-capped at - /// `NostrPoW.miningTimeCap`, so sending is never meaningfully delayed. - func sendGeohashPublicMessage(_ trimmed: String, mentions: [String], channel: GeohashChannel) { - let identity: NostrIdentity - do { - identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - } catch { - SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) - context.addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) - return - } - - let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4)) - let senderPeerID = PeerID(nostr: identity.publicKeyHex) - let teleported = context.isTeleported - let nickname = context.nickname - - // Serialize geohash sends: each send awaits the previous send's task - // before it appends + relays, so user-visible order always matches - // send order even when an earlier message mines longer than a later - // one. Cancelling the previous task only *expedites* its mining (the - // NIP-13 target is polled, not aborted), so it still finishes and - // sends — and it finishes fast, so awaiting it never stacks mining - // delays or blocks a send beyond `NostrPoW.miningTimeCap`. - let previousSend = geohashMiningTask - previousSend?.cancel() - geohashMiningTask = Task { @MainActor [weak context = self.context] in - await previousSend?.value - - let event: NostrEvent - do { - event = try await NostrProtocol.createMinedEphemeralGeohashEvent( - content: trimmed, - geohash: channel.geohash, - senderIdentity: identity, - nickname: nickname, - teleported: teleported - ) - } catch { - SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) - context?.addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) - return - } - guard let context else { return } - - let message = BitchatMessage( - id: event.id, - sender: displaySender, - content: trimmed, - timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)), - isRelay: false, - senderPeerID: senderPeerID, - mentions: mentions.isEmpty ? nil : mentions - ) - - context.appendPublicMessage(message, to: ConversationID(channelID: .location(channel))) - let contentKey = context.normalizedContentKey(message.content) - context.recordContentKey(contentKey, timestamp: message.timestamp) - - context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)") - context.sendGeohash(context: ( - channel: channel, - event: event, - identity: identity, - teleported: teleported - )) - } - } - - func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) { - context.appendPublicMessage(message, to: conversationID) - - let contentKey = context.normalizedContentKey(message.content) - context.recordContentKey(contentKey, timestamp: message.timestamp) - } -} diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift deleted file mode 100644 index e2d0fae8..00000000 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ /dev/null @@ -1,676 +0,0 @@ -import BitFoundation -import BitLogger -import CoreBluetooth -import Foundation - -/// The narrow surface `ChatPeerIdentityCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatPeerIdentityCoordinatorContextTests`) and makes its true -/// dependencies explicit. Several members are flattened service accesses — -/// this coordinator implements the `ChatViewModel`-level peer-identity API, so -/// its context members deliberately sit one level below those wrappers -/// (`unifiedIsBlocked(_:)` vs `isPeerBlocked(_:)`, `unifiedFingerprint(for:)` -/// vs `getFingerprint(for:)`, …) to avoid call cycles. -@MainActor -protocol ChatPeerIdentityContext: AnyObject { - // MARK: Conversation state - var privateChats: [PeerID: [BitchatMessage]] { get } - /// A single private chat's timeline. Witnessed by the store-direct - /// lookup on `ChatViewModel` (no `privateChats` dictionary build). - func privateMessages(for peerID: PeerID) -> [BitchatMessage] - var unreadPrivateMessages: Set { get } - /// Clears the peer's unread flag (single-writer store intent). - func markPrivateChatRead(_ peerID: PeerID) - /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat - /// (dedup by ID, order preserved, unread carried, old chat removed). - func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) - var selectedPrivateChatPeer: PeerID? { get set } - var selectedPrivateChatFingerprint: String? { get set } - var myPeerID: PeerID { get } - var activeChannel: ChannelID { get } - /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). - func notifyUIChanged() - func addSystemMessage(_ content: String) - - // MARK: Private chat session lifecycle - /// Merges messages stored under alternate peer-ID representations into `peerID`'s chat. - /// Returns `true` when unread messages were discovered during consolidation. - @discardableResult - func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool - /// Marks read receipts as sent for own messages already delivered/read in - /// `peerID`'s chat. (Single mutation path into the owner's - /// `sentReadReceipts`; this coordinator never touches the raw set.) - func syncReadReceiptsForSentMessages(for peerID: PeerID) - /// Re-targets the private chat session: selection mutates through the - /// `ConversationStore` intent (the store owns selection). - func beginPrivateChatSession(with peerID: PeerID) - func markPrivateMessagesAsRead(from peerID: PeerID) - - // MARK: Unified peer service - var connectedPeers: Set { get } - /// The peer's current entry in the unified peer service, if known. - func unifiedPeer(for peerID: PeerID) -> BitchatPeer? - func unifiedIsBlocked(_ peerID: PeerID) -> Bool - func unifiedToggleFavorite(_ peerID: PeerID) - func unifiedFingerprint(for peerID: PeerID) -> String? - func unifiedPeerID(forNickname nickname: String) -> PeerID? - /// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected. - func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? - - // MARK: Mesh & Noise sessions - func peerNickname(for peerID: PeerID) -> String? - func meshPeerNicknames() -> [PeerID: String] - func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState - func triggerHandshake(with peerID: PeerID) - func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool - func hasNoiseSession(with peerID: PeerID) -> Bool - /// Our own Noise identity fingerprint. - func noiseIdentityFingerprint() -> String - - // MARK: Identity store (fingerprints & encryption status) - func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) - /// Moves the stored fingerprint mapping from `oldPeerID` to `newPeerID`, - /// falling back to `fallback` when none was stored. Returns the migrated fingerprint. - func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? - func isVerifiedFingerprint(_ fingerprint: String) -> Bool - func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) - func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? - func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) - func invalidateStoredEncryptionCache(for peerID: PeerID?) - func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? - - // MARK: Favorites - /// The persisted favorite relationship for the peer's Noise static key, if any. - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? - /// The persisted favorite relationship for a short (ephemeral) peer ID, if any. - func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? - /// Adds (or updates) a favorite in the favorites store. - func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) - /// Removes a favorite from the favorites store. - func removeFavorite(noiseKey: Data) - - // MARK: Geohash & Nostr - var geoNicknames: [String: String] { get } - func visibleGeohashPeople() -> [GeoPerson] - /// Records the Nostr pubkey behind a (possibly virtual) peer ID. - func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) - func bridgedNostrPublicKey(for noiseKey: Data) -> String? - func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) -} - -extension ChatViewModel: ChatPeerIdentityContext { - // `privateChats`, `unreadPrivateMessages`, `selectedPrivateChatPeer`, - // `selectedPrivateChatFingerprint`, `myPeerID`, - // `activeChannel`, `connectedPeers`, `geoNicknames`, `notifyUIChanged()`, - // `addSystemMessage(_:)`, `peerNickname(for:)`, `meshPeerNicknames()`, - // `ephemeralPeerID(forNoiseKey:)`, `unifiedPeer(for:)`, - // `registerNostrKeyMapping(_:for:)`, `visibleGeohashPeople()`, - // `markPrivateMessagesAsRead(from:)`, `sendFavoriteNotificationViaNostr`, - // and the conversation-store sync methods are shared requirements with - // the other contexts or satisfied by existing `ChatViewModel` members. - // The single-writer intent op `syncReadReceiptsForSentMessages(for:)` - // lives next to its backing state in `ChatViewModel`. The members below - // flatten nested service accesses into intent-named calls. - - @discardableResult - func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool { - privateChatManager.consolidateMessages( - for: peerID, - peerNickname: peerNickname, - persistedReadReceipts: sentReadReceipts - ) - } - - func beginPrivateChatSession(with peerID: PeerID) { - privateChatManager.startChat(with: peerID) - } - - func unifiedIsBlocked(_ peerID: PeerID) -> Bool { - unifiedPeerService.isBlocked(peerID) - } - - func unifiedToggleFavorite(_ peerID: PeerID) { - unifiedPeerService.toggleFavorite(peerID) - } - - func unifiedFingerprint(for peerID: PeerID) -> String? { - unifiedPeerService.getFingerprint(for: peerID) - } - - func unifiedPeerID(forNickname nickname: String) -> PeerID? { - unifiedPeerService.getPeerID(for: nickname) - } - - func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState { - meshService.getNoiseSessionState(for: peerID) - } - - func triggerHandshake(with peerID: PeerID) { - meshService.triggerHandshake(with: peerID) - } - - func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { - if case .established = meshService.getNoiseSessionState(for: peerID) { return true } - return false - } - - func hasNoiseSession(with peerID: PeerID) -> Bool { - switch meshService.getNoiseSessionState(for: peerID) { - case .established, .handshaking: return true - case .none, .handshakeQueued, .failed: return false - } - } - - func noiseIdentityFingerprint() -> String { - meshService.noiseIdentityFingerprint() - } - - func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) { - peerIdentityStore.setFingerprint(fingerprint, for: peerID) - } - - func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? { - peerIdentityStore.migrateFingerprintMapping(from: oldPeerID, to: newPeerID, fallback: fallback) - } - - func isVerifiedFingerprint(_ fingerprint: String) -> Bool { - peerIdentityStore.isVerified(fingerprint) - } - - func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { - peerIdentityStore.setEncryptionStatus(status, for: peerID) - } - - func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? { - peerIdentityStore.cachedEncryptionStatus(for: peerID) - } - - func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) { - peerIdentityStore.setCachedEncryptionStatus(status, for: peerID) - } - - func invalidateStoredEncryptionCache(for peerID: PeerID?) { - peerIdentityStore.invalidateEncryptionCache(for: peerID) - } - - func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? { - identityManager.getSocialIdentity(for: fingerprint) - } - - func bridgedNostrPublicKey(for noiseKey: Data) -> String? { - idBridge.getNostrPublicKey(for: noiseKey) - } - - // `favoriteRelationship(forNoiseKey:)` is shared with - // `ChatPrivateConversationContext`; its witness lives in - // `ChatPrivateConversationCoordinator.swift`. - - func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? { - FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID) - } - - func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) { - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: noiseKey, - peerNostrPublicKey: nostrPublicKey, - peerNickname: nickname - ) - } - - func removeFavorite(noiseKey: Data) { - FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) - } -} - -final class ChatPeerIdentityCoordinator { - private unowned let context: any ChatPeerIdentityContext - - init(context: any ChatPeerIdentityContext) { - self.context = context - } - - @MainActor - func openMostRelevantPrivateChat() { - let unreadSorted = context.unreadPrivateMessages - .map { ($0, context.privateMessages(for: $0).last?.timestamp ?? Date.distantPast) } - .sorted { $0.1 > $1.1 } - if let target = unreadSorted.first?.0 { - startPrivateChat(with: target) - return - } - - let recent = context.privateChats - .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) } - .sorted { $0.ts > $1.ts } - if let target = recent.first?.id { - startPrivateChat(with: target) - } - } - - @MainActor - func isPeerBlocked(_ peerID: PeerID) -> Bool { - context.unifiedIsBlocked(peerID) - } - - @MainActor - func hasUnreadMessages(for peerID: PeerID) -> Bool { - var noiseKeyPeerID: PeerID? - var nostrPeerID: PeerID? - - if let peer = context.unifiedPeer(for: peerID) { - noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey) - if let nostrHex = peer.nostrPublicKey { - nostrPeerID = PeerID(nostr_: nostrHex) - } - } - - let unreadContext = ChatUnreadPeerContext( - peerID: peerID, - noiseKeyPeerID: noiseKeyPeerID, - nostrPeerID: nostrPeerID, - nickname: context.peerNickname(for: peerID) - ) - - return ChatUnreadStateResolver.hasUnreadMessages( - for: unreadContext, - unreadPrivateMessages: context.unreadPrivateMessages, - privateChats: context.privateChats - ) - } - - @MainActor - func toggleFavorite(peerID: PeerID) { - if let noisePublicKey = peerID.noiseKey { - toggleFavoriteForNoiseKey(noisePublicKey, peerID: peerID) - return - } - - context.unifiedToggleFavorite(peerID) - context.notifyUIChanged() - } - - @MainActor - func isFavorite(peerID: PeerID) -> Bool { - if let noisePublicKey = peerID.noiseKey { - return context.favoriteRelationship(forNoiseKey: noisePublicKey)?.isFavorite ?? false - } - - return context.unifiedPeer(for: peerID)?.isFavorite ?? false - } - - @MainActor - func updatePrivateChatPeerIfNeeded() { - guard let chatFingerprint = context.selectedPrivateChatFingerprint, - let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else { - return - } - - if let oldPeerID = context.selectedPrivateChatPeer, oldPeerID != currentPeerID { - migrateChatState(from: oldPeerID, to: currentPeerID) - context.selectedPrivateChatPeer = currentPeerID - } else if context.selectedPrivateChatPeer == nil { - context.selectedPrivateChatPeer = currentPeerID - } - - context.markPrivateChatRead(currentPeerID) - } - - @MainActor - func startPrivateChat(with peerID: PeerID) { - guard peerID != context.myPeerID else { return } - - // Group chats are virtual conversations: no peer identity, favorites, - // handshake, or message consolidation applies — just select the chat. - if peerID.isGroup { - context.selectedPrivateChatFingerprint = nil - context.beginPrivateChatSession(with: peerID) - context.markPrivateChatRead(peerID) - return - } - - let peerNickname = context.peerNickname(for: peerID) ?? "unknown" - - if context.unifiedIsBlocked(peerID) { - context.addSystemMessage( - String( - format: String( - localized: "system.chat.blocked", - comment: "System message when starting chat fails because peer is blocked" - ), - locale: .current, - peerNickname - ) - ) - return - } - - // No mutual-favorite gate: store-and-forward (couriers, bridge drops, - // retained outbox) only needs the recipient's noise key, so an - // offline non-mutual favorite is still worth writing to — the router - // decides what delivery looks like, not chat entry. - - _ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname) - - if !peerID.isGeoDM && !peerID.isGeoChat { - switch context.noiseSessionState(for: peerID) { - case .none, .failed: - context.triggerHandshake(with: peerID) - case .handshakeQueued, .handshaking, .established: - break - } - } else { - SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session) - } - - context.syncReadReceiptsForSentMessages(for: peerID) - - if let fingerprint = getFingerprint(for: peerID) { - context.setStoredFingerprint(fingerprint, for: peerID) - context.selectedPrivateChatFingerprint = fingerprint - } else { - context.selectedPrivateChatFingerprint = nil - } - context.beginPrivateChatSession(with: peerID) - context.markPrivateMessagesAsRead(from: peerID) - } - - @MainActor - func endPrivateChat() { - context.selectedPrivateChatPeer = nil - context.selectedPrivateChatFingerprint = nil - } - - @MainActor - func handlePeerStatusUpdate() { - updatePrivateChatPeerIfNeeded() - } - - func handleFavoriteStatusChanged(_ notification: Notification) { - guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return } - - Task { @MainActor [weak context = self.context] in - guard let context else { return } - - if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, - isKeyUpdate, - let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data { - migrateNoiseKeyUpdate( - oldPeerID: PeerID(hexData: oldKey), - newPeerID: PeerID(hexData: peerPublicKey) - ) - } - - updatePrivateChatPeerIfNeeded() - - if let isFavorite = notification.userInfo?["isFavorite"] as? Bool { - let peerID = PeerID(hexData: peerPublicKey) - let action = isFavorite ? "favorited" : "unfavorited" - let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey) - context.addSystemMessage("\(peerNickname) \(action) you") - } - } - } - - @MainActor - func updateEncryptionStatusForPeers() { - for peerID in context.connectedPeers { - updateEncryptionStatus(for: peerID) - } - } - - @MainActor - func updateEncryptionStatus(for peerID: PeerID) { - if context.hasEstablishedNoiseSession(with: peerID) { - context.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID) - } else if context.hasNoiseSession(with: peerID) { - context.setEncryptionStatus(.noiseHandshaking, for: peerID) - } else { - context.setEncryptionStatus(nil, for: peerID) - } - - invalidateEncryptionCache(for: peerID) - } - - @MainActor - func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { - if let cachedStatus = context.cachedEncryptionStatus(for: peerID) { - return cachedStatus - } - - // The status must reflect the LIVE session, never history. The old - // mapping returned secured/verified for any peer whose fingerprint was - // ever persisted — so after a cold launch or a handshake FAILURE the - // DM header still showed a solid lock and the composer still claimed - // "end-to-end encrypted" with no secure session in existence. A - // remembered fingerprint changes what an established session upgrades - // to (verified vs secured); it must not conjure a lock on its own. - let sessionState = context.noiseSessionState(for: peerID) - - let status: EncryptionStatus - switch sessionState { - case .established: - status = verifiedEncryptionStatus(for: peerID) - case .handshaking, .handshakeQueued: - status = .noiseHandshaking - case .none: - status = .noHandshake - case .failed: - status = .none - } - - context.setCachedEncryptionStatus(status, for: peerID) - return status - } - - @MainActor - func invalidateEncryptionCache(for peerID: PeerID? = nil) { - context.invalidateStoredEncryptionCache(for: peerID) - } - - @MainActor - func getFingerprint(for peerID: PeerID) -> String? { - context.unifiedFingerprint(for: peerID) - } - - @MainActor - func resolveNickname(for peerID: PeerID) -> String { - guard !peerID.isEmpty else { return "unknown" } - - if !peerID.isHex { - return peerID.id - } - - // Local aliases outrank announced nicknames so a saved petname is - // actually visible after the fingerprint sheet dismisses. - if let fingerprint = getFingerprint(for: peerID), - let identity = context.socialIdentity(forFingerprint: fingerprint), - let petname = identity.localPetname, - !petname.isEmpty { - return petname - } - - if let nickname = context.meshPeerNicknames()[peerID] { - return nickname - } - - if let fingerprint = getFingerprint(for: peerID), - let identity = context.socialIdentity(forFingerprint: fingerprint) { - return identity.claimedNickname - } - - let prefixLength = min(4, peerID.id.count) - let prefix = String(peerID.id.prefix(prefixLength)) - return prefix.starts(with: "anon") ? "peer\(prefix)" : "anon\(prefix)" - } - - @MainActor - func getMyFingerprint() -> String { - context.noiseIdentityFingerprint() - } - - @MainActor - func getPeerIDForNickname(_ nickname: String) -> PeerID? { - // Queries arrive from typed commands and message content, so bring - // them to the same canonical (NFC) form nicknames are stored in. - let nickname = nickname.normalizedNickname - switch context.activeChannel { - case .location: - if nickname.contains("#"), - let person = context.visibleGeohashPeople() - .first(where: { $0.displayName == nickname }) { - let conversationKey = PeerID(nostr_: person.id) - context.registerNostrKeyMapping(person.id, for: conversationKey) - return conversationKey - } - - let base = nickname - .split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) - .first - .map(String.init)? - .lowercased() ?? nickname.lowercased() - if let pubkey = context.geoNicknames.first(where: { $0.value.lowercased() == base })?.key { - let conversationKey = PeerID(nostr_: pubkey) - context.registerNostrKeyMapping(pubkey, for: conversationKey) - return conversationKey - } - - case .mesh: - break - } - - return context.unifiedPeerID(forNickname: nickname) - } - - @MainActor - func nicknameForPeer(_ peerID: PeerID) -> String { - if let name = context.peerNickname(for: peerID) { - return name - } - if let favorite = context.favoriteRelationship(forPeerID: peerID), - !favorite.peerNickname.isEmpty { - return favorite.peerNickname - } - if let noiseKey = Data(hexString: peerID.id), - let favorite = context.favoriteRelationship(forNoiseKey: noiseKey), - !favorite.peerNickname.isEmpty { - return favorite.peerNickname - } - // "anon" matches the default-nickname convention; "user" is banned copy. - return "anon" - } -} - -private extension ChatPeerIdentityCoordinator { - @MainActor - func currentPeerID(forFingerprint fingerprint: String) -> PeerID? { - for peerID in context.connectedPeers where getFingerprint(for: peerID) == fingerprint { - return peerID - } - return nil - } - - @MainActor - func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) { - // The store migration dedups by message ID, preserves timestamp - // order, carries the unread flag, and removes the old chat. - context.migratePrivateChat(from: oldPeerID, to: newPeerID) - } - - @MainActor - func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { - // Capture before the migration: the store hands its selection off to - // `newPeerID` during `migrateChatState`, and the manager's selection - // mirrors the store, so the old peer ID is no longer selected after. - let wasSelected = context.selectedPrivateChatPeer == oldPeerID - if wasSelected { - SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) - } else if !context.privateMessages(for: oldPeerID).isEmpty { - SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) - } - - migrateChatState(from: oldPeerID, to: newPeerID) - - if wasSelected { - context.selectedPrivateChatPeer = newPeerID - } - - if let fingerprint = context.migrateFingerprintMapping( - from: oldPeerID, - to: newPeerID, - fallback: getFingerprint(for: newPeerID) - ) { - if context.selectedPrivateChatPeer == newPeerID { - context.selectedPrivateChatFingerprint = fingerprint - } - } - } - - @MainActor - func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String { - if let nickname = context.peerNickname(for: peerID) { - return nickname - } - if let favorite = context.favoriteRelationship(forNoiseKey: peerPublicKey) { - return favorite.peerNickname - } - return "Unknown" - } - - @MainActor - func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { - if let fingerprint = getFingerprint(for: peerID), - context.isVerifiedFingerprint(fingerprint) { - return .noiseVerified - } - return .noiseSecured - } - - @MainActor - func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) { - if let ephemeralID = context.ephemeralPeerID(forNoiseKey: noisePublicKey) { - context.unifiedToggleFavorite(ephemeralID) - context.notifyUIChanged() - return - } - - let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey) - let fallbackNickname = context.privateMessages(for: peerID).first { $0.senderPeerID == peerID }?.sender - let plan = ChatFavoriteTogglePolicy.plan( - currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init), - fallbackNickname: fallbackNickname, - bridgedNostrKey: context.bridgedNostrPublicKey(for: noisePublicKey) - ) - - switch plan.persistenceAction { - case .add(let nickname, let nostrKey): - context.addFavorite( - noiseKey: noisePublicKey, - nostrPublicKey: nostrKey, - nickname: nickname - ) - - case .remove: - context.removeFavorite(noiseKey: noisePublicKey) - } - - context.notifyUIChanged() - - if case .send(let isFavorite) = plan.notification { - context.sendFavoriteNotificationViaNostr( - noisePublicKey: noisePublicKey, - isFavorite: isFavorite - ) - } - } -} - -/// Default for conforming test contexts that model chats as a dictionary; -/// `ChatViewModel` overrides with a store-direct lookup. -extension ChatPeerIdentityContext { - func privateMessages(for peerID: PeerID) -> [BitchatMessage] { - privateChats[peerID] ?? [] - } -} diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift deleted file mode 100644 index 7646523f..00000000 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ /dev/null @@ -1,272 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The narrow surface `ChatPeerListCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatPeerListCoordinatorContextTests`) and makes its true -/// dependencies explicit. -@MainActor -protocol ChatPeerListContext: AnyObject { - // MARK: Connection & chat state - var isConnected: Bool { get set } - /// A single private chat's timeline (store-direct lookup on - /// `ChatViewModel`; no `privateChats` dictionary build). - func privateMessages(for peerID: PeerID) -> [BitchatMessage] - var unreadPrivateMessages: Set { get } - /// Clears the peer's unread flag (single-writer store intent). - func markPrivateChatRead(_ peerID: PeerID) - var hasTrackedPrivateChatSelection: Bool { get } - func updatePrivateChatPeerIfNeeded() - func cleanupOldReadReceipts() - - // MARK: Peers & sessions - var unifiedPeers: [BitchatPeer] { get } - func isPeerConnected(_ peerID: PeerID) -> Bool - func isPeerReachable(_ peerID: PeerID) -> Bool - /// Number of mesh peers currently connected or reachable, from the - /// transport's live peer snapshots. - func activeMeshPeerCount() -> Int - func registerEphemeralSession(peerID: PeerID) - func updateEncryptionStatusForPeers() - - // MARK: Notifications - /// Posts the "bitchatters nearby" local notification. - func notifyNetworkAvailable(peerCount: Int) - - /// Records peers seen within range for the daily ambient sightings tally. - func recordMeshSightings(peerIDs: [PeerID]) -} - -extension ChatViewModel: ChatPeerListContext { - // `isConnected`, `privateMessages(for:)`, `unreadPrivateMessages`, - // `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`, - // `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`, - // `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and - // `updateEncryptionStatusForPeers()` are shared requirements with the - // other contexts or satisfied by existing `ChatViewModel` members. The - // member below flattens the nested transport access into an intent-named - // call. - - func activeMeshPeerCount() -> Int { - meshService - .currentPeerSnapshots() - .filter { snapshot in - snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) - } - .count - } - - func notifyNetworkAvailable(peerCount: Int) { - NotificationService.shared.sendNetworkAvailableNotification(peerCount: peerCount) - } - - func recordMeshSightings(peerIDs: [PeerID]) { - for peerID in peerIDs { - MeshSightingsTracker.shared.recordSighting(peerID: peerID) - } - } -} - -final class ChatPeerListCoordinator: @unchecked Sendable { - private unowned let context: any ChatPeerListContext - private var recentlySeenPeers: Set = [] - // The "bitchatters nearby" notification only fires on the transition from - // an empty mesh to a populated one — joining peers while already meshed - // are visible in the app and must not notify. Set back to true only after - // a confirmed-empty reset, so brief link flaps stay silent. - private var meshWasEmpty = true - private var lastNetworkNotificationTime = Date.distantPast - private var networkResetTimer: Timer? - private var networkEmptyTimer: Timer? - private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds - private let notificationCooldownSeconds: TimeInterval - - init( - context: any ChatPeerListContext, - notificationCooldownSeconds: TimeInterval = TransportConfig.networkNotificationCooldownSeconds - ) { - self.context = context - self.notificationCooldownSeconds = notificationCooldownSeconds - } - - deinit { - networkResetTimer?.invalidate() - networkEmptyTimer?.invalidate() - } - - func didUpdatePeerList(_ peers: [PeerID]) { - Task { @MainActor [weak self] in - self?.didUpdatePeerListSynchronously(peers) - } - } - - @MainActor - func didUpdatePeerListSynchronously(_ peers: [PeerID]) { - handlePeerListUpdate(peers) - } -} - -private extension ChatPeerListCoordinator { - @MainActor - func handlePeerListUpdate(_ peers: [PeerID]) { - context.isConnected = !peers.isEmpty - cleanupStaleUnreadPeerIDs() - - let meshPeers = peers.filter { peerID in - context.isPeerConnected(peerID) || context.isPeerReachable(peerID) - } - - handleNetworkAvailability(meshPeers) - - for peerID in peers { - context.registerEphemeralSession(peerID: peerID) - } - - context.updateEncryptionStatusForPeers() - - if context.hasTrackedPrivateChatSelection { - context.updatePrivateChatPeerIfNeeded() - } - } - - @MainActor - func handleNetworkAvailability(_ meshPeers: [PeerID]) { - let meshPeerSet = Set(meshPeers) - - if meshPeerSet.isEmpty { - scheduleNetworkEmptyTimer() - return - } - - invalidateNetworkEmptyTimer() - context.recordMeshSightings(peerIDs: meshPeers) - - let newPeers = meshPeerSet.subtracting(recentlySeenPeers) - // Record every sighted peer even when no notification fires. A peer - // first seen during the cooldown (or while already meshed) must not - // still count as "new" at some later peer-list event — that re-fired - // the notification while devices sat idle and connected. - recentlySeenPeers.formUnion(meshPeerSet) - - let cameFromEmpty = meshWasEmpty - meshWasEmpty = false - - guard cameFromEmpty, !newPeers.isEmpty else { return } - - if Date().timeIntervalSince(lastNetworkNotificationTime) >= notificationCooldownSeconds { - lastNetworkNotificationTime = Date() - context.notifyNetworkAvailable(peerCount: meshPeers.count) - SecureLogger.info( - "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", - category: .session - ) - } - - scheduleNetworkResetTimer() - } - - @MainActor - func cleanupStaleUnreadPeerIDs() { - let currentPeerIDs = Set(context.unifiedPeers.map(\.peerID)) - let staleIDs = context.unreadPrivateMessages.subtracting(currentPeerIDs) - - guard !staleIDs.isEmpty else { - context.cleanupOldReadReceipts() - return - } - - var idsToRemove: [PeerID] = [] - - for staleID in staleIDs { - if staleID.isGeoDM, !context.privateMessages(for: staleID).isEmpty { - continue - } - - if staleID.isNoiseKeyHex, !context.privateMessages(for: staleID).isEmpty { - continue - } - - idsToRemove.append(staleID) - context.markPrivateChatRead(staleID) - } - - if !idsToRemove.isEmpty { - SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session) - } - - context.cleanupOldReadReceipts() - } - - @MainActor - func scheduleNetworkResetTimer() { - networkResetTimer?.invalidate() - networkResetTimer = Timer.scheduledTimer(withTimeInterval: networkResetGraceSeconds, repeats: false) { [weak self] _ in - MainActor.assumeIsolated { [weak self] in - self?.handleNetworkResetTimerFired() - } - } - } - - @MainActor - func handleNetworkResetTimerFired() { - let activeMeshPeerCount = context.activeMeshPeerCount() - - if activeMeshPeerCount == 0 { - recentlySeenPeers.removeAll() - meshWasEmpty = true - SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session) - } else { - SecureLogger.debug( - "⏱️ Skipped network notification reset; still seeing \(activeMeshPeerCount) mesh peers", - category: .session - ) - } - - networkResetTimer = nil - } - - @MainActor - func scheduleNetworkEmptyTimer() { - guard networkEmptyTimer == nil else { return } - - networkEmptyTimer = Timer.scheduledTimer( - withTimeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds, - repeats: false - ) { [weak self] _ in - MainActor.assumeIsolated { [weak self] in - self?.handleNetworkEmptyTimerFired() - } - } - - SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session) - } - - @MainActor - func invalidateNetworkEmptyTimer() { - guard networkEmptyTimer != nil else { return } - networkEmptyTimer?.invalidate() - networkEmptyTimer = nil - } - - @MainActor - func handleNetworkEmptyTimerFired() { - let activeMeshPeerCount = context.activeMeshPeerCount() - - if activeMeshPeerCount == 0 { - recentlySeenPeers.removeAll() - meshWasEmpty = true - SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session) - } else { - SecureLogger.debug( - "⏳ Mesh empty timer cancelled; \(activeMeshPeerCount) mesh peers detected again", - category: .session - ) - } - - networkEmptyTimer = nil - } -} diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift deleted file mode 100644 index e011c763..00000000 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ /dev/null @@ -1,943 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The narrow surface `ChatPrivateConversationCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatPrivateConversationCoordinatorContextTests`) and makes -/// its true dependencies explicit. The surface is intentionally large — it -/// documents the coordinator's real coupling to private-chat state, peer -/// identity, and the routing/ack transports. -@MainActor -protocol ChatPrivateConversationContext: AnyObject { - // MARK: Conversation state - var privateChats: [PeerID: [BitchatMessage]] { get } - /// A single private chat's timeline. Witnessed by the store-direct - /// lookup on `ChatViewModel` (no `privateChats` dictionary build). - func privateMessages(for peerID: PeerID) -> [BitchatMessage] - var sentReadReceipts: Set { get } - var selectedPrivateChatPeer: PeerID? { get } - var nickname: String { get } - var activeChannel: ChannelID { get } - var nostrKeyMapping: [PeerID: String] { get } - - // MARK: Conversation store intents - // The sole mutation paths for private message state (single-writer - // `ConversationStore` ops; see docs/CONVERSATION-STORE-DESIGN.md). - /// Appends a private message in timestamp order; returns `false` on - /// duplicate message ID. - @discardableResult - func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool - /// Replace-or-append a private message by ID, keeping its position. - func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) - /// Applies a delivery status by message ID; returns `false` when the - /// message is unknown or the update would downgrade the status. - @discardableResult - func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool - func markPrivateChatUnread(_ peerID: PeerID) - func markPrivateChatRead(_ peerID: PeerID) - /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat - /// (dedup by ID, order preserved, unread carried, old chat removed). - func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) - /// `true` when any private chat contains a message with `messageID`. - func privateChatsContainMessage(withID messageID: String) -> Bool - /// `true` when `peerID`'s chat contains a message with `messageID`. - func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool - - /// Records that a read receipt is being sent for `messageID`. - /// Returns `false` when one was already recorded — the caller must skip sending. - @discardableResult - func markReadReceiptSent(_ messageID: String) -> Bool - /// Records that a GeoDM delivery ACK is being sent for `messageID`. - /// Returns `false` when one was already recorded — the caller must skip sending. - @discardableResult - func markGeoDeliveryAckSent(_ messageID: String) -> Bool - /// Moves the open private chat to `newPeerID` when the current selection is - /// one of the peer IDs being migrated away. - func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) - /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). - func notifyUIChanged() - - // MARK: Peers & identity - var myPeerID: PeerID { get } - func peerNickname(for peerID: PeerID) -> String? - func isPeerConnected(_ peerID: PeerID) -> Bool - func isPeerReachable(_ peerID: PeerID) -> Bool - func isPeerBlocked(_ peerID: PeerID) -> Bool - func noisePublicKey(for peerID: PeerID) -> Data? - /// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected. - func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? - func getPeerIDForNickname(_ nickname: String) -> PeerID? - func getFingerprint(for peerID: PeerID) -> String? - func storedFingerprint(for peerID: PeerID) -> String? - func clearStoredFingerprint(for peerID: PeerID) - - // MARK: Nostr identity - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool - func displayNameForNostrPubkey(_ pubkeyHex: String) -> String - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity - func currentNostrIdentity() -> NostrIdentity? - - // MARK: Routing & acknowledgements - func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) - @discardableResult - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool - /// Confirms an authenticated delivery/read acknowledgement so the router - /// stops retaining the original private message for resend. The peer - /// aliases scope the removal to the authenticated sender. - @discardableResult - func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool - func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) - func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) - func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) - func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) - - // MARK: System messages - func addMeshOnlySystemMessage(_ content: String) - /// Appends a local-only system line into a specific private thread — - /// errors about a DM belong in that DM, not on the active timeline. - func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) - - // MARK: Favorites & notifications - /// The persisted favorite relationship for the peer's Noise static key, if any. - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? - /// The persisted favorite relationship resolved from a short 16-hex mesh - /// peer ID (matched against the IDs derived from stored noise keys). - func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? - /// Persists that the peer favorited/unfavorited us (favorites store write). - func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) - /// Posts the incoming-private-message local notification. - func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) -} - -extension ChatViewModel: ChatPrivateConversationContext { - // `privateChats` and `notifyUIChanged()` are shared requirements with - // `ChatDeliveryContext`; the single-writer intent ops (`markReadReceiptSent`, - // `markGeoDeliveryAckSent`, `handOffSelectedPrivateChat`) live next to their - // backing state in `ChatViewModel`. The remaining state members are - // satisfied by existing `ChatViewModel` properties and methods. - - var myPeerID: PeerID { meshService.myPeerID } - - func peerNickname(for peerID: PeerID) -> String? { - meshService.peerNickname(peerID: peerID) - } - - func isPeerConnected(_ peerID: PeerID) -> Bool { - meshService.isPeerConnected(peerID) - } - - func isPeerReachable(_ peerID: PeerID) -> Bool { - meshService.isPeerReachable(peerID) - } - - func noisePublicKey(for peerID: PeerID) -> Data? { - unifiedPeerService.getPeer(by: peerID)?.noisePublicKey - } - - func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { - unifiedPeerService.peers.first(where: { $0.noisePublicKey == noiseKey })?.peerID - } - - func storedFingerprint(for peerID: PeerID) -> String? { - peerIDToPublicKeyFingerprint[peerID] - } - - func clearStoredFingerprint(for peerID: PeerID) { - peerIdentityStore.setFingerprint(nil, for: peerID) - } - - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { - identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) - } - - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { - try idBridge.deriveIdentity(forGeohash: geohash) - } - - func currentNostrIdentity() -> NostrIdentity? { - try? idBridge.getCurrentNostrIdentity() - } - - func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { - messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) - } - - @discardableResult - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { - messageRouter.sendReadReceipt(receipt, to: peerID) - } - - @discardableResult - func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool { - messageRouter.markDelivered(messageID, for: peerIDs) - } - - func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { - messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) - } - - func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { - meshService.sendReadReceipt(receipt, to: peerID) - } - - func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { - makeGeohashNostrTransport().sendPrivateMessageGeohash( - content: content, - toRecipientHex: recipientHex, - from: identity, - messageID: messageID - ) - } - - func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - makeGeohashNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity) - } - - func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - makeGeohashNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity) - } - - func addSystemMessage(_ content: String) { - addSystemMessage(content, timestamp: Date()) - } - - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { - FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) - } - - // `favoriteRelationship(forPeerID:)` is shared with - // `ChatPeerIdentityContext`; its witness lives in - // `ChatPeerIdentityCoordinator.swift`. - - func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { - FavoritesPersistenceService.shared.updatePeerFavoritedUs( - peerNoisePublicKey: noiseKey, - favorited: favorited, - peerNickname: nickname, - peerNostrPublicKey: nostrPublicKey - ) - } - - func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) { - NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID) - } - - private func makeGeohashNostrTransport() -> NostrTransport { - let transport = NostrTransport(keychain: keychain, idBridge: idBridge) - transport.senderPeerID = meshService.myPeerID - return transport - } -} - -@MainActor -final class ChatPrivateConversationCoordinator { - private unowned let context: any ChatPrivateConversationContext - - // Outbox retries re-wrap the same message in fresh gift-wrap events, so - // relay-level event-ID dedup can't catch them; track inbound GeoDM - // message IDs so each copy past the first costs one (already-deduped) - // ack check and nothing else. - private var seenInboundGeoDMIDs: Set = [] - private var seenInboundGeoDMOrder: [String] = [] - private static let seenInboundGeoDMCap = 512 - - init(context: any ChatPrivateConversationContext) { - self.context = context - } - - /// Returns `false` if this GeoDM message ID was already handled. - private func markInboundGeoDMSeen(_ messageId: String) -> Bool { - guard !seenInboundGeoDMIDs.contains(messageId) else { return false } - seenInboundGeoDMIDs.insert(messageId) - seenInboundGeoDMOrder.append(messageId) - if seenInboundGeoDMOrder.count > Self.seenInboundGeoDMCap { - seenInboundGeoDMIDs.remove(seenInboundGeoDMOrder.removeFirst()) - } - return true - } - - /// Account DMs can arrive under the authenticated peer's full Noise key - /// while an existing mesh conversation is keyed by its derived short ID. - /// These are the only aliases we may safely join: the short ID is derived - /// directly from the authenticated key, rather than guessed from a - /// nickname or found by scanning unrelated chats. - private func accountConversationAliases(for peerID: PeerID) -> [PeerID] { - guard peerID.noiseKey != nil else { return [peerID] } - let shortPeerID = peerID.toShort() - return shortPeerID == peerID ? [peerID] : [peerID, shortPeerID] - } - - /// Keeps a connected account DM on its short routing ID and an offline DM - /// on its stable Noise-key ID, folding the other authenticated alias into - /// it and handing an open sheet across without closing it. - private func consolidateAccountConversationAliases(for peerID: PeerID) -> PeerID { - let aliases = accountConversationAliases(for: peerID) - guard aliases.count > 1 else { return peerID } - - let shortPeerID = peerID.toShort() - let targetPeerID = context.isPeerConnected(shortPeerID) ? shortPeerID : peerID - let sourcePeerIDs = aliases.filter { $0 != targetPeerID } - - for sourcePeerID in sourcePeerIDs where !context.privateMessages(for: sourcePeerID).isEmpty { - context.migratePrivateChat(from: sourcePeerID, to: targetPeerID) - // ConversationStore deliberately preserves message values during a - // generic migration, including its destination-wins rule for - // duplicate IDs. Rewrite the resulting canonical copies so later - // read-receipt scans compare against the new routing key without - // replacing a newer destination value with the source snapshot. - let canonicalMessages = context.privateMessages(for: targetPeerID) - for message in canonicalMessages where message.senderPeerID == sourcePeerID { - context.upsertPrivateMessage( - BitchatMessage( - id: message.id, - sender: message.sender, - content: message.content, - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: message.isPrivate, - recipientNickname: message.recipientNickname, - senderPeerID: targetPeerID, - mentions: message.mentions, - deliveryStatus: message.deliveryStatus, - isBridged: message.isBridged - ), - in: targetPeerID - ) - } - } - context.handOffSelectedPrivateChat(from: sourcePeerIDs, to: targetPeerID) - return targetPeerID - } - - func sendPrivateMessage(_ content: String, to peerID: PeerID) { - guard !content.isEmpty else { return } - - if context.isPeerBlocked(peerID) { - let nickname = context.peerNickname(for: peerID) ?? "anon" - context.addLocalPrivateSystemMessage( - String( - format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked person"), - locale: .current, - nickname - ), - to: peerID - ) - return - } - - if peerID.isGeoDM { - sendGeohashDM(content, to: peerID) - return - } - - // Resolve the favorite behind this conversation. It may be keyed by - // the full 64-hex noise-key ID (offline favorite row) or the short - // 16-hex mesh ID — the raw hex bytes of a short ID are a routing ID, - // never a noise key, so they must not be used as a favorites key. - let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID) - let isConnected = context.isPeerConnected(peerID) - let isReachable = context.isPeerReachable(peerID) - let favoriteStatus = noiseKey.flatMap { context.favoriteRelationship(forNoiseKey: $0) } - ?? context.favoriteRelationship(forPeerID: peerID) - let isMutualFavorite = favoriteStatus?.isMutual ?? false - let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil - - // "anon" matches the app's default-nickname convention; "user" is - // banned copy. - let recipientNickname = context.peerNickname(for: peerID) - ?? favoriteStatus?.peerNickname - ?? "anon" - - let messageID = UUID().uuidString - let message = BitchatMessage( - id: messageID, - sender: context.nickname, - content: content, - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: recipientNickname, - senderPeerID: context.myPeerID, - mentions: nil, - deliveryStatus: .sending - ) - - context.appendPrivateMessage(message, to: peerID) - context.notifyUIChanged() - - // Always hand the message to the router — it owns delivery. A live - // link sends now; an unreachable peer gets the retained-outbox path - // (resend on reconnect, courier deposits, bridge drops). Pre-judging - // reachability here used to mark the message failed without ever - // routing it, silently bypassing all of that (field-found: DMs - // composed after a peer's reachability window lapsed were dead on - // arrival while identical DMs sent a minute earlier delivered). - context.routePrivateMessage( - content, - to: peerID, - recipientNickname: recipientNickname, - messageID: messageID - ) - if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { - context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID) - } - // Otherwise the message stays "sending"; router callbacks move it to - // carried (📦) when a courier/bridge copy ships, delivered/read on - // acks, or failed when the outbox TTL expires. - } - - func sendGeohashDM(_ content: String, to peerID: PeerID) { - guard case .location(let channel) = context.activeChannel else { - // The failure happened inside a geoDM thread — surface it there, - // not on the public timeline (matches the sibling blocked/unknown - // errors routed into the thread by #1415). - context.addLocalPrivateSystemMessage( - String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel"), - to: peerID - ) - return - } - - let messageID = UUID().uuidString - let message = BitchatMessage( - id: messageID, - sender: context.nickname, - content: content, - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: context.nickname, - senderPeerID: context.myPeerID, - deliveryStatus: .sending - ) - - context.appendPrivateMessage(message, to: peerID) - context.notifyUIChanged() - - guard let recipientHex = context.nostrKeyMapping[peerID] else { - context.setPrivateDeliveryStatus( - .failed( - reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") - ), - forMessageID: messageID, - peerID: peerID - ) - return - } - - if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) { - context.setPrivateDeliveryStatus( - .failed( - reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked") - ), - forMessageID: messageID, - peerID: peerID - ) - context.addLocalPrivateSystemMessage( - String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because the person is blocked"), - to: peerID - ) - return - } - - do { - let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - if recipientHex.lowercased() == identity.publicKeyHex.lowercased() { - context.setPrivateDeliveryStatus( - .failed( - reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself") - ), - forMessageID: messageID, - peerID: peerID - ) - return - } - - SecureLogger.debug( - "GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", - category: .session - ) - context.sendGeohashPrivateMessage( - content, - toRecipientHex: recipientHex, - from: identity, - messageID: messageID - ) - context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID) - } catch { - context.setPrivateDeliveryStatus( - .failed( - reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error") - ), - forMessageID: messageID, - peerID: peerID - ) - } - } - - func handlePrivateMessage( - _ payload: NoisePayload, - senderPubkey: String, - convKey: PeerID, - id: NostrIdentity, - messageTimestamp: Date - ) { - guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } - let messageId = pm.messageID - - // Ack before the dedup guard: a re-sent copy means the sender may not - // have our DELIVERED yet, and markGeoDeliveryAckSent dedups the - // actual sends. - sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) - - guard markInboundGeoDMSeen(messageId) else { return } - - SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session) - - if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { - return - } - - // Prefer the favorite's stored nickname when the sender resolved to a - // known noise key; the Nostr display name is a geohash-scoped - // fallback (e.g. "anon#678e") that would mislabel favorite-transport - // DMs. Geohash conversations (nostr_ keys) keep the geo name. - let senderName: String = { - if let noiseKey = convKey.noiseKey, - let favoriteNickname = context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname, - !favoriteNickname.isEmpty { - return favoriteNickname - } - return context.displayNameForNostrPubkey(senderPubkey) - }() - - // Favorite notifications ride the PM channel over Nostr too; intercept - // them so they update the relationship instead of rendering as text. - if pm.content.hasPrefix("[FAVORITED]") || pm.content.hasPrefix("[UNFAVORITED]") { - handleFavoriteNotification( - pm.content, - from: convKey, - senderNickname: senderName - ) - return - } - - let conversationPeerID = consolidateAccountConversationAliases(for: convKey) - - if context.privateChatsContainMessage(withID: messageId) { return } - - let message = BitchatMessage( - id: messageId, - sender: senderName, - content: pm.content, - timestamp: messageTimestamp, - isRelay: false, - isPrivate: true, - recipientNickname: context.nickname, - senderPeerID: conversationPeerID, - deliveryStatus: .delivered(to: context.nickname, at: Date()) - ) - - context.appendPrivateMessage(message, to: conversationPeerID) - - let isViewing = context.selectedPrivateChatPeer == conversationPeerID - let wasReadBefore = context.sentReadReceipts.contains(messageId) - let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 - let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage - if shouldMarkUnread { - context.markPrivateChatUnread(conversationPeerID) - } - - if isViewing { - sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) - } - - if !isViewing && shouldMarkUnread { - context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: conversationPeerID) - } - - context.notifyUIChanged() - } - - func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { - guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - - let aliases = accountConversationAliases(for: convKey) - let clearedRetainedMessage = convKey.noiseKey != nil - ? context.markMessageDelivered(messageID, for: aliases) - : false - let hasConversationMessage = aliases.contains { - context.privateChat($0, containsMessageWithID: messageID) - } - if hasConversationMessage { - let conversationPeerID = consolidateAccountConversationAliases(for: convKey) - let didChange = context.setPrivateDeliveryStatus( - .delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()), - forMessageID: messageID, - peerID: conversationPeerID - ) - if didChange { - context.notifyUIChanged() - } - SecureLogger.info( - "GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", - category: .session - ) - } else if clearedRetainedMessage { - SecureLogger.debug( - "GeoDM: recv DELIVERED for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", - category: .session - ) - } else { - // A stale ack for a message this device no longer tracks (dropped - // outbox entry, cleared chat, or a peer re-acking after losing our - // receipt) — expected occasionally, not actionable. - SecureLogger.debug("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) - } - } - - func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { - guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - - let aliases = accountConversationAliases(for: convKey) - let clearedRetainedMessage = convKey.noiseKey != nil - ? context.markMessageDelivered(messageID, for: aliases) - : false - let hasConversationMessage = aliases.contains { - context.privateChat($0, containsMessageWithID: messageID) - } - if hasConversationMessage { - let conversationPeerID = consolidateAccountConversationAliases(for: convKey) - let didChange = context.setPrivateDeliveryStatus( - .read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()), - forMessageID: messageID, - peerID: conversationPeerID - ) - if didChange { - context.notifyUIChanged() - } - SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) - } else if clearedRetainedMessage { - SecureLogger.debug( - "GeoDM: recv READ for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", - category: .session - ) - } else { - SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) - } - } - - func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { - guard context.markGeoDeliveryAckSent(messageId) else { return } - context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id) - } - - func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { - guard context.markReadReceiptSent(messageId) else { return } - context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id) - } - - func handlePrivateMessage(_ message: BitchatMessage) { - SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) - let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) - - guard let peerID = senderPeerID else { - SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session) - return - } - - if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { - handleFavoriteNotification(message.content, from: peerID, senderNickname: message.sender) - return - } - - migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender) - - if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) { - let stableKeyHex = PeerID(hexData: peerNoiseKey) - let nostrMessages = context.privateMessages(for: stableKeyHex) - if stableKeyHex != peerID, - !nostrMessages.isEmpty { - // Store migration dedups by ID, keeps timestamp order, and - // removes the stable-key chat. - context.migratePrivateChat(from: stableKeyHex, to: peerID) - - SecureLogger.info( - "📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", - category: .session - ) - } - } - - if isDuplicateMessage(message.id, targetPeerID: peerID) { - return - } - - addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID) - let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID) - mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey) - - let isViewing = context.selectedPrivateChatPeer == peerID - if isViewing { - let receipt = ReadReceipt( - originalMessageID: message.id, - readerID: context.myPeerID, - readerNickname: context.nickname - ) - context.sendMeshReadReceipt(receipt, to: peerID) - context.markReadReceiptSent(message.id) - } else { - context.markPrivateChatUnread(peerID) - context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID) - } - - context.notifyUIChanged() - } - - /// O(1)-per-conversation dedup via the store's message-ID indexes - /// (replaces the full scan over every private chat). - func isDuplicateMessage(_ messageId: String, targetPeerID _: PeerID) -> Bool { - context.privateChatsContainMessage(withID: messageId) - } - - func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) { - // Store upsert replaces in place by message ID or inserts in - // timestamp order; the old per-append sanitize re-sort is obsolete. - context.upsertPrivateMessage(message, in: targetPeerID) - } - - func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) { - guard let key, - let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key), - ephemeralPeerID != targetPeerID - else { - return - } - - context.upsertPrivateMessage(message, in: ephemeralPeerID) - } - - func handleViewingThisChat( - _ message: BitchatMessage, - targetPeerID: PeerID, - key: Data?, - senderPubkey: String - ) { - context.markPrivateChatRead(targetPeerID) - if let key, - let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) { - context.markPrivateChatRead(ephemeralPeerID) - } - guard !context.sentReadReceipts.contains(message.id) else { return } - - if let key { - let receipt = ReadReceipt( - originalMessageID: message.id, - readerID: context.myPeerID, - readerNickname: context.nickname - ) - SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session) - context.routeReadReceipt(receipt, to: PeerID(hexData: key)) - context.markReadReceiptSent(message.id) - } else if let identity = context.currentNostrIdentity() { - context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) - context.markReadReceiptSent(message.id) - SecureLogger.debug( - "Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", - category: .session - ) - } - } - - func markAsUnreadIfNeeded( - shouldMarkAsUnread: Bool, - targetPeerID: PeerID, - key: Data?, - isRecentMessage: Bool, - senderNickname: String, - messageContent: String - ) { - guard shouldMarkAsUnread else { return } - - context.markPrivateChatUnread(targetPeerID) - if let key, - let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key), - ephemeralPeerID != targetPeerID { - context.markPrivateChatUnread(ephemeralPeerID) - } - if isRecentMessage { - context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID) - } - } - - /// Applies an inbound `[FAVORITED]`/`[UNFAVORITED]` marker from either - /// transport. `peerID` must resolve to a noise key — a full 64-hex ID or - /// one the unified peer list knows; otherwise the notification is dropped. - func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) { - let isFavorite = content.hasPrefix("[FAVORITED]") - let parts = content.split(separator: ":") - - var nostrPubkey: String? - if parts.count > 1 { - nostrPubkey = String(parts[1]) - SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session) - } - - let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID) - guard let finalNoiseKey = noiseKey else { - SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session) - return - } - - let prior = context.favoriteRelationship(forNoiseKey: finalNoiseKey)?.theyFavoritedUs ?? false - context.updatePeerFavoritedUs( - noiseKey: finalNoiseKey, - favorited: isFavorite, - nickname: senderNickname, - nostrPublicKey: nostrPubkey - ) - - if isFavorite && nostrPubkey != nil { - SecureLogger.info( - "💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", - category: .session - ) - } - - if prior != isFavorite { - let action = isFavorite ? "favorited" : "unfavorited" - context.addMeshOnlySystemMessage("\(senderNickname) \(action) you") - } - } - - func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { - let isActionMessage = message.content.hasPrefix("* ") - && message.content.hasSuffix(" *") - && (message.content.contains("🫂") - || message.content.contains("🐟") - || message.content.contains("took a screenshot")) - - guard isActionMessage else { return message } - - return BitchatMessage( - id: message.id, - sender: "system", - content: String(message.content.dropFirst(2).dropLast(2)), - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: message.isPrivate, - recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID, - mentions: message.mentions, - deliveryStatus: message.deliveryStatus - ) - } - - func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { - let currentFingerprint = context.getFingerprint(for: peerID) - - if context.privateMessages(for: peerID).isEmpty { - // Chats migrated wholesale go through the store's - // `migrateConversation` intent; partially-migrated chats keep - // their non-recent tail, so the recent messages are copied in - // via ordered append (dedup by ID) instead. - var partiallyMigratedMessages: [BitchatMessage] = [] - var oldPeerIDsToRemove: [PeerID] = [] - var didMigrate = false - let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) - - for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID { - let oldFingerprint = context.storedFingerprint(for: oldPeerID) - let recentMessages = messages.filter { $0.timestamp > cutoffTime } - guard !recentMessages.isEmpty else { continue } - - if let currentFp = currentFingerprint, - let oldFp = oldFingerprint, - currentFp == oldFp { - didMigrate = true - if recentMessages.count == messages.count { - oldPeerIDsToRemove.append(oldPeerID) - } else { - partiallyMigratedMessages.append(contentsOf: recentMessages) - SecureLogger.info( - "📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", - category: .session - ) - } - - SecureLogger.info( - "📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", - category: .session - ) - } else if currentFingerprint == nil || oldFingerprint == nil { - let isRelevantChat = recentMessages.contains { msg in - (msg.sender == senderNickname && msg.sender != context.nickname) - || (msg.sender == context.nickname && msg.recipientNickname == senderNickname) - } - - if isRelevantChat { - didMigrate = true - if recentMessages.count == messages.count { - oldPeerIDsToRemove.append(oldPeerID) - } else { - partiallyMigratedMessages.append(contentsOf: recentMessages) - } - - SecureLogger.warning( - "📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", - category: .session - ) - } - } - } - - if !oldPeerIDsToRemove.isEmpty { - for oldID in oldPeerIDsToRemove { - // The old behavior dropped the unread flag of removed - // chats instead of transferring it; clear it before the - // migration so the store doesn't carry it over. - context.markPrivateChatRead(oldID) - context.migratePrivateChat(from: oldID, to: peerID) - context.clearStoredFingerprint(for: oldID) - } - - context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID) - } - - for message in partiallyMigratedMessages { - context.appendPrivateMessage(message, to: peerID) - } - - if didMigrate { - context.notifyUIChanged() - } - } - } - - func isMessageBlocked(_ message: BitchatMessage) -> Bool { - if let peerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) { - if context.isPeerBlocked(peerID) { return true } - if peerID.isGeoChat || peerID.isGeoDM, - let full = context.nostrKeyMapping[peerID]?.lowercased(), - context.isNostrBlocked(pubkeyHexLowercased: full) { - return true - } - } - return false - } -} - -/// Default for conforming test contexts that model chats as a dictionary; -/// `ChatViewModel` overrides with a store-direct lookup. -extension ChatPrivateConversationContext { - func privateMessages(for peerID: PeerID) -> [BitchatMessage] { - privateChats[peerID] ?? [] - } -} diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift deleted file mode 100644 index d12e2c1d..00000000 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ /dev/null @@ -1,603 +0,0 @@ -import BitFoundation -import BitLogger -import CoreBluetooth -import Foundation -import SwiftUI -#if os(iOS) -import UIKit -#endif - -/// The narrow surface `ChatPublicConversationCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatPublicConversationCoordinatorContextTests`) and makes -/// its true dependencies explicit. The surface is intentionally large — it -/// documents the coordinator's real coupling to the public timeline, the -/// conversation stores, geohash participants, and the inbound public message -/// pipeline. -@MainActor -protocol ChatPublicConversationContext: AnyObject { - // MARK: Channel state - var activeChannel: ChannelID { get } - var currentGeohash: String? { get } - var nickname: String { get } - var myPeerID: PeerID { get } - /// Publishes the public-timeline batching state (UI animation suppression). - /// (Single mutation path for the owner's `isBatchingPublic`; this - /// coordinator never reads it.) - func setPublicBatching(_ isBatching: Bool) - /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). - func notifyUIChanged() - - // MARK: Public conversation store (single-writer intents) - /// Appends a public message in timestamp order. Returns `false` when a - /// message with the same ID is already in that conversation. - @discardableResult - func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool - func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool - /// Removes a message by ID from whichever public conversation contains it. - @discardableResult - func removePublicMessage(withID messageID: String) -> BitchatMessage? - /// Removes every matching message from a geohash conversation (block purge). - func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) - /// Empties a public conversation's timeline (`/clear`). - func clearPublicConversation(_ conversationID: ConversationID) - /// Erases the on-disk archive of carried public mesh messages, so clearing - /// the mesh timeline deletes that history instead of only hiding it. - func purgeArchivedPublicMessages() - /// Queues a system message for the next geohash channel visit. - func queueGeohashSystemMessage(_ content: String) - - // MARK: Private chats (block cleanup & message removal) - /// Removes the peer's chat entirely, including unread state - /// (single-writer store intent; no-op for unknown peers). - func removePrivateChat(_ peerID: PeerID) - /// Removes a message by ID from every private chat containing it, - /// dropping chats that become empty. Returns the removed message. - @discardableResult - func removePrivateMessage(withID messageID: String) -> BitchatMessage? - func cleanupLocalFile(forMessage message: BitchatMessage) - - // MARK: Geohash participants & presence - var geoNicknames: [String: String] { get } - var isTeleported: Bool { get } - var nostrKeyMapping: [PeerID: String] { get } - /// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey. - func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) - func visibleGeoPeople() -> [GeoPerson] - func geoParticipantCount(for geohash: String) -> Int - func removeGeoParticipant(pubkeyHex: String) - - // MARK: Nostr identity & blocking (shared with the other contexts) - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool - func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) - - // MARK: Mesh transport - func meshPeerNicknames() -> [PeerID: String] - func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) - - // MARK: Inbound public message processing - func processActionMessage(_ message: BitchatMessage) -> BitchatMessage - func isMessageBlocked(_ message: BitchatMessage) -> Bool - /// `powBits` is the validated NIP-13 difficulty of the source Nostr event - /// (0 for mesh messages); sufficient PoW relaxes the per-sender bucket. - func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool - /// Buffers a visible-channel message for the batched (~80 ms) pipeline - /// flush, which commits it to `conversationID` in the store. - func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) - func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? - - // MARK: Content dedup & formatting - func normalizedContentKey(_ content: String) -> String - func contentTimestamp(forKey key: String) -> Date? - func recordContentKey(_ key: String, timestamp: Date) - /// Pre-renders the message so the formatting cache is warm before display. - func prewarmMessageFormatting(_ message: BitchatMessage) - - // MARK: Notifications - /// Posts the you-were-mentioned local notification. - func notifyMention(from sender: String, message: String) -} - -extension ChatViewModel: ChatPublicConversationContext { - // `unreadPrivateMessages`, `nostrKeyMapping`, - // `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`, - // `myPeerID`, `isTeleported`, `notifyUIChanged()`, - // `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`, - // `deriveNostrIdentity(forGeohash:)`, the public conversation store - // intents (`appendPublicMessage(_:to:)`, - // `publicConversationContainsMessage(withID:in:)`, - // `removePublicMessage(withID:)`, - // `removePublicMessages(fromGeohash:where:)`, - // `clearPublicConversation(_:)`, and `queueGeohashSystemMessage(_:)`) - // are shared requirements with `ChatDeliveryContext` / - // `ChatPrivateConversationContext` / `ChatNostrContext` or satisfied by - // existing `ChatViewModel` members. The members below flatten nested - // service accesses into intent-named calls. - - func visibleGeoPeople() -> [GeoPerson] { - participantTracker.getVisiblePeople() - } - - func removeGeoParticipant(pubkeyHex: String) { - participantTracker.removeParticipant(pubkeyHex: pubkeyHex) - } - - func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { - identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: isBlocked) - } - - func meshPeerNicknames() -> [PeerID: String] { - meshService.getPeerNicknames() - } - - func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { - meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp) - } - - func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool { - publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits) - } - - func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) { - publicMessagePipeline.enqueue(message, to: conversationID) - } - - func normalizedContentKey(_ content: String) -> String { - deduplicationService.normalizedContentKey(content) - } - - func contentTimestamp(forKey key: String) -> Date? { - deduplicationService.contentTimestamp(forKey: key) - } - - func recordContentKey(_ key: String, timestamp: Date) { - deduplicationService.recordContentKey(key, timestamp: timestamp) - } - - func prewarmMessageFormatting(_ message: BitchatMessage) { - _ = formatMessageAsText(message, colorScheme: currentColorScheme) - } - - func notifyMention(from sender: String, message: String) { - NotificationService.shared.sendMentionNotification(from: sender, message: message) - } -} - -@MainActor -final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { - private unowned let context: any ChatPublicConversationContext - - init(context: any ChatPublicConversationContext) { - self.context = context - } - - func visibleGeohashPeople() -> [GeoPerson] { - context.visibleGeoPeople() - } - - func getVisibleGeoParticipants() -> [CommandGeoParticipant] { - visibleGeohashPeople().map { CommandGeoParticipant(id: $0.id, displayName: $0.displayName) } - } - - func geohashParticipantCount(for geohash: String) -> Int { - context.geoParticipantCount(for: geohash) - } - - func displayNameForPubkey(_ pubkeyHex: String) -> String { - displayNameForNostrPubkey(pubkeyHex) - } - - func isBlocked(_ pubkeyHexLowercased: String) -> Bool { - context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) - } - - func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { - context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) - } - - func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { - let hex = pubkeyHexLowercased.lowercased() - context.setNostrBlocked(hex, isBlocked: true) - context.removeGeoParticipant(pubkeyHex: hex) - - if let gh = context.currentGeohash { - let predicate: (BitchatMessage) -> Bool = { [unowned context] message in - guard let senderPeerID = message.senderPeerID, - senderPeerID.isGeoDM || senderPeerID.isGeoChat else { - return false - } - if let full = context.nostrKeyMapping[senderPeerID]?.lowercased() { - return full == hex - } - return false - } - context.removePublicMessages(fromGeohash: gh, where: predicate) - } - - // The store intent no-ops when no such chat exists. - context.removePrivateChat(PeerID(nostr_: hex)) - - context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex) - - addSystemMessage( - String( - format: String( - localized: "system.geohash.blocked", - comment: "System message shown when a user is blocked in geohash chats" - ), - locale: .current, - displayName - ) - ) - } - - func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { - context.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) - addSystemMessage( - String( - format: String( - localized: "system.geohash.unblocked", - comment: "System message shown when a user is unblocked in geohash chats" - ), - locale: .current, - displayName - ) - ) - } - - func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { - let suffix = String(pubkeyHex.suffix(4)) - if let geohash = context.currentGeohash, - let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: geohash), - myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() { - return context.nickname + "#" + suffix - } - if let nick = context.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty { - return nick + "#" + suffix - } - return "anon#\(suffix)" - } - - func currentPublicSender() -> (name: String, peerID: PeerID) { - var displaySender = context.nickname - var senderPeerID = context.myPeerID - if case .location(let channel) = context.activeChannel, - let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - let suffix = String(identity.publicKeyHex.suffix(4)) - displaySender = context.nickname + "#" + suffix - senderPeerID = PeerID(nostr: identity.publicKeyHex) - } - return (displaySender, senderPeerID) - } - - func removeMessage(withID messageID: String, cleanupFile: Bool = false) { - var removedMessage = context.removePublicMessage(withID: messageID) - - if let removedPrivateMessage = context.removePrivateMessage(withID: messageID) { - removedMessage = removedMessage ?? removedPrivateMessage - } - - if cleanupFile, let removedMessage { - context.cleanupLocalFile(forMessage: removedMessage) - } - - context.notifyUIChanged() - } - - func clearCurrentPublicTimeline() { - context.clearPublicConversation(ConversationID(channelID: context.activeChannel)) - - // Clearing the mesh timeline also dismisses its archived echoes for - // good: the watermark stops the next launch from re-seeding them, the - // archive on disk is erased so the cleared history is actually gone - // rather than merely hidden, and the dedup keys go so a cleared - // message arriving live shows again. - if case .mesh = context.activeChannel { - MeshEchoSettings.clearedThrough = Date() - archivedEchoKeys.removeAll() - context.purgeArchivedPublicMessages() - } - - // The SPM test process shares the real Application Support tree, so this - // detached deletion can land mid-test under parallel scheduling and flake - // a file-dependent test. Tests never need the on-disk media cleared. - guard !TestEnvironment.isRunningTests else { return } - - Task.detached(priority: .utility) { - // Skipped under tests: the test process shares the user's real - // ~/Library/Application Support/files tree, and this detached - // wipe fires at a nondeterministic time — racing tests that - // write media there (see the same guard in panicClearAllData). - guard !TestEnvironment.isRunningTests else { return } - do { - let base = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let filesDir = base.appendingPathComponent("files", isDirectory: true) - let outgoingDirs = [ - filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), - filesDir.appendingPathComponent("images/outgoing", isDirectory: true), - filesDir.appendingPathComponent("files/outgoing", isDirectory: true) - ] - - for dir in outgoingDirs { - if FileManager.default.fileExists(atPath: dir.path) { - try? FileManager.default.removeItem(at: dir) - try? FileManager.default.createDirectory( - at: dir, - withIntermediateDirectories: true, - attributes: nil - ) - } - } - } catch { - SecureLogger.error("Failed to clear media files: \(error)", category: .session) - } - } - } - - func addSystemMessage(_ content: String, timestamp: Date = Date()) { - let systemMessage = BitchatMessage( - sender: "system", - content: content, - timestamp: timestamp, - isRelay: false - ) - context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel)) - } - - func addMeshOnlySystemMessage(_ content: String) { - let systemMessage = BitchatMessage( - sender: "system", - content: content, - timestamp: Date(), - isRelay: false - ) - context.appendPublicMessage(systemMessage, to: .mesh) - } - - func addPublicSystemMessage(_ content: String) { - let systemMessage = BitchatMessage( - sender: "system", - content: content, - timestamp: Date(), - isRelay: false - ) - context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel)) - let contentKey = context.normalizedContentKey(systemMessage.content) - context.recordContentKey(contentKey, timestamp: systemMessage.timestamp) - } - - func addGeohashOnlySystemMessage(_ content: String) { - if case .location = context.activeChannel { - addPublicSystemMessage(content) - } else { - context.queueGeohashSystemMessage(content) - } - } - - func sendPublicRaw(_ content: String) { - if case .location(let channel) = context.activeChannel { - Task { @MainActor [weak context] in - guard let context else { return } - do { - let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( - content: content, - geohash: channel.geohash, - senderIdentity: identity, - nickname: context.nickname, - teleported: context.isTeleported - ) - let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) - if targetRelays.isEmpty { - NostrRelayManager.shared.sendEvent(event) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } - } catch { - SecureLogger.error("❌ Failed to send geohash raw message: \(error)", category: .session) - } - } - return - } - - context.sendMeshMessage( - content, - mentions: [], - messageID: UUID().uuidString, - timestamp: Date() - ) - } - - /// - Parameter powBits: validated NIP-13 difficulty of the source Nostr - /// event (0 for mesh messages). Sufficient PoW relaxes the per-sender - /// rate limit; low/no-PoW events keep the strict limits so old clients - /// still get through at normal rates. - /// Identity keys of the archived echoes seeded into the mesh timeline at - /// launch. Re-synced copies of others' messages now arrive with the same - /// derived stable ID (`MeshMessageIdentity`), so the store's insert-by-ID - /// catches those — but the archive-restored rows themselves carry - /// `echo-`-prefixed IDs, and self echoes get fresh UUIDs, so this content - /// identity remains the way to recognize a live copy of an - /// already-rendered echo. - private var archivedEchoKeys = Set() - - func registerArchivedEcho(senderPeerID: PeerID?, timestamp: Date, content: String) { - archivedEchoKeys.insert(Self.archivedEchoKey(senderPeerID: senderPeerID, timestamp: timestamp, content: content)) - } - - static func archivedEchoKey(senderPeerID: PeerID?, timestamp: Date, content: String) -> String { - let ms = UInt64((timestamp.timeIntervalSince1970 * 1000).rounded()) - return "\(senderPeerID?.id ?? "")|\(ms)|\(content)" - } - - func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) { - let finalMessage = context.processActionMessage(message) - if context.isMessageBlocked(finalMessage) { return } - - let isGeo = finalMessage.senderPeerID?.isGeoChat == true - let isSystem = finalMessage.sender == "system" - let shouldRateLimit = !isSystem || finalMessage.senderPeerID != nil - if shouldRateLimit { - let senderKey = normalizedSenderKey(for: finalMessage) - let contentKey = context.normalizedContentKey(finalMessage.content) - if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) { - return - } - } - - if !isSystem && finalMessage.content.count > 16000 { return } - // Empty content never rendered before (the old visible-array enqueue - // filtered it); with the store as the sole timeline it is dropped - // outright instead of lingering invisibly in a backing buffer. - guard !finalMessage.content.trimmed.isEmpty else { return } - - // Resolve the destination conversation. System messages surface on - // the active channel (matching their old visible-only routing); geo - // messages require a current geohash, mesh messages always land in - // the mesh conversation. - let destination: ConversationID? - if isSystem { - destination = ConversationID(channelID: context.activeChannel) - } else if isGeo { - destination = context.currentGeohash.map { .geohash($0.lowercased()) } - } else { - destination = .mesh - } - guard let destination else { return } - - // A live copy of a message already rendered as an archived echo - // (e.g. re-served by a peer's gossip sync) would duplicate the row. - if destination == .mesh, !isSystem { - let key = Self.archivedEchoKey( - senderPeerID: finalMessage.senderPeerID, - timestamp: finalMessage.timestamp, - content: finalMessage.content - ) - if archivedEchoKeys.contains(key) { return } - } - - let channelMatches: Bool = { - switch context.activeChannel { - case .mesh: return !isGeo || isSystem - case .location: return isGeo || isSystem - } - }() - - if channelMatches { - // Visible-channel arrivals are batched: the pipeline's ~80 ms - // flush commits them to the store (which dedups by ID), keeping - // the deliberate UI flush cadence. - guard !context.publicConversationContainsMessage(withID: finalMessage.id, in: destination) else { return } - context.enqueuePublicMessage(finalMessage, to: destination) - } else { - // Background-channel arrivals have no rendering observers to - // batch for; they land in the store immediately. - context.appendPublicMessage(finalMessage, to: destination) - } - } - - func checkForMentions(_ message: BitchatMessage) { - let myNickname = context.nickname.normalizedNickname - var myTokens: Set = [myNickname] - let meshPeers = context.meshPeerNicknames() - let collisions = meshPeers.values.filter { $0.normalizedNickname.hasPrefix(myNickname + "#") } - if !collisions.isEmpty { - let suffix = "#" + String(context.myPeerID.id.prefix(4)) - myTokens = [myNickname + suffix] - } - let isMentioned = message.mentions?.contains { myTokens.contains($0.normalizedNickname) } ?? false - - if isMentioned && message.sender != context.nickname { - SecureLogger.info("🔔 Mention from \(message.sender)", category: .session) - context.notifyMention(from: message.sender, message: message.content) - } - } - - func sendHapticFeedback(for message: BitchatMessage) { - #if os(iOS) - guard UIApplication.shared.applicationState == .active else { return } - - var tokens: [String] = [context.nickname] - switch context.activeChannel { - case .location(let channel): - if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - tokens.append(context.nickname + "#" + String(identity.publicKeyHex.suffix(4))) - } - case .mesh: - break - } - - let hugsMe = tokens.contains { message.content.contains("hugs \($0)") } || message.content.contains("hugs you") - let slapsMe = tokens.contains { message.content.contains("slaps \($0) around") } || message.content.contains("slaps you around") - let isHugForMe = message.content.contains("🫂") && hugsMe - let isSlapForMe = message.content.contains("🐟") && slapsMe - - if isHugForMe && message.sender != context.nickname { - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.prepare() - - for i in 0..<8 { - DispatchQueue.main.asyncAfter( - deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds - ) { - impactFeedback.impactOccurred() - } - } - } else if isSlapForMe && message.sender != context.nickname { - let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) - impactFeedback.prepare() - impactFeedback.impactOccurred() - } - #endif - } - - func pipeline(_: PublicMessagePipeline, normalizeContent content: String) -> String { - context.normalizedContentKey(content) - } - - func pipeline(_: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { - context.contentTimestamp(forKey: key) - } - - func pipeline(_: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { - context.recordContentKey(key, timestamp: timestamp) - } - - func pipeline(_: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool { - context.appendPublicMessage(message, to: conversationID) - } - - func pipelinePrewarmMessage(_: PublicMessagePipeline, message: BitchatMessage) { - context.prewarmMessageFormatting(message) - } - - func pipelineSetBatchingState(_: PublicMessagePipeline, isBatching: Bool) { - context.setPublicBatching(isBatching) - } -} - -private extension ChatPublicConversationCoordinator { - func normalizedSenderKey(for message: BitchatMessage) -> String { - if let senderPeerID = message.senderPeerID { - if senderPeerID.isGeoChat || senderPeerID.isGeoDM { - let full = (context.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased() - return "nostr:" + full - } else if senderPeerID.id.count == 16, - let full = context.cachedStablePeerID(for: senderPeerID)?.id.lowercased() { - return "noise:" + full - } else { - return "mesh:" + senderPeerID.id.lowercased() - } - } - return "name:" + message.sender.lowercased() - } -} diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift deleted file mode 100644 index ee2d8b6c..00000000 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ /dev/null @@ -1,538 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The narrow surface `ChatTransportEventCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatTransportEventCoordinatorContextTests`) and makes its -/// true dependencies explicit. -@MainActor -protocol ChatTransportEventContext: AnyObject { - // MARK: Connection & chat state - var isConnected: Bool { get set } - var nickname: String { get } - var myPeerID: PeerID { get } - /// A single private chat's timeline (store-direct lookup on - /// `ChatViewModel`; no `privateChats` dictionary build). - func privateMessages(for peerID: PeerID) -> [BitchatMessage] - var unreadPrivateMessages: Set { get } - var selectedPrivateChatPeer: PeerID? { get set } - /// Appends a private message via the single-writer store intent; - /// returns `false` on duplicate message ID. - @discardableResult - func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool - /// Removes the peer's chat entirely, including unread state. - func removePrivateChat(_ peerID: PeerID) - func markPrivateChatUnread(_ peerID: PeerID) - func markPrivateChatRead(_ peerID: PeerID) - /// Forgets that read receipts were sent for `ids` so READ acks can be - /// re-sent after the peer reconnects. (Single mutation path for the - /// owner's `sentReadReceipts`; this coordinator never reads the raw set.) - func unmarkReadReceiptsSent(_ ids: [String]) - /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). - func notifyUIChanged() - - // MARK: Inbound message handling - func isMessageBlocked(_ message: BitchatMessage) -> Bool - func handlePrivateMessage(_ message: BitchatMessage) - func handlePublicMessage(_ message: BitchatMessage) - func checkForMentions(_ message: BitchatMessage) - func sendHapticFeedback(for message: BitchatMessage) - func parseMentions(from content: String) -> [String] - - // MARK: Peer identity & sessions - func isPeerBlocked(_ peerID: PeerID) -> Bool - /// The peer's current entry in the unified peer service, if known. - func unifiedPeer(for peerID: PeerID) -> BitchatPeer? - func resolveNickname(for peerID: PeerID) -> String - func registerEphemeralSession(peerID: PeerID) - func removeEphemeralSession(peerID: PeerID) - /// Resolves the peer's Noise static key from the active Noise session, if any. - func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? - func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) - func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? - - // MARK: Routing & acknowledgements - func flushRouterOutbox(for peerID: PeerID) - /// Offer queued mail for *other* peers to this newly connected courier. - func retryCourierDeposits(via peerID: PeerID) - func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) - - // MARK: Delivery status - /// Applies an authenticated receipt to the message only when it belongs - /// to the supplied peer conversation aliases. Returns `false` for an - /// unknown ID, wrong peer, or rejected status transition. - @discardableResult - func applyAcknowledgedMessageDeliveryStatus( - _ messageID: String, - status: DeliveryStatus, - from peerIDAliases: Set - ) -> Bool - func deliveryStatus(for messageID: String) -> DeliveryStatus? - - // MARK: Verification payloads - func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) - func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) - - // MARK: Live voice (push-to-talk) - func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) - - // MARK: Group payloads (creator-signed state over Noise) - func handleGroupInvitePayload(from peerID: PeerID, payload: Data) - func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) - func handleVouchPayload(from peerID: PeerID, payload: Data) -} - -extension ChatViewModel: ChatTransportEventContext { - // `isConnected`, `nickname`, `myPeerID`, `privateMessages(for:)`, - // `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`, - // the inbound message handlers, `isPeerBlocked(_:)`, - // `parseMentions(from:)`, `resolveNickname(for:)`, - // `cacheStablePeerID(_:for:)`, and `cachedStablePeerID(for:)` are shared - // requirements with the other contexts or satisfied by existing - // `ChatViewModel` members. The single-writer intent op - // `unmarkReadReceiptsSent(_:)` lives next to its backing state in - // `ChatViewModel`. The members below flatten nested service accesses into - // intent-named calls. - - func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { - unifiedPeerService.getPeer(by: peerID) - } - - func registerEphemeralSession(peerID: PeerID) { - identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) - } - - func removeEphemeralSession(peerID: PeerID) { - identityManager.removeEphemeralSession(peerID: peerID) - } - - func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { - meshService.noiseSessionPublicKeyData(for: peerID) - } - - func flushRouterOutbox(for peerID: PeerID) { - messageRouter.flushOutbox(for: peerID) - } - - func retryCourierDeposits(via peerID: PeerID) { - messageRouter.courierBecameAvailable(peerID) - } - - func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { - meshService.sendDeliveryAck(for: messageID, to: peerID) - } - - @discardableResult - func applyAcknowledgedMessageDeliveryStatus( - _ messageID: String, - status: DeliveryStatus, - from peerIDAliases: Set - ) -> Bool { - deliveryCoordinator.updateAcknowledgedMessageDeliveryStatus( - messageID, - status: status, - from: peerIDAliases - ) - } - - func deliveryStatus(for messageID: String) -> DeliveryStatus? { - deliveryCoordinator.deliveryStatus(for: messageID) - } - - func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) { - verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload) - } - - func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { - verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) - } - - // `handleVoiceFramePayload(from:payload:timestamp:)` lives in - // ChatViewModel+PrivateChat.swift next to the rest of the live-voice - // surface. - - func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { - groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload) - } - - func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { - groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload) - } - - func handleVouchPayload(from peerID: PeerID, payload: Data) { - vouchCoordinator.handleVouchPayload(from: peerID, payload: payload) - } -} - -final class ChatTransportEventCoordinator { - private unowned let context: any ChatTransportEventContext - - init(context: any ChatTransportEventContext) { - self.context = context - } - - func didReceiveMessage(_ message: BitchatMessage) { - runOnMain { [self] context in - handleReceivedMessage(message, in: context) - } - } - - /// Typed transport events already arrive on the main actor. Handle them - /// synchronously so observers see the ConversationStore mutation before - /// the transport completes delivery. - @MainActor - @discardableResult - func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool { - handleReceivedMessage(message, in: context) - } - - func didReceivePublicMessage( - from peerID: PeerID, - nickname: String, - content: String, - timestamp: Date, - messageID: String? - ) { - runOnMain { [self] context in - handlePublicMessage( - from: peerID, - nickname: nickname, - content: content, - timestamp: timestamp, - messageID: messageID, - in: context - ) - } - } - - @MainActor - func didReceivePublicMessageSynchronously( - from peerID: PeerID, - nickname: String, - content: String, - timestamp: Date, - messageID: String? - ) { - handlePublicMessage( - from: peerID, - nickname: nickname, - content: content, - timestamp: timestamp, - messageID: messageID, - in: context - ) - } - - func didReceiveNoisePayload( - from peerID: PeerID, - type: NoisePayloadType, - payload: Data, - timestamp: Date - ) { - runOnMain { [self] context in - handleNoisePayload( - from: peerID, - type: type, - payload: payload, - timestamp: timestamp, - in: context - ) - } - } - - @MainActor - func didReceiveNoisePayloadSynchronously( - from peerID: PeerID, - type: NoisePayloadType, - payload: Data, - timestamp: Date - ) { - handleNoisePayload( - from: peerID, - type: type, - payload: payload, - timestamp: timestamp, - in: context - ) - } - - func didConnectToPeer(_ peerID: PeerID) { - runOnMain { [weak self] _ in - self?.didConnectToPeerSynchronously(peerID) - } - } - - @MainActor - func didConnectToPeerSynchronously(_ peerID: PeerID) { - SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) - - context.isConnected = true - context.registerEphemeralSession(peerID: peerID) - context.notifyUIChanged() - - if let peer = context.unifiedPeer(for: peerID) { - let stablePeerID = PeerID(hexData: peer.noisePublicKey) - context.cacheStablePeerID(stablePeerID, for: peerID) - } - - context.flushRouterOutbox(for: peerID) - context.retryCourierDeposits(via: peerID) - } - - func didDisconnectFromPeer(_ peerID: PeerID) { - runOnMain { [weak self] _ in - self?.didDisconnectFromPeerSynchronously(peerID) - } - } - - @MainActor - func didDisconnectFromPeerSynchronously(_ peerID: PeerID) { - SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) - - context.removeEphemeralSession(peerID: peerID) - - var stablePeerID = context.cachedStablePeerID(for: peerID) - if stablePeerID == nil, - let key = context.noiseSessionPublicKeyData(for: peerID) { - let derivedPeerID = PeerID(hexData: key) - context.cacheStablePeerID(derivedPeerID, for: peerID) - stablePeerID = derivedPeerID - } - - if let currentPeerID = context.selectedPrivateChatPeer, - currentPeerID == peerID, - let stablePeerID { - migrateSelectedConversationIfNeeded( - from: peerID, - to: stablePeerID, - in: context - ) - } - - let receiptIDs = context.privateMessages(for: peerID) - .filter { $0.senderPeerID == peerID } - .map(\.id) - context.unmarkReadReceiptsSent(receiptIDs) - - context.notifyUIChanged() - } -} - -private extension ChatTransportEventCoordinator { - @MainActor - func handlePublicMessage( - from peerID: PeerID, - nickname: String, - content: String, - timestamp: Date, - messageID: String?, - in context: any ChatTransportEventContext - ) { - let normalized = content.trimmed - let mentions = context.parseMentions(from: normalized) - let message = BitchatMessage( - id: messageID, - sender: nickname, - content: normalized, - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: peerID, - mentions: mentions.isEmpty ? nil : mentions - ) - - context.handlePublicMessage(message) - context.checkForMentions(message) - context.sendHapticFeedback(for: message) - } - - @MainActor - @discardableResult - func handleReceivedMessage( - _ message: BitchatMessage, - in context: any ChatTransportEventContext - ) -> Bool { - guard !context.isMessageBlocked(message) else { return false } - guard !message.content.trimmed.isEmpty || message.isPrivate else { return false } - - if message.isPrivate { - context.handlePrivateMessage(message) - } else { - context.handlePublicMessage(message) - } - - context.checkForMentions(message) - context.sendHapticFeedback(for: message) - return true - } - - func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) { - Task { @MainActor [weak context = self.context] in - guard let context else { return } - action(context) - } - } - - @MainActor - func migrateSelectedConversationIfNeeded( - from shortPeerID: PeerID, - to stablePeerID: PeerID, - in context: any ChatTransportEventContext - ) { - let hadUnread = context.unreadPrivateMessages.contains(shortPeerID) - - let shortPeerMessages = context.privateMessages(for: shortPeerID) - if !shortPeerMessages.isEmpty { - for message in shortPeerMessages { - // Rewrite senderPeerID to the stable key so read receipts - // keep working; store append dedups by ID and keeps order. - let migrated = BitchatMessage( - id: message.id, - sender: message.sender, - content: message.content, - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: message.isPrivate, - recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID == context.myPeerID - ? context.myPeerID - : stablePeerID, - mentions: message.mentions, - deliveryStatus: message.deliveryStatus - ) - context.appendPrivateMessage(migrated, to: stablePeerID) - } - - context.removePrivateChat(shortPeerID) - } - - if hadUnread { - context.markPrivateChatRead(shortPeerID) - context.markPrivateChatUnread(stablePeerID) - } - - context.selectedPrivateChatPeer = stablePeerID - } - - @MainActor - func handleNoisePayload( - from peerID: PeerID, - type: NoisePayloadType, - payload: Data, - timestamp: Date, - in context: any ChatTransportEventContext - ) { - switch type { - case .privateMessage: - guard let packet = PrivateMessagePacket.decode(from: payload) else { return } - - guard !context.isPeerBlocked(peerID) else { - SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security) - return - } - - let senderName = context.unifiedPeer(for: peerID)?.nickname ?? "Unknown" - let mentions = context.parseMentions(from: packet.content) - let message = BitchatMessage( - id: packet.messageID, - sender: senderName, - content: packet.content, - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: context.nickname, - senderPeerID: peerID, - mentions: mentions.isEmpty ? nil : mentions - ) - context.handlePrivateMessage(message) - context.sendMeshDeliveryAck(for: packet.messageID, to: peerID) - - case .delivered: - guard let messageID = String(data: payload, encoding: .utf8) else { return } - - let name = deliveryStatusName(for: peerID, in: context) - let didUpdate = context.applyAcknowledgedMessageDeliveryStatus( - messageID, - status: .delivered(to: name, at: Date()), - from: receiptPeerAliases(for: peerID, in: context) - ) - - if !didUpdate { - if case .read? = context.deliveryStatus(for: messageID) { - SecureLogger.debug("📬 Ignored stale delivered ACK for already-read message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session) - } else { - SecureLogger.debug("📬 Delivered ACK for unknown message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session) - } - } - - case .readReceipt: - guard let messageID = String(data: payload, encoding: .utf8) else { return } - - let name = deliveryStatusName(for: peerID, in: context) - let didUpdate = context.applyAcknowledgedMessageDeliveryStatus( - messageID, - status: .read(by: name, at: Date()), - from: receiptPeerAliases(for: peerID, in: context) - ) - - if !didUpdate { - SecureLogger.debug("📖 Read receipt for unknown message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session) - } - - case .verifyChallenge: - context.handleVerifyChallengePayload(from: peerID, payload: payload) - - case .verifyResponse: - context.handleVerifyResponsePayload(from: peerID, payload: payload) - - case .groupInvite: - context.handleGroupInvitePayload(from: peerID, payload: payload) - - case .groupKeyUpdate: - context.handleGroupKeyUpdatePayload(from: peerID, payload: payload) - - case .vouch: - context.handleVouchPayload(from: peerID, payload: payload) - - case .voiceFrame: - context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp) - - case .privateFile, .authenticatedPeerState: - // BLEService validates and persists decrypted private files before - // emitting a normal `.messageReceived` event, and consumes peer - // state inside the transport. Neither payload crosses this - // UI-facing typed-payload fallback. - break - } - } - - @MainActor - func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String { - context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID) - } - - @MainActor - func receiptPeerAliases( - for peerID: PeerID, - in context: any ChatTransportEventContext - ) -> Set { - var aliases: Set = [peerID] - // The active authenticated Noise key is authoritative. A cached - // ephemeral→stable mapping can predate an identity replacement, so - // use it only when the live session cannot provide its static key. - if let keyData = context.noiseSessionPublicKeyData(for: peerID) { - aliases.insert(PeerID(hexData: keyData)) - } else if let stablePeerID = context.cachedStablePeerID(for: peerID) { - aliases.insert(stablePeerID) - } - return aliases - } -} diff --git a/bitchat/ViewModels/ChatUnreadStateResolver.swift b/bitchat/ViewModels/ChatUnreadStateResolver.swift deleted file mode 100644 index 378f6f2f..00000000 --- a/bitchat/ViewModels/ChatUnreadStateResolver.swift +++ /dev/null @@ -1,43 +0,0 @@ -import BitFoundation -import Foundation - -struct ChatUnreadPeerContext { - let peerID: PeerID - let noiseKeyPeerID: PeerID? - let nostrPeerID: PeerID? - let nickname: String? -} - -enum ChatUnreadStateResolver { - static func hasUnreadMessages( - for context: ChatUnreadPeerContext, - unreadPrivateMessages: Set, - privateChats: [PeerID: [BitchatMessage]] - ) -> Bool { - if unreadPrivateMessages.contains(context.peerID) { - return true - } - - if let noiseKeyPeerID = context.noiseKeyPeerID, - unreadPrivateMessages.contains(noiseKeyPeerID) { - return true - } - - if let nostrPeerID = context.nostrPeerID, - unreadPrivateMessages.contains(nostrPeerID) { - return true - } - - guard let peerNickname = context.nickname?.lowercased(), !peerNickname.isEmpty else { - return false - } - - return unreadPrivateMessages.contains { unreadPeerID in - guard unreadPeerID.isGeoDM, - let firstMessage = privateChats[unreadPeerID]?.first else { - return false - } - return firstMessage.sender.lowercased() == peerNickname - } - } -} diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift deleted file mode 100644 index 42b39a94..00000000 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ /dev/null @@ -1,419 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation -import Security - -/// The narrow surface `ChatVerificationCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatVerificationCoordinatorContextTests`) and makes its true -/// dependencies explicit. -@MainActor -protocol ChatVerificationContext: AnyObject { - // MARK: Fingerprints & verification state - func getFingerprint(for peerID: PeerID) -> String? - /// The UI-facing verified-fingerprint set (peer identity store backed). - var verifiedFingerprints: Set { get set } - /// The persisted verified-fingerprint set from the identity manager. - func persistedVerifiedFingerprints() -> Set - /// Persists the verified flag in the identity manager. - func setIdentityVerified(fingerprint: String, verified: Bool) - /// Updates the UI-facing verified flag in the peer identity store. - func setStoredVerified(_ fingerprint: String, verified: Bool) - func isVerifiedFingerprint(_ fingerprint: String) -> Bool - func saveIdentityState() - /// After a fingerprint becomes verified, run a transitive-vouch pass over - /// currently connected peers (so verifying a peer you're already connected - /// to sends vouches immediately, and the new identity propagates onward). - func vouchToConnectedVerifiedPeers() - - // MARK: Encryption status - func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) - func updateEncryptionStatus(for peerID: PeerID) - func invalidateEncryptionCache(for peerID: PeerID?) - /// Signals that verification state changed so observers refresh (e.g. `objectWillChange.send()`). - func notifyUIChanged() - - // MARK: Peers - var unifiedPeers: [BitchatPeer] { get } - var unifiedFavorites: [BitchatPeer] { get } - /// The peer's current entry in the unified peer service, if known. - func unifiedPeer(for peerID: PeerID) -> BitchatPeer? - func unifiedFingerprint(for peerID: PeerID) -> String? - func resolveNickname(for peerID: PeerID) -> String - func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? - func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) - - // MARK: Noise sessions & verification transport - /// Installs the Noise service's session callbacks (single registration point). - func installNoiseSessionCallbacks( - onPeerAuthenticated: @escaping (PeerID, String) -> Void, - onHandshakeRequired: @escaping (PeerID) -> Void - ) - /// Resolves the peer's Noise static key from the active Noise session, if any. - func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? - /// Our own Noise static public key. - func noiseStaticPublicKeyData() -> Data - func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool - func triggerHandshake(with peerID: PeerID) - func privateMediaPeerDidAuthenticate(_ peerID: PeerID) - /// Retries only private messages previously transmitted through a secure - /// session and still pending an ack. Both ephemeral and stable aliases - /// are supplied because either can own the outbox entry. - func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) - func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) - func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) - - // MARK: Notifications (shared with `ChatNostrContext`) - /// Posts a generic local user notification. - func postLocalNotification(title: String, body: String, identifier: String) -} - -extension ChatViewModel: ChatVerificationContext { - // `getFingerprint(for:)`, `verifiedFingerprints`, `saveIdentityState()`, - // `updateEncryptionStatus(for:)`, `invalidateEncryptionCache(for:)`, - // `notifyUIChanged()`, `unifiedPeer(for:)`, `unifiedFingerprint(for:)`, - // `isVerifiedFingerprint(_:)`, `setEncryptionStatus(_:for:)`, - // `resolveNickname(for:)`, `cachedStablePeerID(for:)`, - // `cacheStablePeerID(_:for:)`, `noiseSessionPublicKeyData(for:)`, - // `hasEstablishedNoiseSession(with:)`, and `triggerHandshake(with:)` are - // shared requirements with the other contexts or satisfied by existing - // `ChatViewModel` members. The members below flatten nested service - // accesses into intent-named calls. - - func persistedVerifiedFingerprints() -> Set { - identityManager.getVerifiedFingerprints() - } - - func setIdentityVerified(fingerprint: String, verified: Bool) { - identityManager.setVerified(fingerprint: fingerprint, verified: verified) - } - - func setStoredVerified(_ fingerprint: String, verified: Bool) { - peerIdentityStore.setVerified(fingerprint, verified: verified) - } - - func vouchToConnectedVerifiedPeers() { - vouchCoordinator.vouchToConnectedVerifiedPeers() - } - - var unifiedPeers: [BitchatPeer] { - unifiedPeerService.peers - } - - var unifiedFavorites: [BitchatPeer] { - unifiedPeerService.favorites - } - - func installNoiseSessionCallbacks( - onPeerAuthenticated: @escaping (PeerID, String) -> Void, - onHandshakeRequired: @escaping (PeerID) -> Void - ) { - meshService.installNoiseSessionCallbacks( - onPeerAuthenticated: onPeerAuthenticated, - onHandshakeRequired: onHandshakeRequired - ) - } - - func noiseStaticPublicKeyData() -> Data { - meshService.noiseStaticPublicKeyData() - } - - func privateMediaPeerDidAuthenticate(_ peerID: PeerID) { - mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort()) - } - - func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) { - messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases) - } - - /// QR verification rides the mesh's Noise sessions only. - private var verifyTransport: MeshVerifying? { meshService as? MeshVerifying } - - func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { - verifyTransport?.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) - } - - func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { - verifyTransport?.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) - } - - func postLocalNotification(title: String, body: String, identifier: String) { - NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: identifier) - } -} - -extension ChatVerificationContext { - func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {} -} - -@MainActor -final class ChatVerificationCoordinator { - struct PendingVerification { - let noiseKeyHex: String - let signKeyHex: String - let nonceA: Data - var sent: Bool - } - - private unowned let context: any ChatVerificationContext - private var pendingQRVerifications: [PeerID: PendingVerification] = [:] - private var lastVerifyNonceByPeer: [PeerID: Data] = [:] - private var lastInboundVerifyChallengeAt: [String: Date] = [:] - private var lastMutualToastAt: [String: Date] = [:] - - init(context: any ChatVerificationContext) { - self.context = context - } - - func verifyFingerprint(for peerID: PeerID) { - guard let fingerprint = context.getFingerprint(for: peerID) else { return } - - context.setIdentityVerified(fingerprint: fingerprint, verified: true) - context.saveIdentityState() - context.setStoredVerified(fingerprint, verified: true) - context.updateEncryptionStatus(for: peerID) - // Verifying a peer is a vouch trigger: push attestations to my other - // connected verified peers (and to this one if already connected). - context.vouchToConnectedVerifiedPeers() - } - - func unverifyFingerprint(for peerID: PeerID) { - guard let fingerprint = context.getFingerprint(for: peerID) else { return } - context.setIdentityVerified(fingerprint: fingerprint, verified: false) - context.saveIdentityState() - context.setStoredVerified(fingerprint, verified: false) - context.updateEncryptionStatus(for: peerID) - } - - func loadVerifiedFingerprints() { - context.verifiedFingerprints = context.persistedVerifiedFingerprints() - let sample = Array(context.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)) - .map { $0.prefix(8) } - .joined(separator: ", ") - SecureLogger.info("🔐 Verified loaded: \(context.verifiedFingerprints.count) [\(sample)]", category: .security) - - let offlineFavorites = context.unifiedFavorites.filter { !$0.isConnected } - for favorite in offlineFavorites { - let fingerprint = context.unifiedFingerprint(for: favorite.peerID) - let isVerified = fingerprint.flatMap { context.isVerifiedFingerprint($0) } ?? false - let shortFingerprint = fingerprint?.prefix(8) ?? "nil" - SecureLogger.info( - "⭐️ Favorite offline: \(favorite.nickname) fp=\(shortFingerprint) verified=\(isVerified)", - category: .security - ) - } - - context.invalidateEncryptionCache(for: nil) - context.notifyUIChanged() - } - - func setupNoiseCallbacks() { - context.installNoiseSessionCallbacks( - onPeerAuthenticated: { [weak self] peerID, fingerprint in - DispatchQueue.main.async { [weak self] in - guard let self else { return } - - SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) - self.context.privateMediaPeerDidAuthenticate(peerID) - - if self.context.isVerifiedFingerprint(fingerprint) { - self.context.setEncryptionStatus(.noiseVerified, for: peerID) - } else { - self.context.setEncryptionStatus(.noiseSecured, for: peerID) - } - - self.context.invalidateEncryptionCache(for: peerID) - - var authenticatedStablePeerID: PeerID? - if let keyData = self.context.noiseSessionPublicKeyData(for: peerID) { - let stablePeerID = PeerID(hexData: keyData) - authenticatedStablePeerID = stablePeerID - if self.context.cachedStablePeerID(for: peerID) != stablePeerID { - // The freshly authenticated Noise key outranks a - // stale announce-derived alias. - self.context.cacheStablePeerID(stablePeerID, for: peerID) - } - SecureLogger.debug( - "🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…", - category: .session - ) - } - - // A locally established session may have belonged to the - // peer's previous app process. The first ciphertext sent - // into that stale session is retained by MessageRouter; - // retry it now that this newly authenticated/replacement - // session can actually decrypt it. - var peerIDAliases = [peerID] - if let stablePeerID = authenticatedStablePeerID - ?? self.context.cachedStablePeerID(for: peerID), - stablePeerID != peerID { - // Conversations can migrate from the ephemeral BLE ID - // to the authenticated Noise-key ID. Retry both aliases - // because either may own the retained outbox entry. - peerIDAliases.append(stablePeerID) - } - self.context.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases) - - if var pending = self.pendingQRVerifications[peerID], pending.sent == false { - self.context.sendVerifyChallenge( - to: peerID, - noiseKeyHex: pending.noiseKeyHex, - nonceA: pending.nonceA - ) - pending.sent = true - self.pendingQRVerifications[peerID] = pending - SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security) - } - } - }, - onHandshakeRequired: { [weak self] peerID in - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.context.setEncryptionStatus(.noiseHandshaking, for: peerID) - self.context.invalidateEncryptionCache(for: peerID) - } - } - ) - } - - func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { - let targetNoise = qr.noiseKeyHex.lowercased() - guard let peer = context.unifiedPeers.first(where: { - $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise - }) else { - return false - } - - let peerID = peer.peerID - if pendingQRVerifications[peerID] != nil { - return true - } - - var nonce = Data(count: 16) - let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } - guard status == errSecSuccess else { return false } - var pending = PendingVerification( - noiseKeyHex: qr.noiseKeyHex, - signKeyHex: qr.signKeyHex, - nonceA: nonce, - sent: false - ) - pendingQRVerifications[peerID] = pending - - if context.hasEstablishedNoiseSession(with: peerID) { - context.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) - pending.sent = true - pendingQRVerifications[peerID] = pending - } else { - context.triggerHandshake(with: peerID) - } - - return true - } - - func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) { - guard let challenge = VerificationService.shared.parseVerifyChallenge(payload) else { return } - - let myNoiseHex = context.noiseStaticPublicKeyData() - .hexEncodedString() - .lowercased() - guard challenge.noiseKeyHex.lowercased() == myNoiseHex else { return } - guard lastVerifyNonceByPeer[peerID] != challenge.nonceA else { return } - - lastVerifyNonceByPeer[peerID] = challenge.nonceA - - if let fingerprint = context.getFingerprint(for: peerID) { - lastInboundVerifyChallengeAt[fingerprint] = Date() - - if context.isVerifiedFingerprint(fingerprint) { - maybeSendMutualVerificationNotification( - fingerprint: fingerprint, - peerID: peerID, - title: "Mutual verification", - bodyName: context.unifiedPeer(for: peerID)?.nickname - ?? context.resolveNickname(for: peerID), - notificationPrefix: "verify-mutual" - ) - } - } - - context.sendVerifyResponse( - to: peerID, - noiseKeyHex: challenge.noiseKeyHex, - nonceA: challenge.nonceA - ) - } - - func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { - guard let response = VerificationService.shared.parseVerifyResponse(payload), - let pending = pendingQRVerifications[peerID], - response.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), - response.nonceA == pending.nonceA else { return } - - let isValid = VerificationService.shared.verifyResponseSignature( - noiseKeyHex: response.noiseKeyHex, - nonceA: response.nonceA, - signature: response.signature, - signerPublicKeyHex: pending.signKeyHex - ) - guard isValid else { return } - - pendingQRVerifications.removeValue(forKey: peerID) - - guard let fingerprint = context.getFingerprint(for: peerID) else { return } - - let shortFingerprint = fingerprint.prefix(8) - SecureLogger.info("🔐 Marking verified fingerprint: \(shortFingerprint)", category: .security) - context.setIdentityVerified(fingerprint: fingerprint, verified: true) - context.saveIdentityState() - context.setStoredVerified(fingerprint, verified: true) - - let peerName = context.unifiedPeer(for: peerID)?.nickname - ?? context.resolveNickname(for: peerID) - context.postLocalNotification( - title: "Verified", - body: "You verified \(peerName)", - identifier: "verify-success-\(peerID)-\(UUID().uuidString)" - ) - - if let challengeTime = lastInboundVerifyChallengeAt[fingerprint], - Date().timeIntervalSince(challengeTime) < 600 { - maybeSendMutualVerificationNotification( - fingerprint: fingerprint, - peerID: peerID, - title: "Mutual verification", - bodyName: peerName, - notificationPrefix: "verify-mutual" - ) - } - - context.updateEncryptionStatus(for: peerID) - // QR verification just completed — same vouch trigger as manual verify. - context.vouchToConnectedVerifiedPeers() - } -} - -private extension ChatVerificationCoordinator { - func maybeSendMutualVerificationNotification( - fingerprint: String, - peerID: PeerID, - title: String, - bodyName: String, - notificationPrefix: String - ) { - let now = Date() - let lastToast = lastMutualToastAt[fingerprint] ?? .distantPast - guard now.timeIntervalSince(lastToast) > 60 else { return } - - lastMutualToastAt[fingerprint] = now - context.postLocalNotification( - title: title, - body: "You and \(bodyName) verified each other", - identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)" - ) - } -} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 63fba0f8..a6f5b23c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -84,45 +84,16 @@ import SwiftUI import Combine import CommonCrypto import CoreBluetooth +import Tor #if os(iOS) import UIKit #endif import UniformTypeIdentifiers -struct PanicNetworkLifecycle { - let stop: @MainActor () -> Void - let restart: @MainActor () -> Void - - static let noop = PanicNetworkLifecycle(stop: {}, restart: {}) - - static var live: PanicNetworkLifecycle { - PanicNetworkLifecycle( - stop: { - GeohashPresenceService.shared.stopForPanic() - NetworkActivationService.shared.stopForPanic() - }, - restart: { - NetworkActivationService.shared.start() - GeohashPresenceService.shared.start() - } - ) - } -} - -private struct PendingPrivateChatClear { - let peerID: PeerID - let sourceConversationID: ConversationID - let messages: [BitchatMessage] - let otherMessageIDs: Set - let localPeerID: PeerID - let nickname: String - let outgoingMedia: [BitchatMessage] -} - /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. -final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext { +final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext { // Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled) typealias Patterns = MessageFormattingEngine.Patterns @@ -131,9 +102,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage @MainActor var canSendMediaInCurrentContext: Bool { if let peer = selectedPrivateChatPeer { - // Media transfer is not wired for groups in v1 (sendFilePrivate - // rejects the virtual group_ recipient), so keep the affordance off. - return !(peer.isGeoDM || peer.isGeoChat || peer.isGroup) + return !(peer.isGeoDM || peer.isGeoChat) } switch activeChannel { case .mesh: return true @@ -141,56 +110,54 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage } } - var publicRateLimiter = MessageRateLimiter( + private var publicRateLimiter = MessageRateLimiter( senderCapacity: TransportConfig.uiSenderRateBucketCapacity, senderRefillPerSec: TransportConfig.uiSenderRateBucketRefillPerSec, contentCapacity: TransportConfig.uiContentRateBucketCapacity, contentRefillPerSec: TransportConfig.uiContentRateBucketRefillPerSec ) - // MARK: - Published Properties - - /// Read-only derived view of the ACTIVE public channel's conversation in - /// the single-writer `ConversationStore`. SwiftUI renders through - /// `PublicChatModel` (which observes the `Conversation` object directly); - /// this view serves the coordinators/commands that need "the visible - /// timeline" plus tests. Hot enough that the array is cached and - /// invalidated from the store's `changes` subject (filtered to the - /// active conversation) and on channel switches. `objectWillChange` - /// fires on every store change via the sink in `init`. @MainActor - var messages: [BitchatMessage] { - if let cached = visibleMessagesCache { return cached } - // Read-only lookup (never creates the conversation): this getter - // runs during SwiftUI renders, where mutating the store's - // `@Published` collections would publish mid-view-update. - let current = conversations.conversationsByID[ConversationID(channelID: activeChannel)]?.messages ?? [] - visibleMessagesCache = current - return current + private func normalizedSenderKey(for message: BitchatMessage) -> String { + if let spid = message.senderPeerID { + if spid.isGeoChat || spid.isGeoDM { + let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased() + return "nostr:" + full + } else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.id.lowercased() { + return "noise:" + full + } else { + return "mesh:" + spid.id.lowercased() + } + } + return "name:" + message.sender.lowercased() } - private var visibleMessagesCache: [BitchatMessage]? + + // MARK: - Published Properties + + @Published var messages: [BitchatMessage] = [] @Published var currentColorScheme: ColorScheme = .light - @Published var currentTheme: AppTheme = .matrix + private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed @Published var isConnected = false - @Published private(set) var panicRecoveryBlocked = false - var networkActivationAllowed: Bool { !panicRecoveryBlocked } + private var recentlySeenPeers: Set = [] + private var lastNetworkNotificationTime = Date.distantPast + private var networkResetTimer: Timer? = nil + private var networkEmptyTimer: Timer? = nil + private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects @Published var nickname: String = "" { didSet { - // Canonicalize whenever nickname is set: trim whitespace - // (whitespace-only becomes "") and apply Unicode NFC so accented - // names match regardless of how they were typed. - let cleaned = (nickname.trimmedOrNilIfEmpty ?? "").normalizedNickname - if cleaned != nickname { - nickname = cleaned + // Trim whitespace whenever nickname is set; whitespace-only becomes "" + let trimmed = nickname.trimmedOrNilIfEmpty ?? "" + if trimmed != nickname { + nickname = trimmed return } // Update mesh service nickname if it's initialized - if !isPanicResetting, !meshService.myPeerID.isEmpty { + if !meshService.myPeerID.isEmpty { meshService.setNickname(nickname) } } } - + // MARK: - Service Delegates let commandProcessor: CommandProcessor @@ -199,51 +166,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage let unifiedPeerService: UnifiedPeerService let autocompleteService: AutocompleteService let deduplicationService: MessageDeduplicationService // internal for test access - private lazy var outgoingCoordinator = ChatOutgoingCoordinator(context: self) - private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self) - private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self) - private lazy var peerListCoordinator = ChatPeerListCoordinator(context: self) - private lazy var messageFormatter = ChatMessageFormatter(viewModel: self) - lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self) - lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self) - lazy var composerCoordinator = ChatComposerCoordinator(context: self) - lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self) - lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self) - lazy var nostrCoordinator = ChatNostrCoordinator(context: self) - lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) - lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator( - context: self, - sweepsOnInit: !TestEnvironment.isRunningTests - ) - lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) - lazy var groupCoordinator = ChatGroupCoordinator(context: self) - lazy var vouchCoordinator = ChatVouchCoordinator(context: self) - + // Computed properties for compatibility @MainActor var connectedPeers: Set { unifiedPeerService.connectedPeerIDs } @Published var allPeers: [BitchatPeer] = [] - /// Nickname of whoever is talking live in the public mesh channel right - /// now (floor-courtesy indicator on the composer mic), nil when nobody. - @Published var activePublicVoiceTalker: String? - - /// Read-only derived view of all direct conversations in the - /// `ConversationStore`, keyed by routing peer ID. Serves the coordinator - /// reads that genuinely need the whole dictionary (migration scans, - /// unread resolution); simple per-peer reads go through - /// `privateMessages(for:)` instead. All mutations go through the - /// private-chat intent ops below. Rebuilt per access — - /// O(#conversations) thanks to COW message arrays; measured equal to a - /// change-invalidated cache on `pipeline.privateIngest`, so the simpler - /// form wins. - @MainActor var privateChats: [PeerID: [BitchatMessage]] { - conversations.directMessagesByRoutingPeerID() + get { privateChatManager.privateChats } + set { privateChatManager.privateChats = newValue } } - @MainActor var selectedPrivateChatPeer: PeerID? { get { privateChatManager.selectedPeer } - set { + set { if let peerID = newValue { privateChatManager.startChat(with: peerID) } else { @@ -251,28 +185,55 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage } } } - /// Read-only derived view of the store's unread direct conversations. - /// Mutate via `markPrivateChatUnread(_:)` / `markPrivateChatRead(_:)`. - @MainActor var unreadPrivateMessages: Set { - conversations.unreadDirectRoutingPeerIDs() + get { privateChatManager.unreadMessages } + set { privateChatManager.unreadMessages = newValue } + } + + /// Check if there are any unread messages (including from temporary Nostr peer IDs) + var hasAnyUnreadMessages: Bool { + !unreadPrivateMessages.isEmpty } /// Open the most relevant private chat when tapping the toolbar unread icon. /// Prefers the most recently active unread conversation, otherwise the most recent PM. @MainActor func openMostRelevantPrivateChat() { - peerIdentityCoordinator.openMostRelevantPrivateChat() + // Pick most recent unread by last message timestamp + let unreadSorted = unreadPrivateMessages + .map { ($0, privateChats[$0]?.last?.timestamp ?? Date.distantPast) } + .sorted { $0.1 > $1.1 } + if let target = unreadSorted.first?.0 { + startPrivateChat(with: target) + return + } + // Otherwise pick most recent private chat overall + let recent = privateChats + .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) } + .sorted { $0.ts > $1.ts } + if let target = recent.first?.id { + startPrivateChat(with: target) + } } - + // - var peerIDToPublicKeyFingerprint: [PeerID: String] { - get { peerIdentityStore.peerFingerprintsByPeerID } - set { peerIdentityStore.replaceFingerprintMappings(newValue) } - } - var selectedPrivateChatFingerprint: String? { - get { peerIdentityStore.selectedPrivateChatFingerprint } - set { peerIdentityStore.setSelectedPrivateChatFingerprint(newValue) } + var peerIDToPublicKeyFingerprint: [PeerID: String] = [:] + private var selectedPrivateChatFingerprint: String? = nil + // Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity + private var shortIDToNoiseKey: [PeerID: PeerID] = [:] + + // Resolve full Noise key for a peer's short ID (used by UI header rendering) + @MainActor + private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? { + if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped } + // Fallback: derive from active Noise session if available + if shortPeerID.id.count == 16, + let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) { + let stable = PeerID(hexData: key) + shortIDToNoiseKey[shortPeerID] = stable + return stable + } + return nil } // Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex) @@ -284,874 +245,163 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage return match.peerID } // Also search cache mapping - if let shortPeerID = peerIdentityStore.shortPeerID(forStablePeerID: fullNoiseKeyHex) { - return shortPeerID + if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) { + return pair.key } return fullNoiseKeyHex } - - @MainActor - func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) { - peerIdentityStore.setStablePeerID(stablePeerID, forShortID: shortPeerID) - } - - @MainActor - func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { - peerIdentityStore.stablePeerID(forShortID: shortPeerID) - } - - var hasTrackedPrivateChatSelection: Bool { - selectedPrivateChatFingerprint != nil - } - - var peerIndex: [PeerID: BitchatPeer] = [:] - + private var peerIndex: [PeerID: BitchatPeer] = [:] + // MARK: - Autocomplete Properties - + @Published var autocompleteSuggestions: [String] = [] @Published var showAutocomplete: Bool = false @Published var autocompleteRange: NSRange? = nil @Published var selectedAutocompleteIndex: Int = 0 - + + // Temporary property to fix compilation + @Published var showPasswordPrompt = false + // MARK: - Services and Storage - + let meshService: Transport let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol - /// Single source of truth for conversation message state and selection - /// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed - /// through. - let conversations: ConversationStore - let peerIdentityStore: PeerIdentityStore - let locationPresenceStore: LocationPresenceStore - let locationManager: LocationChannelManager - + var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol - private let panicRecoveryOperations: PanicRecoveryOperations - private let panicNetworkLifecycle: PanicNetworkLifecycle - private var isPanicResetting = false - /// Private group membership: keys in the keychain, metadata on disk. - let groupStore: GroupStore private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) - var activeChannel: ChannelID { - get { conversations.activeChannel } - set { - guard conversations.activeChannel != newValue else { return } - // Leaving a channel expedites any in-flight NIP-13 mining: the - // pending message still sends, at the difficulty already reached. - outgoingCoordinator.expeditePendingGeohashMining() - conversations.setActiveChannel(newValue) - visibleMessagesCache = nil - objectWillChange.send() - } - } - // Single-writer: mutate only via `setGeoChatSubscriptionID(_:)` / `setGeoDmSubscriptionID(_:)` below. - private(set) var geoSubscriptionID: String? = nil - private(set) var geoDmSubscriptionID: String? = nil - var currentGeohash: String? { - get { locationPresenceStore.currentGeohash } - set { locationPresenceStore.setCurrentGeohash(newValue) } - } + @Published var activeChannel: ChannelID = .mesh + var geoSubscriptionID: String? = nil + var geoDmSubscriptionID: String? = nil + var currentGeohash: String? = nil var cachedGeohashIdentity: (geohash: String, identity: NostrIdentity)? = nil // Cache current geohash identity - var geoNicknames: [String: String] { - get { locationPresenceStore.geoNicknames } - set { locationPresenceStore.replaceGeoNicknames(newValue) } - } // pubkeyHex(lowercased) -> nickname + var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname // Show Tor status once per app launch var torStatusAnnounced = false // Track whether a Tor restart is pending so we only announce // "tor restarted" after an actual restart, not the first launch. var torRestartPending: Bool = false - // Announce a stalled bootstrap once per attempt, not once per poll. - var torStallAnnounced: Bool = false - // Live "tor is blocked" state for the connectivity banner. The system - // message above only reaches geohash timelines; this is the chrome-level - // signal that stays up until tor actually gets through. - @Published var torBlocked: Bool = false // Ensure we set up DM subscription only once per app session var nostrHandlersSetup: Bool = false var geoChannelCoordinator: GeoChannelCoordinator? - + // MARK: - Caches - + + // Caches for expensive computations + private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:] + // MARK: - Social Features (Delegated to PeerStateManager) - + + @MainActor + var favoritePeers: Set { unifiedPeerService.favoritePeers } @MainActor var blockedUsers: Set { unifiedPeerService.blockedUsers } - + // MARK: - Encryption and Security - - var verifiedFingerprints: Set { - get { peerIdentityStore.verifiedFingerprints } - set { peerIdentityStore.setVerifiedFingerprints(newValue) } - } // Set of verified fingerprints - + + // Noise Protocol encryption status + @Published var peerEncryptionStatus: [PeerID: EncryptionStatus] = [:] + @Published var verifiedFingerprints: Set = [] // Set of verified fingerprints + @Published var showingFingerprintFor: PeerID? = nil // Currently showing fingerprint sheet for peer + // Bluetooth state management @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown - @Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? - @MainActor private var queuedPrivateChatClears: [ - PendingPrivateChatClear - ] = [] - @MainActor private var privateChatClearInFlight = false - @MainActor private var privateChatClearGeneration: UInt64 = 0 - private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = [] - private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) { - if Thread.isMainThread { - MainActor.assumeIsolated { - update(deliveryCoordinator) - } - return - } - - Task { @MainActor [weak self] in - guard let self else { return } - update(self.deliveryCoordinator) - } - } + // Presentation state for privacy gating + @Published var isLocationChannelsSheetPresented: Bool = false + @Published var isAppInfoPresented: Bool = false + @Published var showScreenshotPrivacyWarning: Bool = false + + var timelineStore = PublicTimelineStore( + meshCap: TransportConfig.meshTimelineCap, + geohashCap: TransportConfig.geoTimelineCap + ) // Channel activity tracking for background nudges var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time // Geohash participant tracker let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds) // Participants who indicated they teleported (by tag in their events) - var teleportedGeo: Set { - get { locationPresenceStore.teleportedGeo } - set { locationPresenceStore.replaceTeleportedGeo(newValue) } - } // lowercased pubkey hex + @Published var teleportedGeo: Set = [] // lowercased pubkey hex // Sampling subscriptions for multiple geohashes (when channel sheet is open) - // Single-writer: mutate only via `addGeoSamplingSub` / `removeGeoSamplingSub` / `clearGeoSamplingSubs` below. - private(set) var geoSamplingSubs: [String: String] = [:] // subID -> geohash + var geoSamplingSubs: [String: String] = [:] // subID -> geohash var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time - + + // MARK: - Message Delivery Tracking - + + // Delivery tracking var cancellables = Set() + var transferIdToMessageIDs: [String: [String]] = [:] + var messageIDToTransferId: [String: String] = [:] - var transferIdToMessageIDs: [String: [String]] { - mediaTransferCoordinator.transferIdToMessageIDs - } - - var messageIDToTransferId: [String: String] { - mediaTransferCoordinator.messageIDToTransferId + // MARK: - QR Verification (pending state) + private struct PendingVerification { + let noiseKeyHex: String + let signKeyHex: String + let nonceA: Data + let startedAt: Date + var sent: Bool } + private var pendingQRVerifications: [PeerID: PendingVerification] = [:] + // Last handled challenge nonce per peer to avoid duplicate responses + private var lastVerifyNonceByPeer: [PeerID: Data] = [:] + // Track when we last received a verify challenge from a peer (fingerprint-keyed) + private var lastInboundVerifyChallengeAt: [String: Date] = [:] // key: fingerprint + // Throttle mutual verification toasts per fingerprint + private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint // MARK: - Public message batching (UI perf) let publicMessagePipeline: PublicMessagePipeline - // Single-writer: mutate only via `setPublicBatching(_:)` below. @Published private(set) var isBatchingPublic: Bool = false - - // Backing store for `sentReadReceipts` persistence. `.standard` in - // production; injectable so tests can use a scratch suite that does not - // leak state between runs. - let readReceiptsDefaults: UserDefaults - - /// Default read-receipt persistence store. Production uses `.standard`. - /// Under test, every instance gets its own scratch suite: a per-process - /// shared suite let one test's persisted receipts leak into another - /// test's freshly constructed view model (surfaced as an order-dependent - /// CI flake on a duplicated message ID), and tests never pollute - /// `.standard`. - static func defaultReadReceiptsDefaults() -> UserDefaults { - guard TestEnvironment.isRunningTests else { return .standard } - let suiteName = "chat.bitchat.tests.readReceipts.\(UUID().uuidString)" - guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard } - scratch.removePersistentDomain(forName: suiteName) - return scratch - } - + // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed var sentReadReceipts: Set = [] { // messageID set didSet { // Only persist if there are changes guard oldValue != sentReadReceipts else { return } - - // Persist whenever it changes (no manual synchronize/verify re-read) + + // Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read) if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) { - readReceiptsDefaults.set(data, forKey: "sentReadReceipts") + UserDefaults.standard.set(data, forKey: "sentReadReceipts") } else { SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session) } } } + // Throttle verification response toasts per peer to avoid spam + var lastVerifyToastAt: [String: Date] = [:] + // Track which GeoDM messages we've already sent a delivery ACK for (by messageID) - // Single-writer: mutate only via `markGeoDeliveryAckSent(_:)` below. - private(set) var sentGeoDeliveryAcks: Set = [] - + var sentGeoDeliveryAcks: Set = [] + // Track app startup phase to prevent marking old messages as unread - var isStartupPhase = true - - // ConversationStore field audit bookkeeping (see auditConversationStore()): - // runs on the read-receipt cleanup cadence, heartbeat sampled first + - // every `TransportConfig.conversationStoreAuditLogInterval`th audit. - private var storeAuditCount = 0 - private var storeAuditLastAppendCount = 0 + private var isStartupPhase = true // Announce Tor initial readiness once per launch to avoid duplicates var torInitialReadyAnnounced: Bool = false - + // Track Nostr pubkey mappings for unknown senders - // Single-writer: mutate only via `registerNostrKeyMapping` / `removeNostrKeyMappings` below. - private(set) var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey - - // MARK: - Single-Writer Intent Operations - // Owner-side mutation paths for state the coordinator contexts may read - // but not write directly. Each op is the sole way to mutate its backing - // state, so check-then-mutate races between coordinators cannot occur. - - /// Records the Nostr pubkey behind a (possibly virtual) peer ID. - @MainActor - func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { - nostrKeyMapping[peerID] = pubkey - } - - /// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey. - @MainActor - func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) { - for (key, value) in nostrKeyMapping where value.lowercased() == hex { - nostrKeyMapping.removeValue(forKey: key) - } - } - - /// Whether a read receipt has already been recorded for `messageID`. - @MainActor - func hasSentReadReceipt(_ messageID: String) -> Bool { - sentReadReceipts.contains(messageID) - } - - /// Records that a read receipt is being sent for `messageID`. - /// Returns `false` when one was already recorded — the caller must skip sending. - @MainActor - @discardableResult - func markReadReceiptSent(_ messageID: String) -> Bool { - sentReadReceipts.insert(messageID).inserted - } - - /// Records that a GeoDM delivery ACK is being sent for `messageID`. - /// Returns `false` when one was already recorded — the caller must skip sending. - @MainActor - @discardableResult - func markGeoDeliveryAckSent(_ messageID: String) -> Bool { - sentGeoDeliveryAcks.insert(messageID).inserted - } - - /// Forgets that read receipts were sent for `ids` so READ acks can be - /// re-sent after the peer reconnects. - @MainActor - func unmarkReadReceiptsSent(_ ids: [String]) { - sentReadReceipts.subtract(ids) - } - - /// Marks read receipts as sent for own messages already delivered/read in - /// `peerID`'s chat, syncing the chat manager's tracking with the persisted - /// set. (Wraps the manager's `inout` sync so the raw set never leaks.) - @MainActor - func syncReadReceiptsForSentMessages(for peerID: PeerID) { - privateChatManager.syncReadReceiptsForSentMessages( - peerID: peerID, - nickname: nickname, - externalReceipts: &sentReadReceipts - ) - } - - /// Drops every recorded read receipt whose message ID is no longer valid. - /// Returns the number of receipts removed. - @MainActor - func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int { - let oldCount = sentReadReceipts.count - sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) - return oldCount - sentReadReceipts.count - } - - /// Publishes the public-timeline batching state (UI animation suppression). - @MainActor - func setPublicBatching(_ isBatching: Bool) { - isBatchingPublic = isBatching - } - - @MainActor - func setGeoChatSubscriptionID(_ id: String?) { - geoSubscriptionID = id - } - - @MainActor - func setGeoDmSubscriptionID(_ id: String?) { - geoDmSubscriptionID = id - } - - @MainActor - func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { - geoSamplingSubs[subID] = geohash - } - - @MainActor - func removeGeoSamplingSub(_ subID: String) { - geoSamplingSubs.removeValue(forKey: subID) - } - - /// Clears all sampling subscriptions and returns the removed subscription IDs - /// so the caller can unsubscribe them from the relay manager. - @MainActor - func clearGeoSamplingSubs() -> [String] { - let subIDs = Array(geoSamplingSubs.keys) - geoSamplingSubs.removeAll() - return subIDs - } - - /// Moves the open private chat to `newPeerID` when the current selection is - /// one of the peer IDs being migrated away (side-effectful: re-targets the - /// private chat session — fingerprint refresh, read receipts). - /// - /// Note: when this runs after a store `migrateConversation`, the store has - /// already handed the selection itself off to `newPeerID` (and the manager - /// mirrors it), so a selection that reads `newPeerID` is also re-targeted - /// to run the session side effects. Selections on unrelated peers are - /// untouched. - @MainActor - func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) { - guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) - || selectedPrivateChatPeer == newPeerID else { return } - selectedPrivateChatPeer = newPeerID - } - - // MARK: - Private Conversation Store Intents - // The sole mutation paths for private (direct) message state. Each op - // forwards to the single-writer `ConversationStore` - // (docs/CONVERSATION-STORE-DESIGN.md); the read-only `privateChats` / - // `unreadPrivateMessages` views above are derived from the same store. - - /// Appends a private message in timestamp order. Returns `false` when a - /// message with the same ID is already in that chat (O(1) dedup via the - /// conversation's ID index). - @MainActor - @discardableResult - func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { - conversations.append(message, to: .directPeer(peerID)) - } - - /// Replace-or-append a private message by ID (media progress, mirrored - /// copies); an existing message keeps its timeline position. - @MainActor - func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { - conversations.upsertByID(message, in: .directPeer(peerID)) - } - - /// Applies a delivery status to a private message by ID. Returns `false` - /// when the message is unknown or the update would downgrade the status - /// (read beats delivered beats sent). - @MainActor - @discardableResult - func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool { - conversations.setDeliveryStatus(status, forMessageID: messageID, in: .directPeer(peerID)) - } - - /// Flags the peer's chat as unread (store unread state). - @MainActor - func markPrivateChatUnread(_ peerID: PeerID) { - conversations.markUnread(.directPeer(peerID)) - } - - /// Clears the peer's unread flag (store unread state only; read-receipt - /// sending stays in `PrivateChatManager.markAsRead`). - @MainActor - func markPrivateChatRead(_ peerID: PeerID) { - conversations.markRead(.directPeer(peerID)) - } - - /// Empties the peer's chat but keeps the conversation alive (`/clear`). - @MainActor - func clearPrivateChat(_ peerID: PeerID) { - let sourceConversationID = ConversationID.directPeer(peerID) - // An active live-voice row owns an open FileHandle and may be - // republished as frames/final media arrive. Treat it like an in-flight - // arrival rather than unlinking its capture or removing its bubble. - let messages = privateMessages(for: peerID).filter { - !liveVoiceCoordinator.isLiveVoiceMessage($0) - } - let localPeerID = meshService.myPeerID.toShort() - let currentNickname = nickname - let mediaPrefixes = [ - MimeType.Category.audio.messagePrefix, - MimeType.Category.image.messagePrefix, - MimeType.Category.file.messagePrefix - ] - let outgoingMedia = messages.filter { message in - guard mediaPrefixes.contains(where: { - message.content.hasPrefix($0) - }) else { - return false - } - if let senderPeerID = message.senderPeerID { - return senderPeerID.toShort() == localPeerID - } - return message.sender == currentNickname - || message.sender.hasPrefix(currentNickname + "#") - } - - // Send ownership is canceled at command time even when another clear - // transaction is ahead in the queue. UI and files remain untouched - // until this request's receiver journal commit succeeds. - for message in outgoingMedia { - mediaTransferCoordinator - .cancelMediaTransferForConversationClear( - messageID: message.id - ) - } - - queuedPrivateChatClears.append(PendingPrivateChatClear( - peerID: peerID, - sourceConversationID: sourceConversationID, - messages: messages, - otherMessageIDs: Set( - privateChats - .filter { $0.key != peerID } - .flatMap { $0.value.map(\.id) } - ), - localPeerID: localPeerID, - nickname: currentNickname, - outgoingMedia: outgoingMedia - )) - startNextPrivateChatClearIfNeeded() - } - - @MainActor - private func startNextPrivateChatClearIfNeeded() { - guard !privateChatClearInFlight, - !queuedPrivateChatClears.isEmpty else { - return - } - privateChatClearInFlight = true - let request = queuedPrivateChatClears.removeFirst() - let generation = privateChatClearGeneration - performPrivateChatClear( - request, - generation: generation - ) { [weak self] in - guard let self, - self.privateChatClearGeneration == generation else { - return - } - self.privateChatClearInFlight = false - self.startNextPrivateChatClearIfNeeded() - } - } - - @MainActor - private func performPrivateChatClear( - _ request: PendingPrivateChatClear, - generation: UInt64, - completion: @escaping @MainActor () -> Void - ) { - guard privateChatClearGeneration == generation else { - completion() - return - } - let peerID = request.peerID - let selectedConversationID = request.sourceConversationID - let messagesToClear = request.messages - guard !messagesToClear.isEmpty else { - completion() - return - } - - // Capture the transaction's exact UI set before any off-main receipt - // I/O. Messages arriving while the journal is written are not part of - // this command and must remain visible. - let capturedMessageIDs = Set(messagesToClear.map(\.id)) - let survivingMessageIDs = request.otherMessageIDs - let mediaPrefixes = [ - MimeType.Category.audio.messagePrefix, - MimeType.Category.image.messagePrefix, - MimeType.Category.file.messagePrefix - ] - let localPeerID = request.localPeerID - let isMedia: (BitchatMessage) -> Bool = { message in - mediaPrefixes.contains(where: message.content.hasPrefix) - } - let isFromMe: (BitchatMessage) -> Bool = { [nickname = request.nickname] message in - if let senderPeerID = message.senderPeerID { - return senderPeerID.toShort() == localPeerID - } - return message.sender == nickname - || message.sender.hasPrefix(nickname + "#") - } - - let outgoingMedia = request.outgoingMedia - - let capturedExclusiveIDs = - capturedMessageIDs.subtracting(survivingMessageIDs) - let capturedIncomingMedia = messagesToClear.filter { - isMedia($0) && !isFromMe($0) - } - let capturedStableMediaIDs = Set( - capturedIncomingMedia.compactMap { message in - PrivateMediaMessageIdentity.isStableID(message.id) - ? message.id - : nil - } - ) - - func currentRemovalPlan() -> [ConversationID: Set] { - // Identity handoff removes the source conversation and inserts its - // rows elsewhere. The old source may then be recreated by a new - // arrival before journal I/O finishes, so always scan all direct - // conversations. Only IDs exclusive at command time may follow a - // migration; shared aliases remain outside the source. - var plan: [ConversationID: Set] = [:] - for (conversationID, conversation) in - conversations.conversationsByID { - guard case .direct = conversationID else { continue } - let eligibleIDs = conversationID == selectedConversationID - ? capturedMessageIDs - : capturedExclusiveIDs - let matchingIDs = Set(conversation.messages.map(\.id)) - .intersection(eligibleIDs) - if !matchingIDs.isEmpty { - plan[conversationID] = matchingIDs - } - } - return plan - } - - func hasRemainingCopy( - of messageID: String, - after plan: [ConversationID: Set] - ) -> Bool { - conversations.conversationsByID.contains { conversationID, conversation in - guard case .direct = conversationID else { return false } - return conversation.messages.contains { message in - message.id == messageID - && plan[conversationID]?.contains(messageID) != true - } - } - } - - @MainActor - func continueClear( - persisted: Bool, - durableStableIDs: Set - ) { - guard privateChatClearGeneration == generation else { - completion() - return - } - guard persisted else { - SecureLogger.error( - "Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))…", - category: .session - ) - notifyPrivateMediaDeletionRefused(peerID: peerID) - completion() - return - } - - let plan = currentRemovalPlan() - let newlyLastStableIDs = Set( - capturedStableMediaIDs.filter { - !durableStableIDs.contains($0) - && !hasRemainingCopy(of: $0, after: plan) - } - ) - if !newlyLastStableIDs.isEmpty { - persistDeletedPrivateMedia( - messageIDs: Array(newlyLastStableIDs).sorted() - ) { persisted in - continueClear( - persisted: persisted, - durableStableIDs: - durableStableIDs.union(newlyLastStableIDs) - ) - } - return - } - - // A stable receiver tombstone is global for that message ID. - // Remove any alias that arrived while journal I/O was in flight. - if !durableStableIDs.isEmpty { - let directConversationIDs = conversations - .conversationsByID.keys.filter { - if case .direct = $0 { return true } - return false - } - for conversationID in directConversationIDs { - conversations.removeMessages(from: conversationID) { - durableStableIDs.contains($0.id) - } - } - } - - // Outgoing media mirrors the incoming alias protection: an ID - // whose copy survives in a conversation this clear does not - // touch (identity-alias handoff) keeps that bubble and its local - // file. Only IDs with no surviving copy are removed from every - // direct conversation and have their payload unlinked. - let outgoingPlan = currentRemovalPlan() - let removableOutgoingMedia = outgoingMedia.filter { - !hasRemainingCopy(of: $0.id, after: outgoingPlan) - } - for message in removableOutgoingMedia { - mediaTransferCoordinator.cleanupOutgoingLocalFile( - forMessage: message - ) - } - let removableOutgoingIDs = Set( - removableOutgoingMedia.map(\.id) - ) - if !removableOutgoingIDs.isEmpty { - let directConversationIDs = conversations - .conversationsByID.keys.filter { - if case .direct = $0 { return true } - return false - } - for conversationID in directConversationIDs { - conversations.removeMessages(from: conversationID) { - removableOutgoingIDs.contains($0.id) - } - } - } - - // Stable payload cleanup belongs entirely to the durable receiver - // journal. Legacy/raw incoming payloads have no durable identity, - // so once their bubbles are gone the transport's gated cleanup - // decides per basename: unlink when unreferenced, or leave any - // pending/reserved path for bounded quota cleanup. - let finalPlan = currentRemovalPlan() - - for (conversationID, messageIDs) in finalPlan { - conversations.removeMessages(from: conversationID) { - messageIDs.contains($0.id) - } - } - cleanupLegacyIncomingMediaPayloads(for: capturedIncomingMedia) - completion() - } - - let initialPlan = currentRemovalPlan() - let initialStableIDs = Set( - capturedStableMediaIDs.filter { - !hasRemainingCopy(of: $0, after: initialPlan) - } - ) - persistDeletedPrivateMedia( - messageIDs: Array(initialStableIDs).sorted() - ) { persisted in - continueClear( - persisted: persisted, - durableStableIDs: initialStableIDs - ) - } - } - - /// Removes the peer's chat entirely, including unread state. - @MainActor - func removePrivateChat(_ peerID: PeerID) { - conversations.removeConversation(.directPeer(peerID)) - } - - /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat - /// (ephemeral↔stable peer-ID handoff): dedups by ID, preserves order, - /// carries unread state, removes the old chat. - @MainActor - func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) { - conversations.migrateConversation(from: .directPeer(oldPeerID), to: .directPeer(newPeerID)) - } - - /// A single private chat's timeline, read straight from the store — - /// an O(1) lookup that skips the `privateChats` dictionary build. The - /// context protocols' simple per-peer reads dispatch here. - @MainActor - func privateMessages(for peerID: PeerID) -> [BitchatMessage] { - conversations.conversationsByID[.directPeer(peerID)]?.messages ?? [] - } - - /// `true` when any private chat contains a message with `messageID` - /// (O(1) per conversation via the store's ID indexes). - @MainActor - func privateChatsContainMessage(withID messageID: String) -> Bool { - conversations.directConversationsContainMessage(withID: messageID) - } - - /// `true` when `peerID`'s chat contains a message with `messageID`. - @MainActor - func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool { - conversations.conversationsByID[.directPeer(peerID)]?.containsMessage(withID: messageID) ?? false - } - - /// Removes a message by ID from every private chat that contains it, - /// dropping chats that become empty. Returns the removed message, if any. - @MainActor - @discardableResult - func removePrivateMessage(withID messageID: String) -> BitchatMessage? { - var removed: BitchatMessage? - for (id, conversation) in conversations.conversationsByID { - guard case .direct = id, conversation.containsMessage(withID: messageID) else { continue } - let message = conversations.removeMessage(withID: messageID, from: id) - removed = removed ?? message - if conversation.messages.isEmpty { - conversations.removeConversation(id) - } - } - return removed - } - - // MARK: - Public Conversation Store Intents - // The sole mutation paths for public (mesh/geohash) message state, - // mirroring the private intents above. The store's per-conversation cap - // and timestamp-ordered insert replace `PublicTimelineStore`'s trim and - // the pipeline's late-insert positioning; the read-only `messages` shim - // above is derived from the same store. - - /// Appends a public message in timestamp order. Returns `false` when a - /// message with the same ID is already in that conversation (O(1) dedup - /// via the conversation's ID index). - @MainActor - @discardableResult - func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool { - conversations.append(message, to: conversationID) - } - - /// Appends a geohash message if absent. Returns `true` when stored - /// (the legacy `PublicTimelineStore.appendIfAbsent` contract). - @MainActor - @discardableResult - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { - conversations.append(message, to: .geohash(geohash.lowercased())) - } - - /// A public (mesh/geohash) channel's full timeline. - @MainActor - func publicMessages(for channel: ChannelID) -> [BitchatMessage] { - conversations.conversation(for: ConversationID(channelID: channel)).messages - } - - /// `true` when the conversation contains a message with `messageID`. - @MainActor - func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool { - conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false - } - - @MainActor - func bridgeInjectedPublicMessageIsPresent(withID messageID: String) -> Bool { - publicMessagePipeline.containsMessage(withID: messageID) || - publicConversationContainsMessage(withID: messageID, in: .mesh) - } - - /// Removes a message by ID from whichever public conversation contains - /// it. Returns the removed message, if any. - @MainActor - @discardableResult - func removePublicMessage(withID messageID: String) -> BitchatMessage? { - publicMessagePipeline.removeMessage(withID: messageID) - return conversations.removePublicMessage(withID: messageID) - } - - /// Replaces an unauthenticated bridge alias with a later authenticated - /// radio row. In addition to both storage layers, clear the content-window - /// marker written by an already-flushed alias or it would suppress the - /// genuine row during the next public-message batch. - @MainActor - func removeBridgeInjectedPublicMessage(withID messageID: String) { - publicMessagePipeline.removeMessage(withID: messageID) - guard let removed = conversations.removePublicMessage(withID: messageID) else { return } - deduplicationService.forgetContent(removed.content, ifRecordedAt: removed.timestamp) - } - - /// Removes every message matching `predicate` from a geohash - /// conversation (block-user purge). - @MainActor - func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) { - conversations.removeMessages(from: .geohash(geohash.lowercased()), where: predicate) - } - - /// Empties a public conversation's timeline (`/clear`). - @MainActor - func clearPublicConversation(_ conversationID: ConversationID) { - conversations.clear(conversationID) - } - - func purgeArchivedPublicMessages() { - (meshService as? MeshPublicArchiving)?.purgeAllArchivedPublicMessages() - } - - /// Queues a system message for the next geohash channel visit. (Tiny - /// UI-flow queue formerly on `PublicTimelineStore`; it is notice text, - /// not conversation state, so it stays on the owner.) - @MainActor - func queueGeohashSystemMessage(_ content: String) { - pendingGeohashSystemMessages.append(content) - } - - /// Drains the queued geohash system messages (single consumer: - /// `GeohashSubscriptionManager.switchLocationChannel`). - @MainActor - func drainPendingGeohashSystemMessages() -> [String] { - defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) } - return pendingGeohashSystemMessages - } - - // Single-writer: mutate only via `queueGeohashSystemMessage(_:)` / - // `drainPendingGeohashSystemMessages()` above. - private var pendingGeohashSystemMessages: [String] = [] - + var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey + // MARK: - Initialization @MainActor convenience init( keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, - identityManager: SecureIdentityStateManagerProtocol, - conversations: ConversationStore? = nil, - peerIdentityStore: PeerIdentityStore? = nil, - locationPresenceStore: LocationPresenceStore? = nil, - locationManager: LocationChannelManager = .shared + identityManager: SecureIdentityStateManagerProtocol ) { - let livePanicRecoveryOperations = PanicRecoveryOperations.live() - let startSuspendedForRecovery: Bool - do { - startSuspendedForRecovery = - try livePanicRecoveryOperations.isPending() - } catch { - startSuspendedForRecovery = true - } - // Preserve the preflight decision used to defer CoreBluetooth. A - // transiently successful second read must not skip recovery and leave - // the service permanently suspended without running the wipe. - let panicRecoveryOperations = PanicRecoveryOperations( - isPending: { - if startSuspendedForRecovery { - return true - } - return try livePanicRecoveryOperations.isPending() - }, - begin: livePanicRecoveryOperations.begin, - wipeMedia: livePanicRecoveryOperations.wipeMedia, - complete: livePanicRecoveryOperations.complete - ) - let meshService = BLEService( - keychain: keychain, - idBridge: idBridge, - identityManager: identityManager, - startSuspendedForPanicRecovery: startSuspendedForRecovery - ) - meshService.sfMetrics = .shared self.init( keychain: keychain, idBridge: idBridge, identityManager: identityManager, - transport: meshService, - conversations: conversations, - peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), - locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), - locationManager: locationManager, - outboxStore: MessageOutboxStore(keychain: keychain), - sfMetrics: .shared, - panicRecoveryOperations: panicRecoveryOperations, - panicNetworkLifecycle: .live + transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) ) } @@ -1162,105 +412,342 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - transport: Transport, - conversations: ConversationStore? = nil, - peerIdentityStore: PeerIdentityStore? = nil, - locationPresenceStore: LocationPresenceStore? = nil, - locationManager: LocationChannelManager = .shared, - readReceiptsDefaults: UserDefaults? = nil, - outboxStore: MessageOutboxStore? = nil, - sfMetrics: StoreAndForwardMetrics? = nil, - panicMediaWipe: (() throws -> Void)? = nil, - panicRecoveryOperations: PanicRecoveryOperations? = nil, - panicNetworkLifecycle: PanicNetworkLifecycle = .noop + transport: Transport ) { - let conversations = conversations ?? ConversationStore() - let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() - let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore() - let services = ChatViewModelServiceBundle( - keychain: keychain, - idBridge: idBridge, - identityManager: identityManager, - meshService: transport, - outboxStore: outboxStore, - sfMetrics: sfMetrics - ) - self.keychain = keychain - self.panicRecoveryOperations = panicRecoveryOperations - ?? .ephemeral(wipeMedia: panicMediaWipe ?? {}) - self.panicNetworkLifecycle = panicNetworkLifecycle - self.groupStore = GroupStore(keychain: keychain) self.idBridge = idBridge self.identityManager = identityManager - self.conversations = conversations - self.peerIdentityStore = peerIdentityStore - self.locationPresenceStore = locationPresenceStore - self.locationManager = locationManager self.meshService = transport - self.commandProcessor = services.commandProcessor - self.messageRouter = services.messageRouter - self.privateChatManager = services.privateChatManager - self.unifiedPeerService = services.unifiedPeerService - self.autocompleteService = services.autocompleteService - self.deduplicationService = services.deduplicationService - self.publicMessagePipeline = services.publicMessagePipeline - let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults() - self.readReceiptsDefaults = readReceiptsDefaults - self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts(userDefaults: readReceiptsDefaults) + self.publicMessagePipeline = PublicMessagePipeline() + + // Load persisted read receipts + if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"), + let receipts = try? JSONDecoder().decode([String].self, from: data) { + self.sentReadReceipts = Set(receipts) + // Successfully loaded read receipts + } else { + // No persisted read receipts found + } + + // Initialize services + self.commandProcessor = CommandProcessor(identityManager: identityManager) + self.privateChatManager = PrivateChatManager(meshService: meshService) + self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager) + let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) + nostrTransport.senderPeerID = meshService.myPeerID + self.messageRouter = MessageRouter(transports: [meshService, nostrTransport]) + // Route receipts from PrivateChatManager through MessageRouter + self.privateChatManager.messageRouter = self.messageRouter + // Allow PrivateChatManager to look up peer info for message consolidation + self.privateChatManager.unifiedPeerService = self.unifiedPeerService + // Allow UnifiedPeerService to route favorite notifications via mesh/Nostr + self.unifiedPeerService.messageRouter = self.messageRouter + self.autocompleteService = AutocompleteService() + self.deduplicationService = MessageDeduplicationService() - // Republish on every store change so SwiftUI observers of the - // view model refresh. This replaces the UI-update role of the old - // `PrivateChatManager.@Published` dictionaries and the old - // `@Published var messages`. Changes touching the ACTIVE public - // conversation also invalidate the derived `messages` cache before - // observers re-read it. - conversations.changes - .sink { [weak self] change in - guard let self else { return } - if self.changeAffectsActivePublicConversation(change) { - self.visibleMessagesCache = nil - } - self.objectWillChange.send() + // Wire up dependencies + self.commandProcessor.contextProvider = self + self.participantTracker.configure(context: self) + + // Subscribe to privateChatManager changes to trigger UI updates + privateChatManager.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() } .store(in: &cancellables) - let recoveryRequired: Bool - do { - recoveryRequired = try self.panicRecoveryOperations.isPending() - } catch { - // Failure to read the latch cannot fail open. Re-run the complete - // transaction; a persistent storage failure leaves services - // blocked below. - recoveryRequired = true - SecureLogger.error( - "Could not read panic-recovery state; retrying the full wipe before startup: \(error)", - category: .security + // Subscribe to participantTracker changes to trigger UI updates + participantTracker.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + self.commandProcessor.meshService = meshService + + loadNickname() + loadVerifiedFingerprints() + meshService.delegate = self + + // Log startup info + + // Log fingerprint after a delay to ensure encryption service is ready + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak self] in + if let self = self { + _ = self.getMyFingerprint() + } + } + + // Set nickname before starting services + meshService.setNickname(nickname) + + // Start mesh service immediately + meshService.startServices() + + publicMessagePipeline.delegate = self + publicMessagePipeline.updateActiveChannel(activeChannel) + + // Check initial Bluetooth state after a brief delay to allow centralManager initialization + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self = self else { return } + if let bleService = self.meshService as? BLEService { + let state = bleService.getCurrentBluetoothState() + self.updateBluetoothState(state) + } + } + + // Announce Tor status (geohash-only; do not show in mesh chat). Only when auto-start is allowed. + if TorManager.shared.torEnforced && !torStatusAnnounced && TorManager.shared.isAutoStartAllowed() { + torStatusAnnounced = true + addGeohashOnlySystemMessage( + String(localized: "system.tor.starting", comment: "System message when Tor is starting") + ) + } else if !TorManager.shared.torEnforced && !torStatusAnnounced { + torStatusAnnounced = true + addGeohashOnlySystemMessage( + String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development") ) } - if recoveryRequired { - SecureLogger.warning( - "Pending panic recovery detected; wiping before runtime services start", - category: .security - ) - _ = panicClearAllData(restartServices: false) + // Initialize Nostr relay manager regardless of Tor readiness; connection is controlled elsewhere + nostrRelayManager = NostrRelayManager.shared + // Attempt to flush any queued outbox (mesh/Nostr routing will gate appropriately) + messageRouter.flushAllOutbox() + // End startup phase after a short delay + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000)) + self.isStartupPhase = false + } + // Bind unified peer service's peer list to our published property + let peersCancellable = unifiedPeerService.$peers + .receive(on: DispatchQueue.main) + .sink { [weak self] peers in + guard let self = self else { return } + // Update peers directly; @Published drives UI updates + self.allPeers = peers + // Update peer index for O(1) lookups + // Deduplicate peers by ID to prevent crash from duplicate keys + var uniquePeers: [PeerID: BitchatPeer] = [:] + for peer in peers { + // Keep the first occurrence of each peer ID + if uniquePeers[peer.peerID] == nil { + uniquePeers[peer.peerID] = peer + } else { + SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.peerID) (\(peer.displayName))", category: .session) + } + } + self.peerIndex = uniquePeers + // Update private chat peer ID if needed when peers change + if self.selectedPrivateChatFingerprint != nil { + self.updatePrivateChatPeerIfNeeded() + } + } + self.cancellables.insert(peersCancellable) + + // Resubscribe geohash on relay reconnect + if let relayMgr = self.nostrRelayManager { + relayMgr.$isConnected + .receive(on: DispatchQueue.main) + .sink { [weak self] connected in + guard let self = self else { return } + if connected { + Task { @MainActor in + // Set up DM handler once on first connect + if !self.nostrHandlersSetup { + self.setupNostrMessageHandling() + self.nostrHandlersSetup = true + } + self.resubscribeCurrentGeohash() + // Re-init sampling for regional + bookmarked geohashes after reconnect + self.geoChannelCoordinator?.refreshSampling() + } + } + } + .store(in: &self.cancellables) } - if networkActivationAllowed { - ChatViewModelBootstrapper(viewModel: self).configure() - } + // Set up Noise encryption callbacks + setupNoiseCallbacks() + + TransferProgressManager.shared.publisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + self?.handleTransferEvent(event) + } + .store(in: &cancellables) + + geoChannelCoordinator = GeoChannelCoordinator( + onChannelSwitch: { [weak self] channel in + self?.switchLocationChannel(to: channel) + }, + beginSampling: { [weak self] geohashes in + self?.beginGeohashSampling(for: geohashes) + }, + endSampling: { [weak self] in + self?.endGeohashSampling() + } + ) + + // Track teleport flag changes to keep our own teleported marker in sync with regional status + LocationChannelManager.shared.$teleported + .receive(on: DispatchQueue.main) + .sink { [weak self] isTeleported in + guard let self = self else { return } + Task { @MainActor in + guard case .location(let ch) = self.activeChannel, + let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return } + let key = id.publicKeyHex.lowercased() + let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty + let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } + if isTeleported && hasRegional && !inRegional { + self.teleportedGeo = self.teleportedGeo.union([key]) + } else { + self.teleportedGeo.remove(key) + } + } + } + .store(in: &cancellables) + + // Request notification permission (guards test environment internally) + NotificationService.shared.requestAuthorization() + + + // Listen for favorite status changes + NotificationCenter.default.addObserver( + self, + selector: #selector(handleFavoriteStatusChanged), + name: .favoriteStatusChanged, + object: nil + ) + + // Listen for peer status updates to refresh UI + NotificationCenter.default.addObserver( + self, + selector: #selector(handlePeerStatusUpdate), + name: Notification.Name("peerStatusUpdated"), + object: nil + ) + + // When app becomes active, send read receipts for visible messages + #if os(macOS) + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + + // Add app lifecycle observers to save data + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillResignActive), + name: NSApplication.willResignActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillTerminate), + name: NSApplication.willTerminateNotification, + object: nil + ) + // Tor lifecycle notifications: inform user when Tor restarts and when ready (macOS) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorWillRestart), + name: .TorWillRestart, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorDidBecomeReady), + name: .TorDidBecomeReady, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorWillStart), + name: .TorWillStart, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorPreferenceChanged(_:)), + name: .TorUserPreferenceChanged, + object: nil + ) + #else + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + // Resubscribe geohash on app foreground + // Resubscribe handled via appDidBecomeActive selector + + // Add screenshot detection for iOS + NotificationCenter.default.addObserver( + self, + selector: #selector(userDidTakeScreenshot), + name: UIApplication.userDidTakeScreenshotNotification, + object: nil + ) + + // Add app lifecycle observers to save data + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillTerminate), + name: UIApplication.willTerminateNotification, + object: nil + ) + // Tor lifecycle notifications: inform user when Tor restarts and when ready + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorWillRestart), + name: .TorWillRestart, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorDidBecomeReady), + name: .TorDidBecomeReady, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorWillStart), + name: .TorWillStart, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTorPreferenceChanged(_:)), + name: .TorUserPreferenceChanged, + object: nil + ) + #endif } - + // MARK: - Deinitialization - + deinit { // No need to force UserDefaults synchronization } - // MARK: - Nickname Management - func loadNickname() { + + + + + + // MARK: - Nickname Management + + private func loadNickname() { if let savedNickname = userDefaults.string(forKey: nicknameKey) { nickname = savedNickname.trimmed } else { @@ -1268,67 +755,363 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage saveNickname() } } - + func saveNickname() { userDefaults.set(nickname, forKey: nicknameKey) // Persist nickname; no need to force synchronize - + // Send announce with new nickname to all peers meshService.sendBroadcastAnnounce() } - + func validateAndSaveNickname() { nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))" saveNickname() } // MARK: - Blocked Users Management (Delegated to PeerStateManager) - + + /// Check if a peer has unread messages, including messages stored under stable Noise keys and temporary Nostr peer IDs @MainActor func hasUnreadMessages(for peerID: PeerID) -> Bool { - peerIdentityCoordinator.hasUnreadMessages(for: peerID) - } + // First check direct unread messages + if unreadPrivateMessages.contains(peerID) { + return true + } + + // Check if messages are stored under the stable Noise key hex + if let peer = unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) + if unreadPrivateMessages.contains(noiseKeyHex) { + return true + } + // Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey + if let nostrHex = peer.nostrPublicKey { + let convKey = PeerID(nostr_: nostrHex) + if unreadPrivateMessages.contains(convKey) { + return true + } + } + } + + // Get the peer's nickname to check for temporary Nostr peer IDs + let peerNickname = meshService.peerNickname(peerID: peerID)?.lowercased() ?? "" + // Check if any temporary Nostr peer IDs have unread messages from this nickname + for unreadPeerID in unreadPrivateMessages { + if unreadPeerID.isGeoDM { + // Check if messages from this temporary peer match the nickname + if let messages = privateChats[unreadPeerID], + let firstMessage = messages.first, + firstMessage.sender.lowercased() == peerNickname { + return true + } + } + } + + return false + } + @MainActor func toggleFavorite(peerID: PeerID) { - peerIdentityCoordinator.toggleFavorite(peerID: peerID) + // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars) + // Ephemeral peer IDs are 8 bytes = 16 hex characters + // Noise public keys are 32 bytes = 64 hex characters + + if let noisePublicKey = peerID.noiseKey { + // This is a stable Noise key hex (used in private chats) + // Find the ephemeral peer ID for this Noise key + let ephemeralPeerID = unifiedPeerService.peers.first { peer in + peer.noisePublicKey == noisePublicKey + }?.peerID + + if let ephemeralID = ephemeralPeerID { + // Found the ephemeral peer, use normal toggle + unifiedPeerService.toggleFavorite(ephemeralID) + // Also trigger UI update + objectWillChange.send() + } else { + // No ephemeral peer found, directly toggle via FavoritesPersistenceService + let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) + let wasFavorite = currentStatus?.isFavorite ?? false + + if wasFavorite { + // Remove favorite + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) + } else { + // Add favorite - get nickname from current status or from private chat messages + var nickname = currentStatus?.peerNickname + + // If no nickname in status, try to get from private chat messages + if nickname == nil, let messages = privateChats[peerID], !messages.isEmpty { + // Get the nickname from the first message where this peer was the sender + nickname = messages.first { $0.senderPeerID == peerID }?.sender + } + + let finalNickname = nickname ?? "Unknown" + let nostrKey = currentStatus?.peerNostrPublicKey ?? idBridge.getNostrPublicKey(for: noisePublicKey) + + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noisePublicKey, + peerNostrPublicKey: nostrKey, + peerNickname: finalNickname + ) + } + + // Trigger UI update + objectWillChange.send() + + // Send favorite notification via Nostr if we're mutual favorites + if !wasFavorite && currentStatus?.theyFavoritedUs == true { + // We just favorited them and they already favorite us - send via Nostr + sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: true) + } else if wasFavorite { + // We're unfavoriting - send via Nostr if they still favorite us + sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: false) + } + } + } else { + // This is an ephemeral peer ID (16 hex chars), use normal toggle + unifiedPeerService.toggleFavorite(peerID) + // Trigger UI update + objectWillChange.send() + } } - + @MainActor func isFavorite(peerID: PeerID) -> Bool { - peerIdentityCoordinator.isFavorite(peerID: peerID) + // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars) + if let noisePublicKey = peerID.noiseKey { + // This is a Noise public key + if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) { + return status.isFavorite + } + } else { + // This is an ephemeral peer ID - check with UnifiedPeerService + if let peer = unifiedPeerService.getPeer(by: peerID) { + return peer.isFavorite + } + } + + return false } - + // MARK: - Public Key and Identity Management - + @MainActor func isPeerBlocked(_ peerID: PeerID) -> Bool { - peerIdentityCoordinator.isPeerBlocked(peerID) + return unifiedPeerService.isBlocked(peerID) } - + + // Helper method to find current peer ID for a fingerprint + @MainActor + private func getCurrentPeerIDForFingerprint(_ fingerprint: String) -> PeerID? { + // Search through all connected peers to find the one with matching fingerprint + for peerID in connectedPeers { + if let mappedFingerprint = peerIDToPublicKeyFingerprint[peerID], + mappedFingerprint == fingerprint { + return peerID + } + } + return nil + } + // Helper method to update selectedPrivateChatPeer if fingerprint matches @MainActor - func updatePrivateChatPeerIfNeeded() { - peerIdentityCoordinator.updatePrivateChatPeerIfNeeded() + private func updatePrivateChatPeerIfNeeded() { + guard let chatFingerprint = selectedPrivateChatFingerprint else { return } + + // Find current peer ID for the fingerprint + if let currentPeerID = getCurrentPeerIDForFingerprint(chatFingerprint) { + // Update the selected peer if it's different + if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID { + + // Migrate messages from old peer ID to new peer ID + if let oldMessages = privateChats[oldPeerID] { + var chats = privateChats + if chats[currentPeerID] == nil { + chats[currentPeerID] = [] + } + chats[currentPeerID]?.append(contentsOf: oldMessages) + // Sort by timestamp + chats[currentPeerID]?.sort { $0.timestamp < $1.timestamp } + + // Remove duplicates + var seen = Set() + chats[currentPeerID] = chats[currentPeerID]?.filter { msg in + if seen.contains(msg.id) { + return false + } + seen.insert(msg.id) + return true + } + + // Remove old peer ID + chats.removeValue(forKey: oldPeerID) + + // Update all at once + privateChats = chats // Trigger setter + } + + // Migrate unread status + if unreadPrivateMessages.contains(oldPeerID) { + unreadPrivateMessages.remove(oldPeerID) + unreadPrivateMessages.insert(currentPeerID) + } + + selectedPrivateChatPeer = currentPeerID + + // Schedule UI update for encryption status change + // UI will update automatically + + // Also refresh the peer list to update encryption status + Task { @MainActor in + // UnifiedPeerService updates automatically via subscriptions + } + } else if selectedPrivateChatPeer == nil { + // Just set the peer ID if we don't have one + selectedPrivateChatPeer = currentPeerID + // UI will update automatically + } + + // Clear unread messages for the current peer ID + unreadPrivateMessages.remove(currentPeerID) + } } - + // MARK: - Message Sending - + /// Sends a message through the BitChat network. /// - Parameter content: The message content to send /// - Note: Automatically handles command processing if content starts with '/' /// Routes to private chat if one is selected, otherwise broadcasts @MainActor func sendMessage(_ content: String) { - outgoingCoordinator.sendMessage(content) + // Ignore messages that are empty or whitespace-only to prevent blank lines + guard let trimmed = content.trimmedOrNilIfEmpty else { return } + + // Check for commands + if content.hasPrefix("/") { + Task { @MainActor in + handleCommand(content) + } + return + } + + if selectedPrivateChatPeer != nil { + // Update peer ID in case it changed due to reconnection + updatePrivateChatPeerIfNeeded() + + if let selectedPeer = selectedPrivateChatPeer { + sendPrivateMessage(content, to: selectedPeer) + } + return + } + + // Parse mentions from the content (use original content for user intent) + let mentions = parseMentions(from: content) + + var geoContext: GeoOutgoingContext? = nil + + // Add message to local display + var displaySender = nickname + var localSenderPeerID = meshService.myPeerID + var messageID: String? = nil + var messageTimestamp = Date() + + switch activeChannel { + case .mesh: + break + case .location(let ch): + do { + let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) + let suffix = String(identity.publicKeyHex.suffix(4)) + displaySender = nickname + "#" + suffix + localSenderPeerID = PeerID(nostr: identity.publicKeyHex) + let teleported = LocationChannelManager.shared.teleported + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: trimmed, + geohash: ch.geohash, + senderIdentity: identity, + nickname: nickname, + teleported: teleported + ) + messageID = event.id + messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + geoContext = (channel: ch, event: event, identity: identity, teleported: teleported) + } catch { + SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) + addSystemMessage( + String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + ) + return + } + } + + let message = BitchatMessage( + id: messageID, + sender: displaySender, + content: trimmed, + timestamp: messageTimestamp, + isRelay: false, + senderPeerID: localSenderPeerID, + mentions: mentions.isEmpty ? nil : mentions + ) + + timelineStore.append(message, to: activeChannel) + refreshVisibleMessages(from: activeChannel) + + // Update content LRU for near-dup detection + let ckey = deduplicationService.normalizedContentKey(message.content) + deduplicationService.recordContentKey(ckey, timestamp: message.timestamp) + + trimMessagesIfNeeded() + + // UI updates automatically via @Published var messages + + updateChannelActivityTimeThenSend(content: content, + trimmed: trimmed, + mentions: mentions, + geoContext: geoContext, + messageID: message.id, + timestamp: message.timestamp) } - /// Sends a 👋 to the mesh channel regardless of the active channel. - @MainActor - func sendMeshWave() { - outgoingCoordinator.sendMeshWave() + private func updateChannelActivityTimeThenSend(content: String, + trimmed: String, + mentions: [String], + geoContext: GeoOutgoingContext?, + messageID: String, + timestamp: Date) { + switch activeChannel { + case .mesh: + lastPublicActivityAt["mesh"] = Date() + // Send via mesh with mentions + meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp) + case .location(let ch): + lastPublicActivityAt["geo:\(ch.geohash)"] = Date() + guard let context = geoContext, context.channel.geohash == ch.geohash else { + SecureLogger.error("Geo: missing send context for \(ch.geohash)", category: .session) + addSystemMessage( + String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + ) + return + } + // Send to geohash channel via Nostr ephemeral + Task { @MainActor in + self.sendGeohash(context: context) + } + } } + + + + + + + + // MARK: - Geohash Participants @@ -1357,112 +1140,197 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // MARK: - Public helpers + /// Published geohash people list for SwiftUI observation + var geohashPeople: [GeoPerson] { + participantTracker.visiblePeople + } + /// Return the current, pruned, sorted people list for the active geohash without mutating state. @MainActor func visibleGeohashPeople() -> [GeoPerson] { - publicConversationCoordinator.visibleGeohashPeople() + participantTracker.getVisiblePeople() } /// CommandContextProvider conformance - returns visible geo participants func getVisibleGeoParticipants() -> [CommandGeoParticipant] { - publicConversationCoordinator.getVisibleGeoParticipants() + visibleGeohashPeople().map { CommandGeoParticipant(id: $0.id, displayName: $0.displayName) } } /// Returns the current participant count for a specific geohash, using the 5-minute activity window. @MainActor func geohashParticipantCount(for geohash: String) -> Int { - publicConversationCoordinator.geohashParticipantCount(for: geohash) + participantTracker.participantCount(for: geohash) } // MARK: - GeohashParticipantContext Protocol func displayNameForPubkey(_ pubkeyHex: String) -> String { - publicConversationCoordinator.displayNameForPubkey(pubkeyHex) + displayNameForNostrPubkey(pubkeyHex) } func isBlocked(_ pubkeyHexLowercased: String) -> Bool { - publicConversationCoordinator.isBlocked(pubkeyHexLowercased) + identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } // Geohash block helpers @MainActor func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { - publicConversationCoordinator.isGeohashUserBlocked(pubkeyHexLowercased: pubkeyHexLowercased) + return identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } @MainActor func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { - publicConversationCoordinator.blockGeohashUser( - pubkeyHexLowercased: pubkeyHexLowercased, - displayName: displayName + let hex = pubkeyHexLowercased.lowercased() + identityManager.setNostrBlocked(hex, isBlocked: true) + + // Remove from participants for all geohashes + participantTracker.removeParticipant(pubkeyHex: hex) + + // Remove their public messages from current geohash timeline and visible list + if let gh = currentGeohash { + let predicate: (BitchatMessage) -> Bool = { [self] msg in + guard let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat else { return false } + if let full = self.nostrKeyMapping[spid]?.lowercased() { return full == hex } + return false + } + timelineStore.removeMessages(in: gh, where: predicate) + if case .location = activeChannel { + messages.removeAll(where: predicate) + } + } + + // Remove geohash DM conversation if exists + let convKey = PeerID(nostr_: hex) + if privateChats[convKey] != nil { + privateChats.removeValue(forKey: convKey) + unreadPrivateMessages.remove(convKey) + } + + // Remove mapping keys pointing to this pubkey to avoid accidental resolution + for (key, value) in self.nostrKeyMapping where value.lowercased() == hex { + self.nostrKeyMapping.removeValue(forKey: key) + } + + addSystemMessage( + String( + format: String(localized: "system.geohash.blocked", comment: "System message shown when a user is blocked in geohash chats"), + locale: .current, + displayName + ) ) } @MainActor func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { - publicConversationCoordinator.unblockGeohashUser( - pubkeyHexLowercased: pubkeyHexLowercased, - displayName: displayName - ) - } - - // Mesh (Noise identity) block helpers. Unlike the `/block ` - // command, these resolve and persist the block by the peer's stable - // fingerprint (derived from `peerID`), so the exact tapped peer is - // (un)blocked — unambiguous across nickname collisions and functional for - // offline peers that can no longer be resolved through the mesh service. - @MainActor - func blockMeshPeer(peerID: PeerID, displayName: String) { - setMeshPeerBlocked(peerID, blocked: true, displayName: displayName) - } - - @MainActor - func unblockMeshPeer(peerID: PeerID, displayName: String) { - setMeshPeerBlocked(peerID, blocked: false, displayName: displayName) - } - - @MainActor - private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) { - guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else { - addCommandOutput( - String( - format: String( - localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed", - comment: "System message shown when a mesh peer cannot be blocked or unblocked" - ), - locale: .current, - displayName - ) - ) - return - } - addCommandOutput( + identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) + addSystemMessage( String( - format: String( - localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked", - comment: "System message shown when a mesh peer is blocked or unblocked" - ), + format: String(localized: "system.geohash.unblocked", comment: "System message shown when a user is unblocked in geohash chats"), locale: .current, displayName ) ) } + + func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { - publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) + let suffix = String(pubkeyHex.suffix(4)) + // If this is our per-geohash identity, use our nickname + if let gh = currentGeohash, let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh) { + if myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() { + return nickname + "#" + suffix + } + } + // If we have a known nickname tag for this pubkey, use it + if let nick = geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty { + return nick + "#" + suffix + } + // Otherwise, anonymous with collision-resistant suffix + return "anon#\(suffix)" } + + + // MARK: - Media Transfers + + private enum MediaSendError: Error { + case encodingFailed + case tooLarge + case copyFailed + } + + + + + + + + func currentPublicSender() -> (name: String, peerID: PeerID) { - publicConversationCoordinator.currentPublicSender() + var displaySender = nickname + var senderPeerID = meshService.myPeerID + if case .location(let ch) = activeChannel, + let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { + let suffix = String(identity.publicKeyHex.suffix(4)) + displaySender = nickname + "#" + suffix + senderPeerID = PeerID(nostr: identity.publicKeyHex) + } + return (displaySender, senderPeerID) } @MainActor func nicknameForPeer(_ peerID: PeerID) -> String { - peerIdentityCoordinator.nicknameForPeer(peerID) + if let name = meshService.peerNickname(peerID: peerID) { + return name + } + if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), + !favorite.peerNickname.isEmpty { + return favorite.peerNickname + } + if let noiseKey = Data(hexString: peerID.id), + let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + !favorite.peerNickname.isEmpty { + return favorite.peerNickname + } + return "user" } + + @MainActor func removeMessage(withID messageID: String, cleanupFile: Bool = false) { - publicConversationCoordinator.removeMessage(withID: messageID, cleanupFile: cleanupFile) + var removedMessage: BitchatMessage? + + if let idx = messages.firstIndex(where: { $0.id == messageID }) { + removedMessage = messages.remove(at: idx) + } + + if let storeRemoved = timelineStore.removeMessage(withID: messageID) { + removedMessage = removedMessage ?? storeRemoved + } + + var chats = privateChats + for (peerID, items) in chats { + let filtered = items.filter { $0.id != messageID } + if filtered.count != items.count { + if filtered.isEmpty { + chats.removeValue(forKey: peerID) + } else { + chats[peerID] = filtered + } + if removedMessage == nil { + removedMessage = items.first(where: { $0.id == messageID }) + } + } + } + privateChats = chats + + if cleanupFile, let message = removedMessage { + cleanupLocalFile(forMessage: message) + } + + objectWillChange.send() } + /// Add a local system message to a private chat (no network send) @MainActor func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { @@ -1476,24 +1344,41 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage recipientNickname: meshService.peerNickname(peerID: peerID), senderPeerID: meshService.myPeerID ) - appendPrivateMessage(systemMessage, to: peerID) + if privateChats[peerID] == nil { privateChats[peerID] = [] } + privateChats[peerID]?.append(systemMessage) objectWillChange.send() } - + // MARK: - Bluetooth State Management - + /// Updates the Bluetooth state and shows appropriate alerts /// - Parameter state: The current Bluetooth manager state @MainActor func updateBluetoothState(_ state: CBManagerState) { bluetoothState = state - let alertUpdate = ChatBluetoothAlertPolicy.update(for: state) - showBluetoothAlert = alertUpdate.isPresented - if let message = alertUpdate.message { - bluetoothAlertMessage = message + + switch state { + case .poweredOff: + bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off") + showBluetoothAlert = true + case .unauthorized: + bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing") + showBluetoothAlert = true + case .unsupported: + bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support") + showBluetoothAlert = true + case .poweredOn: + // Hide alert when Bluetooth is powered on + showBluetoothAlert = false + bluetoothAlertMessage = "" + case .unknown, .resetting: + // Don't show alerts for transient states + showBluetoothAlert = false + @unknown default: + showBluetoothAlert = false } } - + // MARK: - Private Chat Management /// Initiates a private chat session with a peer. @@ -1501,288 +1386,626 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage /// - Note: Switches the UI to private chat mode and loads message history @MainActor func startPrivateChat(with peerID: PeerID) { - peerIdentityCoordinator.startPrivateChat(with: peerID) - } + // Safety check: Don't allow starting chat with ourselves + if peerID == meshService.myPeerID { + return + } - @MainActor + let peerNickname = meshService.peerNickname(peerID: peerID) ?? "unknown" + + // Check if the peer is blocked + if unifiedPeerService.isBlocked(peerID) { + addSystemMessage( + String( + format: String(localized: "system.chat.blocked", comment: "System message when starting chat fails because peer is blocked"), + locale: .current, + peerNickname + ) + ) + return + } + + // Check mutual favorites for offline messaging + if let peer = unifiedPeerService.getPeer(by: peerID), + peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { + addSystemMessage( + String( + format: String(localized: "system.chat.requires_favorite", comment: "System message when mutual favorite requirement blocks chat"), + locale: .current, + peerNickname + ) + ) + return + } + + // Consolidate messages from different peer ID representations (stable Noise key, temp Nostr IDs) + // Pass persisted sentReadReceipts to correctly identify already-read messages after app restart + _ = privateChatManager.consolidateMessages(for: peerID, peerNickname: peerNickname, persistedReadReceipts: sentReadReceipts) + + // Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys. + if !peerID.isGeoDM && !peerID.isGeoChat { + let sessionState = meshService.getNoiseSessionState(for: peerID) + switch sessionState { + case .none, .failed: + meshService.triggerHandshake(with: peerID) + case .handshakeQueued, .handshaking, .established: + break + } + } else { + SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session) + } + + // Sync read receipt tracking to prevent duplicates + privateChatManager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: nickname, externalReceipts: &sentReadReceipts) + + privateChatManager.startChat(with: peerID) + + // Also mark messages as read for Nostr ACKs + // This ensures read receipts are sent even for consolidated messages + markPrivateMessagesAsRead(from: peerID) + } + func endPrivateChat() { - peerIdentityCoordinator.endPrivateChat() + selectedPrivateChatPeer = nil + selectedPrivateChatFingerprint = nil } - + @MainActor - @objc func handlePeerStatusUpdate(_: Notification) { - peerIdentityCoordinator.handlePeerStatusUpdate() + @objc private func handlePeerStatusUpdate(_ notification: Notification) { + // Update private chat peer if needed when peer status changes + updatePrivateChatPeerIfNeeded() } - - @objc func handleFavoriteStatusChanged(_ notification: Notification) { - peerIdentityCoordinator.handleFavoriteStatusChanged(notification) + + @objc private func handleFavoriteStatusChanged(_ notification: Notification) { + guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return } + + Task { @MainActor in + // Handle noise key updates + if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, + isKeyUpdate, + let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data { + let oldPeerID = PeerID(hexData: oldKey) + let newPeerID = PeerID(hexData: peerPublicKey) + + // If we have a private chat open with the old peer ID, update it to the new one + if selectedPrivateChatPeer == oldPeerID { + SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) + + // Transfer private chat messages to new peer ID + if let messages = privateChats[oldPeerID] { + var chats = privateChats + chats[newPeerID] = messages + chats.removeValue(forKey: oldPeerID) + privateChats = chats // Trigger setter + } + + // Transfer unread status + if unreadPrivateMessages.contains(oldPeerID) { + unreadPrivateMessages.remove(oldPeerID) + unreadPrivateMessages.insert(newPeerID) + } + + // Update selected peer + selectedPrivateChatPeer = newPeerID + + // Update fingerprint tracking if needed + if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { + peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) + peerIDToPublicKeyFingerprint[newPeerID] = fingerprint + selectedPrivateChatFingerprint = fingerprint + } + + // Schedule UI refresh + // UI will update automatically + } else { + // Even if the chat isn't open, migrate any existing private chat data + if let messages = privateChats[oldPeerID] { + SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) + var chats = privateChats + chats[newPeerID] = messages + chats.removeValue(forKey: oldPeerID) + privateChats = chats // Trigger setter + } + + // Transfer unread status + if unreadPrivateMessages.contains(oldPeerID) { + unreadPrivateMessages.remove(oldPeerID) + unreadPrivateMessages.insert(newPeerID) + } + + // Update fingerprint mapping + if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { + peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) + peerIDToPublicKeyFingerprint[newPeerID] = fingerprint + } + } + } + + // First check if this is a peer ID update for our current chat + updatePrivateChatPeerIfNeeded() + + // Then handle favorite/unfavorite messages if applicable + if let isFavorite = notification.userInfo?["isFavorite"] as? Bool { + let peerID = PeerID(hexData: peerPublicKey) + let action = isFavorite ? "favorited" : "unfavorited" + + // Find peer nickname + let peerNickname: String + if let nickname = meshService.peerNickname(peerID: peerID) { + peerNickname = nickname + } else if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) { + peerNickname = favorite.peerNickname + } else { + peerNickname = "Unknown" + } + + // Create system message + let systemMessage = BitchatMessage( + id: UUID().uuidString, + sender: "System", + content: "\(peerNickname) \(action) you", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: nil, + mentions: nil + ) + + // Add to message stream + addMessage(systemMessage) + + // Update peer manager to refresh UI + // UnifiedPeerService updates automatically via subscriptions + } + } } - + // MARK: - App Lifecycle - + @MainActor - func handleDidBecomeActive() { - lifecycleCoordinator.handleDidBecomeActive() - } + @objc private func appDidBecomeActive() { + // Check Bluetooth state and show alert if needed + if let bleService = meshService as? BLEService { + let currentState = bleService.getCurrentBluetoothState() + updateBluetoothState(currentState) + } + // When app becomes active, send read receipts for visible private chat + if let peerID = selectedPrivateChatPeer { + // Try immediately + self.markPrivateMessagesAsRead(from: peerID) + // And again with a delay + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { + self.markPrivateMessagesAsRead(from: peerID) + } + } + // Subscriptions will be resent after connections come back up + } + @MainActor - func handleScreenshotCaptured() { - lifecycleCoordinator.handleScreenshotCaptured() - } + @objc private func userDidTakeScreenshot() { + // Respect privacy: do not broadcast screenshots taken from non-chat sheets + if isLocationChannelsSheetPresented { + // Show a warning about sharing location screenshots publicly + showScreenshotPrivacyWarning = true + return + } + if isAppInfoPresented { + // Silently ignore screenshots of app info + return + } + // Send screenshot notification based on current context + let screenshotMessage = "* \(nickname) took a screenshot *" + + if let peerID = selectedPrivateChatPeer { + // In private chat - send to the other person + if let peerNickname = meshService.peerNickname(peerID: peerID) { + // Only send screenshot notification if we have an established session + // This prevents triggering handshake requests for screenshot notifications + let sessionState = meshService.getNoiseSessionState(for: peerID) + switch sessionState { + case .established: + // Send the message directly without going through sendPrivateMessage to avoid local echo + messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString) + case .none, .failed, .handshakeQueued, .handshaking: + // Don't send screenshot notification if no session exists + SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security) + } + } + + // Show local notification immediately as system message (only in chat) + let localNotification = BitchatMessage( + sender: "system", + content: "you took a screenshot", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: meshService.peerNickname(peerID: peerID), + senderPeerID: meshService.myPeerID + ) + var chats = privateChats + if chats[peerID] == nil { + chats[peerID] = [] + } + chats[peerID]?.append(localNotification) + privateChats = chats // Trigger setter + + } else { + // In public chat - send to active public channel + switch activeChannel { + case .mesh: + meshService.sendMessage(screenshotMessage, + mentions: [], + messageID: UUID().uuidString, + timestamp: Date()) + case .location(let ch): + Task { @MainActor in + do { + let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: screenshotMessage, + geohash: ch.geohash, + senderIdentity: identity, + nickname: self.nickname, + teleported: LocationChannelManager.shared.teleported + ) + let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + if targetRelays.isEmpty { + SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } + // Track ourselves as active participant + self.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) + } catch { + SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) + self.addSystemMessage( + String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + ) + } + } + } + + + // Show local notification immediately as system message (only in chat) + let localNotification = BitchatMessage( + sender: "system", + content: "you took a screenshot", + timestamp: Date(), + isRelay: false + ) + // Add system message + addMessage(localNotification) + } + } + + @objc private func appWillResignActive() { + // No-op; avoid forcing synchronize on resign + } + /// Save identity state without stopping services (for backgrounding) func saveIdentityState() { - lifecycleCoordinator.saveIdentityState() + // Force save any pending identity changes (verifications, favorites, etc) + identityManager.forceSave() + + // Verify identity key is still there + _ = keychain.verifyIdentityKeyExists() } @objc func applicationWillTerminate() { - lifecycleCoordinator.applicationWillTerminate() - } + // Send leave message to all peers + meshService.stopServices() + // Save identity state + saveIdentityState() + } + + @MainActor + private func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID, originalTransport: String? = nil) { + // First, try to resolve the current peer ID in case they reconnected with a new ID + var actualPeerID = peerID + + // Check if this peer ID exists in current nicknames + if meshService.peerNickname(peerID: peerID) == nil { + // Peer not found with this ID, try to find by fingerprint or nickname + if let oldNoiseKey = Data(hexString: peerID.id), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: oldNoiseKey) { + let peerNickname = favoriteStatus.peerNickname + + // Search for the current peer ID with the same nickname + for (currentPeerID, currentNickname) in meshService.getPeerNicknames() { + if currentNickname == peerNickname { + SecureLogger.info("📖 Resolved updated peer ID for read receipt: \(peerID) -> \(currentPeerID)", category: .session) + actualPeerID = currentPeerID + break + } + } + } + } + + // If this originated over Nostr, skip (handled by Nostr code paths) + if originalTransport == "nostr" { + return + } + // Use router to decide (mesh if reachable, else Nostr if available) + messageRouter.sendReadReceipt(receipt, to: actualPeerID) + } + @MainActor func markPrivateMessagesAsRead(from peerID: PeerID) { - lifecycleCoordinator.markPrivateMessagesAsRead(from: peerID) + privateChatManager.markAsRead(from: peerID) + + // Handle GeoDM (nostr_*) read receipts directly via per-geohash identity + if peerID.isGeoDM, + let recipientHex = nostrKeyMapping[peerID], + case .location(let ch) = LocationChannelManager.shared.selectedChannel, + let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { + let messages = privateChats[peerID] ?? [] + for message in messages where message.senderPeerID == peerID && !message.isRelay { + if !sentReadReceipts.contains(message.id) { + SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", category: .session) + let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) + nostrTransport.senderPeerID = meshService.myPeerID + nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id) + sentReadReceipts.insert(message.id) + } + } + return + } + + // Get the peer's Noise key to check for Nostr messages + var noiseKeyHex: PeerID? = nil + var peerNostrPubkey: String? = nil + + // First check if peerID is already a hex Noise key + if let noiseKey = Data(hexString: peerID.id), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { + noiseKeyHex = peerID + peerNostrPubkey = favoriteStatus.peerNostrPublicKey + } + // Otherwise get the Noise key from the peer info + else if let peer = unifiedPeerService.getPeer(by: peerID) { + noiseKeyHex = PeerID(hexData: peer.noisePublicKey) + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey) + peerNostrPubkey = favoriteStatus?.peerNostrPublicKey + + // Also remove unread status from the stable Noise key if it exists + if let keyHex = noiseKeyHex, unreadPrivateMessages.contains(keyHex) { + unreadPrivateMessages.remove(keyHex) + } + } + + // Send Nostr read ACKs if peer has Nostr capability + if peerNostrPubkey != nil { + // Check messages under both ephemeral peer ID and stable Noise key + let messagesToAck = getPrivateChatMessages(for: peerID) + + for message in messagesToAck { + // Only send read ACKs for messages from the peer (not our own) + // Check both the ephemeral peer ID and stable Noise key as sender + if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay { + // Skip if we already sent an ACK for this message + if !sentReadReceipts.contains(message.id) { + // Use stable Noise key hex if available; else fall back to peerID + let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID) + let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) + messageRouter.sendReadReceipt(receipt, to: recipPeer) + sentReadReceipts.insert(message.id) + } + } + } + } } + func getMessages(for peerID: PeerID?) -> [BitchatMessage] { + if let peerID { + return getPrivateChatMessages(for: peerID) + } else { + return messages + } + } + + @MainActor + func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { + var combined: [BitchatMessage] = [] + + // Gather messages under the ephemeral peer ID + if let ephemeralMessages = privateChats[peerID] { + combined.append(contentsOf: ephemeralMessages) + } + + // Also include messages stored under the stable Noise key (Nostr path) + if let peer = unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) + if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] { + combined.append(contentsOf: nostrMessages) + } + } + + // De-duplicate by message ID: keep the item with the most advanced delivery status. + // This prevents duplicate IDs causing LazyVStack warnings and blank rows, and ensures + // we show the row whose status has already progressed to delivered/read. + func statusRank(_ s: DeliveryStatus?) -> Int { + guard let s = s else { return 0 } + switch s { + case .failed: return 1 + case .sending: return 2 + case .sent: return 3 + case .partiallyDelivered: return 4 + case .delivered: return 5 + case .read: return 6 + } + } + + var bestByID: [String: BitchatMessage] = [:] + for msg in combined { + if let existing = bestByID[msg.id] { + let lhs = statusRank(existing.deliveryStatus) + let rhs = statusRank(msg.deliveryStatus) + if rhs > lhs || (rhs == lhs && msg.timestamp > existing.timestamp) { + bestByID[msg.id] = msg + } + } else { + bestByID[msg.id] = msg + } + } + + // Return chronologically sorted, de-duplicated list + return bestByID.values.sorted { $0.timestamp < $1.timestamp } + } + @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { - peerIdentityCoordinator.getPeerIDForNickname(nickname) + // When in a geohash channel, allow resolving by geohash participant nickname + switch LocationChannelManager.shared.selectedChannel { + case .location: + // If a disambiguation suffix is present (e.g., "name#abcd"), try exact displayName match first + if nickname.contains("#") { + if let person = visibleGeohashPeople().first(where: { $0.displayName == nickname }) { + let convKey = PeerID(nostr_: person.id) + nostrKeyMapping[convKey] = person.id + return convKey + } + } + let base: String = { + if let hashIndex = nickname.firstIndex(of: "#") { return String(nickname[.. nickname) + if let pub = geoNicknames.first(where: { (_, nick) in nick.lowercased() == base })?.key { + let convKey = PeerID(nostr_: pub) + nostrKeyMapping[convKey] = pub + return convKey + } + case .mesh: + break + } + // Fallback to mesh nickname resolution + return unifiedPeerService.getPeerID(for: nickname) } - + + // MARK: - Emergency Functions - + // PANIC: Emergency data clearing for activist safety @MainActor - @discardableResult - func panicClearAllData(restartServices: Bool = true) -> Bool { - panicRecoveryBlocked = true - isPanicResetting = true - defer { isPanicResetting = false } - - // Stop internet and location-presence work before clearing identity or - // state. These services cancel their subscriptions and delayed tasks, - // so old callbacks cannot reconnect during the transaction. - panicNetworkLifecycle.stop() - - // Establish both independent durable intents before erasing anything. - // `wipeMedia` will still attempt deletion if neither write succeeds. - let recoveryIntent = panicRecoveryOperations.begin() - - // Quiesce the mesh before clearing stores. Identity replacement below - // deliberately stays stopped until media deletion and marker commit. - if let panicTransport = meshService as? PanicResettingTransport { - panicTransport.suspendForPanicReset() - } else { - meshService.emergencyDisconnectAll() - } - - // Invalidate detached media preparation and close live capture file - // handles before clearing state or removing the media directory. - mediaTransferCoordinator.resetForPanic() - liveVoiceCoordinator.resetForPanic() - privateChatClearGeneration &+= 1 - queuedPrivateChatClears.removeAll(keepingCapacity: false) - privateChatClearInFlight = false - - // Deny and release any clear-media confirmations before identities, - // message state, and local files are wiped. - cancelAllLegacyPrivateMediaConsents() - - // Clear all messages (public timelines and private chats live in the - // single-writer ConversationStore; the derived `messages` view and - // the legacy mirror empty with it) - conversations.clearAll() - pendingGeohashSystemMessages.removeAll() - + func panicClearAllData() { + // Messages are processed immediately - nothing to flush + + // Clear all messages + messages.removeAll() + privateChatManager.privateChats.removeAll() + privateChatManager.unreadMessages.removeAll() + // Delete all keychain data (including Noise and Nostr keys) - let keychainWipeCompleted = keychain.deleteAllKeychainData() - if !keychainWipeCompleted { - SecureLogger.error( - "Panic keychain cleanup incomplete; recovery remains pending", - category: .security - ) - } - + _ = keychain.deleteAllKeychainData() + // Clear UserDefaults identity data userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") userDefaults.removeObject(forKey: "bitchat.messageRetentionKey") - - // Wipe persisted location state (selected channel, teleport set, - // bookmarks). For an activist-safety wipe, where the user has been is - // exactly the data an adversary inspecting the device wants. - LocationStateManager.shared.panicWipe() - + + // Clear verified fingerprints + verifiedFingerprints.removeAll() + // Verified fingerprints are cleared when identity data is cleared below + // Reset nickname to anonymous nickname = "anon\(Int.random(in: 1000...9999))" - userDefaults.set(nickname, forKey: nicknameKey) - + saveNickname() + // Clear favorites and peer mappings // Clear through SecureIdentityStateManager instead of directly identityManager.clearAllIdentityData() - peerIdentityStore.clearAll() - locationPresenceStore.reset() - publicRateLimiter.reset() - + peerIDToPublicKeyFingerprint.removeAll() + // Clear persistent favorites from keychain FavoritesPersistenceService.shared.clearAllFavorites() - - // Drop courier mail carried for third parties (memory and disk), - // our own queued outbox, the carried public history, and the - // counters describing all of it - CourierStore.shared.wipe() - BridgeCourierService.shared.wipe() - messageRouter.wipeOutbox() - GossipMessageArchive.wipeDefault() - StoreAndForwardMetrics.shared.reset() - - // Ambient-liveliness bookkeeping: sampled nearby-chat previews, the - // daily sightings tally, and the echoes-dismissed watermark - GeohashChatActivityTracker.shared.clear() - MeshSightingsTracker.shared.clear() - MeshEchoSettings.reset() - NotificationPrivacySettings.reset() - // A hand-added relay names an operator someone chose to route through, - // which is the kind of trace a wipe should not leave behind. - NostrRelaySettings.reset() - - // Drop private group keys and rosters (keychain + disk) - groupStore.wipe() - // Drop cached peers' prekey bundles (who we could write to is - // metadata too). Our own prekey privates are keychain-backed and go - // with deleteAllKeychainData above plus the identity reset below. - PrekeyBundleStore.shared.wipe() - // Drop bulletin-board posts and tombstones (memory and disk); board - // posts are signed with our identity key and persist for days. - BoardStore.shared.wipe() - - // Drop any share-extension handoff staged in the app group. The normal - // panic path clears this through AppChromeModel.onPanicWipe, but the - // crash-recovery replay calls this method directly and would otherwise - // let a staged envelope survive the wipe. Clearing here is idempotent - // (it only removes the app-group key), so the double-clear is harmless. - if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) { - SharedContentStore(defaults: sharedDefaults).discardAll() - } - + // Identity manager has cleared persisted identity data above - + // Clear autocomplete state autocompleteSuggestions.removeAll() showAutocomplete = false autocompleteRange = nil selectedAutocompleteIndex = 0 - + // Clear selected private chat selectedPrivateChatPeer = nil - - // Clear live location/geohash session state. Persisted location state - // was wiped above, but the running view model can still be scoped to a - // geohash channel and hold subscriptions tied to the old Nostr identity. - activeChannel = .mesh - setGeoChatSubscriptionID(nil) - setGeoDmSubscriptionID(nil) - _ = clearGeoSamplingSubs() - cachedGeohashIdentity = nil - nostrKeyMapping.removeAll() - + selectedPrivateChatFingerprint = nil + // Clear read receipt tracking sentReadReceipts.removeAll() deduplicationService.clearAll() + // Clear all caches + invalidateEncryptionCache() + // IMPORTANT: Clear Nostr-related state - // Drop relay subscriptions, handlers, pending sends, and replay state. - // Geohash DM handlers can capture pre-wipe Nostr identities, so a plain - // disconnect is not enough here. - NostrRelayManager.shared.resetForPanicWipe() - // Clearing relay handlers stops NEW events, but a detached gift-wrap - // decrypt spawned just before the wipe still holds a pre-wipe key and - // ciphertext; bump the pipeline's wipe generation so its result is - // dropped at the main-actor delivery hop instead of landing here. - nostrCoordinator.inbound.invalidateInFlightDecrypts() + // Disconnect from Nostr relays and clear subscriptions + nostrRelayManager?.disconnect() nostrRelayManager = nil - + // Clear Nostr identity associations idBridge.clearAllAssociations() - - // Replace the BLE identity while keeping the radio stopped. It may - // reopen only after the durable panic transaction commits. - if let panicTransport = meshService as? PanicResettingTransport { - panicTransport.resetIdentityForPanic( - currentNickname: nickname, - restartServices: false - ) - } else { - meshService.setNickname(nickname) + + // Disconnect from all peers and clear persistent identity + // This will force creation of a new identity (new fingerprint) on next launch + meshService.emergencyDisconnectAll() + if let bleService = meshService as? BLEService { + bleService.resetIdentityForPanic(currentNickname: nickname) } + + // No need to force UserDefaults synchronization + + // Reinitialize Nostr with new identity + // This will generate new Nostr keys derived from new Noise keys + Task { @MainActor in + // Small delay to ensure cleanup completes + try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds + + // Reinitialize Nostr relay manager with new identity + nostrRelayManager = NostrRelayManager() + setupNostrMessageHandling() + nostrRelayManager?.connect() + } + + // Delete ALL media files (incoming and outgoing) in background + Task.detached(priority: .utility) { + do { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let filesDir = base.appendingPathComponent("files", isDirectory: true) - // The wipe must finish before this security action returns. A detached - // task could otherwise lose a race with a new capture or app exit and - // leave pre-panic media behind. - let panicCompleted: Bool - do { - try panicRecoveryOperations.wipeMedia(recoveryIntent) - if keychainWipeCompleted { - try panicRecoveryOperations.complete() - panicCompleted = true - SecureLogger.info( - "🗑️ Deleted all media files during panic clear", - category: .session - ) - } else { - // Do not clear either durable recovery marker. Startup must - // retry the entire transaction before any transport restarts. - panicCompleted = false + // Delete the entire files directory and recreate it + if FileManager.default.fileExists(atPath: filesDir.path) { + try FileManager.default.removeItem(at: filesDir) + SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session) + } + + // Recreate empty directory structure + try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) + } catch { + SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session) } - } catch { - panicCompleted = false - SecureLogger.error( - "Panic transaction did not commit; services remain stopped: \(error)", - category: .security - ) - } - panicRecoveryBlocked = !panicCompleted - // BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from - // the host user's real cache tree just as the default media wipe does. - #if os(iOS) - if !TestEnvironment.isRunningTests { + // BCH-01-013: Clear iOS app switcher snapshots + // These are stored in Library/Caches/Snapshots// + #if os(iOS) Self.clearAppSwitcherSnapshots() - } - #endif - - guard panicCompleted else { return false } - - if let panicTransport = meshService as? PanicResettingTransport { - // Startup recovery reopens admission but leaves actual service - // start to the bootstrapper immediately after this method. - panicTransport.completePanicReset( - restartServices: restartServices - ) + #endif } - if restartServices { - // All persistent state and media are gone. Bring each service back - // only now, under the new identity — a panic-resetting transport - // owns its own restart sequencing above. - if !(meshService is PanicResettingTransport) { - meshService.startServices() - } + // Force immediate UI update for panic mode + // UI updates immediately - no flushing needed - if !TestEnvironment.isRunningTests { - nostrRelayManager = NostrRelayManager.shared - setupNostrMessageHandling() - } - panicNetworkLifecycle.restart() - } - - // In a duress scenario "did it work?" must not be a guess — the - // natural response to uncertainty is to trigger the wipe again. - // The failure case surfaces separately via `panicRecoveryBlocked`. - addMeshOnlySystemMessage( - String(localized: "system.panic.completed", defaultValue: "all data wiped — new identity created", comment: "System message confirming a successful panic wipe") - ) - - return true } /// BCH-01-013: Clear iOS app switcher snapshots during panic mode @@ -1809,362 +2032,1195 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage #endif // MARK: - Autocomplete - + func updateAutocomplete(for text: String, cursorPosition: Int) { - composerCoordinator.updateAutocomplete(for: text, cursorPosition: cursorPosition) - } + // Build candidate list based on active channel + let peerCandidates: [String] = { + switch activeChannel { + case .mesh: + let values = meshService.getPeerNicknames().values + return Array(values.filter { $0 != meshService.myNickname }) + case .location(let ch): + // From geochash participants we have seen via Nostr events + var tokens = Set() + for (pubkey, nick) in geoNicknames { + let suffix = String(pubkey.suffix(4)) + tokens.insert("\(nick)#\(suffix)") + } + // Optionally exclude self nick#abcd from suggestions + if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { + let myToken = nickname + "#" + String(id.publicKeyHex.suffix(4)) + tokens.remove(myToken) + } + return Array(tokens) + } + }() + let (suggestions, range) = autocompleteService.getSuggestions( + for: text, + peers: peerCandidates, + cursorPosition: cursorPosition + ) + + if !suggestions.isEmpty { + autocompleteSuggestions = suggestions + autocompleteRange = range + showAutocomplete = true + selectedAutocompleteIndex = 0 + } else { + autocompleteSuggestions = [] + autocompleteRange = nil + showAutocomplete = false + selectedAutocompleteIndex = 0 + } + } + func completeNickname(_ nickname: String, in text: inout String) -> Int { - composerCoordinator.completeNickname(nickname, in: &text) + guard let range = autocompleteRange else { return text.count } + + text = autocompleteService.applySuggestion(nickname, to: text, range: range) + + // Hide autocomplete + showAutocomplete = false + autocompleteSuggestions = [] + autocompleteRange = nil + selectedAutocompleteIndex = 0 + + // Return new cursor position + return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2) } - + // MARK: - Message Formatting - + @MainActor - func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { - messageFormatter.formatMessageAsText(message, colorScheme: colorScheme, theme: theme ?? currentTheme) + func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + // Determine if this message was sent by self (mesh, geo, or DM) + let isSelf: Bool = { + if let spid = message.senderPeerID { + // In geohash channels, compare against our per-geohash nostr short ID + if case .location(let ch) = activeChannel, spid.isGeoChat { + let myGeo: NostrIdentity? = { + if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash { + return cached.identity + } + // Fallback: derive and cache (should rarely happen) + if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { + cachedGeohashIdentity = (ch.geohash, identity) + return identity + } + return nil + }() + if let myGeo { + return spid == PeerID(nostr: myGeo.publicKeyHex) + } + } + return spid == meshService.myPeerID + } + // Fallback by nickname + if message.sender == nickname { return true } + if message.sender.hasPrefix(nickname + "#") { return true } + return false + }() + // Check cache first (key includes dark mode + self flag) + let isDark = colorScheme == .dark + if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) { + return cachedText + } + + // Not cached, format the message + var result = AttributedString() + + let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) + + if message.sender != "system" { + // Sender (at the beginning) with light-gray suffix styling if present + let (baseName, suffix) = message.sender.splitSuffix() + var senderStyle = AttributeContainer() + // Use consistent color for all senders + senderStyle.foregroundColor = baseColor + // Bold the user's own nickname + let fontWeight: Font.Weight = isSelf ? .bold : .medium + senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced) + // Make sender clickable: encode senderPeerID into a custom URL + if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") { + senderStyle.link = url + } + + // Prefix "<@" + result.append(AttributedString("<@").mergingAttributes(senderStyle)) + // Base name + result.append(AttributedString(baseName).mergingAttributes(senderStyle)) + // Optional suffix in lighter variant of the base color (green or orange for self) + if !suffix.isEmpty { + var suffixStyle = senderStyle + suffixStyle.foregroundColor = baseColor.opacity(0.6) + result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) + } + // Suffix "> " + result.append(AttributedString("> ").mergingAttributes(senderStyle)) + + // Process content with hashtags and mentions + let content = message.content + + // For extremely long content, render as plain text to avoid heavy regex/layout work, + // unless the content includes Cashu tokens we want to chip-render below + // Compute NSString-backed length for regex/nsrange correctness with multi-byte characters + let nsContent = content as NSString + let nsLen = nsContent.length + let containsCashuEarly: Bool = { + let rx = Patterns.quickCashuPresence + return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0 + }() + if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { + var plainStyle = AttributeContainer() + plainStyle.foregroundColor = baseColor + plainStyle.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + result.append(AttributedString(content).mergingAttributes(plainStyle)) + } else { + // Reuse compiled regexes and detector from MessageFormattingEngine + let hashtagRegex = Patterns.hashtag + let mentionRegex = Patterns.mention + let cashuRegex = Patterns.cashu + let bolt11Regex = Patterns.bolt11 + let lnurlRegex = Patterns.lnurl + let lightningSchemeRegex = Patterns.lightningScheme + let detector = Patterns.linkDetector + let hasMentionsHint = content.contains("@") + let hasHashtagsHint = content.contains("#") + let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http") + let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:") + let hasCashuHint = content.lowercased().contains("cashu") + + let hashtagMatches = hasHashtagsHint ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + let mentionMatches = hasMentionsHint ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + let urlMatches = hasURLHint ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : [] + let cashuMatches = hasCashuHint ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + let lightningMatches = hasLightningHint ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + let bolt11Matches = hasLightningHint ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + let lnurlMatches = hasLightningHint ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + + // Combine and sort matches, excluding hashtags/URLs overlapping mentions + let mentionRanges = mentionMatches.map { $0.range(at: 0) } + func overlapsMention(_ r: NSRange) -> Bool { + for mr in mentionRanges { if NSIntersectionRange(r, mr).length > 0 { return true } } + return false + } + // Helper: check if a hashtag is immediately attached to a preceding @mention (e.g., @name#abcd) + func attachedToMention(_ r: NSRange) -> Bool { + if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex { + var i = content.index(before: nsRange.lowerBound) + while true { + let ch = content[i] + if ch.isWhitespace || ch.isNewline { break } + if ch == "@" { return true } + if i == content.startIndex { break } + i = content.index(before: i) + } + } + return false + } + // Helper: ensure '#' starts a new token (start-of-line or whitespace before '#') + func isStandaloneHashtag(_ r: NSRange) -> Bool { + guard let nsRange = Range(r, in: content) else { return false } + if nsRange.lowerBound == content.startIndex { return true } + let prev = content.index(before: nsRange.lowerBound) + return content[prev].isWhitespace || content[prev].isNewline + } + var allMatches: [(range: NSRange, type: String)] = [] + for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) { + allMatches.append((match.range(at: 0), "hashtag")) + } + for match in mentionMatches { + allMatches.append((match.range(at: 0), "mention")) + } + for match in urlMatches where !overlapsMention(match.range) { + allMatches.append((match.range, "url")) + } + for match in cashuMatches where !overlapsMention(match.range(at: 0)) { + allMatches.append((match.range(at: 0), "cashu")) + } + // Lightning scheme first to avoid overlapping submatches + for match in lightningMatches where !overlapsMention(match.range(at: 0)) { + allMatches.append((match.range(at: 0), "lightning")) + } + // Exclude overlaps with lightning/url for bolt11/lnurl + let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) } + func overlapsOccupied(_ r: NSRange) -> Bool { + for or in occupied { if NSIntersectionRange(r, or).length > 0 { return true } } + return false + } + for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { + allMatches.append((match.range(at: 0), "bolt11")) + } + for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { + allMatches.append((match.range(at: 0), "lnurl")) + } + allMatches.sort { $0.range.location < $1.range.location } + + // Build content with styling + var lastEnd = content.startIndex + let isMentioned = message.mentions?.contains(nickname) ?? false + + for (range, type) in allMatches { + // Add text before match + if let nsRange = Range(range, in: content) { + if lastEnd < nsRange.lowerBound { + let beforeText = String(content[lastEnd..) + let token = String(matchText.dropFirst()).lowercased() + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) } + // Do not link if this hashtag is directly attached to an @mention (e.g., @name#geohash) + let attachedToMention: Bool = { + // nsRange is the Range for this match within content + // Walk left until whitespace/newline; if we encounter '@' first, treat as part of mention + if nsRange.lowerBound > content.startIndex { + var i = content.index(before: nsRange.lowerBound) + while true { + let ch = content[i] + if ch.isWhitespace || ch.isNewline { break } + if ch == "@" { return true } + if i == content.startIndex { break } + i = content.index(before: i) + } + } + return false + }() + // Also require the '#' to start a new token (whitespace or start-of-line before '#') + let standalone: Bool = { + if nsRange.lowerBound == content.startIndex { return true } + let prev = content.index(before: nsRange.lowerBound) + return content[prev].isWhitespace || content[prev].isNewline + }() + var tagStyle = AttributeContainer() + tagStyle.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + tagStyle.foregroundColor = baseColor + if isGeohash && !attachedToMention && standalone, let url = URL(string: "bitchat://geohash/\(token)") { + tagStyle.link = url + tagStyle.underlineStyle = .single + } + result.append(AttributedString(matchText).mergingAttributes(tagStyle)) + } else if type == "cashu" { + // Skip inline token; a styled chip is rendered below the message + // We insert a single space to avoid words sticking together + var spacer = AttributeContainer() + spacer.foregroundColor = baseColor + spacer.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + result.append(AttributedString(" ").mergingAttributes(spacer)) + } else if type == "lightning" || type == "bolt11" || type == "lnurl" { + // Skip inline invoice/link; a styled chip is rendered below the message + var spacer = AttributeContainer() + spacer.foregroundColor = baseColor + spacer.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + result.append(AttributedString(" ").mergingAttributes(spacer)) + } else { + // Keep URL styling and make it tappable via .link attribute + var matchStyle = AttributeContainer() + matchStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced) + if type == "url" { + matchStyle.foregroundColor = isSelf ? .orange : .blue + matchStyle.underlineStyle = .single + if let url = URL(string: matchText) { + matchStyle.link = url + } + } + result.append(AttributedString(matchText).mergingAttributes(matchStyle)) + } + } + // Advance lastEnd safely in case of overlaps + if lastEnd < nsRange.upperBound { + lastEnd = nsRange.upperBound + } + } + } + + // Add remaining text + if lastEnd < content.endIndex { + let remainingText = String(content[lastEnd...]) + var remainingStyle = AttributeContainer() + remainingStyle.foregroundColor = baseColor + remainingStyle.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + if isMentioned { + remainingStyle.font = remainingStyle.font?.bold() + } + result.append(AttributedString(remainingText).mergingAttributes(remainingStyle)) + } + } + + // Add timestamp at the end (smaller, light grey) + let timestamp = AttributedString(" [\(message.formattedTimestamp)]") + var timestampStyle = AttributeContainer() + timestampStyle.foregroundColor = Color.gray.opacity(0.7) + timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) + result.append(timestamp.mergingAttributes(timestampStyle)) + } else { + // System message + var contentStyle = AttributeContainer() + contentStyle.foregroundColor = Color.gray + let content = AttributedString("* \(message.content) *") + contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic() + result.append(content.mergingAttributes(contentStyle)) + + // Add timestamp at the end for system messages too + let timestamp = AttributedString(" [\(message.formattedTimestamp)]") + var timestampStyle = AttributeContainer() + timestampStyle.foregroundColor = Color.gray.opacity(0.5) + timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) + result.append(timestamp.mergingAttributes(timestampStyle)) + } + + // Cache the formatted text + message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf) + + return result } @MainActor - func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { - messageFormatter.formatMessageHeader(message, colorScheme: colorScheme, theme: theme ?? currentTheme) + func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + let isSelf: Bool = { + if let spid = message.senderPeerID { + if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") { + if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { + return spid == PeerID(nostr: myGeo.publicKeyHex) + } + } + return spid == meshService.myPeerID + } + if message.sender == nickname { return true } + if message.sender.hasPrefix(nickname + "#") { return true } + return false + }() + + let isDark = colorScheme == .dark + let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) + + if message.sender == "system" { + var style = AttributeContainer() + style.foregroundColor = baseColor + style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) + return AttributedString(message.sender).mergingAttributes(style) + } + + var result = AttributedString() + let (baseName, suffix) = message.sender.splitSuffix() + var senderStyle = AttributeContainer() + senderStyle.foregroundColor = baseColor + senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced) + if let spid = message.senderPeerID, + let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") { + senderStyle.link = url + } + + result.append(AttributedString("<@").mergingAttributes(senderStyle)) + result.append(AttributedString(baseName).mergingAttributes(senderStyle)) + if !suffix.isEmpty { + var suffixStyle = senderStyle + suffixStyle.foregroundColor = baseColor.opacity(0.6) + result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) + } + result.append(AttributedString("> ").mergingAttributes(senderStyle)) + return result } // MARK: - Noise Protocol Support - + @MainActor func updateEncryptionStatusForPeers() { - peerIdentityCoordinator.updateEncryptionStatusForPeers() + for peerID in connectedPeers { + updateEncryptionStatusForPeer(peerID) + } } - + @MainActor - func updateEncryptionStatus(for peerID: PeerID) { - peerIdentityCoordinator.updateEncryptionStatus(for: peerID) + private func updateEncryptionStatusForPeer(_ peerID: PeerID) { + let noiseService = meshService.getNoiseService() + + if noiseService.hasEstablishedSession(with: peerID) { + peerEncryptionStatus[peerID] = encryptionStatus(for: peerID) + } else if noiseService.hasSession(with: peerID) { + // Session exists but not established - handshaking + peerEncryptionStatus[peerID] = .noiseHandshaking + } else { + // No session at all + peerEncryptionStatus[peerID] = Optional.none + } + + // Invalidate cache when encryption status changes + invalidateEncryptionCache(for: peerID) + + // UI will update automatically via @Published properties } - + @MainActor func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { - peerIdentityCoordinator.getEncryptionStatus(for: peerID) + // Check cache first + if let cachedStatus = encryptionStatusCache[peerID] { + return cachedStatus + } + + // This must be a pure function - no state mutations allowed + // to avoid SwiftUI update loops + + // Check if we've ever established a session by looking for a fingerprint + let hasEverEstablishedSession = getFingerprint(for: peerID) != nil + + let sessionState = meshService.getNoiseSessionState(for: peerID) + + let status: EncryptionStatus + + // Determine status based on session state + switch sessionState { + case .established: + status = encryptionStatus(for: peerID) + case .handshaking, .handshakeQueued: + // If we've ever established a session, show secured instead of handshaking + if hasEverEstablishedSession { + // Check if it was verified before + status = encryptionStatus(for: peerID) + } else { + // First time establishing - show handshaking + status = .noiseHandshaking + } + case .none: + // If we've ever established a session, show secured instead of no handshake + if hasEverEstablishedSession { + // Check if it was verified before + status = encryptionStatus(for: peerID) + } else { + // Never established - show no handshake + status = .noHandshake + } + case .failed: + // If we've ever established a session, show secured instead of failed + if hasEverEstablishedSession { + // Check if it was verified before + status = encryptionStatus(for: peerID) + } else { + // Never established - show failed + status = .none + } + } + + // Cache the result + encryptionStatusCache[peerID] = status + + // Encryption status determined: \(status) + + return status } - + // Clear caches when data changes - @MainActor - func invalidateEncryptionCache(for peerID: PeerID? = nil) { - peerIdentityCoordinator.invalidateEncryptionCache(for: peerID) + private func invalidateEncryptionCache(for peerID: PeerID? = nil) { + if let peerID { + encryptionStatusCache.removeValue(forKey: peerID) + } else { + encryptionStatusCache.removeAll() + } } - + + // MARK: - Message Handling - - /// Invalidates the derived `messages` cache and notifies observers. - /// (Formerly pulled the channel's timeline into a stored `messages` - /// array; `messages` is now derived from the `ConversationStore`, so - /// only the invalidation remains. The `channel` parameter is kept for - /// call-site compatibility — every caller passes the active channel.) - @MainActor - func refreshVisibleMessages(from channel: ChannelID? = nil) { - visibleMessagesCache = nil - objectWillChange.send() - } - - /// `true` when a store change touches the active public conversation - /// (so the derived `messages` cache must be invalidated). - @MainActor - private func changeAffectsActivePublicConversation(_ change: ConversationChange) -> Bool { - let activeID = ConversationID(channelID: activeChannel) - switch change { - case .appended(let id, _), - .updated(let id, _), - .statusChanged(let id, _, _), - .messageRemoved(let id, _), - .cleared(let id), - .removed(let id), - .unreadChanged(let id, _): - return id == activeID - case .migrated(let source, let destination): - return source == activeID || destination == activeID + + func trimMessagesIfNeeded() { + if messages.count > maxMessages { + messages = Array(messages.suffix(maxMessages)) } } + @MainActor + func refreshVisibleMessages(from channel: ChannelID? = nil) { + let target = channel ?? activeChannel + messages = timelineStore.messages(for: target) + } + @MainActor private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { - messageFormatter.senderColor(for: message, isDark: isDark) + if let spid = message.senderPeerID { + if spid.isGeoChat || spid.isGeoDM { + let full = nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased() + return getNostrPaletteColor(for: full, isDark: isDark) + } else if spid.id.count == 16 { + // Mesh short ID + return getPeerPaletteColor(for: spid, isDark: isDark) + } else { + return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark) + } + } + // Fallback when we only have a display name + return Color(peerSeed: message.sender.lowercased(), isDark: isDark) } // MARK: - MessageFormattingContext Protocol @MainActor func isSelfMessage(_ message: BitchatMessage) -> Bool { - messageFormatter.isSelfMessage(message) + if let spid = message.senderPeerID { + // In geohash channels, compare against our per-geohash nostr short ID + if case .location(let ch) = activeChannel, spid.isGeoChat { + let myGeo: NostrIdentity? = { + if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash { + return cached.identity + } + // Derive and cache + if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { + cachedGeohashIdentity = (ch.geohash, identity) + return identity + } + return nil + }() + if let myGeo { + return spid == PeerID(nostr: myGeo.publicKeyHex) + } + } + return spid == meshService.myPeerID + } + // Fallback by nickname + if message.sender == nickname { return true } + if message.sender.hasPrefix(nickname + "#") { return true } + return false } @MainActor func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { - peerColor(for: message, isDark: isDark) + return peerColor(for: message, isDark: isDark) } @MainActor func peerURL(for peerID: PeerID) -> URL? { - messageFormatter.peerURL(for: peerID) + return URL(string: "bitchat://user/\(peerID.toPercentEncoded())") } // Public helpers for views to color peers consistently in lists @MainActor func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { - messageFormatter.colorForNostrPubkey(pubkeyHexLowercased, isDark: isDark) + return getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark) } @MainActor func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color { - messageFormatter.colorForMeshPeer(id: peerID, isDark: isDark) + return getPeerPaletteColor(for: peerID, isDark: isDark) + } + + // MARK: - Peer Palette Coordination + private let meshPalette = MinimalDistancePalette(config: .mesh) + private let nostrPalette = MinimalDistancePalette(config: .nostr) + + @MainActor + private func meshSeed(for peerID: PeerID) -> String { + if let full = getNoiseKeyForShortID(peerID)?.id.lowercased() { + return "noise:" + full + } + return peerID.id.lowercased() + } + + @MainActor + private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color { + if peerID == meshService.myPeerID { + return .orange + } + + meshPalette.ensurePalette(for: currentMeshPaletteSeeds()) + if let color = meshPalette.color(for: peerID.id, isDark: isDark) { + return color + } + return Color(peerSeed: meshSeed(for: peerID), isDark: isDark) + } + + @MainActor + private func currentMeshPaletteSeeds() -> [String: String] { + let myID = meshService.myPeerID + var seeds: [String: String] = [:] + for peer in allPeers where peer.peerID != myID { + seeds[peer.peerID.id] = meshSeed(for: peer.peerID) + } + return seeds + } + + @MainActor + private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color { + let myHex = currentGeohashIdentityHex() + if let myHex, pubkeyHexLowercased == myHex { + return .orange + } + + nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex)) + if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) { + return color + } + return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark) + } + + @MainActor + private func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] { + var seeds: [String: String] = [:] + let excluded = myHex ?? "" + for person in visibleGeohashPeople() where person.id != excluded { + seeds[person.id] = "nostr:" + person.id + } + return seeds + } + + @MainActor + private func currentGeohashIdentityHex() -> String? { + if case .location(let channel) = LocationChannelManager.shared.selectedChannel, + let identity = try? idBridge.deriveIdentity(forGeohash: channel.geohash) { + return identity.publicKeyHex.lowercased() + } + return nil } // Clear the current public channel's timeline (visible + persistent buffer) @MainActor func clearCurrentPublicTimeline() { - publicConversationCoordinator.clearCurrentPublicTimeline() + // Clear messages from current timeline + messages.removeAll() + timelineStore.clear(channel: activeChannel) + + // Delete associated media files (images, voice notes, files) in background + // Only delete from current chat to avoid removing private chat media + Task.detached(priority: .utility) { + do { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let filesDir = base.appendingPathComponent("files", isDirectory: true) + + // Only clear public media (mesh channel only - geohash media is separate) + // Note: This is conservative - only clears outgoing since we authored those + let outgoingDirs = [ + filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), + filesDir.appendingPathComponent("images/outgoing", isDirectory: true), + filesDir.appendingPathComponent("files/outgoing", isDirectory: true) + ] + + for dir in outgoingDirs { + if FileManager.default.fileExists(atPath: dir.path) { + try? FileManager.default.removeItem(at: dir) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + } + } + } catch { + SecureLogger.error("Failed to clear media files: \(error)", category: .session) + } + } } - + + // MARK: - Message Management + + private func addMessage(_ message: BitchatMessage) { + // Check for duplicates + guard !messages.contains(where: { $0.id == message.id }) else { return } + messages.append(message) + trimMessagesIfNeeded() + } + + // Update encryption status in appropriate places, not during view updates + @MainActor + private func updateEncryptionStatus(for peerID: PeerID) { + let noiseService = meshService.getNoiseService() + + if noiseService.hasEstablishedSession(with: peerID) { + peerEncryptionStatus[peerID] = encryptionStatus(for: peerID) + } else if noiseService.hasSession(with: peerID) { + peerEncryptionStatus[peerID] = .noiseHandshaking + } else { + peerEncryptionStatus[peerID] = Optional.none + } + + // Invalidate cache when encryption status changes + invalidateEncryptionCache(for: peerID) + + // UI will update automatically via @Published properties + } + + // MARK: - Fingerprint Management + + func showFingerprint(for peerID: PeerID) { + showingFingerprintFor = peerID + } + // MARK: - Peer Lookup Helpers - + func getPeer(byID peerID: PeerID) -> BitchatPeer? { return peerIndex[peerID] } - + @MainActor func getFingerprint(for peerID: PeerID) -> String? { - peerIdentityCoordinator.getFingerprint(for: peerID) + return unifiedPeerService.getFingerprint(for: peerID) } - + + /// Check if fingerprint is verified using our persisted data + @MainActor + private func encryptionStatus(for peerID: PeerID) -> EncryptionStatus { + if let fp = getFingerprint(for: peerID), verifiedFingerprints.contains(fp) { + return .noiseVerified + } else { + return .noiseSecured + } + } + /// Helper to resolve nickname for a peer ID through various sources @MainActor - func resolveNickname(for peerID: PeerID) -> String { - peerIdentityCoordinator.resolveNickname(for: peerID) + private func resolveNickname(for peerID: PeerID) -> String { + // Guard against empty or very short peer IDs + guard !peerID.isEmpty else { + return "unknown" + } + + // Check if this might already be a nickname (not a hex peer ID) + // Peer IDs are hex strings, so they only contain 0-9 and a-f + if !peerID.isHex { + // If it's already a nickname, just return it + return peerID.id + } + + // First try direct peer nicknames from mesh service + let peerNicknames = meshService.getPeerNicknames() + if let nickname = peerNicknames[peerID] { + return nickname + } + + // Try to resolve through fingerprint and social identity + if let fingerprint = getFingerprint(for: peerID) { + if let identity = identityManager.getSocialIdentity(for: fingerprint) { + // Prefer local petname if set + if let petname = identity.localPetname { + return petname + } + // Otherwise use their claimed nickname + return identity.claimedNickname + } + } + + // Use anonymous with shortened peer ID + // Ensure we have at least 4 characters for the prefix + let prefixLength = min(4, peerID.id.count) + let prefix = String(peerID.id.prefix(prefixLength)) + + // Avoid "anonanon" by checking if ID already starts with "anon" + if prefix.starts(with: "anon") { + return "peer\(prefix)" + } + return "anon\(prefix)" } - - @MainActor + func getMyFingerprint() -> String { - peerIdentityCoordinator.getMyFingerprint() + let fingerprint = meshService.getNoiseService().getIdentityFingerprint() + return fingerprint } - + @MainActor func verifyFingerprint(for peerID: PeerID) { - verificationCoordinator.verifyFingerprint(for: peerID) + guard let fingerprint = getFingerprint(for: peerID) else { return } + + // Update secure storage with verified status + identityManager.setVerified(fingerprint: fingerprint, verified: true) + saveIdentityState() + + // Update local set for UI + verifiedFingerprints.insert(fingerprint) + + // Update encryption status after verification + updateEncryptionStatus(for: peerID) } @MainActor func unverifyFingerprint(for peerID: PeerID) { - verificationCoordinator.unverifyFingerprint(for: peerID) + guard let fingerprint = getFingerprint(for: peerID) else { return } + identityManager.setVerified(fingerprint: fingerprint, verified: false) + saveIdentityState() + verifiedFingerprints.remove(fingerprint) + updateEncryptionStatus(for: peerID) } - + @MainActor func loadVerifiedFingerprints() { - verificationCoordinator.loadVerifiedFingerprints() + // Load verified fingerprints directly from secure storage + verifiedFingerprints = identityManager.getVerifiedFingerprints() + // Log snapshot for debugging persistence + let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ") + SecureLogger.info("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: .security) + // Also log any offline favorites and whether we consider them verified + let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected } + for fav in offlineFavorites { + let fp = unifiedPeerService.getFingerprint(for: fav.peerID) + let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false + let fpShort = fp?.prefix(8) ?? "nil" + SecureLogger.info("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: .security) + } + // Invalidate cached encryption statuses so offline favorites can show verified badges immediately + invalidateEncryptionCache() + // Trigger UI refresh of peer list + objectWillChange.send() } + + private func setupNoiseCallbacks() { + let noiseService = meshService.getNoiseService() + + // Set up authentication callback + noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in + DispatchQueue.main.async { + guard let self = self else { return } - func setupNoiseCallbacks() { - verificationCoordinator.setupNoiseCallbacks() - vouchCoordinator.setupNoiseCallbacks() + SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) + + // Update encryption status + if self.verifiedFingerprints.contains(fingerprint) { + self.peerEncryptionStatus[peerID] = .noiseVerified + // Encryption: noiseVerified + } else { + self.peerEncryptionStatus[peerID] = .noiseSecured + // Encryption: noiseSecured + } + + // Invalidate cache when encryption status changes + self.invalidateEncryptionCache(for: peerID) + + // Cache shortID -> full Noise key mapping as soon as session authenticates + if self.shortIDToNoiseKey[peerID] == nil, + let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) { + let stable = PeerID(hexData: keyData) + self.shortIDToNoiseKey[peerID] = stable + SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.id.prefix(8))…", category: .session) + } + + // If a QR verification is pending but not sent yet, send it now that session is authenticated + if var pending = self.pendingQRVerifications[peerID], pending.sent == false { + self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA) + pending.sent = true + self.pendingQRVerifications[peerID] = pending + SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security) + } + + // Schedule UI update + // UI will update automatically + } + } + + // Set up handshake required callback + noiseService.onHandshakeRequired = { [weak self] peerID in + DispatchQueue.main.async { + guard let self = self else { return } + self.peerEncryptionStatus[peerID] = .noiseHandshaking + + // Invalidate cache when encryption status changes + self.invalidateEncryptionCache(for: peerID) + } + } } - - /// Whether the fingerprint currently counts as vouched (≥1 valid vouch - /// from a voucher I verified, and no explicit verification of mine). - @MainActor - func isVouchedFingerprint(_ fingerprint: String) -> Bool { - identityManager.isVouched(fingerprint: fingerprint) - } - + // MARK: - BitchatDelegate Methods - + // MARK: - Command Handling - + /// Processes IRC-style commands starting with '/'. /// - Parameter command: The full command string including the leading slash - /// - Note: Supports commands like /msg, /who, /slap, /clear, /help + /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help @MainActor - func handleCommand(_ command: String) { + private func handleCommand(_ command: String) { let result = commandProcessor.process(command) - + switch result { case .success(let message): if let msg = message { - addCommandOutput(msg) + addSystemMessage(msg) } case .error(let message): - addCommandOutput(message) + addSystemMessage(message) case .handled: // Command was handled, no message needed break } } - - /// Command output belongs in the conversation where the user typed the - /// command; the public timeline is invisible while a DM is open. The DM - /// selection is read *after* processing so commands that switch chats - /// (`/msg`) print into the conversation they just opened. - @MainActor - private func addCommandOutput(_ content: String) { - if let peerID = selectedPrivateChatPeer { - addLocalPrivateSystemMessage(content, to: peerID) - } else { - addSystemMessage(content) - } - } - - /// Origin conversation for deferred command output, captured when the - /// command is issued (before any async work starts). - @MainActor - func currentCommandDestination() -> CommandOutputDestination { - if let peerID = selectedPrivateChatPeer { - return .privateChat(peerID) - } - // Deferring commands (/ping) are rejected in geohash channels, so a - // non-DM origin is always the #mesh timeline. - return .meshTimeline - } - - /// Routes deferred command output (async /ping results) into the - /// conversation captured at issue time, immune to chat switches in the - /// meantime. A DM result lands in the origin chat's history even if that - /// chat is no longer selected (or was cleared — it then reappears as the - /// first message when the chat is reopened). - @MainActor - func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { - switch destination { - case .privateChat(let peerID): - addLocalPrivateSystemMessage(content, to: peerID) - case .meshTimeline: - publicConversationCoordinator.addMeshOnlySystemMessage(content) - } - } - + // MARK: - Message Reception - - @MainActor - func didReceiveTransportEvent(_ event: TransportEvent) { - switch event { - case .messageReceived(let message): - _ = didReceiveTransportMessageSynchronously(message) - - case let .publicMessageReceived( - peerID, - nickname, - content, - timestamp, - messageID - ): - transportEventCoordinator.didReceivePublicMessageSynchronously( - from: peerID, - nickname: nickname, - content: content, - timestamp: timestamp, - messageID: messageID - ) - - case let .noisePayloadReceived(peerID, type, payload, timestamp): - transportEventCoordinator.didReceiveNoisePayloadSynchronously( - from: peerID, - type: type, - payload: payload, - timestamp: timestamp - ) - - case let .groupMessageReceived(payload, timestamp): - groupCoordinator.handleGroupMessagePayload( - payload, - timestamp: timestamp - ) - - case let .publicVoiceFrameReceived( - peerID, - nickname, - payload, - timestamp - ): - liveVoiceCoordinator.handlePublicVoiceFramePayload( - from: peerID, - nickname: nickname, - payload: payload, - timestamp: timestamp - ) - - case .peerConnected(let peerID): - transportEventCoordinator.didConnectToPeerSynchronously(peerID) - mediaTransferCoordinator.peerDidReconnect(peerID) - - case .peerDisconnected(let peerID): - transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID) - mediaTransferCoordinator.peerDidDisconnect(peerID) - - case .peerListUpdated(let peers): - peerListCoordinator.didUpdatePeerListSynchronously(peers) - // A peer-list update follows every verified announce, which is - // where a peer's `.vouch` capability actually arrives. - vouchCoordinator.peersUpdated(peers) - - case .peerSnapshotsUpdated: - break - - case let .messageDeliveryStatusUpdated(messageID, status): - deliveryCoordinator.didUpdateMessageDeliveryStatus( - messageID, - status: status - ) - - case .bluetoothStateUpdated(let state): - updateBluetoothState(state) + + func didReceiveMessage(_ message: BitchatMessage) { + Task { @MainActor in + // Early validation + guard !isMessageBlocked(message) else { return } + guard !message.content.trimmed.isEmpty || message.isPrivate else { return } + + // Route to appropriate handler + if message.isPrivate { + handlePrivateMessage(message) + } else { + handlePublicMessage(message) + } + + // Post-processing + checkForMentions(message) + sendHapticFeedback(for: message) } } - @MainActor - func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool { - transportEventCoordinator.didReceiveMessageSynchronously(message) - } + /// Find message index trying both short (16-hex) and long (64-hex) peer ID formats. + /// Returns the peer ID where the message was found and its index, or nil if not found. + private func findMessageIndex(messageID: String, peerID: PeerID) -> (peerID: PeerID, index: Int)? { + // Try direct lookup first + if let messages = privateChats[peerID], + let idx = messages.firstIndex(where: { $0.id == messageID }) { + return (peerID, idx) + } - func didReceiveMessage(_ message: BitchatMessage) { - transportEventCoordinator.didReceiveMessage(message) + // Try with full noise key if peerID is short (16 hex chars) + if peerID.bare.count == 16, + let peer = unifiedPeerService.getPeer(by: peerID), + !peer.noisePublicKey.isEmpty { + let longID = PeerID(hexData: peer.noisePublicKey) + if let messages = privateChats[longID], + let idx = messages.firstIndex(where: { $0.id == messageID }) { + return (longID, idx) + } + } + + // Try with short form if peerID is long (64 hex = noise key) + if peerID.bare.count == 64 { + let shortID = peerID.toShort() + if let messages = privateChats[shortID], + let idx = messages.firstIndex(where: { $0.id == messageID }) { + return (shortID, idx) + } + } + + return nil } // Low-level BLE events func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { - transportEventCoordinator.didReceiveNoisePayload( - from: peerID, - type: type, - payload: payload, - timestamp: timestamp - ) - } + Task { @MainActor in + switch type { + case .privateMessage: + guard let pm = PrivateMessagePacket.decode(from: payload) else { return } - func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { - transportEventCoordinator.didReceivePublicMessage( - from: peerID, - nickname: nickname, - content: content, - timestamp: timestamp, - messageID: messageID - ) - } + // BCH-01-012: Check blocking before processing private message to prevent notification bypass + if isPeerBlocked(peerID) { + SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security) + return + } - func didReceiveGroupMessage(payload: Data, timestamp: Date) { - Task { @MainActor [weak self] in - self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp) + let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown" + let pmMentions = parseMentions(from: pm.content) + let msg = BitchatMessage( + id: pm.messageID, + sender: senderName, + content: pm.content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: nickname, + senderPeerID: peerID, + mentions: pmMentions.isEmpty ? nil : pmMentions + ) + handlePrivateMessage(msg) + // Send delivery ACK back over BLE + meshService.sendDeliveryAck(for: pm.messageID, to: peerID) + + case .delivered: + guard let messageID = String(data: payload, encoding: .utf8) else { return } + guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname, + let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return } + + // Don't downgrade from .read to .delivered + if case .read = privateChats[foundPeerID]?[idx].deliveryStatus { return } + + privateChats[foundPeerID]?[idx].deliveryStatus = .delivered(to: name, at: Date()) + objectWillChange.send() + + case .readReceipt: + guard let messageID = String(data: payload, encoding: .utf8) else { return } + guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname, + let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return } + + // Explicitly unwrap and re-assign to ensure the @Published setter is called + if let messages = privateChats[foundPeerID], idx < messages.count { + messages[idx].deliveryStatus = .read(by: name, at: Date()) + privateChats[foundPeerID] = messages + privateChatManager.objectWillChange.send() + objectWillChange.send() + } + case .verifyChallenge: + // Parse and respond + guard let tlv = VerificationService.shared.parseVerifyChallenge(payload) else { return } + // Ensure intended for our noise key + let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased() + guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return } + // Deduplicate: ignore if we've already responded to this nonce for this peer + if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return } + lastVerifyNonceByPeer[peerID] = tlv.nonceA + // Record inbound challenge time keyed by stable fingerprint if available + if let fp = getFingerprint(for: peerID) { + lastInboundVerifyChallengeAt[fp] = Date() + // If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side) + if verifiedFingerprints.contains(fp) { + let now = Date() + let last = lastMutualToastAt[fp] ?? .distantPast + if now.timeIntervalSince(last) > 60 { // 1-minute throttle + lastMutualToastAt[fp] = now + let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) + NotificationService.shared.sendLocalNotification( + title: "Mutual verification", + body: "You and \(name) verified each other", + identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)" + ) + } + } + } + meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA) + // Silent response: no toast needed on responder + case .verifyResponse: + guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return } + // Check pending for this peer + guard let pending = pendingQRVerifications[peerID] else { return } + guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return } + // Verify signature with expected sign key + let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex) + if ok { + pendingQRVerifications.removeValue(forKey: peerID) + if let fp = getFingerprint(for: peerID) { + let short = fp.prefix(8) + SecureLogger.info("🔐 Marking verified fingerprint: \(short)", category: .security) + identityManager.setVerified(fingerprint: fp, verified: true) + saveIdentityState() + verifiedFingerprints.insert(fp) + let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) + NotificationService.shared.sendLocalNotification( + title: "Verified", + body: "You verified \(name)", + identifier: "verify-success-\(peerID)-\(UUID().uuidString)" + ) + // If we also recently responded to their challenge, flag mutual and toast (initiator side) + if let t = lastInboundVerifyChallengeAt[fp], Date().timeIntervalSince(t) < 600 { + let now = Date() + let lastToast = lastMutualToastAt[fp] ?? .distantPast + if now.timeIntervalSince(lastToast) > 60 { + lastMutualToastAt[fp] = now + NotificationService.shared.sendLocalNotification( + title: "Mutual verification", + body: "You and \(name) verified each other", + identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)" + ) + } + } + updateEncryptionStatus(for: peerID) + } + } + } } } - func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) { - Task { @MainActor [weak self] in - self?.liveVoiceCoordinator.handlePublicVoiceFramePayload( - from: peerID, - nickname: nickname, - payload: payload, - timestamp: timestamp + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { + Task { @MainActor in + let normalized = content.trimmed + let publicMentions = parseMentions(from: normalized) + let msg = BitchatMessage( + id: messageID, + sender: nickname, + content: normalized, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: peerID, + mentions: publicMentions.isEmpty ? nil : publicMentions ) + handlePublicMessage(msg) + checkForMentions(msg) + sendHapticFeedback(for: msg) } } // MARK: - QR Verification API @MainActor func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { - verificationCoordinator.beginQRVerification(with: qr) + // Find a matching peer by Noise key + let targetNoise = qr.noiseKeyHex.lowercased() + guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else { + return false + } + let peerID = peer.peerID + // If we already have a pending verification with this peer, don't send another + if pendingQRVerifications[peerID] != nil { + return true + } + // Generate nonceA + var nonce = Data(count: 16) + _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + var pending = PendingVerification(noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, startedAt: Date(), sent: false) + pendingQRVerifications[peerID] = pending + // If Noise session is established, send immediately; otherwise trigger handshake and send on auth + let noise = meshService.getNoiseService() + if noise.hasEstablishedSession(with: peerID) { + meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) + pending.sent = true + pendingQRVerifications[peerID] = pending + } else { + meshService.triggerHandshake(with: peerID) + } + return true } // Mention parsing moved from BLE – use the existing non-optional helper below @@ -2179,262 +3235,651 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // MARK: - Peer Connection Events func didConnectToPeer(_ peerID: PeerID) { - transportEventCoordinator.didConnectToPeer(peerID) - Task { @MainActor [weak self] in - self?.mediaTransferCoordinator.peerDidReconnect(peerID) - } - } + SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) + + // Handle all main actor work async + Task { @MainActor in + isConnected = true + + // Register ephemeral session with identity manager + identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) + + // Intentionally do not resend favorites on reconnect. + // We only send our npub when a favorite is toggled on, or if our npub changes. + + // Force UI refresh + objectWillChange.send() - func didDisconnectFromPeer(_ peerID: PeerID) { - transportEventCoordinator.didDisconnectFromPeer(peerID) - Task { @MainActor [weak self] in - self?.mediaTransferCoordinator.peerDidDisconnect(peerID) - } - } - - func didUpdatePeerList(_ peers: [PeerID]) { - peerListCoordinator.didUpdatePeerList(peers) - // A peer-list update follows every verified announce, which is where a - // peer's `.vouch` capability actually arrives — retry vouching now that - // capabilities may finally be known (closes the auth-time capability race). - Task { @MainActor [weak self] in - self?.vouchCoordinator.peersUpdated(peers) - } - } - - @MainActor - func cleanupOldReadReceipts() { - deliveryCoordinator.cleanupOldReadReceipts() - auditConversationStore() - } - - /// Periodic on-device verification of the `ConversationStore`'s - /// correctness invariants, piggybacked on the read-receipt cleanup - /// cadence (peer-list updates) so no extra timer exists. Loud on - /// violation (one error line each), near-silent when healthy (sampled - /// heartbeat: first + every Nth audit). The audit is O(total messages) - /// and allocation-free while healthy — measured ~0.5 ms at 5k messages - /// (see `PerformanceBaselineTests.testConversationStoreAudit`), cheap - /// relative to its cadence, so it always runs. - @MainActor - private func auditConversationStore() { - storeAuditCount += 1 - let violations = conversations.auditInvariants() - guard violations.isEmpty else { - for violation in violations { - SecureLogger.error("🚨 ConversationStore invariant violated: \(violation)", category: .session) + // Cache mapping to full Noise key for session continuity on disconnect + if let peer = unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) + shortIDToNoiseKey[peerID] = noiseKeyHex } + + // Flush any queued messages for this peer via router + messageRouter.flushOutbox(for: peerID) + } + } + + func didDisconnectFromPeer(_ peerID: PeerID) { + SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) + + // Remove ephemeral session from identity manager + identityManager.removeEphemeralSession(peerID: peerID) + + // If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite) + var derivedStableKeyHex = shortIDToNoiseKey[peerID] + if derivedStableKeyHex == nil, + let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) { + derivedStableKeyHex = PeerID(hexData: key) + shortIDToNoiseKey[peerID] = derivedStableKeyHex + } + + if let current = selectedPrivateChatPeer, current == peerID, let stableKeyHex = derivedStableKeyHex { + // Migrate messages view context to stable key so header shows favorite + Nostr globe + if let messages = privateChats[peerID] { + if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] } + let existing = Set(privateChats[stableKeyHex]!.map { $0.id }) + for msg in messages where !existing.contains(msg.id) { + let updated = BitchatMessage( + id: msg.id, + sender: msg.sender, + content: msg.content, + timestamp: msg.timestamp, + isRelay: msg.isRelay, + originalSender: msg.originalSender, + isPrivate: msg.isPrivate, + recipientNickname: msg.recipientNickname, + senderPeerID: msg.senderPeerID == meshService.myPeerID ? meshService.myPeerID : stableKeyHex, + mentions: msg.mentions, + deliveryStatus: msg.deliveryStatus + ) + privateChats[stableKeyHex]?.append(updated) + } + privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp } + privateChats.removeValue(forKey: peerID) + } + if unreadPrivateMessages.contains(peerID) { + unreadPrivateMessages.remove(peerID) + unreadPrivateMessages.insert(stableKeyHex) + } + selectedPrivateChatPeer = stableKeyHex + objectWillChange.send() + } + + // Update peer list immediately and force UI refresh + DispatchQueue.main.async { [weak self] in + // UnifiedPeerService updates automatically via subscriptions + self?.objectWillChange.send() + } + + // Clear sent read receipts for this peer since they'll need to be resent after reconnection + // Only clear receipts for messages from this specific peer + if let messages = privateChats[peerID] { + for message in messages { + // Remove read receipts for messages FROM this peer (not TO this peer) + if message.senderPeerID == peerID { + sentReadReceipts.remove(message.id) + } + } + } + } + + func didUpdatePeerList(_ peers: [PeerID]) { + // UI updates must run on the main thread. + // The delegate callback is not guaranteed to be on the main thread. + DispatchQueue.main.async { + // Update through peer manager + // UnifiedPeerService updates automatically via subscriptions + self.isConnected = !peers.isEmpty + + // Clean up stale unread peer IDs whenever peer list updates + self.cleanupStaleUnreadPeerIDs() + + // Smart notification logic for "bitchatters nearby" + let meshPeers = peers.filter { peerID in + self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID) + } + let meshPeerSet = Set(meshPeers) + + if meshPeerSet.isEmpty { + self.scheduleNetworkEmptyTimer() + } else { + self.invalidateNetworkEmptyTimer() + // Don't trim recentlySeenPeers here - let timers handle cleanup. + // Trimming immediately causes peers to be treated as "new" when they + // briefly drop and reconnect, triggering notification floods. + let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers) + + if !newPeers.isEmpty { + // Rate limit: max one notification per 5 minutes + let cooldown = TransportConfig.networkNotificationCooldownSeconds + if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown { + // Only mark peers as seen when we actually notify about them + // This ensures peers arriving during cooldown will be included in the next notification + self.recentlySeenPeers.formUnion(newPeers) + self.lastNetworkNotificationTime = Date() + NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) + SecureLogger.info( + "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", + category: .session + ) + } + self.scheduleNetworkResetTimer() + } + } + + // Register ephemeral sessions for all connected peers + for peerID in peers { + self.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) + } + + // Schedule UI refresh to ensure offline favorites are shown + // UI will update automatically + + // Update encryption status for all peers + self.updateEncryptionStatusForPeers() + + // Schedule UI update for peer list change + // UI will update automatically + + // Check if we need to update private chat peer after reconnection + if self.selectedPrivateChatFingerprint != nil { + self.updatePrivateChatPeerIfNeeded() + } + + // Don't end private chat when peer temporarily disconnects + // The fingerprint tracking will allow us to reconnect when they come back + } + } + + // MARK: - Helper Methods + + /// Clean up stale unread peer IDs that no longer exist in the peer list + @MainActor + private func cleanupStaleUnreadPeerIDs() { + let currentPeerIDs = Set(unifiedPeerService.peers.map { $0.peerID }) + let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs) + + if !staleIDs.isEmpty { + var idsToRemove: [PeerID] = [] + for staleID in staleIDs { + // Don't remove temporary Nostr peer IDs that have messages + if staleID.isGeoDM { + // Check if we have messages from this temporary peer + if let messages = privateChats[staleID], !messages.isEmpty { + // Keep this ID - it has messages + continue + } + } + + // Don't remove stable Noise key hexes (64 char hex strings) that have messages + // These are used for Nostr messages when peer is offline + if staleID.isNoiseKeyHex { + if let messages = privateChats[staleID], !messages.isEmpty { + // Keep this ID - it's a stable key with messages + continue + } + } + + // Remove this stale ID + idsToRemove.append(staleID) + unreadPrivateMessages.remove(staleID) + } + + if !idsToRemove.isEmpty { + SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session) + } + } + + // Also clean up old sentReadReceipts to prevent unlimited growth + // Keep only receipts from messages we still have + cleanupOldReadReceipts() + } + + @MainActor + private func scheduleNetworkResetTimer() { + networkResetTimer?.invalidate() + networkResetTimer = Timer.scheduledTimer( + timeInterval: networkResetGraceSeconds, + target: self, + selector: #selector(onNetworkResetTimerFired(_:)), + userInfo: nil, + repeats: false + ) + } + + @MainActor + @objc private func onNetworkResetTimerFired(_ timer: Timer) { + let activeMeshPeers = meshService + .currentPeerSnapshots() + .filter { snapshot in + snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) + } + if activeMeshPeers.isEmpty { + recentlySeenPeers.removeAll() + SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session) + } else { + SecureLogger.debug("⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", category: .session) + } + networkResetTimer = nil + } + + @MainActor + private func scheduleNetworkEmptyTimer() { + guard networkEmptyTimer == nil else { return } + networkEmptyTimer = Timer.scheduledTimer( + timeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds, + target: self, + selector: #selector(onNetworkEmptyTimerFired(_:)), + userInfo: nil, + repeats: false + ) + SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session) + } + + @MainActor + private func invalidateNetworkEmptyTimer() { + if networkEmptyTimer != nil { + networkEmptyTimer?.invalidate() + networkEmptyTimer = nil + } + } + + @MainActor + @objc private func onNetworkEmptyTimerFired(_ timer: Timer) { + let activeMeshPeers = meshService + .currentPeerSnapshots() + .filter { snapshot in + snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) + } + if activeMeshPeers.isEmpty { + recentlySeenPeers.removeAll() + SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session) + } else { + SecureLogger.debug("⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", category: .session) + } + networkEmptyTimer = nil + } + + private func cleanupOldReadReceipts() { + // Skip cleanup during startup phase or if privateChats is empty + // This prevents removing valid receipts before messages are loaded + if isStartupPhase || privateChats.isEmpty { return } - let appendCount = conversations.appendCount - if storeAuditCount == 1 || storeAuditCount.isMultiple(of: TransportConfig.conversationStoreAuditLogInterval) { - SecureLogger.debug( - "Store audit OK: \(conversations.conversationsByID.count) conversations, \(conversations.totalMessageCount) messages, map=\(conversations.messageIDMapCount), appends since last audit=\(appendCount - storeAuditLastAppendCount)", - category: .session - ) + + // Build set of all message IDs we still have + var validMessageIDs = Set() + for (_, messages) in privateChats { + for message in messages { + validMessageIDs.insert(message.id) + } + } + + // Remove receipts for messages we no longer have + let oldCount = sentReadReceipts.count + sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) + + let removedCount = oldCount - sentReadReceipts.count + if removedCount > 0 { + SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session) } - storeAuditLastAppendCount = appendCount } - + func parseMentions(from content: String) -> [String] { - composerCoordinator.parseMentions(from: content) + // Allow optional disambiguation suffix '#abcd' for duplicate nicknames + let regex = Patterns.mention + let nsContent = content as NSString + let nsLen = nsContent.length + let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) + + var mentions: [String] = [] + let peerNicknames = meshService.getPeerNicknames() + // Compose the valid mention tokens based on current peers (already suffixed where needed) + var validTokens = Set(peerNicknames.values) + // Always allow mentioning self by base nickname and suffixed disambiguator + validTokens.insert(nickname) + let selfSuffixToken = nickname + "#" + String(meshService.myPeerID.id.prefix(4)) + validTokens.insert(selfSuffixToken) + + for match in matches { + if let range = Range(match.range(at: 1), in: content) { + let mentionedName = String(content[range]) + // Only include if it's a current valid token (base or suffixed) + if validTokens.contains(mentionedName) { + mentions.append(mentionedName) + } + } + } + + return Array(Set(mentions)) // Remove duplicates } - + func isFavorite(fingerprint: String) -> Bool { return identityManager.isFavorite(fingerprint: fingerprint) } - + // MARK: - Delivery Tracking - + func didReceiveReadReceipt(_ receipt: ReadReceipt) { - performDeliveryUpdate { coordinator in - coordinator.didReceiveReadReceipt(receipt) - } + // Find the message and update its read status + updateMessageDeliveryStatus(receipt.originalMessageID, status: .read(by: receipt.readerNickname, at: receipt.timestamp)) } - + func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { - performDeliveryUpdate { coordinator in - coordinator.didUpdateMessageDeliveryStatus(messageID, status: status) - } + updateMessageDeliveryStatus(messageID, status: status) } - + func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { - performDeliveryUpdate { coordinator in - coordinator.updateMessageDeliveryStatus(messageID, status: status) + + // Helper function to check if we should skip this update + func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool { + guard let current = currentStatus else { return false } + + // Don't downgrade from read to delivered + switch (current, newStatus) { + case (.read, .delivered): + return true + case (.read, .sent): + return true + default: + return false + } } + + // Update in main messages + if let index = messages.firstIndex(where: { $0.id == messageID }) { + let currentStatus = messages[index].deliveryStatus + if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { + messages[index].deliveryStatus = status + } + } + + // Update in private chats + for (peerID, chatMessages) in privateChats { + guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue } + + let currentStatus = chatMessages[index].deliveryStatus + guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue } + + // Update delivery status directly (BitchatMessage is a class/reference type) + privateChats[peerID]?[index].deliveryStatus = status + } + + // Trigger UI update for delivery status change + DispatchQueue.main.async { [weak self] in + self?.objectWillChange.send() + } + } - + // MARK: - Helper for System Messages func addSystemMessage(_ content: String, timestamp: Date = Date()) { - publicConversationCoordinator.addSystemMessage(content, timestamp: timestamp) + let systemMessage = BitchatMessage( + sender: "system", + content: content, + timestamp: timestamp, + isRelay: false + ) + messages.append(systemMessage) } /// Add a system message to the mesh timeline only (never geohash). /// If mesh is currently active, also append to the visible `messages`. @MainActor func addMeshOnlySystemMessage(_ content: String) { - publicConversationCoordinator.addMeshOnlySystemMessage(content) - } - - /// Public helper to add a system message to the public chat timeline. - /// Also persists the message into the active channel's backing store so it survives timeline rebinds. - @MainActor - func addPublicSystemMessage(_ content: String) { - publicConversationCoordinator.addPublicSystemMessage(content) - } - - /// Add a system message only if viewing a geohash location channel (never post to mesh). - @MainActor - func addGeohashOnlySystemMessage(_ content: String) { - publicConversationCoordinator.addGeohashOnlySystemMessage(content) - } - - /// Add a local system message to one specific geohash timeline, active or - /// not. Used by the board's new-pin alerts to scope-match the pin's channel. - @MainActor - func addGeohashSystemMessage(_ content: String, geohash: String) { let systemMessage = BitchatMessage( sender: "system", content: content, timestamp: Date(), isRelay: false ) - appendGeohashMessageIfAbsent(systemMessage, toGeohash: geohash) + timelineStore.append(systemMessage, to: .mesh) + refreshVisibleMessages() + trimMessagesIfNeeded() + objectWillChange.send() + } + + /// Public helper to add a system message to the public chat timeline. + /// Also persists the message into the active channel's backing store so it survives timeline rebinds. + @MainActor + func addPublicSystemMessage(_ content: String) { + let systemMessage = BitchatMessage( + sender: "system", + content: content, + timestamp: Date(), + isRelay: false + ) + timelineStore.append(systemMessage, to: activeChannel) + refreshVisibleMessages(from: activeChannel) + // Track the content key so relayed copies of the same system-style message are ignored + let contentKey = deduplicationService.normalizedContentKey(systemMessage.content) + deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp) + trimMessagesIfNeeded() + objectWillChange.send() + } + + /// Add a system message only if viewing a geohash location channel (never post to mesh). + @MainActor + func addGeohashOnlySystemMessage(_ content: String) { + if case .location = activeChannel { + addPublicSystemMessage(content) + } else { + // Not on a location channel yet: queue to show when user switches + timelineStore.queueGeohashSystemMessage(content) + } } // Send a public message without adding a local user echo. // Used for emotes where we want a local system-style confirmation instead. @MainActor func sendPublicRaw(_ content: String) { - publicConversationCoordinator.sendPublicRaw(content) + if case .location(let ch) = activeChannel { + Task { @MainActor in + do { + let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: ch.geohash, + senderIdentity: identity, + nickname: self.nickname, + teleported: LocationChannelManager.shared.teleported + ) + let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + if targetRelays.isEmpty { + NostrRelayManager.shared.sendEvent(event) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } + } catch { + SecureLogger.error("❌ Failed to send geohash raw message: \(error)", category: .session) + } + } + return + } + // Default: send over mesh + meshService.sendMessage(content, + mentions: [], + messageID: UUID().uuidString, + timestamp: Date()) } + - // Send a normal public message (with local echo) to the active channel. - // CommandContextProvider hook for commands that post real messages - // (`/pay`); only called when no private chat is selected. - @MainActor - func sendPublicMessage(_ content: String) { - sendMessage(content) + + + + + + // MARK: - Base64URL utils + static func base64URLDecode(_ s: String) -> Data? { + var str = s.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + // Add padding if needed + let rem = str.count % 4 + if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) } + return Data(base64Encoded: str) } + + // + + + + + + + + + + /// Handle incoming public message @MainActor func handlePublicMessage(_ message: BitchatMessage) { - // Bridge hints are unauthenticated and may never suppress a genuine - // BLE sender. Once the radio packet has passed BLE signature checks, - // replace any earlier bridge alias before this row is enqueued. - if !message.isBridged, - let senderPeerID = message.senderPeerID, - !senderPeerID.isGeoChat { - BridgeService.shared.handleAuthenticatedRadioMessage(messageID: message.id) + let finalMessage = processActionMessage(message) + + // Drop if sender is blocked (covers geohash via Nostr pubkey mapping) + if isMessageBlocked(finalMessage) { return } + + // Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system) + let isGeo = finalMessage.senderPeerID?.isGeoChat == true + + // Apply per-sender and per-content rate limits (drop if exceeded) + // Treat action-style system messages (which carry a senderPeerID) the same as regular user messages + let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil + if shouldRateLimit { + let senderKey = normalizedSenderKey(for: finalMessage) + let contentKey = deduplicationService.normalizedContentKey(finalMessage.content) + if !publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { return } } - // A finalized voice note whose burst already streamed in live swaps - // into the existing bubble instead of appearing twice. - if liveVoiceCoordinator.absorbFinalizedVoiceNote(message) { return } - publicConversationCoordinator.handlePublicMessage(message) - } - /// Handle an incoming public Nostr message with its validated NIP-13 - /// difficulty; sufficient PoW relaxes the per-sender rate limit. - @MainActor - func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { - publicConversationCoordinator.handlePublicMessage(message, powBits: powBits) - } + // Size cap: drop extremely large public messages early + if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return } - /// Check for mentions and send notifications - func checkForMentions(_ message: BitchatMessage) { - publicConversationCoordinator.checkForMentions(message) - } + // Persist mesh messages to mesh timeline always + if !isGeo && finalMessage.sender != "system" { + timelineStore.append(finalMessage, to: .mesh) + } - /// Send haptic feedback for special messages (iOS only) - func sendHapticFeedback(for message: BitchatMessage) { - publicConversationCoordinator.sendHapticFeedback(for: message) + // Persist geochat messages to per-geohash timeline + if isGeo && finalMessage.sender != "system" { + if let gh = currentGeohash { + _ = timelineStore.appendIfAbsent(finalMessage, toGeohash: gh) + } + } + + // Only add message to current timeline if it matches active channel or is system + let isSystem = finalMessage.sender == "system" + let channelMatches: Bool = { + switch activeChannel { + case .mesh: return !isGeo || isSystem + case .location: return isGeo || isSystem + } + }() + + guard channelMatches else { return } + + // Removed background nudge notification for generic "new chats!" + + // Append via batching buffer (skip empty content) with simple dedup by ID + if !finalMessage.content.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) { + publicMessagePipeline.enqueue(finalMessage) + } + } + + /// Check for mentions and send notifications + + func checkForMentions(_ message: BitchatMessage) { // Determine our acceptable mention token. If any connected peer shares our nickname, + // require the disambiguated form '#' to trigger. + var myTokens: Set = [nickname] + let meshPeers = meshService.getPeerNicknames() + let collisions = meshPeers.values.filter { $0.hasPrefix(nickname + "#") } + if !collisions.isEmpty { + let suffix = "#" + String(meshService.myPeerID.id.prefix(4)) + myTokens = [nickname + suffix] + } + let isMentioned = (message.mentions?.contains { myTokens.contains($0) } ?? false) + + if isMentioned && message.sender != nickname { + SecureLogger.info("🔔 Mention from \(message.sender)", category: .session) + NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) } } -@MainActor -extension ChatViewModel { - func enqueueLegacyPrivateMediaConsent( - for peerID: PeerID, - transferId: String, - messageID: String, - completion: @escaping @MainActor (Bool) -> Void - ) { - let request = LegacyPrivateMediaConsentRequest( - id: UUID(), - peerID: peerID, - peerName: nicknameForPeer(peerID), - transferId: transferId, - messageID: messageID - ) - pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent( - request: request, - completion: completion - )) - if legacyPrivateMediaConsentRequest == nil { - legacyPrivateMediaConsentRequest = request - } - } - - func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) { - // SwiftUI may report both the selected button and the presentation - // binding's dismissal. Resolve only the exact request that was shown; - // a duplicate callback for it must not consume the next queued send. - guard legacyPrivateMediaConsentRequest?.id == requestID, - pendingLegacyPrivateMediaConsents.first?.request.id == requestID else { - return - } - let resolved = pendingLegacyPrivateMediaConsents.removeFirst() - // Drive the boolean presentation state through false before showing - // the next queued per-send warning. Otherwise SwiftUI sees true→true, - // closes the first dialog, and never presents the second. - legacyPrivateMediaConsentRequest = nil - resolved.completion(approved) - presentNextLegacyPrivateMediaConsentDeferred() - } - - func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) { - let invalidatedIDs = Set( - pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in - let request = pending.request - return request.transferId == transferId && request.messageID == messageID - ? request.id - : nil + /// Send haptic feedback for special messages (iOS only) + func sendHapticFeedback(for message: BitchatMessage) { #if os(iOS) + guard UIApplication.shared.applicationState == .active else { return } + + // Build acceptable target tokens: base nickname and, if in a location channel, nickname with '#abcd' + var tokens: [String] = [nickname] + #if os(iOS) + switch activeChannel { + case .location(let ch): + if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { + let d = String(id.publicKeyHex.suffix(4)) + tokens.append(nickname + "#" + d) } - ) - guard !invalidatedIDs.isEmpty else { return } + case .mesh: + break + } + #endif - pendingLegacyPrivateMediaConsents.removeAll { - invalidatedIDs.contains($0.request.id) - } - if let currentID = legacyPrivateMediaConsentRequest?.id, - invalidatedIDs.contains(currentID) { - legacyPrivateMediaConsentRequest = nil - presentNextLegacyPrivateMediaConsentDeferred() - } - } + let hugsMe = tokens.contains { message.content.contains("hugs \($0)") } || message.content.contains("hugs you") + let slapsMe = tokens.contains { message.content.contains("slaps \($0) around") } || message.content.contains("slaps you around") - func cancelAllLegacyPrivateMediaConsents() { - let pending = pendingLegacyPrivateMediaConsents - pendingLegacyPrivateMediaConsents.removeAll() - legacyPrivateMediaConsentRequest = nil - for item in pending { - item.completion(false) - } - } - - private func presentNextLegacyPrivateMediaConsentDeferred() { - guard legacyPrivateMediaConsentRequest == nil, - let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else { - return - } - DispatchQueue.main.async { [weak self] in - guard let self, - self.legacyPrivateMediaConsentRequest == nil, - self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else { - return + let isHugForMe = message.content.contains("🫂") && hugsMe + let isSlapForMe = message.content.contains("🐟") && slapsMe + + if isHugForMe && message.sender != nickname { + // Long warm haptic for hugs + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.prepare() + + for i in 0..<8 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds) { + impactFeedback.impactOccurred() + } } - self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request + } else if isSlapForMe && message.sender != nickname { + // Sharp haptic for slaps + let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) + impactFeedback.prepare() + impactFeedback.impactOccurred() } + #endif } } // End of ChatViewModel class + +extension ChatViewModel: PublicMessagePipelineDelegate { + func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { + messages + } + + func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { + self.messages = messages + } + + func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { + deduplicationService.normalizedContentKey(content) + } + + func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { + deduplicationService.contentTimestamp(forKey: key) + } + + func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { + deduplicationService.recordContentKey(key, timestamp: timestamp) + } + + func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) { + trimMessagesIfNeeded() + } + + func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) { + _ = formatMessageAsText(message, colorScheme: currentColorScheme) + } + + func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { + isBatchingPublic = isBatching + } +} diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift deleted file mode 100644 index 3ccb7d12..00000000 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ /dev/null @@ -1,688 +0,0 @@ -import BitFoundation -import BitLogger -import Combine -import Foundation - -struct ChatViewModelServiceBundle { - let commandProcessor: CommandProcessor - let messageRouter: MessageRouter - let privateChatManager: PrivateChatManager - let unifiedPeerService: UnifiedPeerService - let autocompleteService: AutocompleteService - let deduplicationService: MessageDeduplicationService - let publicMessagePipeline: PublicMessagePipeline - - @MainActor - init( - keychain: KeychainManagerProtocol, - idBridge: NostrIdentityBridge, - identityManager: SecureIdentityStateManagerProtocol, - meshService: Transport, - outboxStore: MessageOutboxStore? = nil, - sfMetrics: StoreAndForwardMetrics? = nil - ) { - let commandProcessor = CommandProcessor(identityManager: identityManager) - let privateChatManager = PrivateChatManager(meshService: meshService) - let unifiedPeerService = UnifiedPeerService( - meshService: meshService, - idBridge: idBridge, - identityManager: identityManager - ) - let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) - nostrTransport.senderPeerID = meshService.myPeerID - let messageRouter = MessageRouter( - transports: [meshService, nostrTransport], - outboxStore: outboxStore, - metrics: sfMetrics - ) - - self.commandProcessor = commandProcessor - self.messageRouter = messageRouter - self.privateChatManager = privateChatManager - self.unifiedPeerService = unifiedPeerService - self.autocompleteService = AutocompleteService() - // Persist processed gift-wrap event IDs: NIP-59 randomizes their - // timestamps, so the 24h-lookback DM subscriptions redeliver the same - // events on every launch and only a cross-launch record stops the - // reprocessing (re-sent DELIVERED bursts, phantom-ack noise). - self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore()) - self.publicMessagePipeline = PublicMessagePipeline() - } -} - -@MainActor -final class ChatViewModelBootstrapper { - private unowned let viewModel: ChatViewModel - - init(viewModel: ChatViewModel) { - self.viewModel = viewModel - } - - static func loadPersistedReadReceipts(userDefaults: UserDefaults = .standard) -> Set { - guard let data = userDefaults.data(forKey: "sentReadReceipts"), - let receipts = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return Set(receipts) - } - - func configure() { - wireServiceGraph() - bindFeatureObjectChanges() - loadPersistedViewState() - configureTransport() - startRuntimeServices() - bindPeerService() - configureNoiseCallbacks() - bindTransferProgress() - configureGeoChannels() - configureGateway() - configureBridge() - configureBridgeCourier() - bindTeleportState() - requestNotifications() - registerObservers() - } -} - -private extension ChatViewModelBootstrapper { - func wireServiceGraph() { - viewModel.privateChatManager.conversationStore = viewModel.conversations - viewModel.privateChatManager.messageRouter = viewModel.messageRouter - viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService - viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter - // Surface silent outbox drops (attempt cap, TTL expiry, overflow - // eviction) as a visible failure. The store's no-downgrade rule does - // not cover `.failed` over confirmed receipts, so guard here: a drop - // of an already-delivered/read message (e.g. a stale retained copy) - // must not downgrade its status. - viewModel.messageRouter.onMessageDropped = { [weak viewModel] messageID, peerID in - guard let viewModel else { return } - switch viewModel.conversations.deliveryStatus(forMessageID: messageID) { - case .delivered, .read: - // Field proof of the no-downgrade guard: the drop arrived - // after a confirmed receipt, so the `.failed` write is - // deliberately skipped. - SecureLogger.warning( - "📤 Router dropped message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… → .failed skipped (already delivered/read)", - category: .session - ) - default: - SecureLogger.warning( - "📤 Router dropped message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… → marked failed", - category: .session - ) - viewModel.conversations.setDeliveryStatus( - .failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")), - forMessageID: messageID - ) - } - } - // A message with no reachable transport that was handed to a courier - // shows a distinct "carried" state instead of sitting in "sending" - // forever. Never downgrade a confirmed receipt: the courier copy can - // race direct delivery when the peer reappears. - viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in - guard let viewModel else { return } - switch viewModel.conversations.deliveryStatus(forMessageID: messageID) { - case .delivered, .read: - break - default: - SecureLogger.debug( - "📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried", - category: .session - ) - viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID) - } - } - viewModel.commandProcessor.contextProvider = viewModel - viewModel.commandProcessor.meshService = viewModel.meshService - viewModel.participantTracker.configure(context: viewModel) - } - - func bindFeatureObjectChanges() { - viewModel.privateChatManager.objectWillChange - .sink { [weak viewModel] _ in - viewModel?.objectWillChange.send() - } - .store(in: &viewModel.cancellables) - - // Private message state flows through the single-writer - // `ConversationStore` intents and its `changes` subject; selection - // is owned by the store too (`PrivateChatManager.selectedPeer` is a - // read-only mirror), so no selection bridge is needed here. - viewModel.participantTracker.objectWillChange - .sink { [weak viewModel] _ in - viewModel?.objectWillChange.send() - } - .store(in: &viewModel.cancellables) - - viewModel.participantTracker.$visiblePeople - .receive(on: DispatchQueue.main) - .sink { [weak viewModel] people in - Task { @MainActor [weak viewModel] in - let visible = Set(people.map { $0.id }) - viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible) - viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible) - } - } - .store(in: &viewModel.cancellables) - } - - func loadPersistedViewState() { - viewModel.loadNickname() - viewModel.loadVerifiedFingerprints() - } - - func configureTransport() { - viewModel.meshService.delegate = viewModel - viewModel.meshService.eventDelegate = viewModel - - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in - guard let viewModel else { return } - _ = viewModel.getMyFingerprint() - } - - viewModel.meshService.setNickname(viewModel.nickname) - } - - func startRuntimeServices() { - viewModel.meshService.startServices() - - viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator - - loadArchivedEchoes() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in - guard let viewModel, - let radio = viewModel.meshService as? BluetoothStateReporting else { return } - viewModel.updateBluetoothState(radio.getCurrentBluetoothState()) - } - - viewModel.nostrRelayManager = NostrRelayManager.shared - viewModel.messageRouter.flushAllOutbox() - - Task { @MainActor [weak viewModel] in - guard let viewModel else { return } - try? await Task.sleep( - nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000) - ) - viewModel.isStartupPhase = false - } - } - - /// Surfaces the carried store-and-forward window (up to 6h of public - /// mesh messages, persisted across restarts) as dimmed "heard here - /// earlier" rows, so the mesh timeline opens with the place's memory - /// instead of a void. The archive restore runs async on the sync queue - /// right after transport start, so give it a beat before asking. - private func loadArchivedEchoes() { - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in - guard let viewModel, - let archive = viewModel.meshService as? MeshPublicArchiving else { return } - archive.collectArchivedPublicMessages { [weak viewModel] allArchived in - guard let viewModel else { return } - // A previous /clear dismissed everything heard up to its - // watermark; only newer archive entries come back. Blocking a - // peer purges their carried messages from the archive at - // block time (when the fingerprint↔peerID mapping is known); - // the filter here is defense-in-depth for entries that slip - // past the purge (e.g. re-synced from a nearby peer), and it - // only resolves connected peers or favorites. - let clearedThrough = MeshEchoSettings.clearedThrough ?? .distantPast - let archived = allArchived.filter { - $0.timestamp > clearedThrough && !viewModel.isPeerBlocked($0.senderPeerID) - } - guard !archived.isEmpty else { return } - // Seed only an untouched timeline: with live rows already - // present (or after /clear) splicing history back in would - // be wrong. - guard viewModel.conversations.conversationsByID[.mesh]?.messages.isEmpty != false else { return } - - for item in archived { - let echo = BitchatMessage( - id: BitchatMessage.archivedEchoIDPrefix + item.packetIdHex, - sender: item.senderNickname, - content: item.content, - timestamp: item.timestamp, - isRelay: false, - senderPeerID: item.senderPeerID - ) - viewModel.publicConversationCoordinator.registerArchivedEcho( - senderPeerID: item.senderPeerID, - timestamp: item.timestamp, - content: item.content - ) - _ = viewModel.appendPublicMessage(echo, to: .mesh) - } - - if let firstTimestamp = archived.map(\.timestamp).min() { - // Echo-prefixed ID so the divider joins the tinted, - // dimmed echo block in the timeline. - let divider = BitchatMessage( - id: BitchatMessage.archivedEchoIDPrefix + "divider", - sender: "system", - content: String(localized: "content.echoes.divider", comment: "System line shown above dimmed archived messages replayed on the mesh timeline at launch"), - timestamp: firstTimestamp.addingTimeInterval(-1), - isRelay: false - ) - _ = viewModel.appendPublicMessage(divider, to: .mesh) - } - } - } - } - - func bindPeerService() { - viewModel.unifiedPeerService.$peers - .receive(on: DispatchQueue.main) - .sink { [weak viewModel] peers in - Task { @MainActor [weak viewModel] in - guard let viewModel else { return } - - viewModel.allPeers = peers - - var uniquePeers: [PeerID: BitchatPeer] = [:] - for peer in peers { - if uniquePeers[peer.peerID] == nil { - uniquePeers[peer.peerID] = peer - } else { - SecureLogger.warning( - "⚠️ Duplicate peer ID detected: \(peer.peerID) (\(peer.displayName))", - category: .session - ) - } - } - viewModel.peerIndex = uniquePeers - - if viewModel.hasTrackedPrivateChatSelection { - viewModel.updatePrivateChatPeerIfNeeded() - } - } - } - .store(in: &viewModel.cancellables) - } - - func configureNoiseCallbacks() { - viewModel.setupNoiseCallbacks() - } - - func bindTransferProgress() { - TransferProgressManager.shared.publisher - .receive(on: DispatchQueue.main) - .sink { [weak viewModel] event in - Task { @MainActor [weak viewModel] in - viewModel?.handleTransferEvent(event) - } - } - .store(in: &viewModel.cancellables) - } - - func configureGeoChannels() { - viewModel.geoChannelCoordinator = GeoChannelCoordinator( - locationManager: viewModel.locationManager, - context: viewModel - ) - } - - /// Wires the gateway-mode policy layer (`GatewayService`) to the mesh - /// transport, the relay manager, and the inbound Nostr pipeline. All - /// dependencies are closures so the service stays unit-testable with - /// fakes. - func configureGateway() { - // Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests) - // has no carrier packets to bridge. - guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return } - let gateway = GatewayService.shared - - gateway.publishToRelays = { event, geohash in - let relays = GeoRelayDirectory.shared.closestRelays( - toGeohash: geohash, - count: TransportConfig.nostrGeoRelayCount - ) - // Symmetric with the local send path (GeohashSubscriptionManager - // .sendGeohash): with no known geo relay, refuse rather than - // publish to default relays no geo subscriber reads — that would - // be silent dead traffic, not delivery. - guard !relays.isEmpty else { - SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session) - return - } - NostrRelayManager.shared.sendEvent(event, to: relays) - } - gateway.broadcastToMesh = { [weak bleService] payload in - bleService?.broadcastNostrCarrier(payload) - } - gateway.sendToGatewayPeer = { [weak bleService] payload, peer in - bleService?.sendNostrCarrier(payload, to: peer) ?? false - } - gateway.availableGatewayPeers = { [weak bleService] in - bleService?.reachableGatewayPeers() ?? [] - } - gateway.relaysConnected = { NostrRelayManager.shared.isConnected } - gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash } - // Carried events enter the same pipeline as relay-received events so - // blocking, rate limits, dedup, and rendering behave identically. - gateway.injectInbound = { [weak viewModel] event in - viewModel?.handleNostrEvent(event) - } - // The capability bit is advertised ONLY while the toggle is on; a - // change forces a re-announce so peers learn promptly. - gateway.onEnabledChanged = { [weak bleService] enabled in - bleService?.setLocalCapability(.gateway, enabled: enabled) - } - bleService.onNostrCarrierPacket = { payload, from, directedToUs in - // One decode, two policy engines: geohash-channel carriers go to - // the gateway, mesh-bridge carriers to the bridge. - guard let carrier = NostrCarrierPacket.decode(payload) else { - SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(from.id.prefix(8))…", category: .session) - return - } - switch carrier.direction { - case .toGateway, .fromGateway: - GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs) - case .toBridge, .fromBridge: - BridgeService.shared.handleMeshCarrier(carrier, from: from, directedToUs: directedToUs) - } - } - - // Uplinks deposited while relays were unreachable flush on reconnect. - // The publisher re-emits `true` on every relay state recompute, so - // dedupe: field logs showed presence published 5x in one second. - NostrRelayManager.shared.$isConnected - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { connected in - if connected { - GatewayService.shared.flushQueuedUplinks() - BridgeService.shared.flushQueuedUplinks() - BridgeService.shared.publishPresence() - } - } - .store(in: &viewModel.cancellables) - - // Apply the persisted toggle at launch. - if gateway.isEnabled { - bleService.setLocalCapability(.gateway, enabled: true) - } - } - - /// Wires the mesh-bridge policy layer (`BridgeService`) to the mesh - /// transport, the relay manager, location, and the public timeline. Same - /// closure-injection style as `configureGateway`. - func configureBridge() { - guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return } - let bridge = BridgeService.shared - let idBridge = viewModel.idBridge - - bridge.publishToRelays = { event, cell in - let relays = GeoRelayDirectory.shared.closestRelays( - toGeohash: cell, - count: TransportConfig.nostrGeoRelayCount - ) - guard !relays.isEmpty else { - SecureLogger.warning("🌉 Bridge: no geo relays for cell \(cell); not publishing", category: .session) - return - } - NostrRelayManager.shared.sendEvent(event, to: relays) - } - bridge.openSubscription = { cells in - guard let cell = cells.first else { return } - let relays = GeoRelayDirectory.shared.closestRelays( - toGeohash: cell, - count: TransportConfig.nostrGeoRelayCount - ) - NostrRelayManager.shared.subscribe( - filter: .bridgeRendezvous(cells, since: Date().addingTimeInterval(-BridgeService.Limits.maxEventAgeSeconds)), - id: Self.bridgeSubscriptionID, - relayUrls: relays.isEmpty ? nil : relays, - handler: { event in - BridgeService.shared.handleRendezvousEvent(event) - } - ) - } - bridge.closeSubscription = { - NostrRelayManager.shared.unsubscribe(id: Self.bridgeSubscriptionID) - } - bridge.relaysConnected = { NostrRelayManager.shared.isConnected } - bridge.locationCell = { [weak viewModel] in - viewModel?.locationManager.availableChannels - .first { $0.level == .neighborhood }? - .geohash - } - bridge.requestLocationFix = { [weak viewModel] in - viewModel?.locationManager.refreshChannels() - } - bridge.meshAdvertisedCell = { [weak bleService] in - bleService?.advertisedBridgeGeohash() - } - bridge.sendToBridgePeer = { [weak bleService] payload, peer in - bleService?.sendNostrCarrier(payload, to: peer) ?? false - } - bridge.availableBridgePeers = { [weak bleService] in - bleService?.reachableBridgePeers() ?? [] - } - bridge.broadcastToMesh = { [weak bleService] payload in - bleService?.broadcastNostrCarrier(payload) - } - bridge.injectInbound = { [weak viewModel] inbound in - viewModel?.handlePublicMessage(BitchatMessage( - id: inbound.messageID, - sender: inbound.senderNickname, - content: inbound.content, - timestamp: inbound.timestamp, - isRelay: false, - senderPeerID: PeerID(bridge: inbound.senderPubkey), - isBridged: true - )) - } - bridge.removeInjectedInbound = { [weak viewModel] messageID in - viewModel?.removeBridgeInjectedPublicMessage(withID: messageID) - } - bridge.isInjectedInboundPresent = { [weak viewModel] messageID in - viewModel?.bridgeInjectedPublicMessageIsPresent(withID: messageID) ?? false - } - bridge.isMessageSeenLocally = { [weak viewModel] messageID in - viewModel?.publicConversationContainsMessage(withID: messageID, in: .mesh) ?? false - } - bridge.deriveIdentity = { cell in - try idBridge.deriveIdentity(forBridgeRendezvous: cell) - } - bridge.myNickname = { [weak viewModel] in viewModel?.nickname ?? "" } - - // The `.bridge` capability + cell TLV advertise serving duty: "send - // me deposits, and this is the island's cell". One switch: bridging - // with a known cell is serving (deposits queue through connectivity - // gaps, so the advertisement doesn't flap with the relays). - let updateAdvertisement: @MainActor () -> Void = { [weak bleService] in - let advertise = BridgeService.shared.isEnabled - && BridgeService.shared.activeCell != nil - bleService?.setLocalBridgeGeohash(advertise ? BridgeService.shared.activeCell : nil) - bleService?.setLocalCapability(.bridge, enabled: advertise) - } - bridge.onEnabledChanged = { [weak viewModel] enabled in - updateAdvertisement() - // One switch collapses further: the bridge toggle also drives - // the geohash-channel gateway — bridging with internet means - // sharing it with the mesh around you, full stop. - GatewayService.shared.setEnabled(enabled) - // Flipping the switch is the user-initiated moment to ask for - // location if it was never asked; otherwise the bridge sits - // cell-less with only a settings caption explaining why. - if enabled, viewModel?.locationManager.permissionState == .notDetermined { - viewModel?.locationManager.enableLocationChannels() - } - } - bridge.onActiveCellChanged = { _ in updateAdvertisement() } - // Align a persisted split state (e.g. gateway enabled back when it - // had its own toggle) to the single switch at launch. - if GatewayService.shared.isEnabled != bridge.isEnabled { - GatewayService.shared.setEnabled(bridge.isEnabled) - } - - // Location fixes (or losing them) move the rendezvous cell. - viewModel.locationManager.$availableChannels - .receive(on: DispatchQueue.main) - .sink { _ in BridgeService.shared.refreshRendezvous() } - .store(in: &viewModel.cancellables) - // The authorization callback lands asynchronously after launch; the - // bootstrap-time location request races it and silently no-ops, so - // re-enter when the permission state resolves (field bug: bridge - // stayed cell-less for a whole session). - viewModel.locationManager.$permissionState - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { _ in BridgeService.shared.refreshRendezvous() } - .store(in: &viewModel.cancellables) - - // Apply the persisted toggle at launch. - if bridge.isEnabled { - bridge.refreshRendezvous() - updateAdvertisement() - } - } - - /// Wires courier-over-bridge (`BridgeCourierService`) to the relay - /// manager, the mesh transport's sealing/opening primitives, the courier - /// store, and the message router's deposit path. - func configureBridgeCourier() { - guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return } - let courier = BridgeCourierService.shared - - courier.bridgeEnabled = { BridgeService.shared.isEnabled } - // A geo/custom relay does not make a global courier drop durable. - // Require an actually connected default (DM) relay so `sendEvent` - // writes to at least one intended relay instead of only entering its - // process-local pending queue. - courier.relaysConnected = { NostrRelayManager.shared.isDMRelayConnected } - courier.publishEvent = { event, completion in - // Default (DM) relays: drops need the standing global relay set, - // not geo relays — sender and recipient share no cell. - // This confirmed path never falls back to the volatile relay - // queue; bridge dedup is committed only after NIP-20 OK. - NostrRelayManager.shared.sendEventImmediately(event, completion: completion) - } - courier.openSubscription = { tagsHex in - NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID) - NostrRelayManager.shared.subscribe( - filter: .courierDrops( - recipientTagsHex: tagsHex, - since: Date().addingTimeInterval(-CourierEnvelope.maxLifetimeSeconds) - ), - id: Self.courierDropSubscriptionID, - handler: { event in - BridgeCourierService.shared.handleDropEvent(event) - } - ) - } - courier.closeSubscription = { - NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID) - } - courier.myNoiseKey = { [weak bleService] in - bleService?.myNoiseStaticPublicKey() - } - courier.localVerifiedPeers = { [weak bleService] in - bleService?.verifiedPeersWithNoiseKeys() ?? [] - } - courier.sealEnvelope = { [weak bleService] content, messageID, recipientKey in - bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey) - } - courier.openEnvelope = { [weak bleService] envelope in - bleService?.openBridgedCourierEnvelope(envelope) ?? false - } - courier.deliverToPeer = { [weak bleService] envelope, peerID in - bleService?.deliverBridgedEnvelope(envelope, to: peerID) ?? false - } - courier.heldEnvelopes = { cooldown in - CourierStore.shared.envelopesForBridgePublish(cooldown: cooldown) - } - courier.markHeldEnvelopePublished = { envelope in - CourierStore.shared.markBridgePublished(envelope) - } - - viewModel.messageRouter.bridgeCourierDeposit = { content, messageID, recipientKey, completion in - BridgeCourierService.shared.depositDrop( - content: content, - messageID: messageID, - recipientNoiseKey: recipientKey, - completion: completion - ) - } - // The completion flows back only after a default relay accepts the - // event, so a rejected or unacknowledged write never becomes carried. - viewModel.messageRouter.startBridgeDepositSweep() - bleService.onVerifiedPeerAnnounce = { _ in - Task { @MainActor in - BridgeCourierService.shared.refreshAfterVerifiedAnnounce() - } - } - - // Relay connectivity gates everything; refresh (re)opens or closes. - // Deduped: refresh() resubscribes, and the raw publisher re-emits on - // every relay state recompute (6x in 300ms in field logs). - NostrRelayManager.shared.$isDMRelayConnected - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { _ in BridgeCourierService.shared.refresh() } - .store(in: &viewModel.cancellables) - // Toggle changes re-evaluate the watch set. - BridgeService.shared.$isEnabled - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { _ in BridgeCourierService.shared.refresh() } - .store(in: &viewModel.cancellables) - - courier.refresh() - } - - private static let bridgeSubscriptionID = "bridge-rendezvous" - private static let courierDropSubscriptionID = "bridge-courier-drops" - - func bindTeleportState() { - viewModel.locationManager.$teleported - .receive(on: DispatchQueue.main) - .sink { [weak viewModel] isTeleported in - guard let viewModel else { return } - Task { @MainActor [weak viewModel] in - guard let viewModel, - case .location(let channel) = viewModel.activeChannel, - let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) - else { - return - } - - let key = identity.publicKeyHex.lowercased() - let hasRegional = !viewModel.locationManager.availableChannels.isEmpty - let inRegional = viewModel.locationManager.availableChannels.contains { - $0.geohash == channel.geohash - } - - if isTeleported && hasRegional && !inRegional { - viewModel.locationPresenceStore.markTeleported(key) - } else { - viewModel.locationPresenceStore.clearTeleported(key) - } - } - } - .store(in: &viewModel.cancellables) - } - - func requestNotifications() { - NotificationService.shared.requestAuthorization() - } - - func registerObservers() { - NotificationCenter.default.addObserver( - viewModel, - selector: #selector(ChatViewModel.handleFavoriteStatusChanged(_:)), - name: .favoriteStatusChanged, - object: nil - ) - - NotificationCenter.default.addObserver( - viewModel, - selector: #selector(ChatViewModel.handlePeerStatusUpdate(_:)), - name: Notification.Name("peerStatusUpdated"), - object: nil - ) - } -} diff --git a/bitchat/ViewModels/ChatVouchCoordinator.swift b/bitchat/ViewModels/ChatVouchCoordinator.swift deleted file mode 100644 index 29c82440..00000000 --- a/bitchat/ViewModels/ChatVouchCoordinator.swift +++ /dev/null @@ -1,272 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The narrow surface `ChatVouchCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding an `unowned` back-ref -/// to the whole `ChatViewModel`. This keeps the coordinator independently -/// testable (see `ChatVouchCoordinatorContextTests`) and makes its true -/// dependencies explicit. -@MainActor -protocol ChatVouchContext: AnyObject { - // MARK: Identity & trust state - func getFingerprint(for peerID: PeerID) -> String? - func isVerifiedFingerprint(_ fingerprint: String) -> Bool - /// The peer's announce-bound Ed25519 signing key, if known this session. - func signingKey(forFingerprint fingerprint: String) -> Data? - /// Verified fingerprints ordered most recently verified first. - func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] - /// Stores an accepted vouch (identity manager enforces the storage gates). - @discardableResult - func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool - func lastVouchBatchSent(to fingerprint: String) -> Date? - func markVouchBatchSent(to fingerprint: String, at date: Date) - - // MARK: Transport - func peerCapabilities(for peerID: PeerID) -> PeerCapabilities - /// PeerIDs with a currently established mesh session (used to run a vouch - /// pass over peers we are already connected to when we verify someone). - func connectedPeerIDs() -> [PeerID] - /// Appends a session-established observer (additive; never displaces the - /// verification coordinator's callbacks). - func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) - /// Signs `data` with our Noise (Ed25519) signing key. - func noiseSignData(_ data: Data) -> Data? - func sendVouchAttestations(_ payload: Data, to peerID: PeerID) - - // MARK: UI refresh - /// Signals that derived trust state changed so peer list / fingerprint - /// views recompute badges. - func notifyPeerTrustChanged() -} - -extension ChatViewModel: ChatVouchContext { - // `getFingerprint(for:)` and `isVerifiedFingerprint(_:)` are shared - // requirements with the verification context and satisfied by existing - // `ChatViewModel` members. The members below flatten nested service - // accesses into intent-named calls. - - func signingKey(forFingerprint fingerprint: String) -> Data? { - identityManager.signingPublicKey(forFingerprint: fingerprint) - } - - func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { - identityManager.mostRecentlyVerifiedFingerprints(limit: limit, excluding: fingerprint) - } - - @discardableResult - func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { - identityManager.recordVouch( - voucheeFingerprint: voucheeFingerprint, - voucherFingerprint: voucherFingerprint, - timestamp: timestamp - ) - } - - func lastVouchBatchSent(to fingerprint: String) -> Date? { - identityManager.lastVouchBatchSent(to: fingerprint) - } - - func markVouchBatchSent(to fingerprint: String, at date: Date) { - identityManager.markVouchBatchSent(to: fingerprint, at: date) - } - - func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { - meshService.peerCapabilities(peerID) - } - - func connectedPeerIDs() -> [PeerID] { - Array(connectedPeers) - } - - func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { - meshService.addPeerAuthenticatedObserver(handler) - } - - func noiseSignData(_ data: Data) -> Data? { - meshService.noiseSignData(data) - } - - func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { - (meshService as? MeshVerifying)?.sendVouchAttestations(payload, to: peerID) - } - - func notifyPeerTrustChanged() { - // PeerListModel refreshes on this notification; the view-model change - // covers FingerprintView / VerificationModel consumers. - NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) - notifyUIChanged() - } -} - -/// Transitive verification ("vouching"): when a Noise session comes up with a -/// peer I verified, I attest — over that authenticated, encrypted session — -/// to the other identities I have verified. Receivers accept such vouches -/// only from peers *they* verified, giving a serverless -/// verified-by-people-you-verified tier (`TrustLevel.vouched`). -@MainActor -final class ChatVouchCoordinator { - /// Minimum spacing between vouch batches to the same peer (persisted). - static let batchInterval: TimeInterval = 24 * 60 * 60 - - private unowned let context: any ChatVouchContext - - init(context: any ChatVouchContext) { - self.context = context - } - - /// Registers the session-established hook. Additive alongside the - /// verification coordinator's callbacks; call once at bootstrap. - func setupNoiseCallbacks() { - context.addPeerAuthenticatedObserver { [weak self] peerID, fingerprint in - DispatchQueue.main.async { [weak self] in - self?.peerAuthenticated(peerID, fingerprint: fingerprint) - } - } - } - - /// Trigger — session established: on a Noise session coming up with a peer - /// I verified, attempt to send a vouch batch. Kept as the historical entry - /// point; the real work lives in `attemptVouch`. - func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) { - attemptVouch(to: peerID, fingerprint: fingerprint, now: now) - } - - /// Trigger — verified announce processed: a peer's `.vouch` capability - /// arrives on its *announce*, which is handled independently of the Noise - /// handshake. This is invoked on every peer-list update (fired after each - /// verified announce), so it closes the capability race — the batch that - /// `peerAuthenticated` couldn't send (capabilities not yet known) goes out - /// once the capability-bearing announce lands. Throttled per peer. - func peersUpdated(_ peerIDs: [PeerID], now: Date = Date()) { - for peerID in peerIDs { - guard let fingerprint = context.getFingerprint(for: peerID) else { continue } - attemptVouch(to: peerID, fingerprint: fingerprint, now: now) - } - } - - /// Trigger — local verification completed: the user just verified a peer. - /// Run a vouch pass over every currently connected peer I verified. This - /// makes vouching fire when verifying someone already connected (whose - /// session is authenticated, so `peerAuthenticated` never re-fires), and it - /// propagates the newly-verified identity to my other verified peers. - /// Throttled per peer by `batchInterval`, so it can't spam. - func vouchToConnectedVerifiedPeers(now: Date = Date()) { - var sentCount = 0 - for peerID in context.connectedPeerIDs() { - guard let fingerprint = context.getFingerprint(for: peerID) else { continue } - if attemptVouch(to: peerID, fingerprint: fingerprint, now: now) { - sentCount += 1 - } - } - if sentCount > 0 { - SecureLogger.info( - "🪪 verify-triggered vouch pass sent to \(sentCount) connected peer(s)", - category: .security - ) - } - } - - /// Exchange policy shared by every trigger: to a peer I verified, send - /// attestations for up to `VouchAttestation.maxBatchCount` *other* verified - /// fingerprints (most recently verified first), at most once per peer per - /// `batchInterval`. Returns whether a batch was actually sent. - @discardableResult - func attemptVouch(to peerID: PeerID, fingerprint: String, now: Date = Date()) -> Bool { - guard context.isVerifiedFingerprint(fingerprint) else { return false } - - // Capability gate, race-tolerant: a peer's `.vouch` bit is carried on - // its announce, processed independently of the Noise handshake, so at - // authentication time the capability set is frequently still empty. - // Treat an empty/unknown set as eligible — the payload is a Noise - // `0x12` (`NoisePayloadType.vouch`) that non-supporting peers harmlessly - // ignore, so sending on an unknown set is safe and avoids the race - // dropping the batch. Only skip when the peer advertised a non-empty - // capability set that explicitly lacks `.vouch`. - let capabilities = context.peerCapabilities(for: peerID) - if !capabilities.isEmpty, !capabilities.contains(.vouch) { return false } - - if let lastSent = context.lastVouchBatchSent(to: fingerprint), - now.timeIntervalSince(lastSent) < Self.batchInterval { - return false - } - - let candidates = context.recentlyVerifiedFingerprints( - limit: VouchAttestation.maxBatchCount, - excluding: fingerprint - ) - var attestations: [VouchAttestation] = [] - for candidate in candidates { - // Only fingerprints whose announce-bound signing key we know can - // be anchored to a concrete identity; skip the rest. - guard let fingerprintData = Data(hexString: candidate), - fingerprintData.count == VouchAttestation.fingerprintSize, - let signingKey = context.signingKey(forFingerprint: candidate), - signingKey.count == VouchAttestation.signingKeySize, - let attestation = VouchAttestation.build( - voucheeFingerprint: fingerprintData, - voucheeSigningKey: signingKey, - timestampMs: UInt64(now.timeIntervalSince1970 * 1000), - sign: context.noiseSignData - ) else { - continue - } - attestations.append(attestation) - } - - guard !attestations.isEmpty, - let payload = VouchAttestation.encodeList(attestations) else { return false } - context.sendVouchAttestations(payload, to: peerID) - context.markVouchBatchSent(to: fingerprint, at: now) - SecureLogger.debug( - "🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))…", - category: .security - ) - return true - } - - /// Accept policy: process inbound vouches only from a sender I verified, - /// only with a valid Ed25519 signature under the sender's announce-bound - /// signing key, and only within the validity window. Self-vouches and - /// vouches for already-verified peers are dropped by the identity - /// manager's storage gates. - func handleVouchPayload(from peerID: PeerID, payload: Data, now: Date = Date()) { - guard let senderFingerprint = context.getFingerprint(for: peerID), - context.isVerifiedFingerprint(senderFingerprint) else { - SecureLogger.debug( - "🪪 Ignoring vouch payload from unverified peer \(peerID.id.prefix(8))…", - category: .security - ) - return - } - guard let senderSigningKey = context.signingKey(forFingerprint: senderFingerprint) else { - SecureLogger.debug( - "🪪 No signing key for vouching peer \(peerID.id.prefix(8))…; dropping batch", - category: .security - ) - return - } - - var acceptedCount = 0 - for attestation in VouchAttestation.decodeList(from: payload) { - guard attestation.verifySignature(voucherSigningKey: senderSigningKey), - !attestation.isExpired(now: now) else { continue } - let stored = context.recordVouch( - voucheeFingerprint: attestation.voucheeFingerprintHex, - voucherFingerprint: senderFingerprint, - timestamp: attestation.timestamp - ) - if stored { acceptedCount += 1 } - } - - if acceptedCount > 0 { - SecureLogger.info( - "🪪 Accepted \(acceptedCount) vouch(es) from \(senderFingerprint.prefix(8))…", - category: .security - ) - context.notifyPeerTrustChanged() - } - } -} diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index 8205e13b..b36aa49c 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -5,93 +5,846 @@ // Geohash and Nostr logic for ChatViewModel // -import BitFoundation import Foundation +import Combine +import BitLogger +import BitFoundation +import SwiftUI +import Tor extension ChatViewModel { + // MARK: - Geohash Subscription + + // Resubscribe to the active geohash channel without clearing timeline @MainActor func resubscribeCurrentGeohash() { - nostrCoordinator.subscriptions.resubscribeCurrentGeohash() + guard case .location(let ch) = activeChannel else { return } + guard let subID = geoSubscriptionID else { + // No existing subscription; set it up + switchLocationChannel(to: activeChannel) + return + } + // Ensure participant decay timer is running + participantTracker.startRefreshTimer() + // Unsubscribe + resubscribe + NostrRelayManager.shared.unsubscribe(id: subID) + let filter = NostrFilter.geohashEphemeral( + ch.geohash, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), + limit: TransportConfig.nostrGeohashInitialLimit + ) + let subRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: ch.geohash, + count: TransportConfig.nostrGeoRelayCount + ) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + self?.subscribeNostrEvent(event) + } + // Resubscribe geohash DMs for this identity + if let dmSub = geoDmSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil + } + + if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { + let dmSub = "geo-dm-\(ch.geohash)" + geoDmSubscriptionID = dmSub + let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) + NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + self?.subscribeGiftWrap(giftWrap, id: id) + } + } } - - @MainActor + func subscribeNostrEvent(_ event: NostrEvent) { - nostrCoordinator.inbound.subscribeNostrEvent(event) + guard event.isValidSignature() else { return } + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || + event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), + !deduplicationService.hasProcessedNostrEvent(event.id) + else { + return + } + + deduplicationService.recordNostrEvent(event.id) + + if let gh = currentGeohash, + let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh), + myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { + // Skip very recent self-echo from relay, but allow older events (e.g., after app restart) + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) < 15 { + return + } + } + + if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { + let nick = nickTag[1].trimmed + geoNicknames[event.pubkey.lowercased()] = nick + } + + // Store mapping for geohash sender IDs used in messages (ensures consistent colors) + nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey + nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey + + // Update participants last-seen for this pubkey + participantTracker.recordParticipant(pubkeyHex: event.pubkey) + + // If presence heartbeat (Kind 20001), stop here - no content to display + if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { + return + } + + // Track teleported tag (only our format ["t","teleport"]) for icon state + let hasTeleportTag = event.tags.contains(where: { tag in + tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" + }) + + if hasTeleportTag { + let key = event.pubkey.lowercased() + // Do not mark our own key from historical events; rely on manager.teleported for self + let isSelf: Bool = { + if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) { + return my.publicKeyHex.lowercased() == key + } + return false + }() + if !isSelf { + Task { @MainActor in + teleportedGeo = teleportedGeo.union([key]) + } + } + } + + let senderName = displayNameForNostrPubkey(event.pubkey) + let content = event.content.trimmed + + // Clamp future timestamps to now to avoid future-dated messages skewing order + let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let timestamp = min(rawTs, Date()) + let mentions = parseMentions(from: content) + let msg = BitchatMessage( + id: event.id, + sender: senderName, + content: content, + timestamp: timestamp, + isRelay: false, + senderPeerID: PeerID(nostr: event.pubkey), + mentions: mentions.isEmpty ? nil : mentions + ) + Task { @MainActor in + // BCH-01-012: Check blocking before any notifications + // handlePublicMessage has its own blocking check but returns silently, + // so we must also guard checkForMentions to prevent notification bypass + let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) + + handlePublicMessage(msg) + + // Only check mentions and send haptic if sender is not blocked + if !isBlocked { + checkForMentions(msg) + sendHapticFeedback(for: msg) + } + } } - @MainActor func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id) + guard giftWrap.isValidSignature() else { return } + guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return } + deduplicationService.recordNostrEvent(giftWrap.id) + + guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id), + let packet = Self.decodeEmbeddedBitChatPacket(from: content), + packet.type == MessageType.noiseEncrypted.rawValue, + let noisePayload = NoisePayload.decode(packet.payload) + else { + return + } + + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + let convKey = PeerID(nostr_: senderPubkey) + nostrKeyMapping[convKey] = senderPubkey + + switch noisePayload.type { + case .privateMessage: + handlePrivateMessage(noisePayload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp) + case .delivered: + handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) + case .readReceipt: + handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) + case .verifyChallenge, .verifyResponse: + // QR verification payloads over Nostr are not supported; ignore in geohash DMs + break + } } + // MARK: - Geohash Channel Handling + @MainActor func switchLocationChannel(to channel: ChannelID) { - nostrCoordinator.subscriptions.switchLocationChannel(to: channel) - } + // Reset pending public batches to avoid cross-channel bleed + publicMessagePipeline.reset() + + activeChannel = channel + publicMessagePipeline.updateActiveChannel(channel) + + // Reset deduplication set and optionally hydrate timeline for mesh + deduplicationService.clearNostrCaches() + switch channel { + case .mesh: + refreshVisibleMessages(from: .mesh) + // Debug: log if any empty messages are present + let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count + if emptyMesh > 0 { + SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) + } + participantTracker.stopRefreshTimer() + participantTracker.setActiveGeohash(nil) + teleportedGeo.removeAll() + case .location: + refreshVisibleMessages(from: channel) + } + // If switching to a location channel, flush any pending geohash-only system messages + if case .location = channel { + for content in timelineStore.drainPendingGeohashSystemMessages() { + addPublicSystemMessage(content) + } + } + // Unsubscribe previous + if let sub = geoSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: sub) + geoSubscriptionID = nil + } + if let dmSub = geoDmSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: dmSub) + geoDmSubscriptionID = nil + } + currentGeohash = nil + participantTracker.setActiveGeohash(nil) + // Reset nickname cache for geochat participants + geoNicknames.removeAll() - @MainActor + guard case .location(let ch) = channel else { return } + currentGeohash = ch.geohash + participantTracker.setActiveGeohash(ch.geohash) + + // Ensure self appears immediately in the people list; mark teleported state only when truly teleported + if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { + participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex) + let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty + let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } + let key = id.publicKeyHex.lowercased() + if LocationChannelManager.shared.teleported && hasRegional && !inRegional { + teleportedGeo = teleportedGeo.union([key]) + SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) + } else { + teleportedGeo.remove(key) + } + } + + let subID = "geo-\(ch.geohash)" + geoSubscriptionID = subID + participantTracker.startRefreshTimer() + let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) + let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + self?.handleNostrEvent(event) + } + + subscribeToGeoChat(ch) + } + func handleNostrEvent(_ event: NostrEvent) { - nostrCoordinator.inbound.handleNostrEvent(event) - } + guard event.isValidSignature() else { return } + // Only handle ephemeral kind 20000 or presence kind 20001 with matching tag + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || + event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } + + // Deduplicate + if deduplicationService.hasProcessedNostrEvent(event.id) { return } + deduplicationService.recordNostrEvent(event.id) + + // Log incoming tags for diagnostics + let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") + SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session) + + // If this pubkey is blocked, skip mapping, participants, and timeline + if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { + return + } + + // Track teleport tag for participants – only our format ["t", "teleport"] + let hasTeleportTag: Bool = event.tags.contains { tag in + tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" + } + + let isSelf: Bool = { + if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) { + return my.publicKeyHex.lowercased() == event.pubkey.lowercased() + } + return false + }() + if hasTeleportTag { + // Avoid marking our own key from historical events; rely on manager.teleported for self + if !isSelf { + let key = event.pubkey.lowercased() + Task { @MainActor in + teleportedGeo = teleportedGeo.union([key]) + SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) + } + } + } + + // Update participants last-seen for this pubkey + participantTracker.recordParticipant(pubkeyHex: event.pubkey) + + // Skip only very recent self-echo from relay; include older self events for hydration + if isSelf { + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) < 15 { + return + } + } + + // Cache nickname from tag if present + if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { + geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed + } + + // Store mapping for geohash DM initiation + nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey + nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey + + // If presence heartbeat (Kind 20001), stop here - no content to display + if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { + return + } + + let senderName = displayNameForNostrPubkey(event.pubkey) + let content = event.content + + // If this is a teleport presence event (no content), don't add to timeline + if let teleTag = event.tags.first(where: { $0.first == "t" }), + teleTag.count >= 2, + teleTag[1] == "teleport", + content.trimmed.isEmpty { + return + } + + // Clamp future timestamps + let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let mentions = parseMentions(from: content) + let msg = BitchatMessage( + id: event.id, + sender: senderName, + content: content, + timestamp: min(rawTs, Date()), + isRelay: false, + senderPeerID: PeerID(nostr: event.pubkey), + mentions: mentions.isEmpty ? nil : mentions + ) + + Task { @MainActor in + handlePublicMessage(msg) + checkForMentions(msg) + sendHapticFeedback(for: msg) + } + } + @MainActor + func subscribeToGeoChat(_ ch: GeohashChannel) { + guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return } + + let dmSub = "geo-dm-\(ch.geohash)" + geoDmSubscriptionID = dmSub + // pared back logging: subscribe debug only + // Log GeoDM subscribe only when Tor is ready to avoid early noise + if TorManager.shared.isReady { + SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session) + } + let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) + NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + self?.handleGiftWrap(giftWrap, id: id) + } + } + func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id) + guard giftWrap.isValidSignature() else { return } + if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { + return + } + deduplicationService.recordNostrEvent(giftWrap.id) + + // Decrypt with per-geohash identity + guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { + SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session) + return + } + + SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session) + + guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), + packet.type == MessageType.noiseEncrypted.rawValue, + let payload = NoisePayload.decode(packet.payload) + else { + return + } + + let convKey = PeerID(nostr_: senderPubkey) + nostrKeyMapping[convKey] = senderPubkey + + switch payload.type { + case .privateMessage: + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp) + case .delivered: + handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) + case .readReceipt: + handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) + + // Explicitly list other cases so we get compile-time check if a new case is added in the future + case .verifyChallenge, .verifyResponse: + break + } } @MainActor func sendGeohash(context: GeoOutgoingContext) { - nostrCoordinator.subscriptions.sendGeohash(context: context) + let ch = context.channel + let event = context.event + let identity = context.identity + + let targetRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: ch.geohash, + count: TransportConfig.nostrGeoRelayCount + ) + + if targetRelays.isEmpty { + SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } + + // Track ourselves as active participant + participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) + nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex + SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session) + + // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI + // Only when not in our regional set (and regional list is known) + let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty + let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } + + if context.teleported && hasRegional && !inRegional { + let key = identity.publicKeyHex.lowercased() + teleportedGeo = teleportedGeo.union([key]) + SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) + } + + deduplicationService.recordNostrEvent(event.id) } + // MARK: - Sampling + + /// Begin sampling multiple geohashes (used by channel sheet) without changing active channel. @MainActor func beginGeohashSampling(for geohashes: [String]) { - nostrCoordinator.subscriptions.beginGeohashSampling(for: geohashes) - } + // Disable sampling when app is backgrounded (Tor is stopped there) + if !TorManager.shared.isForeground() { + endGeohashSampling() + return + } + // Determine which to add and which to remove + let desired = Set(geohashes) + let current = Set(geoSamplingSubs.values) + let toAdd = desired.subtracting(current) + let toRemove = current.subtracting(desired) + for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) { + NostrRelayManager.shared.unsubscribe(id: subID) + geoSamplingSubs.removeValue(forKey: subID) + } + + for gh in toAdd { + subscribe(gh) + } + } + @MainActor + func subscribe(_ gh: String) { + let subID = "geo-sample-\(gh)" + geoSamplingSubs[subID] = gh + let filter = NostrFilter.geohashEphemeral( + gh, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), + limit: TransportConfig.nostrGeohashSampleLimit + ) + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + self?.subscribeNostrEvent(event, gh: gh) + } + } + func subscribeNostrEvent(_ event: NostrEvent, gh: String) { - nostrCoordinator.presence.subscribeNostrEvent(event, gh: gh) + guard event.isValidSignature() else { return } + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || + event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } + + // Compute current participant count (5-minute window) BEFORE updating with this event + let existingCount = participantTracker.participantCount(for: gh) + + // Update participants for this specific geohash + participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) + + // Notify only on rising-edge: previously zero people, now someone sends a chat + guard let content = event.content.trimmedOrNilIfEmpty else { return } + + // Respect geohash blocks + if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } + + // Skip self identity for this geohash + if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } + + // Only trigger when there were zero participants in this geohash recently + guard existingCount == 0 else { return } + + // Avoid notifications for old sampled events when launching or (re)subscribing + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) > 30 { return } + + // Foreground-only notifications: app must be active, and not already viewing this geohash + #if os(iOS) + guard UIApplication.shared.applicationState == .active else { return } + if case .location(let ch) = activeChannel, ch.geohash == gh { return } + #elseif os(macOS) + guard NSApplication.shared.isActive else { return } + if case .location(let ch) = activeChannel, ch.geohash == gh { return } + #endif + + cooldownPerGeohash(gh, content: content, event: event) + } + + func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { + let now = Date() + let last = lastGeoNotificationAt[gh] ?? .distantPast + if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } + + // Compose a short preview + let preview: String = { + let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen + if content.count <= maxLen { return content } + let idx = content.index(content.startIndex, offsetBy: maxLen) + return String(content[.. Data? { - nostrCoordinator.inbound.findNoiseKey(for: nostrPubkey) + // Check favorites for this Nostr key + let favorites = FavoritesPersistenceService.shared.favorites.values + var npubToMatch = nostrPubkey + + // Convert hex to npub if needed for comparison + if !nostrPubkey.hasPrefix("npub") { + if let pubkeyData = Data(hexString: nostrPubkey), + let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) { + npubToMatch = encoded + } else { + SecureLogger.warning("⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", category: .session) + } + } + + for relationship in favorites { + // Search through favorites for matching Nostr pubkey + if let storedNostrKey = relationship.peerNostrPublicKey { + // Compare against stored key (could be hex or npub) + if storedNostrKey == npubToMatch { + // SecureLogger.debug("✅ Found Noise key for Nostr sender (npub match)", category: .session) + return relationship.peerNoisePublicKey + } + + // Also try comparing raw hex if stored key is hex + if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { + SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session) + return relationship.peerNoisePublicKey + } + } + } + + SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session) + return nil + } + + func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) { + // If we have a Noise key, try to route securely if possible, otherwise fallback to direct + if let _ = key { + // Ideally we would use MessageRouter here, but for simplicity in this direct callback: + // check if we have an identity + if let id = try? idBridge.getCurrentNostrIdentity() { + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) + nt.senderPeerID = meshService.myPeerID + nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id) + } + } else if let id = try? idBridge.getCurrentNostrIdentity() { + // Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) + nt.senderPeerID = meshService.myPeerID + nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id) + SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) + } + + // Same for READ receipt if viewing + if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID { + if let _ = key { + if let id = try? idBridge.getCurrentNostrIdentity() { + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) + nt.senderPeerID = meshService.myPeerID + nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) + } + } else if let id = try? idBridge.getCurrentNostrIdentity() { + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) + nt.senderPeerID = meshService.myPeerID + nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) + SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) + } + } + } + + func handleFavoriteNotification(content: String, from nostrPubkey: String) { + // Try to find Noise key associated with this Nostr pubkey + guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return } + + let isFavorite = content.contains("FAVORITE:TRUE") + let senderNickname = content.components(separatedBy: "|").last ?? "Unknown" + + // Update favorite status + if isFavorite { + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: senderNoiseKey, + peerNostrPublicKey: nostrPubkey, + peerNickname: senderNickname + ) + } else { + // Only remove if we don't have it set locally + // Logic handled by persistence service usually, here we just update remote state + // Actually for now we just process the notification + } + + // Extract Nostr public key if included + var extractedNostrPubkey: String? = nil + if let range = content.range(of: "NPUB:") { + let suffix = content[range.upperBound...] + let parts = suffix.components(separatedBy: "|") + if let key = parts.first { + extractedNostrPubkey = String(key) + } + } else if content.contains(":") { + // Fallback: simple format FAVORITE:TRUE:npub... + let parts = content.components(separatedBy: ":") + if parts.count >= 3 { + extractedNostrPubkey = String(parts[2]) + } + } + + SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session) + + // If they favorited us and provided their Nostr key, ensure it's stored + if isFavorite && extractedNostrPubkey != nil { + SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session) + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: senderNoiseKey, + peerNostrPublicKey: extractedNostrPubkey, + peerNickname: senderNickname + ) + } + + // Show notification + NotificationService.shared.sendLocalNotification( + title: isFavorite ? "New Favorite" : "Favorite Removed", + body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you", + identifier: "fav-\(UUID().uuidString)" + ) } - @MainActor func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { - nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite) + // Find peer Nostr key + guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey), + relationship.peerNostrPublicKey != nil else { + SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session) + return + } + + let peerID = PeerID(hexData: noisePublicKey) + + // Route via message router + messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) } - @MainActor + private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { + guard content.hasPrefix("bitchat1:") else { return nil } + let encoded = String(content.dropFirst("bitchat1:".count)) + let maxBytes = FileTransferLimits.maxFramedFileBytes + // Base64url length upper bound for maxBytes (padded length; unpadded is <= this). + let maxEncoded = ((maxBytes + 2) / 3) * 4 + guard encoded.count <= maxEncoded else { return nil } + guard let packetData = Self.base64URLDecode(encoded), + packetData.count <= maxBytes + else { return nil } + return BitchatPacket.from(packetData) + } + + // MARK: - Geohash Nickname Resolution (for /block in geohash) + func nostrPubkeyForDisplayName(_ name: String) -> String? { - nostrCoordinator.nostrPubkeyForDisplayName(name) + // Look up current visible geohash participants for an exact displayName match + for p in visibleGeohashPeople() { + if p.displayName == name { + return p.id + } + } + // Also check nickname cache directly + for (pub, nick) in geoNicknames { + if nick == name { return pub } + } + return nil } - - @MainActor + func startGeohashDM(withPubkeyHex hex: String) { - nostrCoordinator.startGeohashDM(withPubkeyHex: hex) + let convKey = PeerID(nostr_: hex) + nostrKeyMapping[convKey] = hex + startPrivateChat(with: convKey) } - - @MainActor + func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? { - nostrCoordinator.fullNostrHex(forSenderPeerID: senderID) + return nostrKeyMapping[senderID] } - - @MainActor + func geohashDisplayName(for convKey: PeerID) -> String { - nostrCoordinator.geohashDisplayName(for: convKey) + guard let full = nostrKeyMapping[convKey] else { + return convKey.bare + } + return displayNameForNostrPubkey(full) } } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index 9dad8030..146c3959 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -5,30 +5,193 @@ // Private chat and media transfer logic for ChatViewModel // -import BitFoundation -import BitLogger import Foundation +import Combine +import BitLogger +import BitFoundation import SwiftUI extension ChatViewModel { + // MARK: - Private Chat Sending + + /// Sends an encrypted private message to a specific peer. + /// - Parameters: + /// - content: The message content to encrypt and send + /// - peerID: The recipient's peer ID + /// - Note: Automatically establishes Noise encryption if not already active @MainActor func sendPrivateMessage(_ content: String, to peerID: PeerID) { - // Group chats reuse the private-chat surface but broadcast a sealed - // envelope instead of routing to a single peer. - if peerID.isGroup { - groupCoordinator.sendGroupMessage(content, to: peerID) + guard !content.isEmpty else { return } + + // Check if blocked + if unifiedPeerService.isBlocked(peerID) { + let nickname = meshService.peerNickname(peerID: peerID) ?? "user" + addSystemMessage( + String( + format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), + locale: .current, + nickname + ) + ) return } - privateConversationCoordinator.sendPrivateMessage(content, to: peerID) + + // Geohash DM routing: conversation keys start with "nostr_" + if peerID.isGeoDM { + sendGeohashDM(content, to: peerID) + return + } + + // Determine routing method and recipient nickname + guard let noiseKey = Data(hexString: peerID.id) else { return } + let isConnected = meshService.isPeerConnected(peerID) + let isReachable = meshService.isPeerReachable(peerID) + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) + let isMutualFavorite = favoriteStatus?.isMutual ?? false + let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil + + // Get nickname from various sources + var recipientNickname = meshService.peerNickname(peerID: peerID) + if recipientNickname == nil && favoriteStatus != nil { + recipientNickname = favoriteStatus?.peerNickname + } + recipientNickname = recipientNickname ?? "user" + + // Generate message ID + let messageID = UUID().uuidString + + // Create the message object + let message = BitchatMessage( + id: messageID, + sender: nickname, + content: content, + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: recipientNickname, + senderPeerID: meshService.myPeerID, + mentions: nil, + deliveryStatus: .sending + ) + + // Add to local chat + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + privateChats[peerID]?.append(message) + + // Trigger UI update for sent message + objectWillChange.send() + + // Send via appropriate transport (BLE if connected/reachable, else Nostr when possible) + if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { + messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID) + // Optimistically mark as sent for both transports; delivery/read will update subsequently + if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[idx].deliveryStatus = .sent + } + } else { + // Update delivery status to failed + if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[index].deliveryStatus = .failed( + reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable") + ) + } + let name = recipientNickname ?? "user" + addSystemMessage( + String( + format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"), + locale: .current, + name + ) + ) + } } - - @MainActor + func sendGeohashDM(_ content: String, to peerID: PeerID) { - privateConversationCoordinator.sendGeohashDM(content, to: peerID) + guard case .location(let ch) = activeChannel else { + addSystemMessage( + String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel") + ) + return + } + let messageID = UUID().uuidString + + // Local echo in the DM thread + let message = BitchatMessage( + id: messageID, + sender: nickname, + content: content, + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: nickname, + senderPeerID: meshService.myPeerID, + deliveryStatus: .sending + ) + + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + + privateChats[peerID]?.append(message) + objectWillChange.send() + + // Resolve recipient hex from mapping + guard let recipientHex = nostrKeyMapping[peerID] else { + if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[msgIdx].deliveryStatus = .failed( + reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") + ) + } + return + } + + // Respect geohash blocks + if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) { + if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[msgIdx].deliveryStatus = .failed( + reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked") + ) + } + addSystemMessage( + String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked") + ) + return + } + + // Send via Nostr using per-geohash identity + do { + let id = try idBridge.deriveIdentity(forGeohash: ch.geohash) + // Prevent messaging ourselves + if recipientHex.lowercased() == id.publicKeyHex.lowercased() { + if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[idx].deliveryStatus = .failed( + reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself") + ) + } + return + } + SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session) + let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) + nostrTransport.senderPeerID = meshService.myPeerID + nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID) + if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[msgIdx].deliveryStatus = .sent + } + } catch { + if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[idx].deliveryStatus = .failed( + reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error") + ) + } + } } - @MainActor + // MARK: - Private Chat Handling (Geohash/Ephemeral) + func handlePrivateMessage( _ payload: NoisePayload, senderPubkey: String, @@ -36,164 +199,892 @@ extension ChatViewModel { id: NostrIdentity, messageTimestamp: Date ) { - privateConversationCoordinator.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: convKey, - id: id, - messageTimestamp: messageTimestamp + guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } + let messageId = pm.messageID + + SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session) + + sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) + + // Respect geohash blocks + if identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { + return + } + + // Duplicate check + if privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return } + for (_, arr) in privateChats { + if arr.contains(where: { $0.id == messageId }) { + return + } + } + + let senderName = displayNameForNostrPubkey(senderPubkey) + let msg = BitchatMessage( + id: messageId, + sender: senderName, + content: pm.content, + timestamp: messageTimestamp, + isRelay: false, + isPrivate: true, + recipientNickname: nickname, + senderPeerID: convKey, + deliveryStatus: .delivered(to: nickname, at: Date()) ) + + if privateChats[convKey] == nil { + privateChats[convKey] = [] + } + privateChats[convKey]?.append(msg) + + let isViewing = selectedPrivateChatPeer == convKey + let wasReadBefore = sentReadReceipts.contains(messageId) + let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 + let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage + if shouldMarkUnread { + unreadPrivateMessages.insert(convKey) + } + + // Send READ if viewing this conversation + if isViewing { + sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) + } + + // Notify for truly unread and recent messages when not viewing + if !isViewing && shouldMarkUnread { + NotificationService.shared.sendPrivateMessageNotification( + from: senderName, + message: pm.content, + peerID: convKey + ) + } + + objectWillChange.send() } - - @MainActor + func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { - privateConversationCoordinator.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) + guard let messageID = String(data: payload.data, encoding: .utf8) else { return } + + if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { + privateChats[convKey]?[idx].deliveryStatus = .delivered(to: displayNameForNostrPubkey(senderPubkey), at: Date()) + objectWillChange.send() + SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) + } else { + SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) + } + } + + func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { + guard let messageID = String(data: payload.data, encoding: .utf8) else { return } + + if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { + privateChats[convKey]?[idx].deliveryStatus = .read(by: displayNameForNostrPubkey(senderPubkey), at: Date()) + objectWillChange.send() + SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) + } else { + SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) + } } - @MainActor - func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { - privateConversationCoordinator.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) + func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { + guard !sentGeoDeliveryAcks.contains(messageId) else { return } + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) + nt.senderPeerID = meshService.myPeerID + nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id) + sentGeoDeliveryAcks.insert(messageId) + } + + func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { + guard !sentReadReceipts.contains(messageId) else { return } + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) + nt.senderPeerID = meshService.myPeerID + nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id) + sentReadReceipts.insert(messageId) + } + + // MARK: - Media Transfers + + private enum MediaSendError: Error { + case encodingFailed + case tooLarge + case copyFailed } @MainActor func sendVoiceNote(at url: URL) { - mediaTransferCoordinator.sendVoiceNote(at: url) - } - - /// Where a live burst would stream right now, or nil when the hold would - /// fall back to a classic voice note. - private enum LiveVoiceTarget { - case peer(PeerID) - case publicMesh - } - - @MainActor - private func liveVoiceTarget() -> LiveVoiceTarget? { - guard PTTSettings.liveVoiceEnabled else { return nil } - - if let selectedPeer = selectedPrivateChatPeer { - guard !selectedPeer.isGeoDM, !selectedPeer.isGeoChat, !selectedPeer.isGroup else { return nil } - // A conversation can be selected under the stable 64-hex Noise key - // (e.g. after migration on disconnect), but Noise sessions are keyed - // by the 16-hex routing ID — normalize once and send to that same - // short ID, like the private-message/file paths do. - let peerID = selectedPeer.toShort() - guard meshService.isPeerReachable(peerID), - case .established = meshService.getNoiseSessionState(for: peerID) - else { return nil } - return .peer(peerID) + guard canSendMediaInCurrentContext else { + SecureLogger.info("Voice note blocked outside mesh/private context", category: .session) + try? FileManager.default.removeItem(at: url) + addSystemMessage("Voice notes are only available in mesh chats.") + return } - // Public mesh timeline: signed live broadcast. Geohash channels never - // reach here (the composer hides media affordances there). - return activeChannel == .mesh ? .publicMesh : nil - } + let targetPeer = selectedPrivateChatPeer + let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer) + let messageID = message.id + let transferId = makeTransferID(messageID: messageID) - /// Picks the capture backend for the composer's hold-to-record gesture: - /// live push-to-talk when the audience can hear it now — a DM peer that - /// is mesh-reachable with an established Noise session, or the public - /// mesh channel — otherwise the classic record-then-send voice note. - /// Either way the release delivers a normal voice note through - /// `sendVoiceNote(at:)`, which live receivers absorb into the live bubble. - @MainActor - func makeVoiceCaptureSession() -> VoiceCaptureSession { - // Live voice rides the mesh only; frames are useful now or never, - // so a transport without the capability just drops them. - let voiceTransport = meshService as? MeshVoiceStreaming - switch liveVoiceTarget() { - case .peer(let peerID): - return PTTLiveVoiceSession(sendPacket: { packet in - voiceTransport?.sendVoiceFrame(packet, to: peerID) - }) - case .publicMesh: - return PTTLiveVoiceSession(sendPacket: { packet in - voiceTransport?.sendVoiceFrameBroadcast(packet) - }) - case nil: - SecureLogger.info("PTT: hold uses classic voice note (liveVoiceEnabled=\(PTTSettings.liveVoiceEnabled), dmSelected=\(selectedPrivateChatPeer != nil))", category: .session) - return VoiceNoteCaptureSession() + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + do { + // Security H1: Check file size BEFORE reading into memory + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + guard let fileSize = attrs[.size] as? Int, + fileSize <= FileTransferLimits.maxVoiceNoteBytes else { + let size = (attrs[.size] as? Int) ?? 0 + SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) + try? FileManager.default.removeItem(at: url) + await MainActor.run { + self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") + } + return + } + + let data = try Data(contentsOf: url) + let packet = BitchatFilePacket( + fileName: url.lastPathComponent, + fileSize: UInt64(data.count), + mimeType: "audio/mp4", + content: data + ) + guard packet.encode() != nil else { throw MediaSendError.encodingFailed } + await MainActor.run { + self.registerTransfer(transferId: transferId, messageID: messageID) + if let peerID = targetPeer { + self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + } else { + self.meshService.sendFileBroadcast(packet, transferId: transferId) + } + } + } catch { + SecureLogger.error("Voice note send failed: \(error)", category: .session) + await MainActor.run { + self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note") + } + } } } - /// Inbound handler for `NoisePayloadType.voiceFrame`. - @MainActor - func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) { - liveVoiceCoordinator.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp) - } - #if os(iOS) func processThenSendImage(_ image: UIImage?) { - mediaTransferCoordinator.processThenSendImage(image) + guard let image else { return } + Task.detached { + do { + let processedURL = try ImageUtils.processImage(image) + await MainActor.run { + self.sendImage(from: processedURL) + } + } catch { + SecureLogger.error("Image processing failed: \(error)", category: .session) + } + } } #elseif os(macOS) func processThenSendImage(from url: URL?) { - mediaTransferCoordinator.processThenSendImage(from: url) + guard let url else { return } + Task.detached { + do { + let processedURL = try ImageUtils.processImage(at: url) + await MainActor.run { + self.sendImage(from: processedURL) + } + } catch { + SecureLogger.error("Image processing failed: \(error)", category: .session) + } + } } #endif @MainActor func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { - mediaTransferCoordinator.sendImage(from: sourceURL, cleanup: cleanup) + guard canSendMediaInCurrentContext else { + SecureLogger.info("Image send blocked outside mesh/private context", category: .session) + cleanup?() + addSystemMessage("Images are only available in mesh chats.") + return + } + + let targetPeer = selectedPrivateChatPeer + + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + var processedURL: URL? + do { + let outputURL = try ImageUtils.processImage(at: sourceURL) + processedURL = outputURL + let data = try Data(contentsOf: outputURL) + guard data.count <= FileTransferLimits.maxImageBytes else { + SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session) + await MainActor.run { + self.addSystemMessage("Image is too large to send.") + } + try? FileManager.default.removeItem(at: outputURL) + return + } + let packet = BitchatFilePacket( + fileName: outputURL.lastPathComponent, + fileSize: UInt64(data.count), + mimeType: "image/jpeg", + content: data + ) + guard packet.encode() != nil else { throw MediaSendError.encodingFailed } + await MainActor.run { + let message = self.enqueueMediaMessage(content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer) + let messageID = message.id + let transferId = self.makeTransferID(messageID: messageID) + self.registerTransfer(transferId: transferId, messageID: messageID) + if let peerID = targetPeer { + self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + } else { + self.meshService.sendFileBroadcast(packet, transferId: transferId) + } + } + } catch { + SecureLogger.error("Image send preparation failed: \(error)", category: .session) + await MainActor.run { + self.addSystemMessage("Failed to prepare image for sending.") + } + if let url = processedURL { + try? FileManager.default.removeItem(at: url) + } + } + } } @MainActor func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage { - mediaTransferCoordinator.enqueueMediaMessage(content: content, targetPeer: targetPeer) + let timestamp = Date() + let message: BitchatMessage + + if let peerID = targetPeer { + message = BitchatMessage( + sender: nickname, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: nicknameForPeer(peerID), + senderPeerID: meshService.myPeerID, + deliveryStatus: .sending + ) + var chats = privateChats + chats[peerID, default: []].append(message) + privateChats = chats + trimMessagesIfNeeded() + } else { + let (displayName, senderPeerID) = currentPublicSender() + message = BitchatMessage( + sender: displayName, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: senderPeerID, + deliveryStatus: .sending + ) + timelineStore.append(message, to: activeChannel) + messages = timelineStore.messages(for: activeChannel) + trimMessagesIfNeeded() + } + + let key = deduplicationService.normalizedContentKey(message.content) + deduplicationService.recordContentKey(key, timestamp: timestamp) + objectWillChange.send() + return message } @MainActor func registerTransfer(transferId: String, messageID: String) { - mediaTransferCoordinator.registerTransfer(transferId: transferId, messageID: messageID) + transferIdToMessageIDs[transferId, default: []].append(messageID) + messageIDToTransferId[messageID] = transferId } func makeTransferID(messageID: String) -> String { - mediaTransferCoordinator.makeTransferID(messageID: messageID) + "\(messageID)-\(UUID().uuidString)" } @MainActor func clearTransferMapping(for messageID: String) { - mediaTransferCoordinator.clearTransferMapping(for: messageID) + guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return } + guard var queue = transferIdToMessageIDs[transferId] else { return } + if !queue.isEmpty { + if queue.first == messageID { + queue.removeFirst() + } else if let idx = queue.firstIndex(of: messageID) { + queue.remove(at: idx) + } + } + transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue + } + + @MainActor + func handleMediaSendFailure(messageID: String, reason: String) { + updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) + clearTransferMapping(for: messageID) } @MainActor func handleTransferEvent(_ event: TransferProgressManager.Event) { - mediaTransferCoordinator.handleTransferEvent(event) + switch event { + case .started(let id, let total): + guard let messageID = transferIdToMessageIDs[id]?.first else { return } + updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) + case .updated(let id, let sent, let total): + guard let messageID = transferIdToMessageIDs[id]?.first else { return } + updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) + case .completed(let id, _): + guard let messageID = transferIdToMessageIDs[id]?.first else { return } + updateMessageDeliveryStatus(messageID, status: .sent) + clearTransferMapping(for: messageID) + case .cancelled(let id, _, _): + guard let messageID = transferIdToMessageIDs[id]?.first else { return } + clearTransferMapping(for: messageID) + removeMessage(withID: messageID, cleanupFile: true) + } } func cleanupLocalFile(forMessage message: BitchatMessage) { - mediaTransferCoordinator.cleanupLocalFile(forMessage: message) + // Check both outgoing and incoming directories for thorough cleanup + let categories: [MimeType.Category] = [.audio, .image, .file] + guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }), + let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty, + let base = try? applicationFilesDirectory(), + // Security: Extract only the last path component to prevent directory traversal + let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty, + safeFilename != "." && safeFilename != ".." + else { + return + } + + // Try all possible locations (outgoing and incoming) + let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] } + for subdir in subdirs { + let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) + + // Security: Verify target is within expected directory before deletion + guard target.path.hasPrefix(base.path) else { continue } + + do { + try FileManager.default.removeItem(at: target) + } catch CocoaError.fileNoSuchFile { + // Expected - file not in this directory + } catch { + SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session) + } + } + } + + func applicationFilesDirectory() throws -> URL { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let filesDir = base.appendingPathComponent("files", isDirectory: true) + try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) + return filesDir } @MainActor func cancelMediaSend(messageID: String) { - mediaTransferCoordinator.cancelMediaSend(messageID: messageID) + if let transferId = messageIDToTransferId[messageID], + let active = transferIdToMessageIDs[transferId]?.first, + active == messageID { + meshService.cancelTransfer(transferId) + } + clearTransferMapping(for: messageID) + removeMessage(withID: messageID, cleanupFile: true) } @MainActor func deleteMediaMessage(messageID: String) { - mediaTransferCoordinator.deleteMediaMessage(messageID: messageID) + clearTransferMapping(for: messageID) + removeMessage(withID: messageID, cleanupFile: true) } + + // MARK: - Private Chat Handling (Main) + @MainActor + func handlePrivateMessage( + _ payload: NoisePayload, + actualSenderNoiseKey: Data?, + senderNickname: String, + targetPeerID: PeerID, + messageTimestamp: Date, + senderPubkey: String + ) { + guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } + let messageId = pm.messageID + let messageContent = pm.content + + // Favorite/unfavorite notifications embedded as private messages + if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") { + if let key = actualSenderNoiseKey { + handleFavoriteNotificationFromMesh(messageContent, from: PeerID(hexData: key), senderNickname: senderNickname) + } + return + } + + if isDuplicateMessage(messageId, targetPeerID: targetPeerID) { + return + } + + let wasReadBefore = sentReadReceipts.contains(messageId) + + // Is viewing? + var isViewingThisChat = false + if selectedPrivateChatPeer == targetPeerID { + isViewingThisChat = true + } else if let selectedPeer = selectedPrivateChatPeer, + let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer), + let key = actualSenderNoiseKey, + selectedPeerData.noisePublicKey == key { + isViewingThisChat = true + } + + // Recency check + let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 + let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage + + let message = BitchatMessage( + id: messageId, + sender: senderNickname, + content: messageContent, + timestamp: messageTimestamp, + isRelay: false, + isPrivate: true, + recipientNickname: nickname, + senderPeerID: targetPeerID, + deliveryStatus: .delivered(to: nickname, at: Date()) + ) + + addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) + mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey) + + // Using simplified internal helper in this file (or make the main one internal) + // sendDeliveryAckViaNostrEmbedded is in ChatViewModel+Nostr.swift and is internal. + // However, it was missing in ChatViewModel+Nostr.swift in previous step check? + // Wait, I added `sendDeliveryAckViaNostrEmbedded` to `ChatViewModel+Nostr.swift` in Step 19? + // Let's re-check `ChatViewModel+Nostr.swift` content in my mind. + // I see `sendDeliveryAckViaNostrEmbedded` in `ChatViewModel+Nostr.swift` in the output of step 33. + // So I can call it. + sendDeliveryAckViaNostrEmbedded( + message, + wasReadBefore: wasReadBefore, + senderPubkey: senderPubkey, + key: actualSenderNoiseKey + ) + + if wasReadBefore { + // do nothing + } else if isViewingThisChat { + handleViewingThisChat( + message, + targetPeerID: targetPeerID, + key: actualSenderNoiseKey, + senderPubkey: senderPubkey + ) + } else { + markAsUnreadIfNeeded( + shouldMarkAsUnread: shouldMarkAsUnread, + targetPeerID: targetPeerID, + key: actualSenderNoiseKey, + isRecentMessage: isRecentMessage, + senderNickname: senderNickname, + messageContent: messageContent + ) + } + + objectWillChange.send() + } + + /// Handle incoming private message (Mesh) @MainActor func handlePrivateMessage(_ message: BitchatMessage) { - // A finalized voice note whose burst already streamed in live swaps - // into the existing bubble instead of appearing (and notifying) twice. - if liveVoiceCoordinator.absorbFinalizedVoiceNote(message) { return } - privateConversationCoordinator.handlePrivateMessage(message) + SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) + let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) + + guard let peerID = senderPeerID else { + SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session) + return + } + + // Check if this is a favorite/unfavorite notification + if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { + handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender) + return // Don't store as a regular message + } + + // Migrate chats if needed + migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender) + + // IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer + // This ensures Nostr messages appear in BLE chats + if peerID.id.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars) + if let peer = unifiedPeerService.getPeer(by: peerID) { + let stableKeyHex = PeerID(hexData: peer.noisePublicKey) + + // If we have messages stored under the stable key, merge them + if stableKeyHex != peerID, let nostrMessages = privateChats[stableKeyHex], !nostrMessages.isEmpty { + // Merge messages from stable key into ephemeral peer ID storage + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + + // Add any messages that aren't already in the ephemeral storage + let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) + for nostrMessage in nostrMessages { + if !existingMessageIds.contains(nostrMessage.id) { + privateChats[peerID]?.append(nostrMessage) + } + } + + // Sort by timestamp + privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + + // Clean up the stable key storage to avoid duplication + privateChats.removeValue(forKey: stableKeyHex) + + SecureLogger.info("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", category: .session) + } + } + } + + // Avoid duplicates + if isDuplicateMessage(message.id, targetPeerID: peerID) { + return + } + + // Store the message + addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID) + + // Mirror to ephemeral if needed (if we are talking to a stable key peer but have an ephemeral session) + // Actually, logic usually mirrors TO stable key storage if available? + // Or mirrors to ephemeral if we received on stable. + // Let's just use the existing helper which seems to mirror TO ephemeral. + // But we need to get the noise key. + let noiseKey = peerID.noiseKey ?? unifiedPeerService.getPeer(by: peerID)?.noisePublicKey + mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey) + + // Notifications and Read Receipts + let isViewing = selectedPrivateChatPeer == peerID + + if isViewing { + // Mark read immediately if viewing + // Use the incoming peerID directly - it has the established Noise session. + // Don't use PeerID(hexData: noiseKey) as that creates a 64-hex ID without a session. + // Use meshService directly (not messageRouter) so it queues if peer disconnects. + let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) + meshService.sendReadReceipt(receipt, to: peerID) + sentReadReceipts.insert(message.id) + } else { + // Notify + unreadPrivateMessages.insert(peerID) + NotificationService.shared.sendPrivateMessageNotification( + from: message.sender, + message: message.content, + peerID: peerID + ) + } + + objectWillChange.send() } + func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool { + if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { + return true + } + for (_, messages) in privateChats where messages.contains(where: { $0.id == messageId }) { + return true + } + return false + } + + func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) { + if privateChats[targetPeerID] == nil { + privateChats[targetPeerID] = [] + } + if let idx = privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) { + privateChats[targetPeerID]?[idx] = message + } else { + privateChats[targetPeerID]?.append(message) + } + // Sanitize to avoid duplicate IDs + privateChatManager.sanitizeChat(for: targetPeerID) + } + @MainActor - func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { - privateConversationCoordinator.processActionMessage(message) + func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) { + guard let key, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, + ephemeralPeerID != targetPeerID + else { + return + } + + if privateChats[ephemeralPeerID] == nil { + privateChats[ephemeralPeerID] = [] + } + if let idx = privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) { + privateChats[ephemeralPeerID]?[idx] = message + } else { + privateChats[ephemeralPeerID]?.append(message) + } + privateChatManager.sanitizeChat(for: ephemeralPeerID) } + + @MainActor + func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?, senderPubkey: String) { + unreadPrivateMessages.remove(targetPeerID) + if let key, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID { + unreadPrivateMessages.remove(ephemeralPeerID) + } + if !sentReadReceipts.contains(message.id) { + if let key { + let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) + SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session) + messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key)) + sentReadReceipts.insert(message.id) + } else if let id = try? idBridge.getCurrentNostrIdentity() { + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) + nt.senderPeerID = meshService.myPeerID + nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) + sentReadReceipts.insert(message.id) + SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) + } + } + } + + @MainActor + func markAsUnreadIfNeeded( + shouldMarkAsUnread: Bool, + targetPeerID: PeerID, + key: Data?, + isRecentMessage: Bool, + senderNickname: String, + messageContent: String + ) { + guard shouldMarkAsUnread else { return } + + unreadPrivateMessages.insert(targetPeerID) + if let key, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, + ephemeralPeerID != targetPeerID { + unreadPrivateMessages.insert(ephemeralPeerID) + } + if isRecentMessage { + NotificationService.shared.sendPrivateMessageNotification( + from: senderNickname, + message: messageContent, + peerID: targetPeerID + ) + } + } + + @MainActor + func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { + // Parse the message format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..." + let isFavorite = content.hasPrefix("[FAVORITED]") + let parts = content.split(separator: ":") + + // Extract Nostr public key if included + var nostrPubkey: String? = nil + if parts.count > 1 { + nostrPubkey = String(parts[1]) + SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session) + } + + // Get the noise public key for this peer + let noiseKey = peerID.noiseKey ?? unifiedPeerService.getPeer(by: peerID)?.noisePublicKey + + guard let finalNoiseKey = noiseKey else { + SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session) + return + } + // Determine prior state to avoid duplicate system messages on repeated notifications + let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false + // Update the favorite relationship (idempotent storage) + FavoritesPersistenceService.shared.updatePeerFavoritedUs( + peerNoisePublicKey: finalNoiseKey, + favorited: isFavorite, + peerNickname: senderNickname, + peerNostrPublicKey: nostrPubkey + ) + + // If they favorited us and provided their Nostr key, ensure it's stored (log only) + if isFavorite && nostrPubkey != nil { + SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", category: .session) + } + + // Only show a system message when the state changes, and only in mesh + if prior != isFavorite { + let action = isFavorite ? "favorited" : "unfavorited" + addMeshOnlySystemMessage("\(senderNickname) \(action) you") + } + } + + /// Process action messages (hugs, slaps) into system messages + func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { + let isActionMessage = message.content.hasPrefix("* ") && message.content.hasSuffix(" *") && + (message.content.contains("🫂") || message.content.contains("🐟") || + message.content.contains("took a screenshot")) + + if isActionMessage { + return BitchatMessage( + id: message.id, + sender: "system", + content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: message.senderPeerID, + mentions: message.mentions, + deliveryStatus: message.deliveryStatus + ) + } + return message + } + + /// Migrate private chats when peer reconnects with new ID @MainActor func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { - privateConversationCoordinator.migratePrivateChatsIfNeeded(for: peerID, senderNickname: senderNickname) + let currentFingerprint = getFingerprint(for: peerID) + + if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true { + var migratedMessages: [BitchatMessage] = [] + var oldPeerIDsToRemove: [PeerID] = [] + + // Only migrate messages from the last 24 hours to prevent old messages from flooding + let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) + + for (oldPeerID, messages) in privateChats { + if oldPeerID != peerID { + let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID] + + // Filter messages to only recent ones + let recentMessages = messages.filter { $0.timestamp > cutoffTime } + + // Skip if no recent messages + guard !recentMessages.isEmpty else { continue } + + // Check fingerprint match first (most reliable) + if let currentFp = currentFingerprint, + let oldFp = oldFingerprint, + currentFp == oldFp { + migratedMessages.append(contentsOf: recentMessages) + + // Only remove old peer ID if we migrated ALL its messages + if recentMessages.count == messages.count { + oldPeerIDsToRemove.append(oldPeerID) + } else { + // Keep old messages in original location but don't show in UI + SecureLogger.info("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", category: .session) + } + + SecureLogger.info("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", category: .session) + } else if currentFingerprint == nil || oldFingerprint == nil { + // Check if this chat contains messages with this sender by nickname + let isRelevantChat = recentMessages.contains { msg in + (msg.sender == senderNickname && msg.sender != nickname) || + (msg.sender == nickname && msg.recipientNickname == senderNickname) + } + + if isRelevantChat { + migratedMessages.append(contentsOf: recentMessages) + + // Only remove if all messages were migrated + if recentMessages.count == messages.count { + oldPeerIDsToRemove.append(oldPeerID) + } + + SecureLogger.warning("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", category: .session) + } + } + } + } + + // Remove old peer ID entries + if !oldPeerIDsToRemove.isEmpty { + // Track if we need to update selectedPrivateChatPeer + let needsSelectedUpdate = oldPeerIDsToRemove.contains { selectedPrivateChatPeer == $0 } + + for oldID in oldPeerIDsToRemove { + privateChats.removeValue(forKey: oldID) + unreadPrivateMessages.remove(oldID) + + // Also clean up fingerprint mapping + if peerIDToPublicKeyFingerprint[oldID] != nil { + peerIDToPublicKeyFingerprint.removeValue(forKey: oldID) + } + } + + if needsSelectedUpdate { + selectedPrivateChatPeer = peerID + } + } + + // Add migrated messages to new peer ID + if !migratedMessages.isEmpty { + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + privateChats[peerID]?.append(contentsOf: migratedMessages) + + // Sort by timestamp + privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + + // De-duplicate just in case + privateChatManager.sanitizeChat(for: peerID) + + objectWillChange.send() + } + } + } + + @MainActor + func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { + // Handle both ephemeral peer IDs and Noise key hex strings + var noiseKey: Data? + + // First check if peerID is a hex-encoded Noise key + if let hexKey = Data(hexString: peerID.id) { + noiseKey = hexKey + } else { + // It's an ephemeral peer ID, get the Noise key from UnifiedPeerService + if let peer = unifiedPeerService.getPeer(by: peerID) { + noiseKey = peer.noisePublicKey + } + } + + // Try mesh first for connected peers + if meshService.isPeerConnected(peerID) { + messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session) + } else if let key = noiseKey { + // Send via Nostr for offline peers (using router) + messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite) + } else { + SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session) + } } + /// Check if a message should be blocked based on sender @MainActor func isMessageBlocked(_ message: BitchatMessage) -> Bool { - privateConversationCoordinator.isMessageBlocked(message) + if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) { + // Check mesh/known peers first + if isPeerBlocked(peerID) { return true } + // Check geohash (Nostr) blocks using mapping to full pubkey + if peerID.isGeoChat || peerID.isGeoDM { + if let full = nostrKeyMapping[peerID]?.lowercased() { + if identityManager.isNostrBlocked(pubkeyHexLowercased: full) { return true } + } + } + return false + } + return false } } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift index 3c1f9005..443849db 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift @@ -15,8 +15,6 @@ extension ChatViewModel { @objc func handleTorWillStart() { Task { @MainActor in - // A fresh attempt can stall again, so let it be reported again. - self.torStallAnnounced = false if !self.torStatusAnnounced && TorManager.shared.torEnforced { self.torStatusAnnounced = true // Post only in geohash channels (queue if not active) @@ -39,8 +37,6 @@ extension ChatViewModel { @objc func handleTorDidBecomeReady() { Task { @MainActor in - self.torStallAnnounced = false - self.torBlocked = false // Only announce "restarted" if we actually restarted this session if self.torRestartPending { // Post only in geohash channels (queue if not active) @@ -58,39 +54,11 @@ extension ChatViewModel { } } - /// Bootstrap spent its whole deadline without connecting. Say so rather - /// than leaving "starting tor…" on screen indefinitely: on a network that - /// blocks Tor this is the terminal state, and someone needs to know that - /// internet features are stalled while the mesh still works. - @objc func handleTorBootstrapDidStall() { - Task { @MainActor in - guard TorManager.shared.torEnforced else { return } - // torEnforced is a compile-time constant in release builds; the - // runtime preference is what says whether anyone is waiting on - // Tor. Turning Tor off mid-bootstrap must not read as blocking. - guard NetworkActivationService.persistedTorPreference() else { return } - self.torBlocked = true - guard !self.torStallAnnounced else { return } - self.torStallAnnounced = true - self.addGeohashOnlySystemMessage( - String( - localized: "system.tor.blocked", - defaultValue: "tor could not connect — this network may be blocking it. mesh messaging still works; location channels and internet delivery are paused until tor gets through.", - comment: "System message shown when Tor bootstrap runs out its deadline without connecting, which is what a network that blocks Tor looks like" - ) - ) - } - } - - @objc func handleTorPreferenceChanged(_: Notification) { + @objc func handleTorPreferenceChanged(_ notification: Notification) { Task { @MainActor in self.torStatusAnnounced = false self.torInitialReadyAnnounced = false self.torRestartPending = false - self.torStallAnnounced = false - // Turning tor off means nobody is waiting on it; turning it on - // starts a fresh attempt. Either way the stall banner resets. - self.torBlocked = false } } } diff --git a/bitchat/ViewModels/GeoChannelCoordinator.swift b/bitchat/ViewModels/GeoChannelCoordinator.swift index 64a8db5d..60879334 100644 --- a/bitchat/ViewModels/GeoChannelCoordinator.swift +++ b/bitchat/ViewModels/GeoChannelCoordinator.swift @@ -9,75 +9,40 @@ import Combine import Foundation import Tor -/// The narrow surface `GeoChannelCoordinator` needs from its owner. -/// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of capturing `ChatViewModel` in -/// per-callback closures. This keeps the coordinator independently testable -/// (see `GeoChannelCoordinatorContextTests`) and makes its true dependencies -/// explicit. Held `weak` — the owner retains the coordinator, and every -/// callback was previously a `[weak viewModel]` capture. -@MainActor -protocol GeoChannelContext: AnyObject { - func switchLocationChannel(to channel: ChannelID) - func beginGeohashSampling(for geohashes: [String]) - func endGeohashSampling() -} - -// `switchLocationChannel(to:)`, `beginGeohashSampling(for:)`, and -// `endGeohashSampling()` are satisfied by existing `ChatViewModel` members. -extension ChatViewModel: GeoChannelContext {} - @MainActor final class GeoChannelCoordinator { private let locationManager: LocationChannelManager private let bookmarksStore: GeohashBookmarksStore private let torManager: TorManager - private weak var context: (any GeoChannelContext)? + private let onChannelSwitch: (ChannelID) -> Void + private let beginSampling: ([String]) -> Void + private let endSampling: () -> Void private var cancellables = Set() - private var regionalChannels: [GeohashChannel] = [] + private var regionalGeohashes: [String] = [] private var bookmarkedGeohashes: [String] = [] - private var permissionState: LocationChannelManager.PermissionState - private var locationNotesEnabled: Bool - /// Mirrors `NearbyNotesCounter.revealed` (injectable for tests): the - /// session's one explicit notes act. Until it happens, background - /// sampling must not include the building-precision cell — see - /// `sampledRegionalGeohashes`. - private var notesRevealed = false - private let notesRevealedPublisher: AnyPublisher - private let locationNotesSettingsPublisher: AnyPublisher init( locationManager: LocationChannelManager? = nil, bookmarksStore: GeohashBookmarksStore? = nil, torManager: TorManager? = nil, - notesRevealed: AnyPublisher? = nil, - locationNotesEnabled: Bool? = nil, - locationNotesSettings: AnyPublisher? = nil, - context: any GeoChannelContext + onChannelSwitch: @escaping (ChannelID) -> Void, + beginSampling: @escaping ([String]) -> Void, + endSampling: @escaping () -> Void ) { - let resolvedLocationManager = locationManager ?? Self.defaultLocationManager() - self.locationManager = resolvedLocationManager + self.locationManager = locationManager ?? Self.defaultLocationManager() self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared self.torManager = torManager ?? Self.defaultTorManager() - self.permissionState = resolvedLocationManager.permissionState - self.locationNotesEnabled = locationNotesEnabled ?? LocationNotesSettings.enabled - self.notesRevealedPublisher = notesRevealed - ?? NearbyNotesCounter.shared.$revealed.eraseToAnyPublisher() - self.locationNotesSettingsPublisher = locationNotesSettings - ?? NotificationCenter.default - .publisher(for: LocationNotesSettings.didChangeNotification) - .map { _ in LocationNotesSettings.enabled } - .eraseToAnyPublisher() - self.context = context + self.onChannelSwitch = onChannelSwitch + self.beginSampling = beginSampling + self.endSampling = endSampling start() } func start() { - regionalChannels = locationManager.availableChannels + regionalGeohashes = locationManager.availableChannels.map { $0.geohash } bookmarkedGeohashes = bookmarksStore.bookmarks locationManager.$selectedChannel @@ -85,7 +50,7 @@ final class GeoChannelCoordinator { .sink { [weak self] channel in guard let self else { return } Task { @MainActor in - self.context?.switchLocationChannel(to: channel) + self.onChannelSwitch(channel) } } .store(in: &cancellables) @@ -94,30 +59,7 @@ final class GeoChannelCoordinator { .receive(on: DispatchQueue.main) .sink { [weak self] channels in guard let self else { return } - self.regionalChannels = channels - self.updateSampling() - } - .store(in: &cancellables) - - // Revealing the nearby-notes counter is the session's explicit notes - // act; it widens sampling to include the building cell (below). - notesRevealedPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] revealed in - guard let self, self.notesRevealed != revealed else { return } - self.notesRevealed = revealed - self.updateSampling() - } - .store(in: &cancellables) - - // The location-notes preference is a live privacy kill switch. It - // removes the device-derived building cell even if the session was - // previously revealed. Explicit bookmarks remain eligible below. - locationNotesSettingsPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] enabled in - guard let self, self.locationNotesEnabled != enabled else { return } - self.locationNotesEnabled = enabled + self.regionalGeohashes = channels.map { $0.geohash } self.updateSampling() } .store(in: &cancellables) @@ -134,48 +76,31 @@ final class GeoChannelCoordinator { locationManager.$permissionState .receive(on: DispatchQueue.main) .sink { [weak self] state in - guard let self else { return } - self.permissionState = state - if state == .authorized { - self.locationManager.refreshChannels() + guard let self, state == .authorized else { return } + Task { @MainActor [weak self] in + self?.locationManager.refreshChannels() } - // Cached channels outlive authorization by design. Recompute - // regardless of direction so revocation tears down regional - // sampling instead of continuing from stale coordinates. - self.updateSampling() } .store(in: &cancellables) Task { @MainActor in - self.context?.switchLocationChannel(to: self.locationManager.selectedChannel) + self.onChannelSwitch(self.locationManager.selectedChannel) } updateSampling() } - /// Regional geohashes eligible for background sampling. The - /// building-precision (precision-8) cell identifies a single address, so - /// sampling it passively would leak the same location signal the - /// nearby-notes tap-to-reveal exists to gate — it joins only after the - /// session's explicit notes act. The coarser levels (block and up) keep - /// the nearby-conversation hint and channel participant counts working. - /// Bookmarks are exempt: bookmarking a geohash is itself explicit. - private var sampledRegionalGeohashes: [String] { - guard permissionState == .authorized else { return [] } - return regionalChannels - .filter { (notesRevealed && locationNotesEnabled) || $0.level != .building } - .map { $0.geohash } - } - private func updateSampling() { - let union = Array(Set(sampledRegionalGeohashes).union(bookmarkedGeohashes)) - guard !union.isEmpty else { - context?.endGeohashSampling() - return - } - if torManager.isForeground() { - context?.beginGeohashSampling(for: union) - } else { - context?.endGeohashSampling() + let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes)) + Task { @MainActor in + guard !union.isEmpty else { + endSampling() + return + } + if torManager.isForeground() { + beginSampling(union) + } else { + endSampling() + } } } diff --git a/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift deleted file mode 100644 index c2f89b79..00000000 --- a/bitchat/ViewModels/GeoPresenceTracker.swift +++ /dev/null @@ -1,221 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation -import SwiftUI - -/// The narrow surface `GeoPresenceTracker` needs from its owner. -/// -/// Split out of `ChatNostrContext`: member names are shared with the sibling -/// component contexts so `ChatViewModel` provides a single witness for each. -@MainActor -protocol GeoPresenceContext: AnyObject { - var activeChannel: ChannelID { get } - /// Per-geohash notification cooldown: geohash -> last notify time. - var lastGeoNotificationAt: [String: Date] { get set } - var geoNicknames: [String: String] { get } - var teleportedGeoCount: Int { get } - - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool - func parseMentions(from content: String) -> [String] - - func recordGeoParticipant(pubkeyHex: String, geohash: String) - func geoParticipantCount(for geohash: String) -> Int - func markGeoTeleported(_ pubkeyHexLowercased: String) - - /// Appends a geohash message if absent (single-writer store intent). - /// Returns `true` when stored. - @discardableResult - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool - - /// Posts the sampled-geohash-activity local notification. - func notifyGeohashActivity(geohash: String, bodyPreview: String) -} - -extension ChatViewModel: GeoPresenceContext { - // `activeChannel`, `lastGeoNotificationAt`, `geoNicknames`, the Nostr - // identity/blocking members, and the - // `appendGeohashMessageIfAbsent(_:toGeohash:)` store intent already have - // witnesses on `ChatViewModel`. The members below flatten nested service - // accesses into intent-named calls. - - var teleportedGeoCount: Int { - locationPresenceStore.teleportedGeo.count - } - - func recordGeoParticipant(pubkeyHex: String, geohash: String) { - participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash) - } - - func geoParticipantCount(for geohash: String) -> Int { - participantTracker.participantCount(for: geohash) - } - - func markGeoTeleported(_ pubkeyHexLowercased: String) { - locationPresenceStore.markTeleported(pubkeyHexLowercased) - } - - func notifyGeohashActivity(geohash: String, bodyPreview: String) { - NotificationService.shared.sendGeohashActivityNotification(geohash: geohash, bodyPreview: bodyPreview) - } -} - -/// Geohash presence bookkeeping that is independent of relay subscriptions: -/// teleport-tag detection and marking, the sampling-event LRU dedup, and the -/// per-geohash notification cooldown for sampled activity. -final class GeoPresenceTracker { - private weak var context: (any GeoPresenceContext)? - private var recentGeoSamplingEventIDs = Set() - private var recentGeoSamplingEventIDOrder: [String] = [] - - init(context: any GeoPresenceContext) { - self.context = context - } - - /// True when the event carries a `["t", "teleport"]` tag. - static func hasTeleportTag(_ event: NostrEvent) -> Bool { - event.tags.contains { tag in - tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" - } - } - - /// Marks a peer teleported on a follow-up main-actor hop (keeps the - /// inbound hot path free of presence-store writes). - @MainActor - func scheduleMarkPeerTeleported(_ key: String, logged: Bool) { - Task { @MainActor [weak context] in - guard let context else { return } - context.markGeoTeleported(key) - if logged { - SecureLogger.info( - "GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", - category: .session - ) - } - } - } - - @MainActor - func subscribeNostrEvent(_ event: NostrEvent, gh: String) { - guard let context else { return } - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) - else { - return - } - // The signature was already verified (exactly once, off the main - // actor) by NostrRelayManager before delivery. - guard shouldProcessGeoSamplingEvent(event.id) else { return } - - let existingCount = context.geoParticipantCount(for: gh) - context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) - - guard let content = event.content.trimmedOrNilIfEmpty else { return } - if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } - if let my = try? context.deriveNostrIdentity(forGeohash: gh), - my.publicKeyHex.lowercased() == event.pubkey.lowercased() { - return - } - - // Non-empty content on a sampled event means an actual chat message - // (presence events are empty) — feed the nearby-conversation hint. - GeohashChatActivityTracker.shared.recordChatMessage( - geohash: gh, - senderName: Self.sampledSenderName(for: event, context: context), - content: content, - timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)) - ) - - guard existingCount == 0 else { return } - - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) > 30 { return } - - #if os(iOS) - guard UIApplication.shared.applicationState == .active else { return } - if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } - #elseif os(macOS) - guard NSApplication.shared.isActive else { return } - if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } - #endif - - cooldownPerGeohash(gh, content: content, event: event) - } - - /// Attribution for a sampled event: the event's own `n` tag wins (the - /// active-channel nickname table only covers the selected geohash), - /// falling back to the table, then "anon", always suffixed with the - /// pubkey tail like every other geohash display name. - @MainActor - static func sampledSenderName(for event: NostrEvent, context: any GeoPresenceContext) -> String { - let suffix = String(event.pubkey.suffix(4)) - let tagNick = event.tags.first { $0.count >= 2 && $0[0].lowercased() == "n" }?[1] - let nick = tagNick?.trimmedOrNilIfEmpty - ?? context.geoNicknames[event.pubkey.lowercased()]?.trimmedOrNilIfEmpty - return (nick ?? "anon") + "#" + suffix - } - - @MainActor - func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { - guard let context else { return } - let now = Date() - let last = context.lastGeoNotificationAt[gh] ?? .distantPast - if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } - - let preview: String = { - let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen - if content.count <= maxLen { return content } - let idx = content.index(content.startIndex, offsetBy: maxLen) - return String(content[.. Bool { - guard !eventID.isEmpty else { return true } - guard recentGeoSamplingEventIDs.insert(eventID).inserted else { - return false - } - recentGeoSamplingEventIDOrder.append(eventID) - - let cap = TransportConfig.geoSamplingEventLRUCap - if recentGeoSamplingEventIDOrder.count > cap { - let removeCount = recentGeoSamplingEventIDOrder.count - cap - for staleID in recentGeoSamplingEventIDOrder.prefix(removeCount) { - recentGeoSamplingEventIDs.remove(staleID) - } - recentGeoSamplingEventIDOrder.removeFirst(removeCount) - } - return true - } - - func clearGeoSamplingEventDedup() { - recentGeoSamplingEventIDs.removeAll() - recentGeoSamplingEventIDOrder.removeAll() - } -} diff --git a/bitchat/ViewModels/GeohashSubscriptionManager.swift b/bitchat/ViewModels/GeohashSubscriptionManager.swift deleted file mode 100644 index ce4c9448..00000000 --- a/bitchat/ViewModels/GeohashSubscriptionManager.swift +++ /dev/null @@ -1,402 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation -import Tor - -/// The narrow surface `GeohashSubscriptionManager` needs from its owner. -/// -/// Split out of `ChatNostrContext`: member names are shared with the sibling -/// component contexts so `ChatViewModel` provides a single witness for each. -@MainActor -protocol GeohashSubscriptionContext: AnyObject { - // MARK: Channel & subscription state - var activeChannel: ChannelID { get set } - var currentGeohash: String? { get set } - var geoSubscriptionID: String? { get } - var geoDmSubscriptionID: String? { get } - func setGeoChatSubscriptionID(_ id: String?) - func setGeoDmSubscriptionID(_ id: String?) - /// Geohash sampling subscriptions: subscription ID -> geohash. - var geoSamplingSubs: [String: String] { get } - func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) - func removeGeoSamplingSub(_ subID: String) - /// Clears all sampling subscriptions and returns the removed subscription IDs - /// so the caller can unsubscribe them from the relay manager. - func clearGeoSamplingSubs() -> [String] - var nostrRelayManager: NostrRelayManager? { get } - - // MARK: Public timeline & pipeline - var messages: [BitchatMessage] { get } - /// Commits any batched-but-unflushed public messages to the store so a - /// channel switch never strands them in the pipeline buffer. - func flushPublicMessagePipeline() - func refreshVisibleMessages(from channel: ChannelID?) - func addPublicSystemMessage(_ content: String) - func drainPendingGeohashSystemMessages() -> [String] - - // MARK: Nostr identity & dedup - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity - func currentNostrIdentity() -> NostrIdentity? - func recordProcessedNostrEvent(_ eventID: String) - func clearProcessedNostrEvents() - /// Records the Nostr pubkey behind a (possibly virtual) peer ID. - func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) - - // MARK: Geo participants & presence - var teleportedGeoCount: Int { get } - func startGeoParticipantRefreshTimer() - func stopGeoParticipantRefreshTimer() - func setActiveParticipantGeohash(_ geohash: String?) - func recordGeoParticipant(pubkeyHex: String) - func markGeoTeleported(_ pubkeyHexLowercased: String) - func clearGeoTeleported(_ pubkeyHexLowercased: String) - func clearTeleportedGeo() - func clearGeoNicknames() - - // MARK: Location channels - var isTeleported: Bool { get } - /// True when regional channels are known and the geohash is not one of them. - func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool -} - -extension ChatViewModel: GeohashSubscriptionContext { - // `activeChannel`, `currentGeohash`, the subscription-ID accessors, the - // identity members, and the timeline members already have witnesses on - // `ChatViewModel`. The members below flatten nested service accesses into - // intent-named calls. - - func flushPublicMessagePipeline() { - publicMessagePipeline.flushIfNeeded() - } - - func clearProcessedNostrEvents() { - deduplicationService.clearNostrCaches() - } - - func startGeoParticipantRefreshTimer() { - participantTracker.startRefreshTimer() - } - - func stopGeoParticipantRefreshTimer() { - participantTracker.stopRefreshTimer() - } - - func setActiveParticipantGeohash(_ geohash: String?) { - participantTracker.setActiveGeohash(geohash) - } - - func clearGeoTeleported(_ pubkeyHexLowercased: String) { - locationPresenceStore.clearTeleported(pubkeyHexLowercased) - } - - func clearTeleportedGeo() { - locationPresenceStore.clearTeleportedGeo() - } - - func clearGeoNicknames() { - locationPresenceStore.clearGeoNicknames() - } - - var isTeleported: Bool { - locationManager.teleported - } - - func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { - let channels = locationManager.availableChannels - return !channels.isEmpty && !channels.contains { $0.geohash == geohash } - } -} - -/// Owns subscription IDs and relay lifecycle for geohash channels, geohash -/// DMs, the account gift-wrap mailbox, and background geohash sampling. The -/// only component that talks to `NostrRelayManager`; inbound events are -/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`. -final class GeohashSubscriptionManager { - private weak var context: (any GeohashSubscriptionContext)? - private let inbound: NostrInboundPipeline - private let presence: GeoPresenceTracker - /// Geohashes already told "sent via mesh gateway" this session, so the - /// notice appears once per channel instead of once per message. - private var gatewayNoticeGeohashes = Set() - - init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) { - self.context = context - self.inbound = inbound - self.presence = presence - } - - @MainActor - func resubscribeCurrentGeohash() { - guard let context else { return } - guard case .location(let channel) = context.activeChannel else { return } - guard let subID = context.geoSubscriptionID else { - switchLocationChannel(to: context.activeChannel) - return - } - - context.startGeoParticipantRefreshTimer() - NostrRelayManager.shared.unsubscribe(id: subID) - let filter = NostrFilter.geohashEphemeral( - channel.geohash, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), - limit: TransportConfig.nostrGeohashInitialLimit - ) - let subRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: channel.geohash, - count: TransportConfig.nostrGeoRelayCount - ) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - Task { @MainActor [weak self] in - self?.inbound.subscribeNostrEvent(event) - // Gateway downlink: rebroadcast relay events for the viewed - // channel onto the mesh (no-op unless gateway mode is on). - GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash) - } - } - - if let dmSub = context.geoDmSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: dmSub) - context.setGeoDmSubscriptionID(nil) - } - - if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - let dmSub = "geo-dm-\(channel.geohash)" - context.setGeoDmSubscriptionID(dmSub) - let dmFilter = NostrFilter.giftWrapsFor( - pubkey: identity.publicKeyHex, - since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) - ) - NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in - Task { @MainActor [weak self] in - self?.inbound.subscribeGiftWrap(giftWrap, id: identity) - } - } - } - } - - @MainActor - func switchLocationChannel(to channel: ChannelID) { - guard let context else { return } - context.flushPublicMessagePipeline() - context.activeChannel = channel - - context.clearProcessedNostrEvents() - switch channel { - case .mesh: - context.refreshVisibleMessages(from: .mesh) - let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count - if emptyMesh > 0 { - SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) - } - context.stopGeoParticipantRefreshTimer() - context.setActiveParticipantGeohash(nil) - context.clearTeleportedGeo() - - case .location: - context.refreshVisibleMessages(from: channel) - } - - if case .location = channel { - for content in context.drainPendingGeohashSystemMessages() { - context.addPublicSystemMessage(content) - } - } - - if let sub = context.geoSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: sub) - context.setGeoChatSubscriptionID(nil) - } - if let dmSub = context.geoDmSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: dmSub) - context.setGeoDmSubscriptionID(nil) - } - context.currentGeohash = nil - context.setActiveParticipantGeohash(nil) - context.clearGeoNicknames() - - guard case .location(let channel) = channel else { return } - context.currentGeohash = channel.geohash - context.setActiveParticipantGeohash(channel.geohash) - - if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - let key = identity.publicKeyHex.lowercased() - if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { - context.markGeoTeleported(key) - SecureLogger.info( - "GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", - category: .session - ) - } else { - context.clearGeoTeleported(key) - } - } - - let subID = "geo-\(channel.geohash)" - context.setGeoChatSubscriptionID(subID) - context.startGeoParticipantRefreshTimer() - let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) - let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) - let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - Task { @MainActor [weak self] in - self?.inbound.handleNostrEvent(event) - // Gateway downlink: rebroadcast relay events for the viewed - // channel onto the mesh (no-op unless gateway mode is on). - GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash) - } - } - - subscribeToGeoChat(channel) - } - - @MainActor - func subscribeToGeoChat(_ channel: GeohashChannel) { - guard let context else { return } - guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return } - - let dmSub = "geo-dm-\(channel.geohash)" - context.setGeoDmSubscriptionID(dmSub) - if TorManager.shared.isReady { - SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session) - } - let dmFilter = NostrFilter.giftWrapsFor( - pubkey: identity.publicKeyHex, - since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) - ) - NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in - Task { @MainActor [weak self] in - self?.inbound.handleGiftWrap(giftWrap, id: identity) - } - } - } - - @MainActor - func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) { - guard let context else { return } - let channel = geoContext.channel - let event = geoContext.event - let identity = geoContext.identity - - let targetRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: channel.geohash, - count: TransportConfig.nostrGeoRelayCount - ) - - if targetRelays.isEmpty { - SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } - - // Mesh gateway uplink: with no working relay connection, hand the - // locally signed event to a mesh peer advertising the gateway - // capability (keys never leave this device — only the finished, - // signed event travels). Uplink is only ever attempted here, for a - // freshly composed event, never for received carrier events (loop - // rule 3 in GatewayService). - if GatewayService.shared.uplinkViaMesh(event: event, geohash: channel.geohash), - gatewayNoticeGeohashes.insert(channel.geohash).inserted { - context.addPublicSystemMessage( - String( - localized: "system.gateway.sent_via_mesh", - defaultValue: "sent via mesh gateway", - comment: "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable" - ) - ) - } - - context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) - SecureLogger.debug( - "GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)", - category: .session - ) - - if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { - let key = identity.publicKeyHex.lowercased() - context.markGeoTeleported(key) - SecureLogger.info( - "GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", - category: .session - ) - } - - context.recordProcessedNostrEvent(event.id) - } - - @MainActor - func beginGeohashSampling(for geohashes: [String]) { - guard let context else { return } - if !TorManager.shared.isForeground() { - endGeohashSampling() - return - } - - let desired = Set(geohashes) - let current = Set(context.geoSamplingSubs.values) - let toAdd = desired.subtracting(current) - let toRemove = current.subtracting(desired) - - for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) { - NostrRelayManager.shared.unsubscribe(id: subID) - context.removeGeoSamplingSub(subID) - } - - for gh in toAdd { - subscribe(gh) - } - } - - @MainActor - func subscribe(_ gh: String) { - guard let context else { return } - let subID = "geo-sample-\(gh)" - context.addGeoSamplingSub(subID, forGeohash: gh) - let filter = NostrFilter.geohashEphemeral( - gh, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), - limit: TransportConfig.nostrGeohashSampleLimit - ) - let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - Task { @MainActor [weak self] in - self?.presence.subscribeNostrEvent(event, gh: gh) - } - } - } - - @MainActor - func endGeohashSampling() { - guard let context else { return } - for subID in context.clearGeoSamplingSubs() { - NostrRelayManager.shared.unsubscribe(id: subID) - } - presence.clearGeoSamplingEventDedup() - } - - @MainActor - func setupNostrMessageHandling() { - guard let context else { return } - guard let currentIdentity = context.currentNostrIdentity() else { - SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session) - return - } - - SecureLogger.debug( - "🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", - category: .session - ) - - let filter = NostrFilter.giftWrapsFor( - pubkey: currentIdentity.publicKeyHex, - since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) - ) - - context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in - Task { @MainActor [weak self] in - self?.inbound.handleNostrMessage(event) - } - } - } -} diff --git a/bitchat/ViewModels/MessageRateLimiter.swift b/bitchat/ViewModels/MessageRateLimiter.swift index 4551e2a2..5bca9a6d 100644 --- a/bitchat/ViewModels/MessageRateLimiter.swift +++ b/bitchat/ViewModels/MessageRateLimiter.swift @@ -26,10 +26,6 @@ struct MessageRateLimiter { } return false } - - func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool { - now.timeIntervalSince(lastRefill) >= idleTTL - } } private var senderBuckets: [String: TokenBucket] = [:] @@ -39,115 +35,43 @@ struct MessageRateLimiter { private let senderRefill: Double private let contentCapacity: Double private let contentRefill: Double - private let maxSenderBuckets: Int - private let maxContentBuckets: Int - private let bucketIdleTTL: TimeInterval init( senderCapacity: Double, senderRefillPerSec: Double, contentCapacity: Double, - contentRefillPerSec: Double, - maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries, - maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries, - bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL + contentRefillPerSec: Double ) { self.senderCapacity = senderCapacity self.senderRefill = senderRefillPerSec self.contentCapacity = contentCapacity self.contentRefill = contentRefillPerSec - self.maxSenderBuckets = max(1, maxSenderBuckets) - self.maxContentBuckets = max(1, maxContentBuckets) - self.bucketIdleTTL = bucketIdleTTL } - /// - Parameter powBits: validated NIP-13 difficulty of the event - /// (`NostrPoW.validatedDifficulty`; 0 for mesh or no-PoW events). - /// At or above `NostrPoW.rateLimitBypassBits` the per-sender bucket is - /// skipped entirely — each such message paid for itself with work — but - /// the per-content flood bucket still applies. - mutating func allow(senderKey: String, contentKey: String, powBits: Int = 0, now: Date = Date()) -> Bool { - let senderAllowed: Bool - if powBits >= NostrPoW.rateLimitBypassBits { - senderAllowed = true - } else { - var senderBucket = Self.bucket( - for: senderKey, - in: &senderBuckets, - capacity: senderCapacity, - refillPerSec: senderRefill, - maxBuckets: maxSenderBuckets, - idleTTL: bucketIdleTTL, - now: now - ) - senderAllowed = senderBucket.allow(now: now) - senderBuckets[senderKey] = senderBucket - } + mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool { + var senderBucket = senderBuckets[senderKey] ?? TokenBucket( + capacity: senderCapacity, + tokens: senderCapacity, + refillPerSec: senderRefill, + lastRefill: now + ) + let senderAllowed = senderBucket.allow(now: now) + senderBuckets[senderKey] = senderBucket - // Rejected senders must not mint attacker-keyed content entries. - guard senderAllowed else { return false } - - var contentBucket = Self.bucket( - for: contentKey, - in: &contentBuckets, + var contentBucket = contentBuckets[contentKey] ?? TokenBucket( capacity: contentCapacity, + tokens: contentCapacity, refillPerSec: contentRefill, - maxBuckets: maxContentBuckets, - idleTTL: bucketIdleTTL, - now: now + lastRefill: now ) let contentAllowed = contentBucket.allow(now: now) contentBuckets[contentKey] = contentBucket - return contentAllowed + return senderAllowed && contentAllowed } mutating func reset() { senderBuckets.removeAll() contentBuckets.removeAll() } - - var bucketCountsForTesting: (sender: Int, content: Int) { - (senderBuckets.count, contentBuckets.count) - } - - // Static so we can take `inout` on a stored dictionary without overlapping - // exclusive access through a mutating method on `self`. - private static func bucket( - for key: String, - in buckets: inout [String: TokenBucket], - capacity: Double, - refillPerSec: Double, - maxBuckets: Int, - idleTTL: TimeInterval, - now: Date - ) -> TokenBucket { - if let existing = buckets[key] { - return existing - } - - evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, idleTTL: idleTTL, now: now) - return TokenBucket( - capacity: capacity, - tokens: capacity, - refillPerSec: refillPerSec, - lastRefill: now - ) - } - - private static func evictIfNeeded( - from buckets: inout [String: TokenBucket], - maxBuckets: Int, - idleTTL: TimeInterval, - now: Date - ) { - guard buckets.count >= maxBuckets else { return } - - buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: idleTTL) } - guard buckets.count >= maxBuckets else { return } - - if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key { - buckets.removeValue(forKey: oldestKey) - } - } } diff --git a/bitchat/ViewModels/MinimalDistancePalette.swift b/bitchat/ViewModels/MinimalDistancePalette.swift index c142382c..fb05d3c9 100644 --- a/bitchat/ViewModels/MinimalDistancePalette.swift +++ b/bitchat/ViewModels/MinimalDistancePalette.swift @@ -82,6 +82,13 @@ final class MinimalDistancePalette { return Color(hue: entry.hue, saturation: saturation, brightness: brightness) } + @MainActor + func reset() { + currentSeeds.removeAll() + entries.removeAll() + previousEntries.removeAll() + } + @MainActor private func rebuildEntries() { guard !currentSeeds.isEmpty else { diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift deleted file mode 100644 index a6592802..00000000 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ /dev/null @@ -1,588 +0,0 @@ -import BitFoundation -import BitLogger -import Foundation - -/// The narrow surface `NostrInboundPipeline` needs from its owner. -/// -/// Split out of `ChatNostrContext`: member names are shared with the sibling -/// component contexts so `ChatViewModel` provides a single witness for each. -@MainActor -protocol NostrInboundPipelineContext: AnyObject { - var currentGeohash: String? { get } - - // MARK: Event dedup - func hasProcessedNostrEvent(_ eventID: String) -> Bool - func recordProcessedNostrEvent(_ eventID: String) - - // MARK: Nostr identity & blocking - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity - func currentNostrIdentity() -> NostrIdentity? - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool - func displayNameForNostrPubkey(_ pubkeyHex: String) -> String - - // MARK: Favorites bridge - /// All favorite relationships, used to bridge a Nostr pubkey back to a - /// Noise key on the inbound DM path. - func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] - - // MARK: Presence & key mapping - func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) - /// Records the Nostr pubkey behind a (possibly virtual) peer ID. - func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) - func recordGeoParticipant(pubkeyHex: String) - - // MARK: Inbound public messages - /// `powBits` is the validated NIP-13 difficulty of the source event - /// (`NostrPoW.validatedDifficulty`); it relaxes the per-sender rate limit - /// downstream. - func handlePublicMessage(_ message: BitchatMessage, powBits: Int) - func checkForMentions(_ message: BitchatMessage) - func sendHapticFeedback(for message: BitchatMessage) - func parseMentions(from content: String) -> [String] - - // MARK: Inbound private (DM) payloads - func handlePrivateMessage( - _ payload: NoisePayload, - senderPubkey: String, - convKey: PeerID, - id: NostrIdentity, - messageTimestamp: Date - ) - func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) - func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) -} - -extension ChatViewModel: NostrInboundPipelineContext { - // `currentGeohash`, the identity/blocking members, key mapping, and the - // inbound message handlers already have witnesses on `ChatViewModel`. - // The members below flatten nested service accesses into intent-named calls. - - func hasProcessedNostrEvent(_ eventID: String) -> Bool { - deduplicationService.hasProcessedNostrEvent(eventID) - } - - func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { - Array(FavoritesPersistenceService.shared.favorites.values) - } - - func recordProcessedNostrEvent(_ eventID: String) { - deduplicationService.recordNostrEvent(eventID) - } - - func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { - locationPresenceStore.setNickname(nickname, for: pubkeyHex) - } - - func recordGeoParticipant(pubkeyHex: String) { - participantTracker.recordParticipant(pubkeyHex: pubkeyHex) - } -} - -/// The inbound Nostr hot path: verified relay events in, chat messages / -/// Noise payloads out. Pure transformation plus dedup — no relay lifecycle. -/// -/// Every event arriving here already had its Schnorr signature verified -/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound -/// pipeline (which records events into its own dedup cache only AFTER -/// verification, so forged copies can't suppress genuine events). This -/// pipeline therefore never re-verifies; it keeps its own event-ID dedup -/// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption — two -/// ECDH+ChaCha layers — off the main actor with an atomic main-actor -/// check-and-record. -final class NostrInboundPipeline { - private weak var context: (any NostrInboundPipelineContext)? - private let presence: GeoPresenceTracker - private var geoEventLogCount = 0 - - /// Monotonic panic-wipe generation for this pipeline. A panic wipe clears - /// relay handlers so no NEW events flow, but a detached decrypt task - /// spawned just BEFORE the wipe — which strongly captures a pre-wipe Nostr - /// private key and ciphertext — survives it. Spawn sites capture this - /// value; the task compares it at its main-actor hops and drops its result - /// (no delivery; the captured identity and plaintext die with the task) - /// if `invalidateInFlightDecrypts()` bumped it in between. - @MainActor private(set) var wipeGeneration: UInt64 = 0 - - /// Called from `ChatViewModel.panicClearAllData()` so plaintext decrypted - /// with pre-wipe keys can never land in post-wipe state. - @MainActor - func invalidateInFlightDecrypts() { - wipeGeneration &+= 1 - } - - init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) { - self.context = context - self.presence = presence - } - - @MainActor - func subscribeNostrEvent(_ event: NostrEvent) { - guard let context else { return } - // Cheap rejects (kind, dedup lookup) — duplicates dominate real - // traffic. The signature was already verified (exactly once, off the - // main actor) by NostrRelayManager before delivery. - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), - !context.hasProcessedNostrEvent(event.id) - else { - return - } - - context.recordProcessedNostrEvent(event.id) - - if let gh = context.currentGeohash, - let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh), - myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) < 15 { - return - } - } - - if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - let nick = nickTag[1].trimmed - context.setGeoNickname(nick, forPubkey: event.pubkey) - } - - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) - context.recordGeoParticipant(pubkeyHex: event.pubkey) - - if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { - return - } - - if GeoPresenceTracker.hasTeleportTag(event) { - let key = event.pubkey.lowercased() - let isSelf: Bool = { - if let gh = context.currentGeohash, - let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) { - return myIdentity.publicKeyHex.lowercased() == key - } - return false - }() - if !isSelf { - presence.scheduleMarkPeerTeleported(key, logged: false) - } - } - - let senderName = context.displayNameForNostrPubkey(event.pubkey) - let content = event.content.trimmed - let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let timestamp = min(rawTs, Date()) - let mentions = context.parseMentions(from: content) - let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) - let message = BitchatMessage( - id: event.id, - sender: senderName, - content: content, - timestamp: timestamp, - isRelay: false, - senderPeerID: PeerID(nostr: event.pubkey), - mentions: mentions.isEmpty ? nil : mentions - ) - - Task { @MainActor [weak context] in - guard let context else { return } - let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) - context.handlePublicMessage(message, powBits: powBits) - if !isBlocked { - context.checkForMentions(message) - context.sendHapticFeedback(for: message) - } - } - } - - @MainActor - func handleNostrEvent(_ event: NostrEvent) { - guard let context else { return } - // Cheap rejects (kind, dedup lookup) — the signature was already - // verified (exactly once, off the main actor) by NostrRelayManager. - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) - else { - return - } - if context.hasProcessedNostrEvent(event.id) { return } - context.recordProcessedNostrEvent(event.id) - - let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) - - // Sampled: fires for every geo event and floods dev logs in busy geohashes. - geoEventLogCount += 1 - if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { - SecureLogger.debug( - "GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tagCount=\(event.tags.count)", - category: .session - ) - } - - if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { - return - } - - let hasTeleportTag = GeoPresenceTracker.hasTeleportTag(event) - - let isSelf: Bool = { - if let gh = context.currentGeohash, - let my = try? context.deriveNostrIdentity(forGeohash: gh) { - return my.publicKeyHex.lowercased() == event.pubkey.lowercased() - } - return false - }() - - if hasTeleportTag, !isSelf { - presence.scheduleMarkPeerTeleported(event.pubkey.lowercased(), logged: true) - } - - context.recordGeoParticipant(pubkeyHex: event.pubkey) - - if isSelf { - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) < 15 { - return - } - } - - if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey) - } - - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) - - if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { - return - } - - let senderName = context.displayNameForNostrPubkey(event.pubkey) - let content = event.content - - if let teleTag = event.tags.first(where: { $0.first == "t" }), - teleTag.count >= 2, - teleTag[1] == "teleport", - content.trimmed.isEmpty { - return - } - - let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let mentions = context.parseMentions(from: content) - let message = BitchatMessage( - id: event.id, - sender: senderName, - content: content, - timestamp: min(rawTs, Date()), - isRelay: false, - senderPeerID: PeerID(nostr: event.pubkey), - mentions: mentions.isEmpty ? nil : mentions - ) - - Task { @MainActor [weak context] in - guard let context else { return } - context.handlePublicMessage(message, powBits: powBits) - context.checkForMentions(message) - context.sendHapticFeedback(for: message) - } - } - - @MainActor - func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - guard let context else { return } - // Cheap dedup pre-check only; processGeohashGiftWrap does the - // authoritative main-actor check-and-record before the off-main - // NIP-17 unwrap. The outer signature was already verified (exactly - // once, off the main actor) by NostrRelayManager. - guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } - - // Capture the wipe generation at spawn, alongside the per-geohash - // identity (private key) the detached task strongly captures. A panic - // wipe between spawn and delivery bumps the generation, and the task - // drops its result instead of delivering plaintext post-wipe. - let wipeGeneration = self.wipeGeneration - Task.detached(priority: .userInitiated) { [weak self] in - await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration) - } - } - - @MainActor - func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - guard let context else { return } - // Cheap dedup pre-check only; see subscribeGiftWrap. - if context.hasProcessedNostrEvent(giftWrap.id) { - return - } - - // Spawn-time wipe-generation capture; see subscribeGiftWrap. - let wipeGeneration = self.wipeGeneration - Task.detached(priority: .userInitiated) { [weak self] in - await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: true, wipeGeneration: wipeGeneration) - } - } - - /// Geohash-DM gift wrap ingest. The NIP-17 unwrap (two ECDH+ChaCha - /// layers) runs off the main actor; results hop back for state updates. - /// `verbose` keeps `handleGiftWrap`'s decrypt logging without adding it - /// to the sampling path. - /// - /// `wipeGeneration` is this pipeline's generation captured at spawn (the - /// moment the pre-wipe `id` was captured); a mismatch at either main-actor - /// hop means a panic wipe happened in between, so the task bails without - /// decrypting (first hop) or without delivering the plaintext (second - /// hop) — the captured identity and any decrypted material are simply - /// dropped with the task. - private func processGeohashGiftWrap( - _ giftWrap: NostrEvent, - id: NostrIdentity, - verbose: Bool, - wipeGeneration: UInt64 - ) async { - guard let context else { return } - // Authoritative check-and-record, atomic on the main actor so two - // concurrent detached tasks can't both process the same event. - let alreadyProcessed: Bool = await MainActor.run { - guard self.wipeGeneration == wipeGeneration else { return true } - if context.hasProcessedNostrEvent(giftWrap.id) { return true } - context.recordProcessedNostrEvent(giftWrap.id) - return false - } - if alreadyProcessed { return } - - guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: id - ) else { - if verbose { - SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session) - } - return - } - - guard Self.isPlausibleRumorTimestamp(rumorTs) else { - if verbose { - SecureLogger.warning("GeoDM: dropping gift-wrap with implausible rumor timestamp id=\(giftWrap.id.prefix(8))…", category: .session) - } - return - } - - if verbose { - SecureLogger.debug( - "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", - category: .session - ) - } - - await MainActor.run { - // A panic wipe during the off-main decrypt must not let the - // pre-wipe plaintext reach post-wipe state; drop it here, atomic - // with the wipe on the main actor. - guard self.wipeGeneration == wipeGeneration else { return } - guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), - packet.type == MessageType.noiseEncrypted.rawValue, - let payload = NoisePayload.decode(packet.payload) - else { - return - } - - let convKey = PeerID(nostr_: senderPubkey) - context.registerNostrKeyMapping(senderPubkey, for: convKey) - - switch payload.type { - case .privateMessage: - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - context.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: convKey, - id: id, - messageTimestamp: messageTimestamp - ) - case .delivered: - context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) - case .readReceipt: - context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - // Group state travels only over mesh Noise sessions in v1; anything - // claiming to be group traffic over Nostr is ignored. - // Live voice is mesh-only: latency and relay cost make it - // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: - break - } - } - } - - @MainActor - func handleNostrMessage(_ giftWrap: NostrEvent) { - guard let context else { return } - // Cheap dedup pre-check only; processNostrMessage does the - // authoritative check-and-record before the off-main NIP-17 unwrap. - // The outer signature was already verified (exactly once, off the - // main actor) by NostrRelayManager, and only verified events are - // recorded, so a forged-signature copy can never poison the dedup - // set and suppress the genuine event. - if context.hasProcessedNostrEvent(giftWrap.id) { return } - - Task.detached(priority: .userInitiated) { [weak self] in - await self?.processNostrMessage(giftWrap) - } - } - - func processNostrMessage(_ giftWrap: NostrEvent) async { - guard let context else { return } - // Authoritative check-and-record, atomic on the main actor so two - // concurrent detached tasks can't both process the same event. - let alreadyProcessed: Bool = await MainActor.run { - if context.hasProcessedNostrEvent(giftWrap.id) { return true } - context.recordProcessedNostrEvent(giftWrap.id) - return false - } - if alreadyProcessed { return } - // Fetch the identity and the wipe generation in ONE main-actor hop: - // the generation then vouches for exactly this identity. A wipe after - // this point bumps the generation and the delivery hop below drops - // the decrypted result (same guard as processGeohashGiftWrap; this - // account-mailbox path had the identical hazard). - let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run { - (context.currentNostrIdentity(), self.wipeGeneration) - } - guard let currentIdentity else { return } - - do { - let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: currentIdentity - ) - - guard Self.isPlausibleRumorTimestamp(rumorTimestamp) else { - SecureLogger.warning("Dropping Nostr DM with implausible rumor timestamp id=\(giftWrap.id.prefix(8))…", category: .session) - return - } - - if content.hasPrefix("verify:") { - return - } - - if content.hasPrefix("bitchat1:") { - let packet: BitchatPacket? = await MainActor.run { - Self.decodeEmbeddedBitChatPacket(from: content) - } - guard let packet else { - SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) - return - } - - let actualSenderNoiseKey: Data? = await MainActor.run { - self.findNoiseKey(for: senderPubkey) - } - let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) - - if packet.type == MessageType.noiseEncrypted.rawValue, - let payload = NoisePayload.decode(packet.payload) { - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) - await MainActor.run { - // Drop pre-wipe plaintext if a panic wipe landed - // during the off-main decrypt (see above). - guard self.wipeGeneration == wipeGeneration else { return } - context.registerNostrKeyMapping(senderPubkey, for: targetPeerID) - - switch payload.type { - case .privateMessage: - context.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: targetPeerID, - id: currentIdentity, - messageTimestamp: messageTimestamp - ) - case .delivered: - context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .readReceipt: - context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - // Group state travels only over mesh Noise sessions - // in v1; group traffic over Nostr is ignored. - // Live voice is mesh-only: latency and relay cost make it - // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: - break - } - } - } - } else { - SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session) - } - } catch { - SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session) - } - } - - /// Resolves the Noise static key behind a Nostr pubkey via the favorites - /// store. Lives here because the inbound DM path needs it per message. - @MainActor - func findNoiseKey(for nostrPubkey: String) -> Data? { - guard let context else { return nil } - let favorites = context.allFavoriteRelationships() - var npubToMatch = nostrPubkey - - if !nostrPubkey.hasPrefix("npub") { - if let pubkeyData = Data(hexString: nostrPubkey), - let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) { - npubToMatch = encoded - } else { - SecureLogger.warning( - "⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", - category: .session - ) - } - } - - for relationship in favorites { - if let storedNostrKey = relationship.peerNostrPublicKey { - if storedNostrKey == npubToMatch { - return relationship.peerNoisePublicKey - } - if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { - SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session) - return relationship.peerNoisePublicKey - } - } - } - - SecureLogger.debug( - "⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", - category: .session - ) - return nil - } -} - -extension NostrInboundPipeline { - /// Client-side mirror of the relay-side `since` filter on DM - /// subscriptions: a relay that ignores `since` — or replays archived - /// events — must not inject stale or future-dated DMs. The inner rumor - /// timestamp is the sender's true send time (only the outer gift wrap - /// is randomized per NIP-17), so the plausible window is the - /// subscription lookback plus tolerated clock skew on both ends. - /// Internal (not private) so tests can pin the window directly. - static func isPlausibleRumorTimestamp(_ ts: Int, now: Date = Date()) -> Bool { - let age = now.timeIntervalSince1970 - TimeInterval(ts) - return age >= -TransportConfig.nostrDMMaxClockSkewSeconds - && age <= TransportConfig.nostrDMSubscribeLookbackSeconds - + TransportConfig.nostrDMMaxClockSkewSeconds - } -} - -private extension NostrInboundPipeline { - @MainActor - static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { - guard content.hasPrefix("bitchat1:") else { return nil } - let encoded = String(content.dropFirst("bitchat1:".count)) - let maxBytes = FileTransferLimits.maxFramedFileBytes - let maxEncoded = ((maxBytes + 2) / 3) * 4 - guard encoded.count <= maxEncoded else { return nil } - guard let packetData = Base64URLCoding.decode(encoded), - packetData.count <= maxBytes - else { - return nil - } - return BitchatPacket.from(packetData) - } -} diff --git a/bitchat/ViewModels/PublicMessagePipeline.swift b/bitchat/ViewModels/PublicMessagePipeline.swift index 9d30f83e..a1b794e8 100644 --- a/bitchat/ViewModels/PublicMessagePipeline.swift +++ b/bitchat/ViewModels/PublicMessagePipeline.swift @@ -2,40 +2,35 @@ // PublicMessagePipeline.swift // bitchat // -// Batches visible-channel public messages before committing them to the -// ConversationStore: the deliberate ~80 ms UI flush cadence survives the -// store cutover, while ordering, dedup, and caps live in the store itself -// (its timestamp-ordered insert replaced this pipeline's late-insert -// threshold positioning; see docs/CONVERSATION-STORE-DESIGN.md). +// Handles batching and deduplication of public chat messages before surfacing them to the UI. // -import BitFoundation import Foundation @MainActor protocol PublicMessagePipelineDelegate: AnyObject { - func pipeline(_: PublicMessagePipeline, normalizeContent content: String) -> String - func pipeline(_: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? - func pipeline(_: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) - /// Commits a batched message to its conversation in the store. - /// Returns `false` when the message was already present (ID dedup). - @discardableResult - func pipeline(_: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool - func pipelinePrewarmMessage(_: PublicMessagePipeline, message: BitchatMessage) - func pipelineSetBatchingState(_: PublicMessagePipeline, isBatching: Bool) + func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] + func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) + func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String + func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? + func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) + func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) + func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) + func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) } @MainActor final class PublicMessagePipeline { weak var delegate: PublicMessagePipelineDelegate? - private var buffer: [(message: BitchatMessage, conversationID: ConversationID)] = [] + private var buffer: [BitchatMessage] = [] private var timer: Timer? private let baseFlushInterval: TimeInterval private var dynamicFlushInterval: TimeInterval private var recentBatchSizes: [Int] = [] private let maxRecentBatchSamples: Int private let dedupWindow: TimeInterval + private var activeChannel: ChannelID = .mesh init( baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval, @@ -52,32 +47,25 @@ final class PublicMessagePipeline { timer?.invalidate() } - /// Buffers a message destined for `conversationID`; the next batched - /// flush commits it to the store. Each entry carries its destination so - /// a channel switch mid-batch can never misroute buffered messages. - func enqueue(_ message: BitchatMessage, to conversationID: ConversationID) { - buffer.append((message, conversationID)) + func updateActiveChannel(_ channel: ChannelID) { + activeChannel = channel + } + + func enqueue(_ message: BitchatMessage) { + buffer.append(message) scheduleFlush() } - /// Discards an uncommitted row by ID. Bridge-first/radio-second dedup uses - /// this before inserting the authenticated radio copy, so the ~80 ms UI - /// batch cannot resurrect the replaced bridge alias after store removal. - func removeMessage(withID messageID: String) { - buffer.removeAll { $0.message.id == messageID } - if buffer.isEmpty { - timer?.invalidate() - timer = nil - } - } - - func containsMessage(withID messageID: String) -> Bool { - buffer.contains { $0.message.id == messageID } - } - func flushIfNeeded() { flushBuffer() } + + func reset() { + timer?.invalidate() + timer = nil + buffer.removeAll(keepingCapacity: false) + } + } private extension PublicMessagePipeline { @@ -102,38 +90,57 @@ private extension PublicMessagePipeline { delegate.pipelineSetBatchingState(self, isBatching: true) - // Content-window dedup against recorded keys and within the batch; - // ID dedup happens in the store at commit time. - var pending: [(message: BitchatMessage, conversationID: ConversationID, contentKey: String)] = [] + var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id }) + var pending: [(message: BitchatMessage, contentKey: String)] = [] var batchContentLatest: [String: Date] = [:] - for item in buffer { - let contentKey = delegate.pipeline(self, normalizeContent: item.message.content) + for message in buffer { + if existingIDs.contains(message.id) { continue } + let contentKey = delegate.pipeline(self, normalizeContent: message.content) if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey), - abs(ts.timeIntervalSince(item.message.timestamp)) < dedupWindow { + abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow { continue } if let ts = batchContentLatest[contentKey], - abs(ts.timeIntervalSince(item.message.timestamp)) < dedupWindow { + abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow { continue } - pending.append((item.message, item.conversationID, contentKey)) - batchContentLatest[contentKey] = item.message.timestamp + existingIDs.insert(message.id) + pending.append((message, contentKey)) + batchContentLatest[contentKey] = message.timestamp } buffer.removeAll(keepingCapacity: true) guard !pending.isEmpty else { delegate.pipelineSetBatchingState(self, isBatching: false) + if !buffer.isEmpty { scheduleFlush() } return } pending.sort { $0.message.timestamp < $1.message.timestamp } + var messages = delegate.pipelineCurrentMessages(self) + let threshold = lateInsertThreshold(for: activeChannel) + let lastTimestamp = messages.last?.timestamp ?? .distantPast + for item in pending { - guard delegate.pipeline(self, commit: item.message, to: item.conversationID) else { continue } - delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: item.message.timestamp) + let message = item.message + if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) { + let index = insertionIndex(for: message.timestamp, in: messages) + if index >= messages.count { + messages.append(message) + } else { + messages.insert(message, at: index) + } + } else { + messages.append(message) + } + delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp) } + delegate.pipeline(self, setMessages: messages) + delegate.pipelineTrimMessages(self) + updateFlushInterval(withBatchSize: pending.count) for item in pending { @@ -157,4 +164,27 @@ private extension PublicMessagePipeline { : Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count) dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval } + + func lateInsertThreshold(for channel: ChannelID) -> TimeInterval { + switch channel { + case .mesh: + return TransportConfig.uiLateInsertThreshold + case .location: + return TransportConfig.uiLateInsertThresholdGeo + } + } + + func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int { + var low = 0 + var high = messages.count + while low < high { + let mid = (low + high) / 2 + if messages[mid].timestamp < timestamp { + low = mid + 1 + } else { + high = mid + } + } + return low + } } diff --git a/bitchat/ViewModels/PublicTimelineStore.swift b/bitchat/ViewModels/PublicTimelineStore.swift new file mode 100644 index 00000000..92a76cc6 --- /dev/null +++ b/bitchat/ViewModels/PublicTimelineStore.swift @@ -0,0 +1,124 @@ +// +// PublicTimelineStore.swift +// bitchat +// +// Maintains mesh and geohash public timelines with simple caps and helpers. +// + +import Foundation + +struct PublicTimelineStore { + private var meshTimeline: [BitchatMessage] = [] + private var geohashTimelines: [String: [BitchatMessage]] = [:] + private var pendingGeohashSystemMessages: [String] = [] + + private let meshCap: Int + private let geohashCap: Int + + init(meshCap: Int, geohashCap: Int) { + self.meshCap = meshCap + self.geohashCap = geohashCap + } + + mutating func append(_ message: BitchatMessage, to channel: ChannelID) { + switch channel { + case .mesh: + guard !meshTimeline.contains(where: { $0.id == message.id }) else { return } + meshTimeline.append(message) + trimMeshTimelineIfNeeded() + case .location(let channel): + append(message, toGeohash: channel.geohash) + } + } + + mutating func append(_ message: BitchatMessage, toGeohash geohash: String) { + var timeline = geohashTimelines[geohash] ?? [] + guard !timeline.contains(where: { $0.id == message.id }) else { return } + timeline.append(message) + trimGeohashTimelineIfNeeded(&timeline) + geohashTimelines[geohash] = timeline + } + + /// Append message if absent, returning true when stored. + mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { + var timeline = geohashTimelines[geohash] ?? [] + guard !timeline.contains(where: { $0.id == message.id }) else { return false } + timeline.append(message) + trimGeohashTimelineIfNeeded(&timeline) + geohashTimelines[geohash] = timeline + return true + } + + mutating func messages(for channel: ChannelID) -> [BitchatMessage] { + switch channel { + case .mesh: + return meshTimeline + case .location(let channel): + let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? [] + geohashTimelines[channel.geohash] = cleaned + return cleaned + } + } + + mutating func clear(channel: ChannelID) { + switch channel { + case .mesh: + meshTimeline.removeAll() + case .location(let channel): + geohashTimelines[channel.geohash] = [] + } + } + + @discardableResult + mutating func removeMessage(withID id: String) -> BitchatMessage? { + if let index = meshTimeline.firstIndex(where: { $0.id == id }) { + return meshTimeline.remove(at: index) + } + + for key in Array(geohashTimelines.keys) { + var timeline = geohashTimelines[key] ?? [] + if let index = timeline.firstIndex(where: { $0.id == id }) { + let removed = timeline.remove(at: index) + geohashTimelines[key] = timeline.isEmpty ? nil : timeline + return removed + } + } + + return nil + } + + mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) { + var timeline = geohashTimelines[geohash] ?? [] + timeline.removeAll(where: predicate) + geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline + } + + mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) { + var timeline = geohashTimelines[geohash] ?? [] + transform(&timeline) + geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline + } + + mutating func queueGeohashSystemMessage(_ content: String) { + pendingGeohashSystemMessages.append(content) + } + + mutating func drainPendingGeohashSystemMessages() -> [String] { + defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) } + return pendingGeohashSystemMessages + } + + func geohashKeys() -> [String] { + Array(geohashTimelines.keys) + } + + private mutating func trimMeshTimelineIfNeeded() { + guard meshTimeline.count > meshCap else { return } + meshTimeline = Array(meshTimeline.suffix(meshCap)) + } + + private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) { + guard timeline.count > geohashCap else { return } + timeline = Array(timeline.suffix(geohashCap)) + } +} diff --git a/bitchat/ViewModels/VoiceRecordingViewModel.swift b/bitchat/ViewModels/VoiceRecordingViewModel.swift index adb7d92a..92e70a02 100644 --- a/bitchat/ViewModels/VoiceRecordingViewModel.swift +++ b/bitchat/ViewModels/VoiceRecordingViewModel.swift @@ -29,7 +29,7 @@ final class VoiceRecordingViewModel: ObservableObject { var alertMessage: String { switch self { case .error(let message): message - case .permissionDenied: String(localized: "voice.error.mic_permission", defaultValue: "microphone access is required to record voice notes.", comment: "Alert message when the microphone permission is denied") + case .permissionDenied: "Microphone access is required to record voice notes." case .idle, .requestingPermission, .preparing, .recording: "" } } @@ -55,18 +55,6 @@ final class VoiceRecordingViewModel: ObservableObject { } @Published private(set) var state = State.idle - /// True while the active session streams audio live (push-to-talk); the - /// composer switches its recording HUD to the LIVE treatment. - @Published private(set) var isLiveStreaming = false - - /// Supplies the capture backend per press. `ChatViewModel` swaps in a - /// live push-to-talk session when the current DM peer can hear it now. - var sessionProvider: () -> VoiceCaptureSession = { VoiceNoteCaptureSession() } - private var activeSession: VoiceCaptureSession? - /// Monotonic press identity. A slow permission/start/finalize task from an - /// older hold may still deliver its file, but it must never mutate the UI - /// state of a newer hold. - private var holdGeneration: UInt64 = 0 func formattedDuration(for date: Date) -> String { let clamped = max(0, state.duration(for: date)) @@ -79,84 +67,27 @@ final class VoiceRecordingViewModel: ObservableObject { func start(shouldShow: Bool) { guard shouldShow, state == .idle else { return } - holdGeneration &+= 1 - let generation = holdGeneration - let session = sessionProvider() - SecureLogger.info("PTT: mic hold began (backend: \(session.isLive ? "live" : "classic"))", category: .session) - activeSession = session state = .requestingPermission Task { - let granted = await session.requestPermission() - guard generation == holdGeneration, - state == .requestingPermission, - activeSession === session - else { return } + let granted = await VoiceRecorder.shared.requestPermission() + guard state == .requestingPermission else { return } guard granted else { state = .permissionDenied - activeSession = nil return } state = .preparing do { - try await session.start() - guard generation == holdGeneration, - state == .preparing, - activeSession === session - else { - await session.cancel() + try await VoiceRecorder.shared.startRecording() + guard state == .preparing else { + cancel() return } state = .recording(startDate: Date()) - isLiveStreaming = session.isLive - } catch VoiceRecorder.RecorderError.recordingInProgress { - // The previous classic hold may still be in its intentional - // finalize-padding window. This press owns no recorder, so - // its owner-scoped cancel is harmless; return to idle instead - // of surfacing a false capture failure while the prior note - // finishes and delivers normally. - SecureLogger.info("Voice recording start deferred while the previous hold finalizes", category: .session) - await session.cancel() - guard generation == holdGeneration, - state == .preparing, - activeSession === session - else { return } - activeSession = nil - state = .idle } catch { SecureLogger.error("Voice recording failed to start: \(error)", category: .session) - await session.cancel() - guard generation == holdGeneration, - state == .preparing, - activeSession === session - else { return } - // The live engine and the classic recorder are separate - // capture stacks: when the live one hits an audio-route - // glitch, fall back within the same hold so the user still - // gets a voice note instead of an error. - if session.isLive { - let fallback = VoiceNoteCaptureSession() - activeSession = fallback - do { - try await fallback.start() - guard generation == holdGeneration, - state == .preparing, - activeSession === fallback - else { - await fallback.cancel() - return - } - SecureLogger.warning("PTT: live capture failed — fell back to classic voice note", category: .session) - state = .recording(startDate: Date()) - isLiveStreaming = false - return - } catch { - SecureLogger.error("Voice recording fallback failed to start: \(error)", category: .session) - await fallback.cancel() - guard generation == holdGeneration, state == .preparing else { return } - } - } - activeSession = nil - state = .error(message: String(localized: "voice.error.start_failed", defaultValue: "could not start recording.", comment: "Alert message when the recorder fails to start")) + await VoiceRecorder.shared.cancelRecording() + guard state == .preparing else { return } + state = .error(message: "Could not start recording.") } } } @@ -172,48 +103,23 @@ final class VoiceRecordingViewModel: ObservableObject { } state = .idle - isLiveStreaming = false - let session = activeSession - let generation = holdGeneration - activeSession = nil - guard case .recording(let startDate) = previousState, let completion, let session else { - // A quick press releases before the recorder spins up; that has - // always been a silent no-op for voice notes — log it so field - // tests can tell "tapped" apart from "capture broke". - SecureLogger.info("PTT: mic released before recording started (state was \(previousState)) — hold longer to record", category: .session) - Task { await session?.cancel() } + guard case .recording(let startDate) = previousState, let completion else { + Task { await VoiceRecorder.shared.cancelRecording() } return } Task { let finalDuration = Date().timeIntervalSince(startDate) - if let url = await session.finish() { - // Panic and a newer hold both invalidate this completion. - // Never route an old recording using a post-panic target. - guard generation == holdGeneration else { - try? FileManager.default.removeItem(at: url) - return - } - guard isValidRecording( - at: url, - duration: finalDuration - ) else { - guard state == .idle else { return } - state = .error( - message: finalDuration < VoiceRecorder.minRecordingDuration - ? String(localized: "voice.error.too_short", defaultValue: "recording is too short.", comment: "Alert message when a voice note is released too quickly to save") - : String(localized: "voice.error.save_failed", defaultValue: "recording failed to save.", comment: "Alert message when a finished voice note cannot be saved") - ) - return - } + if let url = await VoiceRecorder.shared.stopRecording(), + isValidRecording(at: url, duration: finalDuration) { completion(url) } else { - guard generation == holdGeneration, state == .idle else { return } + guard state == .idle else { return } state = .error( message: finalDuration < VoiceRecorder.minRecordingDuration - ? String(localized: "voice.error.too_short", defaultValue: "recording is too short.", comment: "Alert message when a voice note is released too quickly to save") - : String(localized: "voice.error.save_failed", defaultValue: "recording failed to save.", comment: "Alert message when a finished voice note cannot be saved") + ? "Recording is too short." + : "Recording failed to save." ) } } @@ -223,17 +129,6 @@ final class VoiceRecordingViewModel: ObservableObject { finish(completion: nil) } - /// Invalidates in-flight permission/start/finalize callbacks and tears - /// down an active microphone before the panic transaction continues. - func panicWipe() { - holdGeneration &+= 1 - let session = activeSession - activeSession = nil - state = .idle - isLiveStreaming = false - session?.panicCancelSynchronously() - } - private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool { if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), let fileSize = attributes[.size] as? NSNumber, diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index cb99fbac..68472e81 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -1,129 +1,25 @@ import SwiftUI -/// The sheet behind the "bitchat/" logo: a segmented Settings/Info surface. -/// Settings gathers every user preference (appearance, voice, connectivity -/// toggles, panic wipe); Info keeps the about content (how-to, features, -/// privacy, symbols legend). struct AppInfoView: View { @Environment(\.dismiss) var dismiss - @ThemedPalette private var palette - @AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @ObservedObject private var bridgeService = BridgeService.shared - - /// Supplies the mesh topology map data. Nil (previews, missing wiring) - /// hides the topology row entirely. - var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? - /// Wipes all local data. Nil (previews, missing wiring) hides the danger - /// zone entirely. - var onPanicWipe: (@MainActor () -> Void)? - - @State private var showTopology = false - @State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled - @State private var locationNotesEnabled = LocationNotesSettings.enabled - @State private var hideMessagePreviews = NotificationPrivacySettings.hideMessagePreviews - @State private var customRelays = NostrRelaySettings.customRelays() - @State private var relayInput = "" - @State private var relayError: String? - @ObservedObject private var locationManager = LocationChannelManager.shared - /// Sticky across opens: first-ever open lands on Info (the gentler - /// introduction), and afterwards the sheet reopens wherever it was left. - @AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info - @State private var showPanicConfirmation = false - @AppStorage(AppLanguageSettings.overrideKey) private var languageOverride = "" - /// The override changed this session; localization resolves at process - /// start, so surface the restart hint. - @State private var showLanguageRestartNote = false - - private enum Pane: String { - case settings - case info + @Environment(\.colorScheme) var colorScheme + + private var backgroundColor: Color { + colorScheme == .dark ? Color.black : Color.white } - - private var selectedTheme: AppTheme { - AppTheme(rawValue: appThemeRawValue) ?? .matrix + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } - - private var textColor: Color { palette.primary } - - private var secondaryTextColor: Color { palette.secondary } - + + private var secondaryTextColor: Color { + colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) + } + // MARK: - Constants private enum Strings { static let appName: LocalizedStringKey = "app_info.app_name" static let tagline: LocalizedStringKey = "app_info.tagline" - static let appearanceTitle: LocalizedStringKey = "app_info.appearance.title" - - /// New keys carry their English copy inline (defaultValue) until the - /// i18n pass lands them in the catalog; moved keys keep their homes. - enum Settings { - static let tabPickerLabel = String(localized: "app_info.tab.picker_label", defaultValue: "view", comment: "Accessibility label for the segmented control switching between the settings and info panes of the app info sheet") - static let tabSettings = String(localized: "app_info.tab.settings", defaultValue: "settings", comment: "Segmented control label for the settings pane of the app info sheet") - static let tabInfo = String(localized: "app_info.tab.info", defaultValue: "info", comment: "Segmented control label for the info pane of the app info sheet") - - static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing") - - static let languageTitle = String(localized: "app_info.settings.language.title", defaultValue: "LANGUAGE", comment: "Section header (uppercase) for the app language picker in settings") - static let languagePickerLabel = String(localized: "app_info.settings.language.picker_label", defaultValue: "app language", comment: "Label of the app language picker row in settings") - static let languageSystem = String(localized: "app_info.settings.language.system", defaultValue: "system default", comment: "Menu option that clears the in-app language override so the app follows the device language") - static let languageRestartNote = String(localized: "app_info.settings.language.restart_note", defaultValue: "restart bitchat to apply the new language", comment: "Caption shown after the user picks a different app language; the change takes effect on next launch") - - static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings") - static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does") - static func bridgeCell(_ cell: String) -> String { - String( - format: String(localized: "app_info.settings.bridge.cell", defaultValue: "rendezvous cell: %@", comment: "Caption under the mesh bridge toggle showing the geohash cell the bridge is meeting on"), - locale: .current, - cell - ) - } - static let bridgeNoCell = String(localized: "app_info.settings.bridge.no_cell", defaultValue: "no rendezvous cell yet — needs location access or a nearby bridge peer", comment: "Caption under the mesh bridge toggle when the bridge is on but has no geohash cell to meet on") - - // Moved from LocationChannelsSheet; keys unchanged. (The former - // internet-gateway toggle is gone: the bridge switch drives all - // internet sharing, including geohash-channel gatewaying.) - static let torTitle: LocalizedStringKey = "location_channels.tor.title" - // Replaces `location_channels.tor.subtitle`, which described the - // setting as location-channels-only. It covers private messages and - // relay-directory refreshes too, and said nothing about the cost of - // switching it off. - static let torSubtitle = String(localized: "app_info.settings.tor.subtitle", defaultValue: "sends internet traffic through tor, so relay operators see tor's address instead of yours. covers location channels and private messages delivered over the internet. recommended: on.", comment: "Subtitle for the tor routing toggle in settings, explaining what it covers") - static let torOffWarning = String(localized: "app_info.settings.tor.off_warning", defaultValue: "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages.", comment: "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address") - - static let relaysTitle = String(localized: "app_info.settings.relays.title", defaultValue: "private message relays", comment: "Title of the relay list editor in settings") - static let relaysSubtitle = String(localized: "app_info.settings.relays.subtitle", defaultValue: "when the mesh can't reach someone, private messages travel through these relays. the built-in ones are well-known addresses that a network filter can block, so you can add your own — including .onion addresses.", comment: "Subtitle explaining what the relay list is for and why someone would add a relay") - static let relayBuiltIn = String(localized: "app_info.settings.relays.built_in", defaultValue: "built in", comment: "Label marking a relay as one of the built-in relays, which cannot be removed") - static let relayPlaceholder = String(localized: "app_info.settings.relays.placeholder", defaultValue: "wss://relay.example.com", comment: "Placeholder text in the field for adding a relay address") - static let relayAdd = String(localized: "app_info.settings.relays.add", defaultValue: "add", comment: "Button that adds the typed relay address to the list") - static let relayRemove = String(localized: "app_info.settings.relays.remove", defaultValue: "remove relay", comment: "Accessibility label for the button that removes an added relay") - - static func relayError(_ failure: NostrRelaySettings.AddFailure) -> String { - switch failure { - case .malformed: - return String(localized: "app_info.settings.relays.error.malformed", defaultValue: "that doesn't look like a relay address. try wss://host.", comment: "Error shown when a typed relay address cannot be parsed") - case .alreadyPresent: - return String(localized: "app_info.settings.relays.error.duplicate", defaultValue: "that relay is already in the list.", comment: "Error shown when the typed relay address is already in the list") - case .limitReached: - return String( - format: String(localized: "app_info.settings.relays.error.limit", defaultValue: "you can add up to %d relays.", comment: "Error shown when the relay list is already at its maximum size; %d is that maximum"), - locale: .current, - NostrRelaySettings.maxCustomRelays - ) - } - } - static let toggleOn: LocalizedStringKey = "common.toggle.on" - static let toggleOff: LocalizedStringKey = "common.toggle.off" - - static let privacyTitle = String(localized: "app_info.settings.privacy.title", defaultValue: "PRIVACY", comment: "Section header (uppercase) for privacy settings such as hiding notification previews") - static let hidePreviewsTitle = String(localized: "app_info.settings.hide_previews.title", defaultValue: "hide message previews", comment: "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications") - static let hidePreviewsSubtitle = String(localized: "app_info.settings.hide_previews.subtitle", defaultValue: "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default.", comment: "Subtitle explaining what hiding notification message previews does") - - static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings") - static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation") - static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut") - static let panicConfirmTitle = String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe") - static let panicConfirmAction = String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe") - } enum Features { static let title: LocalizedStringKey = "app_info.features.title" @@ -157,56 +53,6 @@ struct AppInfoView: View { title: "app_info.features.geohash.title", description: "app_info.features.geohash.description" ) - static let bridge = AppInfoFeatureInfo( - icon: "network", - resolvedTitle: String(localized: "app_info.features.bridge.title", defaultValue: "mesh bridging", comment: "Feature row title for the mesh bridge in the app info sheet"), - resolvedDescription: String(localized: "app_info.features.bridge.description", defaultValue: "links nearby mesh islands through the internet so one crowd isn't split by radio range", comment: "Feature row description for the mesh bridge in the app info sheet") - ) - } - - enum Legend { - static let title: LocalizedStringKey = "app_info.legend.title" - /// Every glyph the peer lists and headers use, in one place — - /// nothing else in the app defines them. A nil color renders in - /// the theme's primary text color. - static let items: [(icon: String, color: Color?, text: String)] = [ - ("antenna.radiowaves.left.and.right", nil, String(localized: "app_info.legend.mesh_connected")), - ("point.3.filled.connected.trianglepath.dotted", nil, String(localized: "app_info.legend.mesh_relayed")), - ("globe", nil, String(localized: "app_info.legend.nostr")), - ("network", Color.cyan, String(localized: "app_info.legend.bridged", defaultValue: "message arrived across a mesh bridge", comment: "Symbols legend entry for the cyan network glyph shown on messages carried across a mesh bridge")), - ("person", nil, String(localized: "app_info.legend.offline")), - ("mappin.and.ellipse", nil, String(localized: "app_info.legend.location_nearby")), - ("face.dashed", nil, String(localized: "app_info.legend.teleported")), - ("lock.fill", nil, String(localized: "app_info.legend.encrypted")), - ("lock.slash", nil, String(localized: "app_info.legend.encryption_failed")), - ("checkmark.seal.fill", nil, String(localized: "app_info.legend.verified")), - ("star.fill", nil, String(localized: "app_info.legend.favorite")), - ("envelope.fill", nil, String(localized: "app_info.legend.unread")), - ("nosign", nil, String(localized: "app_info.legend.blocked")) - ] - } - - enum Voice { - static let title: LocalizedStringKey = "app_info.voice.title" - // The live-voice title/description keys are referenced inline at - // the toggle (they ride the shared settingToggle now). - } - - enum Location { - static let notes = AppInfoFeatureInfo( - icon: "mappin.and.ellipse", - title: "app_info.location.notes.title", - description: "app_info.location.notes.description" - ) - } - - enum Network { - static let title: LocalizedStringKey = "app_info.network.title" - static let topology = AppInfoFeatureInfo( - icon: "point.3.connected.trianglepath.dotted", - title: "app_info.network.topology.title", - description: "app_info.network.topology.description" - ) } enum Privacy { @@ -230,25 +76,18 @@ struct AppInfoView: View { enum HowToUse { static let title: LocalizedStringKey = "app_info.how_to_use.title" - /// The instruction strings flowed into one comma-separated - /// paragraph. The translations carry their legacy bullet-list - /// prefix ("• "), so it is stripped here. - static var paragraph: String { - [ - String(localized: "app_info.how_to_use.set_nickname"), - String(localized: "app_info.how_to_use.change_channels"), - String(localized: "app_info.how_to_use.open_sidebar"), - String(localized: "app_info.how_to_use.start_dm"), - String(localized: "app_info.how_to_use.clear_chat"), - String(localized: "app_info.how_to_use.commands") - ] - .map { $0.hasPrefix("• ") ? String($0.dropFirst(2)) : $0 } - .joined(separator: ", ") - } + static let instructions: [LocalizedStringKey] = [ + "app_info.how_to_use.set_nickname", + "app_info.how_to_use.change_channels", + "app_info.how_to_use.open_sidebar", + "app_info.how_to_use.start_dm", + "app_info.how_to_use.clear_chat", + "app_info.how_to_use.commands" + ] } } - + var body: some View { #if os(macOS) VStack(spacing: 0) { @@ -262,529 +101,64 @@ struct AppInfoView: View { .foregroundColor(textColor) .padding() } - .themedSurface(opacity: 0.95) - - VStack(spacing: 0) { - panePicker - - ScrollView { - paneContent - } + .background(backgroundColor.opacity(0.95)) + + ScrollView { + infoContent } - .themedSheetBackground() + .background(backgroundColor) } .frame(width: 600, height: 700) - .sheet(isPresented: $showTopology) { - if let topologyProvider { - MeshTopologyView(provider: topologyProvider) - } - } #else NavigationView { - VStack(spacing: 0) { - panePicker - - ScrollView { - paneContent - } + ScrollView { + infoContent } - .themedSheetBackground() + .background(backgroundColor) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - SheetCloseButton { dismiss() } - .foregroundColor(textColor) + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("app_info.close") } } } - .sheet(isPresented: $showTopology) { - if let topologyProvider { - MeshTopologyView(provider: topologyProvider) - } - } #endif } - - // MARK: - Pane switching - - private var panePicker: some View { - Picker(Strings.Settings.tabPickerLabel, selection: $selectedPane) { - Text(Strings.Settings.tabInfo).tag(Pane.info) - Text(Strings.Settings.tabSettings).tag(Pane.settings) - } - .pickerStyle(.segmented) - .labelsHidden() - .padding(.horizontal) - .padding(.top, 12) - } - - @ViewBuilder - private var paneContent: some View { - switch selectedPane { - case .settings: - settingsContent - case .info: - infoContent - } - } - - // MARK: - Settings pane - - @ViewBuilder - private var settingsContent: some View { - VStack(alignment: .leading, spacing: 24) { - // Appearance — single row: label left, theme chips right - HStack(spacing: 12) { - SectionHeader(Strings.appearanceTitle) - Spacer() - ForEach(AppTheme.allCases) { theme in - Button { - appThemeRawValue = theme.rawValue - } label: { - Text(theme.displayNameKey) - .bitchatFont(size: 13, weight: selectedTheme == theme ? .semibold : .regular) - .foregroundColor(selectedTheme == theme ? palette.accent : secondaryTextColor) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(selectedTheme == theme ? palette.accent.opacity(0.15) : Color.clear) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityAddTraits(selectedTheme == theme ? .isSelected : []) - } - } - - // Language — an in-app override so the UI language can differ - // from the device language (takes effect on next launch). - VStack(alignment: .leading, spacing: 12) { - SectionHeader(verbatim: Strings.Settings.languageTitle) - - settingsCard { - Menu { - Button { - selectLanguage(nil) - } label: { - menuItemLabel(Strings.Settings.languageSystem, isSelected: languageOverride.isEmpty) - } - Divider() - ForEach(AppLanguageSettings.availableLanguages, id: \.self) { code in - Button { - selectLanguage(code) - } label: { - menuItemLabel(AppLanguageSettings.endonym(for: code), isSelected: languageOverride == code) - } - } - } label: { - HStack { - Text(Strings.Settings.languagePickerLabel) - .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(textColor) - Spacer() - Text(languageOverride.isEmpty ? Strings.Settings.languageSystem : AppLanguageSettings.endonym(for: languageOverride)) - .bitchatFont(size: 12) - .foregroundColor(palette.accent) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 10)) - .foregroundColor(secondaryTextColor) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if showLanguageRestartNote { - Text(Strings.Settings.languageRestartNote) - .bitchatFont(size: 11) - .foregroundColor(secondaryTextColor) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - // Voice — same card + IRC pill as every other toggle setting. - VStack(alignment: .leading, spacing: 12) { - SectionHeader(Strings.Voice.title) - - settingsCard { - settingToggle( - title: Text("app_info.voice.live.title"), - subtitle: Text("app_info.voice.live.description"), - isOn: Binding( - get: { liveVoiceEnabled }, - set: { newValue in - liveVoiceEnabled = newValue - PTTSettings.liveVoiceEnabled = newValue - } - ) - ) - } - } - - // Connectivity: mesh bridge, internet gateway, tor routing - VStack(alignment: .leading, spacing: 12) { - SectionHeader(verbatim: Strings.Settings.connectivityTitle) - - settingsCard { - settingToggle( - title: Text(Strings.Settings.bridgeTitle), - subtitle: Text(Strings.Settings.bridgeSubtitle), - isOn: bridgeToggleBinding - ) - // Where the bridge meets: the geohash rendezvous cell, or - // a hint about why there isn't one yet (no location and no - // bridge peer advertising a cell). - if bridgeService.isEnabled { - Text(bridgeService.activeCell.map(Strings.Settings.bridgeCell) ?? Strings.Settings.bridgeNoCell) - .bitchatFont(size: 11) - .foregroundColor(secondaryTextColor) - } - } - - settingsCard { - settingToggle( - title: Text(Strings.Settings.torTitle), - subtitle: Text(verbatim: Strings.Settings.torSubtitle), - isOn: torToggleBinding - ) - // Turning tor off is not a location-channels-only choice, so - // say what it costs while it is off rather than in the - // subtitle everyone skims. - if !locationChannelsModel.userTorEnabled { - Text(verbatim: Strings.Settings.torOffWarning) - .bitchatFont(size: 11) - .foregroundColor(palette.alertRed) - .fixedSize(horizontal: false, vertical: true) - } - } - - relaySettingsCard - - // Location notes / dead drops (merged from main's flat - // layout into the shared card + pill style). Turning it on - // may need the location prompt; the permission control below - // covers the denied path. - settingsCard { - settingToggle( - title: Strings.Location.notes.title, - subtitle: Strings.Location.notes.description, - isOn: Binding( - get: { locationNotesEnabled }, - set: { newValue in - locationNotesEnabled = newValue - LocationNotesSettings.enabled = newValue - if newValue { - locationManager.enableLocationChannels() - } - } - ) - ) - } - - // Location powers the channels list and the bridge cell, so - // its control lives with the other connectivity settings. - // Platform reality shapes the three states: the app may only - // prompt while never-asked; granted/denied both flip in the - // system permission screen. - switch locationChannelsModel.permissionState { - case .authorized: - Button(action: SystemSettings.location.open) { - Text("location_channels.action.remove_access") - .bitchatFont(size: 12) - .foregroundColor(palette.alertRed) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(Color.red.opacity(0.08)) - .cornerRadius(6) - } - .buttonStyle(.plain) - case .notDetermined: - Button(action: { locationChannelsModel.enableLocationChannels() }) { - Text("location_channels.action.request_permissions") - .bitchatFont(size: 12) - .foregroundColor(palette.accent) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(palette.accent.opacity(0.12)) - .cornerRadius(6) - } - .buttonStyle(.plain) - case .denied, .restricted: - settingsCard { - Text("location_channels.permission_denied") - .bitchatFont(size: 11) - .foregroundColor(secondaryTextColor) - Button("location_channels.action.open_settings", action: SystemSettings.location.open) - .buttonStyle(.plain) - .bitchatFont(size: 12) - .foregroundColor(palette.accent) - } - } - } - - // Privacy: what a locked, seized, or borrowed phone gives away - // without being unlocked. - VStack(alignment: .leading, spacing: 12) { - SectionHeader(verbatim: Strings.Settings.privacyTitle) - - settingsCard { - settingToggle( - title: Text(verbatim: Strings.Settings.hidePreviewsTitle), - subtitle: Text(verbatim: Strings.Settings.hidePreviewsSubtitle), - isOn: Binding( - get: { hideMessagePreviews }, - set: { newValue in - hideMessagePreviews = newValue - NotificationPrivacySettings.hideMessagePreviews = newValue - } - ) - ) - } - } - - // Danger zone - if onPanicWipe != nil { - VStack(alignment: .leading, spacing: 12) { - SectionHeader(verbatim: Strings.Settings.dangerTitle) - - Button(action: { showPanicConfirmation = true }) { - Text(Strings.Settings.panicButton) - .bitchatFont(size: 12) - .foregroundColor(palette.alertRed) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(Color.red.opacity(0.08)) - .cornerRadius(6) - } - .buttonStyle(.plain) - .confirmationDialog( - Strings.Settings.panicConfirmTitle, - isPresented: $showPanicConfirmation, - titleVisibility: .visible - ) { - Button(Strings.Settings.panicConfirmAction, role: .destructive) { - onPanicWipe?() - } - Button("common.cancel", role: .cancel) {} - } - - Text(Strings.Settings.panicNote) - .bitchatFont(size: 11) - .foregroundColor(secondaryTextColor) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .padding() - } - - private func selectLanguage(_ code: String?) { - let previous = languageOverride - AppLanguageSettings.setOverride(code) - languageOverride = code ?? "" - if languageOverride != previous { - showLanguageRestartNote = true - } - } - - private func menuItemLabel(_ title: String, isSelected: Bool) -> some View { - HStack { - Text(title) - if isSelected { - Image(systemName: "checkmark") - } - } - } - - private var bridgeToggleBinding: Binding { - Binding( - get: { bridgeService.isEnabled }, - set: { bridgeService.setEnabled($0) } - ) - } - - /// Relay list editor. The built-in relays are four well-known hostnames, so - /// a filter blocking four names ends internet-delivered private messages; - /// adding one here is the only fix that does not need a new build. - @ViewBuilder - private var relaySettingsCard: some View { - settingsCard { - VStack(alignment: .leading, spacing: 2) { - Text(verbatim: Strings.Settings.relaysTitle) - .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(textColor) - Text(verbatim: Strings.Settings.relaysSubtitle) - .bitchatFont(size: 11) - .foregroundColor(secondaryTextColor) - .fixedSize(horizontal: false, vertical: true) - } - - ForEach(NostrRelayManager.builtInRelayURLs.sorted(), id: \.self) { relay in - HStack(spacing: 6) { - Text(verbatim: relay) - .bitchatFont(size: 11) - .foregroundColor(secondaryTextColor) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 4) - Text(verbatim: Strings.Settings.relayBuiltIn) - .bitchatFont(size: 10) - .foregroundColor(secondaryTextColor) - } - } - - ForEach(customRelays, id: \.self) { relay in - HStack(spacing: 6) { - Text(verbatim: relay) - .bitchatFont(size: 11) - .foregroundColor(textColor) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 4) - Button { - NostrRelaySettings.remove(relay) - reloadCustomRelays() - } label: { - Image(systemName: "minus.circle") - .foregroundColor(palette.alertRed) - } - .buttonStyle(.plain) - .accessibilityLabel(Strings.Settings.relayRemove) - } - } - - if customRelays.count < NostrRelaySettings.maxCustomRelays { - HStack(spacing: 6) { - TextField(Strings.Settings.relayPlaceholder, text: $relayInput) - .textFieldStyle(.plain) - .bitchatFont(size: 11) - .foregroundColor(textColor) - .autocorrectionDisabled(true) - #if os(iOS) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - #endif - .onSubmit(addRelay) - Button(action: addRelay) { - Text(verbatim: Strings.Settings.relayAdd) - .bitchatFont(size: 11, weight: .semibold) - .foregroundColor(palette.accent) - } - .buttonStyle(.plain) - .disabled(relayInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - - if let relayError { - Text(verbatim: relayError) - .bitchatFont(size: 11) - .foregroundColor(palette.alertRed) - .fixedSize(horizontal: false, vertical: true) - } - } - // The store can change from outside this view — a panic wipe clears it — - // so follow it rather than trusting the value read at creation. - .onReceive(NotificationCenter.default.publisher(for: NostrRelaySettings.didChangeNotification)) { _ in - reloadCustomRelays() - } - } - - private func addRelay() { - let candidate = relayInput - switch NostrRelaySettings.add(candidate, builtIn: NostrRelayManager.builtInRelayURLs) { - case .success: - relayInput = "" - relayError = nil - reloadCustomRelays() - case .failure(let failure): - relayError = Strings.Settings.relayError(failure) - } - } - - private func reloadCustomRelays() { - customRelays = NostrRelaySettings.customRelays() - } - - private var torToggleBinding: Binding { - Binding( - get: { locationChannelsModel.userTorEnabled }, - set: { locationChannelsModel.setUserTorEnabled($0) } - ) - } - - /// The padded card every connectivity setting sits in (moved look from - /// LocationChannelsSheet's toggle sections). - private func settingsCard(@ViewBuilder _ content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 8, content: content) - .padding(12) - .background(palette.secondary.opacity(0.12)) - .cornerRadius(8) - } - - /// A title+subtitle row driving an IRC-style on/off pill — the one - /// toggle style every setting uses. - private func settingToggle(title: Text, subtitle: Text, isOn: Binding) -> some View { - Toggle(isOn: isOn) { - VStack(alignment: .leading, spacing: 2) { - title - .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(textColor) - subtitle - .bitchatFont(size: 11) - .foregroundColor(secondaryTextColor) - } - } - .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.Settings.toggleOn, offLabel: Strings.Settings.toggleOff)) - } - - // MARK: - Info pane - + @ViewBuilder private var infoContent: some View { VStack(alignment: .leading, spacing: 24) { // Header VStack(alignment: .center, spacing: 8) { Text(Strings.appName) - .bitchatFont(size: 32, weight: .bold) + .font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced)) .foregroundColor(textColor) - + Text(Strings.tagline) - .bitchatFont(size: 16) + .font(.bitchatSystem(size: 16, design: .monospaced)) .foregroundColor(secondaryTextColor) } .frame(maxWidth: .infinity) .padding(.vertical) - + // How to Use VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.HowToUse.title) - Text(verbatim: Strings.HowToUse.paragraph) - .bitchatFont(size: 14) - .foregroundColor(textColor) - .fixedSize(horizontal: false, vertical: true) - } - - // Network diagnostics - if topologyProvider != nil { - VStack(alignment: .leading, spacing: 16) { - SectionHeader(Strings.Network.title) - - Button { - showTopology = true - } label: { - HStack(spacing: 0) { - FeatureRow(info: Strings.Network.topology) - Image(systemName: "chevron.right") - .font(.bitchatSystem(size: 12)) - .foregroundColor(secondaryTextColor) - } - .contentShape(Rectangle()) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in + Text(instruction) } - .buttonStyle(.plain) - .accessibilityHint(Text("app_info.network.topology.hint")) } + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(textColor) } // Features @@ -797,8 +171,6 @@ struct AppInfoView: View { FeatureRow(info: Strings.Features.extendedRange) - FeatureRow(info: Strings.Features.bridge) - FeatureRow(info: Strings.Features.favorites) FeatureRow(info: Strings.Features.geohash) @@ -816,28 +188,6 @@ struct AppInfoView: View { FeatureRow(info: Strings.Privacy.panic) } - - // Symbols legend - VStack(alignment: .leading, spacing: 10) { - SectionHeader(Strings.Legend.title) - - ForEach(Strings.Legend.items, id: \.icon) { item in - HStack(alignment: .top, spacing: 12) { - Image(systemName: item.icon) - .font(.bitchatSystem(size: 14)) - .foregroundColor(item.color ?? textColor) - .frame(width: 30) - - Text(item.text) - .bitchatFont(size: 13) - .foregroundColor(secondaryTextColor) - .fixedSize(horizontal: false, vertical: true) - - Spacer() - } - .accessibilityElement(children: .combine) - } - } } .padding() } @@ -845,43 +195,25 @@ struct AppInfoView: View { struct AppInfoFeatureInfo { let icon: String - let title: Text - let description: Text - - /// Catalog-backed strings (existing keys). - init(icon: String, title: LocalizedStringKey, description: LocalizedStringKey) { - self.icon = icon - self.title = Text(title) - self.description = Text(description) - } - - /// Pre-resolved strings — new keys that carry their English defaultValue - /// inline until the i18n pass adds them to the catalog. - init(icon: String, resolvedTitle: String, resolvedDescription: String) { - self.icon = icon - self.title = Text(resolvedTitle) - self.description = Text(resolvedDescription) - } + let title: LocalizedStringKey + let description: LocalizedStringKey } struct SectionHeader: View { - private let title: Text - @ThemedPalette private var palette - - private var textColor: Color { palette.primary } - + let title: LocalizedStringKey + @Environment(\.colorScheme) var colorScheme + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + init(_ title: LocalizedStringKey) { - self.title = Text(title) + self.title = title } - - /// For pre-resolved strings (new keys with inline defaultValue). - init(verbatim title: String) { - self.title = Text(title) - } - + var body: some View { - title - .bitchatFont(size: 16, weight: .bold) + Text(title) + .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .foregroundColor(textColor) .padding(.top, 8) } @@ -889,30 +221,34 @@ struct SectionHeader: View { struct FeatureRow: View { let info: AppInfoFeatureInfo - @ThemedPalette private var palette - - private var textColor: Color { palette.primary } - - private var secondaryTextColor: Color { palette.secondary } - + @Environment(\.colorScheme) var colorScheme + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + + private var secondaryTextColor: Color { + colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) + } + var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: info.icon) .font(.bitchatSystem(size: 20)) .foregroundColor(textColor) .frame(width: 30) - + VStack(alignment: .leading, spacing: 4) { - info.title - .bitchatFont(size: 14, weight: .semibold) + Text(info.title) + .font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced)) .foregroundColor(textColor) - - info.description - .bitchatFont(size: 12) + + Text(info.description) + .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(secondaryTextColor) .fixedSize(horizontal: false, vertical: true) } - + Spacer() } } @@ -920,17 +256,14 @@ struct FeatureRow: View { #Preview("Default") { AppInfoView() - .environmentObject(LocationChannelsModel()) } #Preview("Dynamic Type XXL") { AppInfoView() - .environmentObject(LocationChannelsModel()) .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge) } #Preview("Dynamic Type XS") { AppInfoView() - .environmentObject(LocationChannelsModel()) .environment(\.sizeCategory, .extraSmall) } diff --git a/bitchat/Views/BridgePeopleList.swift b/bitchat/Views/BridgePeopleList.swift deleted file mode 100644 index 89162397..00000000 --- a/bitchat/Views/BridgePeopleList.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// BridgePeopleList.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI - -/// Shared section header for the people sheet: a small glyph + label pair, -/// identical shape for every section (#mesh, across the bridge, …). -struct PeopleSectionHeader: View { - @ThemedPalette private var palette - let icon: String - let iconColor: Color - let title: String - - var body: some View { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.bitchatSystem(size: 10)) - .foregroundColor(iconColor) - Text(verbatim: title) - .bitchatFont(size: 11, weight: .semibold) - .foregroundColor(palette.secondary) - } - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 4) - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isHeader) - } -} - -/// The people-sheet section for participants visible across the mesh bridge: -/// same place, beyond radio range. Display-only in v1 — bridged identities -/// are per-cell rendezvous keys with no DM route yet. -struct BridgePeopleList: View { - @ObservedObject private var bridgeService = BridgeService.shared - @ThemedPalette private var palette - - private enum Strings { - static let sectionTitle = String(localized: "bridge_people.section_title", defaultValue: "across the bridge", comment: "Section header in the people sheet for participants reachable via the mesh bridge") - static let rowHint = String(localized: "bridge_people.accessibility.row_hint", defaultValue: "In your area, connected through the bridge", comment: "Accessibility hint for a person listed in the bridge section of the people sheet") - } - - var body: some View { - // Not gated on the toggle: bridged people arrive over passive radio - // (a serving neighbor's carriers) even while this device's own - // bridge is off — whoever is visible in the timeline belongs in the - // sheet. - if !bridgeService.bridgedParticipants.isEmpty { - VStack(alignment: .leading, spacing: 0) { - PeopleSectionHeader( - icon: "network", - iconColor: Color.cyan.opacity(0.9), - title: Strings.sectionTitle - ) - - ForEach(bridgeService.bridgedParticipants) { person in - HStack(spacing: 4) { - Text(person.displayName) - .bitchatFont(size: 14) - .foregroundColor(palette.primary) - Spacer() - } - .padding(.horizontal) - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityHint(Strings.rowHint) - } - } - } - } -} diff --git a/bitchat/Views/Components/CommandSuggestionsView.swift b/bitchat/Views/Components/CommandSuggestionsView.swift index cc4f9609..4c9d0213 100644 --- a/bitchat/Views/Components/CommandSuggestionsView.swift +++ b/bitchat/Views/Components/CommandSuggestionsView.swift @@ -8,76 +8,61 @@ import SwiftUI struct CommandSuggestionsView: View { - @EnvironmentObject private var privateConversationModel: PrivateConversationModel - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @ThemedPalette private var palette - + @EnvironmentObject private var viewModel: ChatViewModel + @ObservedObject private var locationManager = LocationChannelManager.shared + @Binding var messageText: String - - /// The command already typed in full, once arguments have begun. - private var typedCommandAlias: String? { - guard messageText.hasPrefix("/"), - let spaceIndex = messageText.firstIndex(of: " ") - else { return nil } - return String(messageText[.. some View { HStack { Text(command.alias) - .bitchatFont(size: 11) - .foregroundColor(palette.primary) + .font(.bitchatSystem(size: 11, design: .monospaced)) + .foregroundColor(textColor) .fontWeight(.medium) - + if let placeholder = command.placeholder { Text(placeholder) - .bitchatFont(size: 10) - .foregroundColor(palette.secondary.opacity(0.8)) + .font(.bitchatSystem(size: 10, design: .monospaced)) + .foregroundColor(secondaryTextColor.opacity(0.8)) } Spacer() - + Text(command.description) - .bitchatFont(size: 10) - .foregroundColor(palette.secondary) + .font(.bitchatSystem(size: 10, design: .monospaced)) + .foregroundColor(secondaryTextColor) } .padding(.horizontal, 12) .padding(.vertical, 3) @@ -94,13 +79,12 @@ struct CommandSuggestionsView: View { idBridge: NostrIdentityBridge(), identityManager: SecureIdentityStateManager(keychain) ) - let privateConversationModel = PrivateConversationModel( - chatViewModel: viewModel, - conversations: viewModel.conversations - ) - let locationChannelsModel = LocationChannelsModel() - CommandSuggestionsView(messageText: $messageText) - .environmentObject(privateConversationModel) - .environmentObject(locationChannelsModel) + CommandSuggestionsView( + messageText: $messageText, + textColor: .green, + backgroundColor: .primary, + secondaryTextColor: .secondary + ) + .environmentObject(viewModel) } diff --git a/bitchat/Views/Components/ConnectivityStatusBanner.swift b/bitchat/Views/Components/ConnectivityStatusBanner.swift deleted file mode 100644 index 4caedccf..00000000 --- a/bitchat/Views/Components/ConnectivityStatusBanner.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// ConnectivityStatusBanner.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import CoreBluetooth -import SwiftUI - -/// The degraded state a banner should surface, in priority order. Bluetooth -/// outranks tor: without the radio the app's core promise (mesh) is dead, -/// while a tor stall only pauses internet features. -enum ConnectivityIssue: Equatable { - case bluetoothOff - case bluetoothDenied - case bluetoothUnsupported - case torBlocked - - /// Pure resolution so the banner's priority logic is testable without a - /// view. `.unknown`/`.resetting` deliberately resolve to nil — the radio - /// is still starting and a flash of "bluetooth is off" at launch would - /// be the same false signal this banner exists to prevent. - static func resolve(bluetoothState: CBManagerState, torBlocked: Bool) -> ConnectivityIssue? { - switch bluetoothState { - case .poweredOff: return .bluetoothOff - case .unauthorized: return .bluetoothDenied - case .unsupported: return .bluetoothUnsupported - case .poweredOn, .unknown, .resetting: break - @unknown default: break - } - return torBlocked ? .torBlocked : nil - } -} - -/// Persistent one-line banner under the header while connectivity is -/// degraded. The Bluetooth alert is a one-shot modal — once dismissed, the -/// app used to look completely normal (empty timeline, radar sweeping) -/// while the radio was off. Tor stalls were only ever announced inside -/// geohash timelines. This banner is the always-visible truth; it renders -/// nothing when everything is fine. -struct ConnectivityStatusBanner: View { - let issue: ConnectivityIssue - @ThemedPalette private var palette - - private var message: String { - switch issue { - case .bluetoothOff: - return String( - localized: "content.banner.bluetooth_off", - defaultValue: "bluetooth is off — nobody nearby can reach you. tap to fix.", - comment: "Persistent banner while Bluetooth is switched off; tapping opens system settings" - ) - case .bluetoothDenied: - return String( - localized: "content.banner.bluetooth_denied", - defaultValue: "bluetooth access denied — mesh is offline. tap to open settings.", - comment: "Persistent banner while Bluetooth permission is denied; tapping opens system settings" - ) - case .bluetoothUnsupported: - return String( - localized: "content.banner.bluetooth_unsupported", - defaultValue: "no bluetooth on this device — mesh is unavailable. location channels still work over the internet.", - comment: "Persistent banner on devices without Bluetooth support" - ) - case .torBlocked: - return String( - localized: "content.banner.tor_blocked", - defaultValue: "tor can't connect — internet features are paused. mesh still works.", - comment: "Persistent banner while Tor bootstrap has stalled, likely because the network blocks it" - ) - } - } - - /// Bluetooth problems deep-link to where the fix actually lives — - /// powered-off goes to the radio controls, denied to the privacy - /// permission pane. The tor stall has no in-app remedy (the network is - /// blocking it), so that banner is inert. - private var settingsDestination: SystemSettings? { - switch issue { - case .bluetoothOff: return .bluetoothPower - case .bluetoothDenied: return .bluetooth - case .bluetoothUnsupported, .torBlocked: return nil - } - } - - var body: some View { - Group { - if let destination = settingsDestination { - Button(action: { destination.open() }) { - label - } - .buttonStyle(.plain) - } else { - label - } - } - } - - private var label: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: issue == .torBlocked ? "network.slash" : "antenna.radiowaves.left.and.right.slash") - .bitchatFont(size: 11, weight: .semibold) - Text(message) - .bitchatFont(size: 11) - .fixedSize(horizontal: false, vertical: true) - } - .foregroundColor(palette.alertRed) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.red.opacity(0.12)) - .accessibilityElement(children: .combine) - } -} diff --git a/bitchat/Views/Components/DeliveryStatusView.swift b/bitchat/Views/Components/DeliveryStatusView.swift index ad8039e9..13c045ee 100644 --- a/bitchat/Views/Components/DeliveryStatusView.swift +++ b/bitchat/Views/Components/DeliveryStatusView.swift @@ -7,42 +7,48 @@ // import SwiftUI -import BitFoundation -extension DeliveryStatus { - /// Localized, user-facing description of the status. Used for macOS - /// tooltips, the tap-to-reveal caption under a message, and VoiceOver — - /// the glyphs alone are unexplained 10pt icons. - var bitchatDescription: String { - switch self { - case .notSentYet: - return String(localized: "content.delivery.not_sent_yet", defaultValue: "Not sent yet", comment: "Delivery status description for a message that has not entered any send pipeline") - case .sending: - return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent") - case .sent: - return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message") - case .carried: - return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery") - case .delivered(let nickname, _): - return String( +struct DeliveryStatusView: View { + @Environment(\.colorScheme) private var colorScheme + let status: DeliveryStatus + + // MARK: - Computed Properties + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + + private var secondaryTextColor: Color { + colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) + } + + private enum Strings { + static func delivered(to nickname: String) -> String { + String( format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"), locale: .current, nickname ) - case .read(let nickname, _): - return String( + } + + static func read(by nickname: String) -> String { + String( format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"), locale: .current, nickname ) - case .failed(let reason): - return String( + } + + static func failed(_ reason: String) -> String { + String( format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"), locale: .current, reason ) - case .partiallyDelivered(let reached, let total): - return String( + } + + static func deliveredToMembers(_ reached: Int, _ total: Int) -> String { + String( format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"), locale: .current, reached, @@ -50,53 +56,22 @@ extension DeliveryStatus { ) } } -} - -struct DeliveryStatusView: View { - @ThemedPalette private var palette - let status: DeliveryStatus - - // MARK: - Computed Properties - - private var textColor: Color { palette.primary } - - private var secondaryTextColor: Color { palette.secondary } - + // MARK: - Body - + var body: some View { - statusGlyph - .help(status.bitchatDescription) - .accessibilityElement(children: .ignore) - .accessibilityLabel(status.bitchatDescription) - } - - @ViewBuilder - private var statusGlyph: some View { switch status { - case .notSentYet: - // Normally hidden by callers; shown as a hollow dotted circle if - // it ever surfaces so the state is visible rather than invisible. - Image(systemName: "circle.dotted") - .font(.bitchatSystem(size: 10)) - .foregroundColor(secondaryTextColor.opacity(0.6)) - case .sending: Image(systemName: "circle") .font(.bitchatSystem(size: 10)) .foregroundColor(secondaryTextColor.opacity(0.6)) - + case .sent: Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) .foregroundColor(secondaryTextColor.opacity(0.6)) - - case .carried: - Image(systemName: "figure.walk") - .font(.bitchatSystem(size: 10)) - .foregroundColor(secondaryTextColor.opacity(0.8)) - - case .delivered: + + case .delivered(let nickname, _): HStack(spacing: -2) { Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) @@ -104,40 +79,41 @@ struct DeliveryStatusView: View { .font(.bitchatSystem(size: 10)) } .foregroundColor(textColor.opacity(0.8)) - - case .read: - // Filled variant so read vs delivered is legible without color. - HStack(spacing: 0) { - Image(systemName: "checkmark.circle.fill") - .font(.bitchatSystem(size: 9, weight: .bold)) - Image(systemName: "checkmark.circle.fill") - .font(.bitchatSystem(size: 9, weight: .bold)) + .help(Strings.delivered(to: nickname)) + + case .read(let nickname, _): + HStack(spacing: -2) { + Image(systemName: "checkmark") + .font(.bitchatSystem(size: 10, weight: .bold)) + Image(systemName: "checkmark") + .font(.bitchatSystem(size: 10, weight: .bold)) } - .foregroundColor(palette.accentBlue) - - case .failed: + .foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue + .help(Strings.read(by: nickname)) + + case .failed(let reason): Image(systemName: "exclamationmark.triangle") .font(.bitchatSystem(size: 10)) .foregroundColor(Color.red.opacity(0.8)) - + .help(Strings.failed(reason)) + case .partiallyDelivered(let reached, let total): HStack(spacing: 1) { Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) Text(verbatim: "\(reached)/\(total)") - .bitchatFont(size: 10) + .font(.bitchatSystem(size: 10, design: .monospaced)) } .foregroundColor(secondaryTextColor.opacity(0.6)) + .help(Strings.deliveredToMembers(reached, total)) } } } #Preview { let statuses: [DeliveryStatus] = [ - .notSentYet, .sending, .sent, - .carried, .delivered(to: "John Doe", at: Date()), .read(by: "Jane Doe", at: Date()), .failed(reason: "Offline"), diff --git a/bitchat/Views/Components/IRCToggleStyle.swift b/bitchat/Views/Components/IRCToggleStyle.swift deleted file mode 100644 index 0b97bf08..00000000 --- a/bitchat/Views/Components/IRCToggleStyle.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftUI - -/// IRC-flavored toggle: the whole row is one button and the state is spelled -/// out as an on/off pill instead of a system switch. Shared by the settings -/// surfaces (App Info's connectivity toggles and friends). -struct IRCToggleStyle: ToggleStyle { - let accent: Color - let onLabel: LocalizedStringKey - let offLabel: LocalizedStringKey - - func makeBody(configuration: Configuration) -> some View { - Button(action: { configuration.isOn.toggle() }) { - HStack(spacing: 12) { - configuration.label - Spacer() - Text(configuration.isOn ? onLabel : offLabel) - .textCase(.uppercase) - .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(configuration.isOn ? accent : .secondary) - .padding(.vertical, 4) - .padding(.horizontal, 10) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(accent.opacity(configuration.isOn ? 0.18 : 0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1) - ) - } - } - .buttonStyle(.plain) - } -} diff --git a/bitchat/Views/Components/MeshEmptyStateView.swift b/bitchat/Views/Components/MeshEmptyStateView.swift deleted file mode 100644 index b225927d..00000000 --- a/bitchat/Views/Components/MeshEmptyStateView.swift +++ /dev/null @@ -1,225 +0,0 @@ -// -// MeshEmptyStateView.swift -// bitchat -// -// The empty mesh timeline, upgraded from a dead end into a live surface: -// a sonar shows the radio scanning, the daily sightings tally proves the -// spot isn't dead, the liveliest nearby geohash conversation is one tap -// away, and notes left at this place surface when there are any. -// This is free and unencumbered software released into the public domain. -// - -import SwiftUI - -struct MeshEmptyStateView: View { - /// Visible chat height to fill; the radar centers in the space left - /// below the narration. Zero (previews) keeps a compact layout. - var fillHeight: CGFloat = 0 - /// Ambient-footer mode, appended below archived echoes: skips the - /// intro/help narration (the timeline isn't empty) and shrinks the - /// radar, keeping the sightings tally and the live hints visible. - var compact: Bool = false - /// Whether the radio can actually scan. With Bluetooth off or denied the - /// sweep must not run — an animated "searching" over a dead radio is an - /// actively false status display. Defaults to true for previews. - var bluetoothAvailable: Bool = true - - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @EnvironmentObject private var peerListModel: PeerListModel - @ObservedObject private var activityTracker = GeohashChatActivityTracker.shared - @ObservedObject private var sightingsTracker = MeshSightingsTracker.shared - @ObservedObject private var nearbyNotes = NearbyNotesCounter.shared - - @ThemedPalette private var palette - - /// The activity window is evaluated at render time; without new events - /// nothing would trigger a re-render, so a stale "people are talking" - /// hint could linger. A slow tick keeps the hints and relative times - /// honest. - @State private var refreshTick = 0 - private let refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() - - private enum Strings { - static let meshIntro = String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is") - static let switchHint = String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen") - static let sightingsOne = String(localized: "content.empty.sightings_one", comment: "Empty mesh timeline stat when exactly one device came within range today") - static let checkNotes = String(localized: "content.empty.check_notes", comment: "Empty mesh timeline action that starts looking for notes left at this place; before tapping, no lookup runs") - - static func sightingsMany(_ count: Int) -> String { - String( - format: String(localized: "content.empty.sightings_many", comment: "Empty mesh timeline stat counting devices that came within range today"), - locale: .current, - count - ) - } - - static func activityOne(_ geohash: String) -> String { - String( - format: String(localized: "content.empty.activity_one", comment: "Empty mesh timeline hint when one person is chatting in a nearby geohash channel; placeholder is the geohash"), - locale: .current, - geohash - ) - } - - static func activityMany(_ geohash: String) -> String { - String( - format: String(localized: "content.empty.activity_many", comment: "Empty mesh timeline hint when several people are chatting in a nearby geohash channel; placeholder is the geohash"), - locale: .current, - geohash - ) - } - - } - - /// The radar means "searching for people": once anyone is connected or - /// reachable on the mesh, the search is over and the sweep goes away. - /// And it only means that while the radio is actually on — the - /// connectivity banner carries the message when it isn't. - private var isSearchingForPeers: Bool { - bluetoothAvailable - && peerListModel.connectedMeshPeerCount == 0 - && peerListModel.reachableMeshPeerCount == 0 - } - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - if compact { - if isSearchingForPeers { - radarBlock - } - if let conversation = nearbyConversation { - conversationHint(conversation) - } - if showsCheckNotesHint { - checkNotesHint - } - } else { - // The radar + tally already say "scanning, nobody yet", so - // the narration stays to two lines with the live hint after - // them, not wedged in between. - narrationLine(Strings.meshIntro) - narrationLine(Strings.switchHint) - if let conversation = nearbyConversation { - conversationHint(conversation) - } - if showsCheckNotesHint { - checkNotesHint - } - - // The radar centers in whatever space is left below the - // text — the flexible spacers split it evenly. - if isSearchingForPeers { - Spacer(minLength: 24) - radarBlock - Spacer(minLength: 12) - } - } - } - .frame(minHeight: compact ? 0 : fillHeight, alignment: .top) - .onReceive(refreshTimer) { _ in - refreshTick += 1 - // Roll the tally over if the local day changed while idle. - sightingsTracker.refreshForDisplay() - } - } - - /// The radar with today's tally as its caption — the stat belongs to - /// the scanning visual, not the narration lines. - private var radarBlock: some View { - VStack(spacing: 4) { - MeshRadarView(height: compact ? 44 : 72) - if sightingsTracker.todayCount > 0 { - Text(verbatim: sightingsText) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary.opacity(0.8)) - } - } - .frame(maxWidth: .infinity) - } -} - -private extension MeshEmptyStateView { - var nearbyConversation: NearbyConversation? { - activityTracker.mostActiveConversation(among: locationChannelsModel.availableChannels) - } - - /// Tap-to-reveal: the nearby-notes counter never subscribes on its own — - /// looking at the mesh timeline must not open a building-precision relay - /// REQ (a passive location side-channel). This static line is the one - /// explicit act that unlocks it; nothing touches the network until the - /// tap. It only renders when location permission is already granted - /// (the tap never prompts, so without permission it would dead-end - /// silently). Once revealed it yields to today's live strip and count, - /// and the app-info setting stays the kill switch. - var showsCheckNotesHint: Bool { - nearbyNotes.offersRevealHint(permissionState: locationChannelsModel.permissionState) - } - - var checkNotesHint: some View { - Button { - NearbyNotesCounter.shared.reveal() - } label: { - actionLine("📍 \(Strings.checkNotes)") - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - // The visual label carries decorative asterisks and an emoji; expose - // just the localized action text to assistive tech. - .accessibilityLabel(Strings.checkNotes) - } - - var sightingsText: String { - sightingsTracker.todayCount == 1 - ? Strings.sightingsOne - : Strings.sightingsMany(sightingsTracker.todayCount) - } - - func conversationHint(_ conversation: NearbyConversation) -> some View { - let headline = conversation.messageCount == 1 - ? Strings.activityOne(conversation.channel.geohash) - : Strings.activityMany(conversation.channel.geohash) - - return Button { - locationChannelsModel.markTeleported(for: conversation.channel.geohash, false) - locationChannelsModel.select(.location(conversation.channel)) - } label: { - VStack(alignment: .leading, spacing: 2) { - actionLine("💬 \(headline)") - narrationLine(" \(previewText(for: conversation.lastMessage))") - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - func previewText(for message: GeohashChatPreview) -> String { - let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen - var content = message.content - if content.count > maxLen { - content = String(content.prefix(maxLen)) + "…" - } - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - let ago = formatter.localizedString(for: message.timestamp, relativeTo: Date()) - return "<\(message.senderName)> \(content) · \(ago)" - } - - func narrationLine(_ text: String) -> some View { - emptyStateLine(text, color: palette.secondary.opacity(0.9)) - } - - /// Tappable lines render in the primary color so they read as actions - /// amid the grey narration. - func actionLine(_ text: String) -> some View { - emptyStateLine(text, color: palette.primary) - } - - func emptyStateLine(_ text: String, color: Color) -> some View { - // Non-breaking space before the closing asterisk so a tight wrap - // can't orphan a lone "*" onto its own line. - Text(verbatim: "* \(text)\u{00A0}*") - .bitchatFont(size: 13) - .foregroundColor(color) - .fixedSize(horizontal: false, vertical: true) - } -} diff --git a/bitchat/Views/Components/MeshRadarView.swift b/bitchat/Views/Components/MeshRadarView.swift deleted file mode 100644 index d59c569e..00000000 --- a/bitchat/Views/Components/MeshRadarView.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// MeshRadarView.swift -// bitchat -// -// Ambient sonar shown on the empty mesh timeline: expanding rings around a -// center dot make it visible that the radio is broadcasting and scanning -// even when nobody is in range. Purely decorative — hidden from -// accessibility, static under Reduce Motion. -// This is free and unencumbered software released into the public domain. -// - -import SwiftUI - -struct MeshRadarView: View { - /// Full size on the empty timeline; the ambient footer under archived - /// echoes uses a smaller one. - var height: CGFloat = 72 - - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @ThemedPalette private var palette - - private let ringCount = 3 - private let period: TimeInterval = 3.0 - - var body: some View { - Group { - if reduceMotion { - radar(at: 0.35) - } else { - TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { context in - radar(at: context.date.timeIntervalSinceReferenceDate) - } - } - } - .frame(height: height) - .frame(maxWidth: .infinity) - .accessibilityHidden(true) - } - - private func radar(at time: TimeInterval) -> some View { - Canvas { context, size in - let center = CGPoint(x: size.width / 2, y: size.height / 2) - let maxRadius = min(size.width, size.height) / 2 - 2 - - for ring in 0.. 1 else { continue } - let alpha = 0.45 * (1 - phase) - let rect = CGRect( - x: center.x - radius, - y: center.y - radius, - width: radius * 2, - height: radius * 2 - ) - context.stroke( - Path(ellipseIn: rect), - with: .color(palette.primary.opacity(alpha)), - lineWidth: 1 - ) - } - - let dot = CGRect(x: center.x - 2, y: center.y - 2, width: 4, height: 4) - context.fill(Path(ellipseIn: dot), with: .color(palette.primary.opacity(0.9))) - } - } -} diff --git a/bitchat/Views/Components/PanicWipeBlockedBanner.swift b/bitchat/Views/Components/PanicWipeBlockedBanner.swift deleted file mode 100644 index 9039caf0..00000000 --- a/bitchat/Views/Components/PanicWipeBlockedBanner.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// PanicWipeBlockedBanner.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI - -/// Shown under the header while a panic wipe has not committed -/// (`ChatViewModel.panicRecoveryBlocked`). A wipe that fails must be as loud -/// as the wipe itself: the person who triggered it may be relying on the -/// device being clean, and networking stays disabled until a relaunch retries -/// the transaction — without this banner the app just looks silently dead. -struct PanicWipeBlockedBanner: View { - @ThemedPalette private var palette - - private var message: String { - String( - localized: "content.banner.panic_blocked", - defaultValue: "wipe incomplete — some data may remain. quit and reopen bitchat to retry.", - comment: "Banner shown when a panic wipe did not fully commit; relaunching the app retries the wipe" - ) - } - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .bitchatFont(size: 11, weight: .semibold) - Text(message) - .bitchatFont(size: 11) - .fixedSize(horizontal: false, vertical: true) - } - .foregroundColor(palette.alertRed) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.red.opacity(0.12)) - .accessibilityElement(children: .combine) - } -} diff --git a/bitchat/Views/Components/PaymentChipView.swift b/bitchat/Views/Components/PaymentChipView.swift index bdda8772..00efc924 100644 --- a/bitchat/Views/Components/PaymentChipView.swift +++ b/bitchat/Views/Components/PaymentChipView.swift @@ -7,17 +7,11 @@ // import SwiftUI -#if os(iOS) -import UIKit -#else -import AppKit -#endif struct PaymentChipView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL - @ThemedPalette private var palette - + enum PaymentType { case cashu(String) case lightning(String) @@ -40,33 +34,14 @@ struct PaymentChipView: View { return URL(string: link) } } - - /// The bare `cashuA…`/`cashuB…` bearer string, when this is a Cashu chip. - var cashuToken: String? { - if case .cashu(let link) = self { - return CashuTokenDecoder.bareToken(from: link) - } - return nil - } - - /// Web fallback for redemption when no wallet handles `cashu:` URLs. - /// The token only reaches the site the user's browser loads; the app - /// itself never contacts a mint. - var cashuWebRedeemURL: URL? { - guard let token = cashuToken, - let enc = token.addingPercentEncoding(withAllowedCharacters: Self.cashuAllowedCharacters) else { - return nil - } - return URL(string: "https://redeem.cashu.me/?token=\(enc)") - } - + var emoji: String { switch self { case .cashu: "🥜" case .lightning: "⚡" } } - + var label: String { switch self { case .cashu: @@ -76,56 +51,29 @@ struct PaymentChipView: View { } } } - + let paymentType: PaymentType - /// Decoded once at construction; tokens are capped in size so this is - /// cheap, and rows re-render often enough that lazy decode in `body` - /// would just repeat the work. - private let cashuInfo: CashuTokenDecoder.TokenInfo? - - init(paymentType: PaymentType) { - self.paymentType = paymentType - if case .cashu(let link) = paymentType { - self.cashuInfo = CashuTokenDecoder.decode(link) - } else { - self.cashuInfo = nil - } + + private var fgColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } - - private var fgColor: Color { palette.primary } private var bgColor: Color { - palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12) + colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12) } private var border: Color { fgColor.opacity(0.25) } - - /// "500 sat · mint.example.com", degrading to the generic label when the - /// token didn't decode (V4 payloads we can't walk, malformed input…). - private var primaryLabel: String { - guard let info = cashuInfo else { return paymentType.label } - var parts: [String] = [] - if let amount = info.displayAmount { parts.append(amount) } - if let host = info.mintHost { parts.append(host) } - return parts.isEmpty ? paymentType.label : parts.joined(separator: " · ") - } - - private var memoLabel: String? { cashuInfo?.memo } - + var body: some View { Button { - primaryAction() + #if os(iOS) + if let url = paymentType.url { openURL(url) } + #else + if let url = paymentType.url { NSWorkspace.shared.open(url) } + #endif } label: { HStack(spacing: 6) { Text(paymentType.emoji) - VStack(alignment: .leading, spacing: 1) { - Text(primaryLabel) - .bitchatFont(size: 12, weight: .semibold) - if let memoLabel { - Text(memoLabel) - .bitchatFont(size: 10) - .opacity(0.7) - .lineLimit(1) - } - } + Text(paymentType.label) + .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) } .padding(.vertical, 6) .padding(.horizontal, 12) @@ -140,102 +88,13 @@ struct PaymentChipView: View { .foregroundColor(fgColor) } .buttonStyle(.plain) - .contextMenu { - if let token = paymentType.cashuToken { - Button { - copyToPasteboard(token) - } label: { - Label(String(localized: "content.payment.copy_token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc") - } - Button { - redeemCashu() - } label: { - Label(String(localized: "content.payment.redeem_wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass") - } - if let webURL = paymentType.cashuWebRedeemURL { - Button { - openExternalURL(webURL) - } label: { - Label(String(localized: "content.payment.redeem_web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari") - } - } - } - } - .accessibilityLabel(Text(verbatim: accessibilityText)) - } - - private var accessibilityText: String { - var text = "\(paymentType.label): \(primaryLabel)" - if let memoLabel { text += ", \(memoLabel)" } - return text - } - - // MARK: - Actions - - private func primaryAction() { - switch paymentType { - case .cashu: - redeemCashu() - case .lightning: - #if os(iOS) - if let url = paymentType.url { openURL(url) } - #else - if let url = paymentType.url { NSWorkspace.shared.open(url) } - #endif - } - } - - /// Redemption is delegated: try a wallet registered for `cashu:` URLs - /// first, then fall back to the web redemption page. Uses the platform - /// opener directly (not the `openURL` environment) because the message - /// list overrides that action for cashu/lightning schemes without a - /// fallback path. - private func redeemCashu() { - let walletURL = paymentType.url - let webURL = paymentType.cashuWebRedeemURL - #if os(iOS) - if let walletURL { - UIApplication.shared.open(walletURL, options: [:]) { accepted in - if !accepted, let webURL { - UIApplication.shared.open(webURL) - } - } - } else if let webURL { - UIApplication.shared.open(webURL) - } - #else - if let walletURL, NSWorkspace.shared.urlForApplication(toOpen: walletURL) != nil { - NSWorkspace.shared.open(walletURL) - } else if let webURL { - NSWorkspace.shared.open(webURL) - } else if let walletURL { - NSWorkspace.shared.open(walletURL) - } - #endif - } - - private func openExternalURL(_ url: URL) { - #if os(iOS) - UIApplication.shared.open(url) - #else - NSWorkspace.shared.open(url) - #endif - } - - private func copyToPasteboard(_ string: String) { - #if os(iOS) - UIPasteboard.general.string = string - #else - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(string, forType: .string) - #endif } } #Preview { let cashuLink = "https://example.com/cashu" let lightningLink = "https://example.com/lightning" - + List { HStack { PaymentChipView(paymentType: .cashu(cashuLink)) diff --git a/bitchat/Views/Components/SheetCloseButton.swift b/bitchat/Views/Components/SheetCloseButton.swift deleted file mode 100644 index e2891e35..00000000 --- a/bitchat/Views/Components/SheetCloseButton.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// SheetCloseButton.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI - -/// The close "X" every sheet and header shares. One glyph size and weight -/// everywhere (the sheets had drifted across 12/13/14pt), a 32pt visual box -/// so existing header metrics don't move, and a hit target extended to 44pt -/// per platform guidelines. Tint comes from the environment, so callers keep -/// their own foreground color. -struct SheetCloseButton: View { - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: "xmark") - .bitchatFont(size: 13, weight: .semibold) - .frame(width: 32, height: 32) - .contentShape(Rectangle().inset(by: -6)) - } - .buttonStyle(.plain) - .accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons")) - } -} diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index ed376acf..194dc002 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -7,120 +7,45 @@ // import SwiftUI -import BitFoundation struct TextMessageView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme - @Environment(\.appTheme) private var theme - @ThemedPalette private var palette - @EnvironmentObject private var conversationUIModel: ConversationUIModel - + @EnvironmentObject private var viewModel: ChatViewModel + let message: BitchatMessage - /// Value snapshot of the message's mutable delivery status, captured at - /// construction. `BitchatMessage` is a reference type mutated in place by - /// `ConversationStore`, and SwiftUI compares reference-typed view fields - /// by identity — so a status-only change (e.g. delivered → read) on the - /// SAME instance would otherwise compare "unchanged" and this row's body - /// would be skipped even though the parent list re-rendered. Snapshotting - /// the enum makes the change visible to SwiftUI's structural diff. - private let deliveryStatus: DeliveryStatus @State private var expandedMessageIDs: Set = [] - @State private var showDeliveryDetail = false - - init(message: BitchatMessage) { - self.message = message - self.deliveryStatus = message.deliveryStatus - } - + var body: some View { VStack(alignment: .leading, spacing: 0) { // Precompute heavy token scans once per row let cashuLinks = message.content.extractCashuLinks() let lightningLinks = message.content.extractLightningLinks() - // Baseline alignment keeps the lock and delivery glyphs on the - // first text line; a fixed top padding left the lock's solid body - // hanging below the line's visual center. - HStack(alignment: .firstTextBaseline, spacing: 0) { - let isLong = message.content.isLongForDisplay() + HStack(alignment: .top, spacing: 0) { + let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty let isExpanded = expandedMessageIDs.contains(message.id) - if message.isPrivate { - Image(systemName: "lock.fill") - .font(.bitchatSystem(size: 8)) - .foregroundColor(Color.orange.opacity(0.75)) - .padding(.trailing, 4) - .accessibilityHidden(true) - } - if conversationUIModel.showsVerifiedSeal(for: message) { - Image(systemName: "checkmark.seal.fill") - .font(.bitchatSystem(size: 8)) - .foregroundColor(Color.green.opacity(0.85)) - .padding(.trailing, 4) - .accessibilityLabel( - String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message") - ) - } - if message.isBridged { - Image(systemName: "network") - .font(.bitchatSystem(size: 8)) - .foregroundColor(Color.cyan.opacity(0.75)) - .padding(.trailing, 4) - .accessibilityLabel( - String(localized: "content.accessibility.bridged_message", defaultValue: "Arrived across a mesh bridge", comment: "Accessibility label for the glyph marking a message that arrived across a mesh bridge") - ) - } - Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme)) + Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .frame(maxWidth: .infinity, alignment: .leading) - // Delivery status indicator for private messages. Tappable: - // .help() tooltips only exist on macOS, so iOS users get the - // explanation as a caption under the row instead. - if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - deliveryStatus != .notSentYet { - Button { - showDeliveryDetail.toggle() - } label: { - DeliveryStatusView(status: deliveryStatus) - .padding(.leading, 4) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityHint( - String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details") - ) - } - } - - // Failure reasons stay visible without a tap; other statuses - // reveal on demand. - if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - deliveryStatus != .notSentYet { - if case .failed = deliveryStatus { - Text(verbatim: deliveryStatus.bitchatDescription) - .bitchatFont(size: 11) - .foregroundColor(Color.red.opacity(0.9)) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 2) - } else if showDeliveryDetail { - Text(verbatim: deliveryStatus.bitchatDescription) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 2) + // Delivery status indicator for private messages + if message.isPrivate && message.sender == viewModel.nickname, + let status = message.deliveryStatus { + DeliveryStatusView(status: status) + .padding(.leading, 4) } } // Expand/Collapse for very long messages - if message.content.isLongForDisplay() { + if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty { let isExpanded = expandedMessageIDs.contains(message.id) let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more") Button(labelKey) { if isExpanded { expandedMessageIDs.remove(message.id) } else { expandedMessageIDs.insert(message.id) } } - .bitchatFont(size: 11, weight: .medium) - .foregroundColor(palette.accentBlue) + .font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(Color.blue) .padding(.top, 4) } @@ -138,40 +63,11 @@ struct TextMessageView: View { .padding(.leading, 2) } } - // Collapse the revealed caption when the status advances (e.g. - // sending → sent → delivered) so a detail opened for one state - // doesn't linger and silently morph into another. Guarded write: - // under a message storm many rows change status within one frame, - // and an unconditional state write per change trips SwiftUI's - // "tried to update multiple times per frame" re-entrancy warning. - .onChange(of: deliveryStatus) { _ in - if showDeliveryDetail { - showDeliveryDetail = false - } - } } } -// Wrapped in #if DEBUG because the preview depends on _PreviewHelpers -// (PreviewKeychainManager, BitchatMessage.preview), a development asset -// excluded from archive builds. -#if DEBUG #Preview { let keychain = PreviewKeychainManager() - let viewModel = ChatViewModel( - keychain: keychain, - idBridge: NostrIdentityBridge(), - identityManager: SecureIdentityStateManager(keychain) - ) - let privateConversationModel = PrivateConversationModel( - chatViewModel: viewModel, - conversations: viewModel.conversations - ) - let conversationUIModel = ConversationUIModel( - chatViewModel: viewModel, - privateConversationModel: privateConversationModel, - conversations: viewModel.conversations - ) Group { List { @@ -190,6 +86,11 @@ struct TextMessageView: View { } .environment(\.colorScheme, .dark) } - .environmentObject(conversationUIModel) + .environmentObject( + ChatViewModel( + keychain: keychain, + idBridge: NostrIdentityBridge(), + identityManager: SecureIdentityStateManager(keychain) + ) + ) } -#endif diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift deleted file mode 100644 index b7deab21..00000000 --- a/bitchat/Views/ContentComposerView.swift +++ /dev/null @@ -1,506 +0,0 @@ -import SwiftUI -#if os(iOS) -import UIKit -#endif -#if os(macOS) -import AppKit -#endif - -struct ContentComposerView: View { - @EnvironmentObject private var conversationUIModel: ConversationUIModel - @EnvironmentObject private var privateConversationModel: PrivateConversationModel - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @ObservedObject private var bridgeService = BridgeService.shared - @Environment(\.appTheme) private var theme - @ThemedPalette private var palette - - @Binding var messageText: String - var isTextFieldFocused: FocusState.Binding - @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel - @Binding var autocompleteDebounceTimer: Timer? - - let onSendMessage: () -> Void - - #if os(iOS) - @Binding var showImagePicker: Bool - @Binding var imagePickerSourceType: UIImagePickerController.SourceType - #else - @Binding var showMacImagePicker: Bool - #endif - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty { - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4).enumerated()), id: \.element) { index, suggestion in - Button(action: { - _ = conversationUIModel.completeNickname(suggestion, in: &messageText) - }) { - HStack { - Text(suggestion) - .bitchatFont(size: 11) - .foregroundColor(palette.primary) - .fontWeight(.medium) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 3) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - index == conversationUIModel.selectedAutocompleteIndex - ? palette.secondary.opacity(0.15) - : Color.clear - ) - } - .buttonStyle(.plain) - } - } - .themedOverlayPanel() - .padding(.horizontal, 12) - } - - CommandSuggestionsView(messageText: $messageText) - - if voiceRecordingVM.state.isActive { - recordingIndicator - } - - HStack(alignment: .center, spacing: 4) { - TextField( - "", - text: $messageText, - prompt: Text(placeholderText) - .foregroundColor(palette.secondary.opacity(0.6)) - ) - .textFieldStyle(.plain) - .bitchatFont(size: 15) - .foregroundColor(palette.primary) - .focused(isTextFieldFocused) - .autocorrectionDisabled(true) - #if os(iOS) - .textInputAutocapitalization(.sentences) - #endif - .submitLabel(.send) - .modifier(AutocompleteKeyboardNavigationModifier( - isActive: { conversationUIModel.showAutocomplete - && !conversationUIModel.autocompleteSuggestions.isEmpty }, - onMove: { delta in - conversationUIModel.moveAutocompleteSelection(by: delta) - }, - onAccept: { - conversationUIModel.completeSelectedSuggestion(in: &messageText) - }, - onDismiss: { - conversationUIModel.dismissAutocomplete() - } - )) - // Return while the mention panel is open completes the - // highlight instead of sending — matches command suggestions - // (#1504) and keeps Tab/Return/Escape on one convention. - .onSubmit { - if conversationUIModel.showAutocomplete, - !conversationUIModel.autocompleteSuggestions.isEmpty, - conversationUIModel.completeSelectedSuggestion(in: &messageText) { - return - } - onSendMessage() - // Only the return-key path: it steals focus on iOS, so - // every message would cost a tap to reopen the keyboard. - // The send button must not reopen a deliberately - // dismissed keyboard, so it stays out of this. - isTextFieldFocused.wrappedValue = true - } - .padding(.vertical, theme.usesGlassChrome ? 8 : 4) - .padding(.horizontal, 6) - .themedInputBackground() - .modifier(FocusEffectDisabledModifier()) - .frame(maxWidth: .infinity, alignment: .leading) - .onChange(of: messageText) { newValue in - autocompleteDebounceTimer?.invalidate() - autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in - let cursorPosition = newValue.count - Task { @MainActor in - conversationUIModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) - } - } - } - - HStack(alignment: .center, spacing: 4) { - if showsNearbyOnlyToggle { - nearbyOnlyToggle - } - - if conversationUIModel.canSendMediaInCurrentContext { - attachmentButton - } - - sendOrMicButton - } - } - } - .padding(.horizontal, 6) - .padding(.top, theme.usesGlassChrome ? 8 : 6) - .padding(.bottom, 8) - .themedChromePanel(edge: .bottom) - .onDisappear { - autocompleteDebounceTimer?.invalidate() - } - } -} - -private extension ContentComposerView { - /// The nearby-only scope toggle appears only where it means something: - /// the public mesh channel with the bridge on. - var showsNearbyOnlyToggle: Bool { - guard bridgeService.isEnabled, - privateConversationModel.selectedHeaderState == nil, - case .mesh = locationChannelsModel.selectedChannel else { - return false - } - return true - } - - /// Scope control for outgoing messages: bridged (default, crosses to - /// other islands in this area) vs nearby-only (radio range, no internet - /// copy exists for any gateway to carry). - var nearbyOnlyToggle: some View { - Button(action: { bridgeService.nearbyOnly.toggle() }) { - Image(systemName: bridgeService.nearbyOnly ? "antenna.radiowaves.left.and.right" : "network") - .font(.bitchatSystem(size: 16)) - .foregroundColor(bridgeService.nearbyOnly ? palette.secondary : Color.cyan.opacity(0.9)) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel( - bridgeService.nearbyOnly - ? String(localized: "content.accessibility.nearby_only_on", defaultValue: "Nearby only: messages stay within radio range", comment: "Accessibility label for the compose scope toggle when messages stay local") - : String(localized: "content.accessibility.nearby_only_off", defaultValue: "Bridged: messages also reach people across the bridge", comment: "Accessibility label for the compose scope toggle when messages cross the mesh bridge") - ) - .help( - bridgeService.nearbyOnly - ? String(localized: "content.composer.nearby_only_on", defaultValue: "Nearby only — this message won't cross the bridge", comment: "Tooltip for the compose scope toggle when messages stay local") - : String(localized: "content.composer.nearby_only_off", defaultValue: "Bridged — reaches people beyond radio range in this area", comment: "Tooltip for the compose scope toggle when messages cross the mesh bridge") - ) - } - - /// States where a message will land: the DM partner's name for private - /// chats, the channel (and its public nature) otherwise — so a stressed - /// user never has to guess who can read what they're typing. - var placeholderText: String { - if let header = privateConversationModel.selectedHeaderState { - // A geohash-DM display name already carries its own "#geohash/@name" - // form, so it must not get another "@" prefix; a mesh nickname does. - let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true - let target = isGeoDM ? header.displayName : "@\(header.displayName)" - return String( - format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"), - locale: .current, - target - ) - } - switch locationChannelsModel.selectedChannel { - case .mesh: - return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel") - case .location(let channel): - return String( - format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"), - locale: .current, - channel.geohash - ) - } - } - - var recordingIndicator: some View { - HStack(spacing: 12) { - Image(systemName: voiceRecordingVM.isLiveStreaming ? "dot.radiowaves.left.and.right" : "waveform.circle.fill") - .foregroundColor(.red) - .font(.bitchatSystem(size: 20)) - .modifier(PulsingOpacityModifier(active: voiceRecordingVM.isLiveStreaming)) - TimelineView(.periodic(from: .now, by: 0.05)) { context in - // Live streaming means audio is heard as you speak — the HUD - // must make that unmistakable, not just show a timer. - if voiceRecordingVM.isLiveStreaming { - Text( - "live \(voiceRecordingVM.formattedDuration(for: context.date))", - comment: "Recording HUD label while a voice message streams live to the recipient" - ) - .bitchatFont(size: 13, weight: .bold) - .foregroundColor(.red) - } else { - Text( - "recording \(voiceRecordingVM.formattedDuration(for: context.date))", - comment: "Voice note recording duration indicator" - ) - .bitchatFont(size: 13) - .foregroundColor(.red) - } - } - Spacer() - Button(action: voiceRecordingVM.cancel) { - Label(String(localized: "common.cancel", comment: "Cancel action in the voice recording HUD"), systemImage: "xmark.circle") - .labelStyle(.iconOnly) - .font(.bitchatSystem(size: 18)) - .foregroundColor(.red) - } - .buttonStyle(.plain) - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.red.opacity(0.15)) - ) - } - - var composerAccentColor: Color { - privateConversationModel.selectedPeerID != nil ? Color.orange : palette.accent - } - - var attachmentButton: some View { - #if os(iOS) - Image(systemName: "camera.circle.fill") - .font(.bitchatSystem(size: 24)) - .foregroundColor(composerAccentColor) - .frame(width: 36, height: 36) - .contentShape(Circle()) - .onTapGesture { - imagePickerSourceType = .photoLibrary - showImagePicker = true - } - .onLongPressGesture(minimumDuration: 0.3) { - imagePickerSourceType = .camera - showImagePicker = true - } - .accessibilityLabel( - String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button") - ) - .accessibilityHint( - String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library") - ) - .accessibilityAddTraits(.isButton) - // The long-press → camera path is unreachable for VoiceOver users; - // mirror it as a named action. - .accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) { - imagePickerSourceType = .camera - showImagePicker = true - } - #else - Button(action: { showMacImagePicker = true }) { - Image(systemName: "photo.circle.fill") - .font(.bitchatSystem(size: 24)) - .foregroundColor(composerAccentColor) - .frame(width: 36, height: 36) - } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button") - ) - #endif - } - - @ViewBuilder - var sendOrMicButton: some View { - let hasText = !messageText.trimmed.isEmpty - if conversationUIModel.canSendMediaInCurrentContext { - ZStack { - micButtonView - .opacity(hasText ? 0 : 1) - .allowsHitTesting(!hasText) - sendButtonView(enabled: hasText) - .opacity(hasText ? 1 : 0) - .allowsHitTesting(hasText) - } - .frame(width: 36, height: 36) - } else { - sendButtonView(enabled: hasText) - .frame(width: 36, height: 36) - } - } - - /// Floor courtesy: someone else is talking live in the public channel. - /// Only advisory — a decentralized mesh has no floor arbiter, so holding - /// the mic still works; the tint just discourages talk-over. - var busyTalker: String? { - guard privateConversationModel.selectedPeerID == nil else { return nil } - return conversationUIModel.activeLiveVoiceTalker - } - - /// Recording > floor-busy > default accent. Whether the hold streams - /// live or records a classic note is signaled by the recording HUD's - /// LIVE treatment, not the idle button color. - var micColor: Color { - if voiceRecordingVM.state.isActive { return .red } - if busyTalker != nil { return Color.red.opacity(0.6) } - return composerAccentColor - } - - var micButtonView: some View { - Image(systemName: "mic.circle.fill") - .font(.bitchatSystem(size: 24)) - .foregroundColor(micColor) - .modifier(PulsingOpacityModifier(active: busyTalker != nil && !voiceRecordingVM.state.isActive)) - .frame(width: 36, height: 36) - .contentShape(Circle()) - .overlay( - Color.clear - .contentShape(Circle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext) - } - .onEnded { _ in - voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote) - } - ) - ) - .accessibilityLabel( - String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button") - ) - .accessibilityValue( - voiceRecordingVM.state.isActive - ? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording") - : busyTalker.map { - String( - format: String(localized: "content.accessibility.someone_speaking", comment: "Accessibility value on the mic button naming who is talking live in the public channel"), - locale: .current, - $0 - ) - } ?? "" - ) - .accessibilityHint( - String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording") - ) - .accessibilityAddTraits(.isButton) - // Press-and-hold drag gestures can't be activated by VoiceOver; - // give it a start/stop toggle as the default action. - .accessibilityAction { - if voiceRecordingVM.state.isActive { - voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote) - } else { - voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext) - } - } - } - - func sendButtonView(enabled: Bool) -> some View { - let activeColor = composerAccentColor - return Button(action: onSendMessage) { - Image(systemName: "arrow.up.circle.fill") - .font(.bitchatSystem(size: 24)) - .foregroundColor(enabled ? activeColor : Color.gray) - .frame(width: 36, height: 36) - } - .buttonStyle(.plain) - .disabled(!enabled) - .accessibilityLabel( - String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button") - ) - .accessibilityHint( - enabled - ? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message") - : String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message") - ) - } -} - -/// Arrow/Tab/Return/Escape navigation for the mention suggestion list. -/// -/// Deployment targets are iOS 16 / macOS 13, so `.onKeyPress` (iOS 17 / -/// macOS 14+) is gated and unavailable on the minimum OS. Separately, on -/// macOS the single-line field editor consumes `moveUp:`/`moveDown:` itself, -/// so arrow keys never reach SwiftUI while the composer has focus — the -/// same reason command suggestions (#1504) use an `NSEvent` local monitor. -/// Mentions follow that mechanism on macOS and keep `.onKeyPress` for iOS 17+. -private struct AutocompleteKeyboardNavigationModifier: ViewModifier { - /// Live activity check, not a captured Bool. The macOS monitor closure is - /// registered once for the view's lifetime; a plain `Bool` would freeze - /// the value captured at install time (this is a value type), so a panel - /// that opens after the monitor installs would never intercept a key. - /// The provider closes over the reference-typed model and reads current - /// state on every event. - let isActive: () -> Bool - let onMove: (Int) -> Void - let onAccept: () -> Bool - let onDismiss: () -> Void - - #if os(macOS) - @State private var keyMonitor: Any? - #endif - - func body(content: Content) -> some View { - #if os(macOS) - content - .onAppear { installKeyMonitor() } - .onDisappear { removeKeyMonitor() } - #else - if #available(iOS 17.0, *) { - content - .onKeyPress(.upArrow) { - guard isActive() else { return .ignored } - onMove(-1) - return .handled - } - .onKeyPress(.downArrow) { - guard isActive() else { return .ignored } - onMove(1) - return .handled - } - .onKeyPress(.tab) { - guard isActive() else { return .ignored } - return onAccept() ? .handled : .ignored - } - .onKeyPress(.escape) { - guard isActive() else { return .ignored } - onDismiss() - return .handled - } - } else { - content - } - #endif - } - - #if os(macOS) - private func installKeyMonitor() { - guard keyMonitor == nil else { return } - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - handleKeyDown(event) - } - } - - private func removeKeyMonitor() { - if let keyMonitor { - NSEvent.removeMonitor(keyMonitor) - } - keyMonitor = nil - } - - /// Standard autocomplete navigation (aligned with #1504): arrows move - /// the highlight, return/tab insert, escape dismisses. Returning nil - /// consumes the event so return completes instead of sending while the - /// list is up. Inactive monitors pass everything through. - private func handleKeyDown(_ event: NSEvent) -> NSEvent? { - guard isActive(), - event.modifierFlags.isDisjoint(with: [.command, .option, .control]) else { - return event - } - - switch event.keyCode { - case 126: // up arrow - onMove(-1) - return nil - case 125: // down arrow - onMove(1) - return nil - case 36, 48: // return, tab - return onAccept() ? nil : event - case 53: // escape - onDismiss() - return nil - default: - return event - } - } - #endif -} diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift deleted file mode 100644 index f857ba38..00000000 --- a/bitchat/Views/ContentHeaderView.swift +++ /dev/null @@ -1,468 +0,0 @@ -import SwiftUI - -struct ContentHeaderView: View { - @EnvironmentObject private var appChromeModel: AppChromeModel - @EnvironmentObject private var verificationModel: VerificationModel - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @EnvironmentObject private var peerListModel: PeerListModel - @EnvironmentObject private var boardAlertsModel: BoardAlertsModel - @ObservedObject private var bridgeService = BridgeService.shared - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.appTheme) private var theme - @ThemedPalette private var palette - - @Binding var showSidebar: Bool - @Binding var showVerifySheet: Bool - var isNicknameFieldFocused: FocusState.Binding - - let headerHeight: CGFloat - let headerPeerIconSize: CGFloat - let headerPeerCountFontSize: CGFloat - - /// Courier envelopes this device is carrying for offline third parties. - @State private var carriedMailCount = 0 - - /// Board posts mirrored from the store so the pin icon can show when the - /// current scope has notices. - @State private var boardPosts: [BoardPostPacket] = [] - - /// Nostr-only location notes at this place (live while the empty mesh - /// timeline is showing) — they should light the pin too. - @ObservedObject private var nearbyNotes = NearbyNotesCounter.shared - - @State private var pendingShareGeohash: String? - @State private var showSharePrecisionWarning = false - @State private var activeSharePayload: ChannelSharePayload? - - /// The bridged-people count belongs to the mesh channel only. - private var showBridgedPeerCount: Bool { - if case .location = locationChannelsModel.selectedChannel { return false } - return bridgeService.bridgedPeerCount > 0 - } - - var body: some View { - HStack(spacing: 0) { - Text(verbatim: "bitchat/") - .bitchatFont(size: 18, weight: .medium) - .lineLimit(1) - .foregroundColor(palette.primary) - // When icons crowd the header, squeeze the nickname first - // (priority 0) and the logo only as a last resort; the icon - // cluster at priority 3 never gives up width. - .layoutPriority(2) - .onTapGesture(count: 3) { - // Confirm before destroying: the same logo is the single-tap - // App Info entry point and sits beside the nickname field, so - // a fumbled tap must never be able to wipe the device. The - // dialog matches the Settings-pane panic button; under duress - // it costs one extra tap. - appChromeModel.requestPanicWipe() - } - .onTapGesture(count: 1) { - appChromeModel.presentAppInfo() - } - // The confirmation dialog itself is hosted on ContentView - // (next to the failed-wipe banner), not on this Text: a host - // that can be covered or removed could take the pending - // dialog down with it. - // This is the only entry point to App Info, but it reads as - // static text; surface the tap. (The triple-tap panic wipe - // stays undiscoverable on purpose — it's destructive.) - .accessibilityAddTraits(.isButton) - .accessibilityHint( - String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info") - ) - .accessibilityAction { - appChromeModel.presentAppInfo() - } - - HStack(spacing: 0) { - Text(verbatim: "@") - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) - // Keep the sigil whole while the field beside it shrinks. - .fixedSize() - - TextField( - "content.input.nickname_placeholder", - text: Binding( - get: { appChromeModel.nickname }, - set: { appChromeModel.setNickname($0) } - ) - ) - .textFieldStyle(.plain) - .bitchatFont(size: 14) - .frame(maxWidth: 80) - .foregroundColor(palette.primary) - .focused(isNicknameFieldFocused) - .autocorrectionDisabled(true) - #if os(iOS) - .textInputAutocapitalization(.never) - #endif - .modifier(FocusEffectDisabledModifier()) - .onChange(of: isNicknameFieldFocused.wrappedValue) { isFocused in - if !isFocused { - appChromeModel.validateAndSaveNickname() - } - } - .onSubmit { - appChromeModel.validateAndSaveNickname() - } - } - - Spacer() - - let countAndColor = channelPeopleCountAndColor() - let headerCountColor = countAndColor.1 - let headerOtherPeersCount: Int = { - if case .location = locationChannelsModel.selectedChannel { - return peerListModel.visibleGeohashPeerCount - } - // One number for the whole room: radio-reachable peers plus - // people across the bridge (visible via carriers even while - // this device's own bridge is off). The sheet breaks it down. - return countAndColor.0 + bridgeService.bridgedPeerCount - }() - - HStack(spacing: 2) { - if locationChannelsModel.gatewayEnabled { - // The gateway toggle lives in the App Info settings pane - // now, so the indicator deep-links there. - Button(action: { appChromeModel.presentAppInfo() }) { - Image(systemName: "globe") - .font(.bitchatSystem(size: 12)) - .foregroundColor(palette.secondary.opacity(0.8)) - .headerTapTarget() - } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator") - ) - .accessibilityHint( - String(localized: "content.accessibility.gateway_settings_hint", defaultValue: "Opens settings to turn the gateway on or off", comment: "Accessibility hint for the internet gateway indicator explaining a tap opens the settings sheet") - ) - .help( - String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator") - ) - } - - if carriedMailCount > 0 { - Image(systemName: "figure.walk") - .font(.bitchatSystem(size: 12)) - .foregroundColor(palette.secondary.opacity(0.8)) - .headerTapTarget() - .accessibilityLabel( - String( - format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"), - locale: .current, - carriedMailCount - ) - ) - .help( - String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator") - ) - } - - if appChromeModel.hasUnreadPrivateMessages { - Button(action: { appChromeModel.openMostRelevantPrivateChat() }) { - Image(systemName: "envelope.fill") - .font(.bitchatSystem(size: 12)) - .foregroundColor(Color.orange) - .headerTapTarget() - } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button") - ) - } - - Button(action: { - var scopes: Set = [""] - if let geoScope = noticesGeoScope { - scopes.insert(geoScope) - } - boardAlertsModel.markSeen(forScopes: scopes) - appChromeModel.presentNotices() - }) { - // Filled whenever the current scope has notices at all - // (matching the orange tint); hollow means nothing here. - Image(systemName: scopeHasNotices || unseenNoticesCount > 0 ? "pin.fill" : "pin") - .font(.bitchatSystem(size: 12)) - .foregroundColor( - scopeHasNotices || unseenNoticesCount > 0 - ? Color.orange.opacity(0.8) - : palette.secondary.opacity(0.9) - ) - .headerTapTarget() - } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.notices", defaultValue: "Notices", comment: "Accessibility label for the notices button") - ) - .accessibilityValue( - unseenNoticesCount > 0 - ? String( - format: String(localized: "content.accessibility.notices_new", defaultValue: "%lld new", comment: "Accessibility value for the notices button when unseen pins arrived"), - locale: .current, - unseenNoticesCount - ) - : "" - ) - .help( - String(localized: "content.header.notices", defaultValue: "Notices: pinned posts for this area and the mesh", comment: "Tooltip for the notices button") - ) - - if case .location(let channel) = locationChannelsModel.selectedChannel { - Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) { - Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") - .font(.bitchatSystem(size: 12)) - .headerTapTarget() - } - .buttonStyle(.plain) - .accessibilityLabel( - String( - format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"), - locale: .current, - channel.geohash - ) - ) - - Button(action: { requestHeaderShare(forGeohash: channel.geohash) }) { - Image(systemName: "square.and.arrow.up") - .font(.bitchatSystem(size: 12)) - .headerTapTarget() - } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "channel.share.action", defaultValue: "share channel", comment: "Accessibility label for sharing the active location channel") - ) - } - - Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) { - let badgeText: String = { - switch locationChannelsModel.selectedChannel { - case .mesh: return "#mesh" - case .location(let channel): return "#\(channel.geohash)" - } - }() - let badgeColor: Color = { - switch locationChannelsModel.selectedChannel { - case .mesh: - return Color(hue: 0.60, saturation: 0.85, brightness: 0.82) - case .location: - return palette.locationAccent - } - }() - - Text(badgeText) - .bitchatFont(size: 14) - .foregroundColor(badgeColor) - .lineLimit(headerLineLimit) - .fixedSize(horizontal: true, vertical: false) - .layoutPriority(2) - .padding(.horizontal, 6) - .frame(maxHeight: .infinity) - .contentShape(Rectangle()) - .accessibilityLabel( - String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button") - ) - } - .buttonStyle(.plain) - - Button(action: { - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - showSidebar.toggle() - } - }) { - HStack(spacing: 4) { - Image(systemName: "person.2.fill") - .font(.system(size: headerPeerIconSize, weight: .regular)) - Text("\(headerOtherPeersCount)") - .font(.system(size: headerPeerCountFontSize, weight: .regular, design: theme.bodyFontDesign)) - .accessibilityHidden(true) - } - .foregroundColor(headerCountColor) - .lineLimit(headerLineLimit) - .fixedSize(horizontal: true, vertical: false) - .padding(.leading, 6) - .frame(maxHeight: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel( - String( - format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"), - locale: .current, - headerOtherPeersCount - ) - ) - // Connected-vs-nobody is otherwise encoded only in the icon's - // color; say it. With a live bridge, also say who's across it. - .accessibilityValue( - showBridgedPeerCount - ? String( - format: String(localized: "content.accessibility.bridged_count", defaultValue: "%lld more people across the bridge", comment: "Accessibility value announcing the number of people reachable via the mesh bridge"), - locale: .current, - bridgeService.bridgedPeerCount - ) - : (headerPeersReachable - ? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable") - : String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")) - ) - } - .layoutPriority(3) - .sheet(isPresented: $showVerifySheet) { - VerificationSheetView(isPresented: $showVerifySheet) - .environmentObject(verificationModel) - } - } - // Fixed height is load-bearing: children fill the bar with - // .frame(maxHeight: .infinity) tap targets, so an open-ended - // minHeight lets the header expand to swallow the whole screen. - // headerHeight is a @ScaledMetric, so it still grows with Dynamic - // Type. - .frame(height: headerHeight) - .padding(.horizontal, 12) - .onReceive(CourierStore.shared.$carriedCount) { count in - carriedMailCount = count - } - .onReceive(BoardStore.shared.$postsSnapshot) { posts in - boardPosts = posts - } - .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { - LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) - .environmentObject(locationChannelsModel) - .environmentObject(peerListModel) - } - .sheet( - isPresented: $appChromeModel.isNoticesSheetPresented, - onDismiss: { appChromeModel.noticesSheetPrefersGeoTab = false } - ) { - NoticesView( - senderNickname: appChromeModel.nickname, - board: appChromeModel.boardManager, - initialTab: initialNoticesTab - ) - .environmentObject(locationChannelsModel) - } - .onAppear { - locationChannelsModel.refreshMeshChannelsIfNeeded() - } - .onChange(of: locationChannelsModel.selectedChannel) { _ in - locationChannelsModel.refreshMeshChannelsIfNeeded() - } - .onChange(of: locationChannelsModel.permissionState) { _ in - locationChannelsModel.refreshMeshChannelsIfNeeded() - } - .alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) { - Button("common.ok", role: .cancel) {} - } message: { - Text("content.alert.screenshot.message") - } - .confirmationDialog( - String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite"), - isPresented: $showSharePrecisionWarning, - titleVisibility: .visible - ) { - Button(String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning")) { - if let gh = pendingShareGeohash { - activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: gh)) - } - pendingShareGeohash = nil - } - Button("common.cancel", role: .cancel) { - pendingShareGeohash = nil - } - } message: { - Text(String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite")) - } - .sheet(item: $activeSharePayload) { payload in - ShareActivityView(text: payload.text) - } - .themedChromePanel(edge: .top) - } - - private func requestHeaderShare(forGeohash geohash: String) { - if ChannelShare.shouldWarn(forGeohash: geohash) { - pendingShareGeohash = geohash - showSharePrecisionWarning = true - } else { - activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash)) - } - } -} - -private extension View { - /// Expands a small header icon to a comfortably tappable, full-bar-height - /// hit area without changing its visual size. - func headerTapTarget() -> some View { - frame(minWidth: 30, maxHeight: .infinity) - .contentShape(Rectangle()) - } -} - -private extension ContentHeaderView { - var headerLineLimit: Int? { - dynamicTypeSize.isAccessibilitySize ? 2 : 1 - } - - /// Open the notices sheet on the tab matching the current channel: the - /// geohash channel's notices, or the mesh-local board in mesh chat. An - /// explicit geo-tab request (the "notes left here" hint) wins. - var initialNoticesTab: NoticesView.Tab { - if appChromeModel.noticesSheetPrefersGeoTab { - return .geo - } - if case .location = locationChannelsModel.selectedChannel { - return .geo - } - return .mesh - } - - /// The geo scope the notices sheet would open on: the selected location - /// channel, or the device's building geohash when chatting on mesh. - var noticesGeoScope: String? { - if case .location(let channel) = locationChannelsModel.selectedChannel { - return channel.geohash - } - return locationChannelsModel.currentBuildingGeohash - } - - /// Whether either tab of the notices sheet currently has content: board - /// posts in scope, plus Nostr-only location notes when the nearby-notes - /// counter happens to be live (it runs with the empty mesh timeline). - var scopeHasNotices: Bool { - boardPosts.contains { $0.geohash.isEmpty || $0.geohash == noticesGeoScope } - || nearbyNotes.noteCount > 0 - } - - /// New pins in either visible scope since the sheet was last opened. - var unseenNoticesCount: Int { - let meshCount = boardAlertsModel.unseenCount(forGeohash: "") - let geoCount = noticesGeoScope.map { boardAlertsModel.unseenCount(forGeohash: $0) } ?? 0 - return meshCount + geoCount - } - - /// Whether anyone is actually reachable on the current channel — the - /// state the count icon's color encodes visually. - var headerPeersReachable: Bool { - switch locationChannelsModel.selectedChannel { - case .location: - return peerListModel.visibleGeohashPeerCount > 0 - case .mesh: - return peerListModel.connectedMeshPeerCount > 0 - } - } - - func channelPeopleCountAndColor() -> (Int, Color) { - switch locationChannelsModel.selectedChannel { - case .location: - let count = peerListModel.visibleGeohashPeerCount - return (count, count > 0 ? palette.locationAccent : palette.secondary) - case .mesh: - let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) - let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary - return (peerListModel.reachableMeshPeerCount, color) - } - } -} diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift deleted file mode 100644 index 88be040f..00000000 --- a/bitchat/Views/ContentSheetViews.swift +++ /dev/null @@ -1,790 +0,0 @@ -import BitFoundation -import SwiftUI -#if os(iOS) -import UIKit -#elseif os(macOS) -import AppKit -#endif - -struct ContentPeopleSheetModalPresentationState { - var isImagePreviewPresented = false - var isVerificationSheetPresented = false - var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? = nil - var isVoiceAlertPresented = false - var isMediaPickerPresented = false - - var hasPresentation: Bool { - isImagePreviewPresented - || isVerificationSheetPresented - || legacyPrivateMediaConsentRequest != nil - || isVoiceAlertPresented - || isMediaPickerPresented - } -} - -struct ContentPeopleSheetView: View { - @EnvironmentObject private var appChromeModel: AppChromeModel - @EnvironmentObject private var privateConversationModel: PrivateConversationModel - @EnvironmentObject private var verificationModel: VerificationModel - @EnvironmentObject private var conversationUIModel: ConversationUIModel - @Environment(\.scenePhase) private var scenePhase - - @Binding var showSidebar: Bool - @Binding var messageText: String - @Binding var selectedMessageSender: String? - @Binding var selectedMessageSenderID: PeerID? - @Binding var imagePreviewURL: URL? - @Binding var windowCountPublic: Int - @Binding var windowCountPrivate: [PeerID: Int] - @Binding var isAtBottomPrivate: Bool - var isTextFieldFocused: FocusState.Binding - @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel - @Binding var autocompleteDebounceTimer: Timer? - @State private var showVerifySheet = false - @ThemedPalette private var palette - - let headerHeight: CGFloat - let onSendMessage: () -> Void - - #if os(iOS) - @Binding var showImagePicker: Bool - @Binding var imagePickerSourceType: UIImagePickerController.SourceType - #else - @Binding var showMacImagePicker: Bool - #endif - - private func modalPresentationState( - includingVoiceAlert: Bool - ) -> ContentPeopleSheetModalPresentationState { - #if os(iOS) - let isMediaPickerPresented = showImagePicker - #else - let isMediaPickerPresented = showMacImagePicker - #endif - - return ContentPeopleSheetModalPresentationState( - isImagePreviewPresented: imagePreviewURL != nil, - isVerificationSheetPresented: showVerifySheet, - legacyPrivateMediaConsentRequest: - conversationUIModel.legacyPrivateMediaConsentRequest, - isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert, - isMediaPickerPresented: isMediaPickerPresented - ) - } - - private var hasModalPresentation: Bool { - modalPresentationState(includingVoiceAlert: true).hasPresentation - } - - /// The voice alert cannot defer to itself: its own binding must keep - /// reporting `true` while it is the presented modal. - private var hasModalPresentationBesidesVoiceAlert: Bool { - modalPresentationState(includingVoiceAlert: false).hasPresentation - } - - private var bluetoothAlertBinding: Binding { - Binding( - get: { - scenePhase == .active - && appChromeModel.showBluetoothAlert - && !hasModalPresentation - }, - set: { isPresented in - guard !isPresented, - scenePhase == .active, - !hasModalPresentation else { - return - } - appChromeModel.showBluetoothAlert = false - } - ) - } - - /// Voice recording happens inside this sheet, so its error alert must - /// present from here as well: the root copy defers whenever this sheet - /// is up, exactly like the Bluetooth alert above. Presenting from the - /// root instead would force-dismiss the sheet and end the conversation. - private var voiceAlertBinding: Binding { - Binding( - get: { - scenePhase == .active - && voiceRecordingVM.showAlert - && !hasModalPresentationBesidesVoiceAlert - }, - set: { isPresented in - guard !isPresented, - scenePhase == .active, - !hasModalPresentationBesidesVoiceAlert else { - return - } - voiceRecordingVM.showAlert = false - } - ) - } - - var body: some View { - let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest - NavigationStack { - Group { - if privateConversationModel.selectedPeerID != nil { - #if os(iOS) - ContentPrivateChatSheetView( - showSidebar: $showSidebar, - messageText: $messageText, - selectedMessageSender: $selectedMessageSender, - selectedMessageSenderID: $selectedMessageSenderID, - imagePreviewURL: $imagePreviewURL, - windowCountPublic: $windowCountPublic, - windowCountPrivate: $windowCountPrivate, - isAtBottomPrivate: $isAtBottomPrivate, - isTextFieldFocused: isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - headerHeight: headerHeight, - onSendMessage: onSendMessage, - showImagePicker: $showImagePicker, - imagePickerSourceType: $imagePickerSourceType - ) - #else - ContentPrivateChatSheetView( - showSidebar: $showSidebar, - messageText: $messageText, - selectedMessageSender: $selectedMessageSender, - selectedMessageSenderID: $selectedMessageSenderID, - imagePreviewURL: $imagePreviewURL, - windowCountPublic: $windowCountPublic, - windowCountPrivate: $windowCountPrivate, - isAtBottomPrivate: $isAtBottomPrivate, - isTextFieldFocused: isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - headerHeight: headerHeight, - onSendMessage: onSendMessage, - showMacImagePicker: $showMacImagePicker - ) - #endif - } else { - ContentPeopleListView( - showSidebar: $showSidebar, - showVerifySheet: $showVerifySheet - ) - } - } - .navigationDestination(isPresented: Binding( - get: { appChromeModel.showingFingerprintFor != nil && (showSidebar || privateConversationModel.selectedPeerID != nil) }, - set: { isPresented in - if !isPresented { - appChromeModel.clearFingerprint() - } - } - )) { - if let peerID = appChromeModel.showingFingerprintFor { - FingerprintView(peerID: peerID) - .environmentObject(verificationModel) - } - } - // This sheet covers the root header where the connectivity - // banner lives; a person sitting in the people list or a DM - // would otherwise get no persistent signal that the radio is - // off or tor is stalled. Mirror it here. - .safeAreaInset(edge: .top, spacing: 0) { - if let issue = ConnectivityIssue.resolve( - bluetoothState: appChromeModel.bluetoothState, - torBlocked: appChromeModel.torBlocked - ) { - ConnectivityStatusBanner(issue: issue) - } - } - } - .themedSheetBackground() - .foregroundColor(palette.primary) - .confirmationDialog( - String( - localized: "content.private_media.legacy_warning.title", - defaultValue: "Send without end-to-end encryption?", - comment: "Title warning before sending private media to an older client in a clear signed envelope" - ), - isPresented: Binding( - get: { legacyConsentRequest != nil }, - set: { isPresented in - if !isPresented, let requestID = legacyConsentRequest?.id { - conversationUIModel.resolveLegacyPrivateMediaConsent( - requestID: requestID, - approved: false - ) - } - } - ), - titleVisibility: .visible - ) { - Button( - String( - localized: "content.private_media.legacy_warning.send", - defaultValue: "send visible file", - comment: "Destructive confirmation action for one legacy clear private-media send" - ), - role: .destructive - ) { - if let requestID = legacyConsentRequest?.id { - conversationUIModel.resolveLegacyPrivateMediaConsent( - requestID: requestID, - approved: true - ) - } - } - Button("common.cancel", role: .cancel) { - if let requestID = legacyConsentRequest?.id { - conversationUIModel.resolveLegacyPrivateMediaConsent( - requestID: requestID, - approved: false - ) - } - } - } message: { - if let request = legacyConsentRequest { - Text( - String( - format: String( - localized: "content.private_media.legacy_warning.message", - defaultValue: "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?", - comment: "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name" - ), - locale: .current, - request.peerName - ) - ) - } - } - #if os(macOS) - .frame(minWidth: 420, minHeight: 520) - #endif - #if os(iOS) - .fullScreenCover(isPresented: Binding( - get: { showImagePicker && (showSidebar || privateConversationModel.selectedPeerID != nil) }, - set: { newValue in - if !newValue { - showImagePicker = false - } - } - )) { - ImagePickerView(sourceType: imagePickerSourceType) { image in - showImagePicker = false - conversationUIModel.processSelectedImage(image) - } - .ignoresSafeArea() - } - #endif - #if os(macOS) - .sheet(isPresented: $showMacImagePicker) { - MacImagePickerView { url in - showMacImagePicker = false - conversationUIModel.processSelectedImage(from: url) - } - } - #endif - .alert(Text(String(localized: "voice.error.title", defaultValue: "recording error", comment: "Title of the voice recording error alert")), isPresented: voiceAlertBinding, actions: { - Button("common.ok", role: .cancel) {} - if voiceRecordingVM.state == .permissionDenied { - Button("location_channels.action.open_settings") { - SystemSettings.microphone.open() - } - } - }, message: { - Text(voiceRecordingVM.state.alertMessage) - }) - .alert( - "content.alert.bluetooth_required.title", - isPresented: bluetoothAlertBinding - ) { - Button("content.alert.bluetooth_required.settings") { - // Powered-off needs the radio controls, not the privacy pane. - (appChromeModel.bluetoothState == .poweredOff - ? SystemSettings.bluetoothPower - : SystemSettings.bluetooth).open() - } - Button("common.ok", role: .cancel) {} - } message: { - Text(appChromeModel.bluetoothAlertMessage) - } - } -} - -private struct ContentPeopleListView: View { - @EnvironmentObject private var appChromeModel: AppChromeModel - @EnvironmentObject private var privateConversationModel: PrivateConversationModel - @EnvironmentObject private var verificationModel: VerificationModel - @EnvironmentObject private var conversationUIModel: ConversationUIModel - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @EnvironmentObject private var peerListModel: PeerListModel - @Environment(\.dismiss) private var dismiss - @ThemedPalette private var palette - - @Binding var showSidebar: Bool - @Binding var showVerifySheet: Bool - - var body: some View { - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Text(peopleSheetTitle) - .bitchatFont(size: 18) - .foregroundColor(palette.primary) - Spacer() - if case .mesh = locationChannelsModel.selectedChannel { - Button(action: { showVerifySheet = true }) { - Image(systemName: "qrcode") - .font(.bitchatSystem(size: 14)) - } - .buttonStyle(.plain) - // .help maps to the accessibility *hint* on iOS, so the - // button still needs a spoken name. - .accessibilityLabel( - String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button") - ) - .help( - String(localized: "content.help.verification", comment: "Help text for verification button") - ) - } - SheetCloseButton { - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - dismiss() - showSidebar = false - showVerifySheet = false - privateConversationModel.endConversation() - } - } - } - - // The mesh sheet titles its sections inline (#mesh / across - // the bridge / groups) — no subtitle or count up here. - // Location channels keep their geohash subtitle. - if case .location(let channel) = locationChannelsModel.selectedChannel { - Text(verbatim: "#\(channel.geohash.lowercased())") - .bitchatFont(size: 12) - .foregroundColor(palette.locationAccent) - } - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) - .themedSurface() - - ScrollView { - // spacing 0: every section supplies its own rhythm (header - // top 12 / bottom 4, rows vertical 4), so inter-child spacing - // here would make the first section's gap read differently. - VStack(alignment: .leading, spacing: 0) { - if case .location = locationChannelsModel.selectedChannel { - GeohashPeopleList( - onTapPerson: { - showSidebar = true - } - ) - // Direct conversations survive channel switches; the - // geoDM someone opened from another cell must stay - // reachable here too. - RecentChatList( - chats: peerListModel.recentChatRows, - onTapChat: { peerID in - peerListModel.startConversation(with: peerID) - showSidebar = true - } - ) - } else { - PeopleSectionHeader( - icon: "antenna.radiowaves.left.and.right", - iconColor: palette.accentBlue, - title: "#mesh" - ) - MeshPeerList( - onTapPeer: { peerID in - peerListModel.startConversation(with: peerID) - showSidebar = true - }, - onToggleFavorite: { peerID in - peerListModel.toggleFavorite(peerID: peerID) - }, - onShowFingerprint: { peerID in - appChromeModel.showFingerprint(for: peerID) - }, - onToggleBlock: { peer in - if peer.isBlocked { - conversationUIModel.unblock(peerID: peer.peerID, displayName: peer.displayName) - } else { - conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName) - } - } - ) - // People in this area but beyond radio range, and - // private groups: one sheet for the whole room. - BridgePeopleList() - GroupChatList( - groups: peerListModel.groupRows, - onTapGroup: { peerID in - peerListModel.startConversation(with: peerID) - showSidebar = true - } - ) - // Conversations with people no roster above lists - // anymore — without this, a read DM from an offline - // non-favorite had no row anywhere in the UI. - RecentChatList( - chats: peerListModel.recentChatRows, - onTapChat: { peerID in - peerListModel.startConversation(with: peerID) - showSidebar = true - } - ) - } - } - .padding(.top, 4) - // Full width even when every row is narrow (empty mesh, no - // groups): without this the VStack hugs its widest child and - // the ScrollView centers it — headers and empty states - // floated mid-screen on iPhone. - .frame(maxWidth: .infinity, alignment: .leading) - .id(peerListModel.renderID) - } - } - .sheet(isPresented: $showVerifySheet) { - VerificationSheetView(isPresented: $showVerifySheet) - .environmentObject(verificationModel) - } - } -} - -private extension ContentPeopleListView { - var peopleSheetTitle: String { - String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased() - } - -} - -private struct ContentPrivateChatSheetView: View { - @EnvironmentObject private var privateConversationModel: PrivateConversationModel - - @Binding var showSidebar: Bool - @Binding var messageText: String - @Binding var selectedMessageSender: String? - @Binding var selectedMessageSenderID: PeerID? - @Binding var imagePreviewURL: URL? - @Binding var windowCountPublic: Int - @Binding var windowCountPrivate: [PeerID: Int] - @Binding var isAtBottomPrivate: Bool - var isTextFieldFocused: FocusState.Binding - @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel - @Binding var autocompleteDebounceTimer: Timer? - @Environment(\.appTheme) private var theme - @ThemedPalette private var palette - - let headerHeight: CGFloat - let onSendMessage: () -> Void - - #if os(iOS) - @Binding var showImagePicker: Bool - @Binding var imagePickerSourceType: UIImagePickerController.SourceType - #else - @Binding var showMacImagePicker: Bool - #endif - - var body: some View { - VStack(spacing: 0) { - if let headerState = privateConversationModel.selectedHeaderState { - HStack(spacing: 12) { - Button(action: { - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - privateConversationModel.endConversation() - } - }) { - Image(systemName: "chevron.left") - .font(.bitchatSystem(size: 12)) - .foregroundColor(palette.primary) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat") - ) - - Spacer(minLength: 0) - - HStack(spacing: 8) { - ContentPrivateHeaderInfoButton( - headerState: headerState, - headerHeight: headerHeight - ) - - if headerState.supportsFavoriteToggle { - Button(action: { - privateConversationModel.toggleFavoriteForSelectedConversation() - }) { - Image(systemName: headerState.isFavorite ? "star.fill" : "star") - .font(.bitchatSystem(size: 14)) - .foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary) - // Same visual box + 44pt hit target as SheetCloseButton. - .frame(width: 32, height: 32) - .contentShape(Rectangle().inset(by: -6)) - } - .buttonStyle(.plain) - .accessibilityLabel( - headerState.isFavorite - ? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") - : String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") - ) - } - } - .frame(maxWidth: .infinity) - - Spacer(minLength: 0) - - SheetCloseButton { - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - privateConversationModel.endConversation() - showSidebar = true - } - } - } - // minHeight so scaled text at accessibility sizes grows the - // bar instead of clipping inside it. - .frame(minHeight: headerHeight) - .padding(.horizontal, 16) - .padding(.top, 10) - .padding(.bottom, 12) - .modifier(PrivateHeaderChrome()) - } - - MessageListView( - privatePeer: privateConversationModel.selectedPeerID, - isAtBottom: $isAtBottomPrivate, - messageText: $messageText, - selectedMessageSender: $selectedMessageSender, - selectedMessageSenderID: $selectedMessageSenderID, - imagePreviewURL: $imagePreviewURL, - windowCountPublic: $windowCountPublic, - windowCountPrivate: $windowCountPrivate, - showSidebar: $showSidebar, - isTextFieldFocused: isTextFieldFocused - ) - .themedSurface() - .frame(maxWidth: .infinity, maxHeight: .infinity) - // Swipe-right-to-leave lives on the message list only. On the - // whole sheet it preempted the composer's press-and-hold mic - // gesture (a high-priority ancestor drag cancels child gestures - // within milliseconds — same starvation as the image-reveal bug). - .highPriorityGesture(swipeToLeaveGesture) - - if !theme.usesGlassChrome { - Divider() - } - - privacyCaption - - #if os(iOS) - ContentComposerView( - messageText: $messageText, - isTextFieldFocused: isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - onSendMessage: onSendMessage, - showImagePicker: $showImagePicker, - imagePickerSourceType: $imagePickerSourceType - ) - #else - ContentComposerView( - messageText: $messageText, - isTextFieldFocused: isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - onSendMessage: onSendMessage, - showMacImagePicker: $showMacImagePicker - ) - #endif - } - .themedSheetBackground() - .foregroundColor(palette.primary) - } - - private var swipeToLeaveGesture: some Gesture { - DragGesture(minimumDistance: 25, coordinateSpace: .local) - .onEnded { value in - let horizontal = value.translation.width - let vertical = abs(value.translation.height) - guard horizontal > 80, vertical < 60 else { return } - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - showSidebar = true - privateConversationModel.endConversation() - } - } - } - - /// Persistent one-line reminder that this composer feeds a private - /// conversation — the DM sheet otherwise renders identically to the - /// public timeline. Claims end-to-end encryption only once the session - /// is actually secured. - private var privacyCaption: some View { - HStack(spacing: 5) { - Image(systemName: "lock.fill") - .font(.bitchatSystem(size: 9)) - // Optical centering: lock.fill's ink is bottom-heavy, so - // geometric centering reads low next to the caption text. - .offset(y: -1) - Text(verbatim: privacyCaptionText) - .bitchatFont(size: 11, weight: .medium) - } - .foregroundColor(Color.orange) - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - // The orange text is signature enough; a tinted band here reads as a - // stray strip against the untinted composer chrome below it, so the - // caption sits on the same surface as the rest of the bottom chrome. - .themedSurface() - .accessibilityElement(children: .combine) - } - - private var privacyCaptionText: String { - // Group chats are ChaCha20-Poly1305 sealed to the roster's shared key. - if privateConversationModel.selectedPeerID?.isGroup == true { - return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members") - } - // Geohash DMs use BitChat's private-envelope encryption over Nostr — - // always end-to-end encrypted, - // even though they carry no Noise session status. Mesh DMs earn the - // "encrypted" claim only once the Noise handshake has secured — or - // when the peer is reachable only over Nostr, where delivery is - // gift-wrapped end-to-end without a Noise session. - let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true - let noiseSecured: Bool = { - switch privateConversationModel.selectedHeaderState?.encryptionStatus { - case .noiseSecured, .noiseVerified: return true - default: return false - } - }() - let nostrTransport = privateConversationModel.selectedHeaderState?.availability == .nostrAvailable - if isGeoDM || noiseSecured || nostrTransport { - return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted") - } - return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established") - } -} - -/// Chrome for the private-chat header. Matrix keeps its orange privacy wash -/// over an opaque themed surface. Glass gets the same floating panel as the -/// main header instead: an orange wash over the backdrop gradient reads as a -/// muddy gray-beige band, and the DM signature is already carried by the -/// orange lock, caption, and composer accents. -private struct PrivateHeaderChrome: ViewModifier { - @Environment(\.appTheme) private var theme - - @ViewBuilder - func body(content: Content) -> some View { - if theme.usesGlassChrome { - content.themedChromePanel(edge: .top) - } else { - // Orange tint before themedSurface so it layers in front of the - // opaque themed background rather than behind it. - content - .background(Color.orange.opacity(0.06)) - .themedSurface() - } - } -} - -private struct ContentPrivateHeaderInfoButton: View { - @EnvironmentObject private var appChromeModel: AppChromeModel - @ThemedPalette private var palette - - let headerState: PrivateConversationHeaderState - let headerHeight: CGFloat - - var body: some View { - Button(action: { - // A group has no single fingerprint to show. - guard !headerState.isGroupConversation else { return } - appChromeModel.showFingerprint(for: headerState.headerPeerID) - }) { - HStack(spacing: 6) { - if headerState.isGroupConversation { - Image(systemName: "person.3.fill") - .font(.bitchatSystem(size: 14)) - .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.group_chat", comment: "Accessibility label for the group chat indicator")) - } else { - switch headerState.availability { - case .bluetoothConnected: - Image(systemName: "dot.radiowaves.left.and.right") - .font(.bitchatSystem(size: 14)) - .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) - case .meshReachable: - Image(systemName: "point.3.filled.connected.trianglepath.dotted") - .font(.bitchatSystem(size: 14)) - .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) - case .nostrAvailable: - Image(systemName: "globe") - .font(.bitchatSystem(size: 14)) - .foregroundColor(.purple) - .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) - case .offline: - // Slashed variant of the connected glyph — offline as - // the negation of connected, no text label (a leading - // one read as part of the name: "sin conexión bob"). - // VoiceOver still says it. - Image(systemName: "antenna.radiowaves.left.and.right.slash") - .font(.bitchatSystem(size: 14)) - .foregroundColor(palette.secondary) - .accessibilityLabel(String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")) - } - } - - Text(headerState.displayName) - .bitchatFont(size: 16, weight: .medium) - .foregroundColor(palette.primary) - // Middle truncation keeps the identity suffix visible on - // long nicknames instead of wrapping into the fixed-height - // header. - .lineLimit(1) - .truncationMode(.middle) - - if let encryptionStatus = headerState.encryptionStatus, - let icon = encryptionStatus.icon { - Image(systemName: icon) - .font(.bitchatSystem(size: 14)) - // Optical centering: the lock glyphs' ink is bottom-heavy - // (solid body, thin shackle), so geometric centering reads - // ~1pt low next to the name. The seal badge is symmetric - // and needs no lift. - .offset(y: icon.hasPrefix("lock") ? -1 : 0) - .foregroundColor( - encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured - ? palette.primary - : Color.red - ) - .accessibilityLabel( - String( - format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"), - locale: .current, - encryptionStatus.accessibilityDescription - ) - ) - } - - } - } - .buttonStyle(.plain) - .accessibilityLabel( - String( - format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"), - locale: .current, - headerState.displayName - ) - ) - .accessibilityHint( - headerState.isGroupConversation - ? "" - : String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") - ) - .frame(minHeight: headerHeight) - } -} diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index db7b0712..88b2f19b 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -9,69 +9,17 @@ import SwiftUI #if os(iOS) import UIKit -#elseif os(macOS) +#endif +#if os(macOS) import AppKit #endif +import UniformTypeIdentifiers +import BitLogger import BitFoundation -struct ContentRootModalPresentationState { - var isPeopleSheetPresented = false - var isAppInfoPresented = false - var isFingerprintPresented = false - var isLocationChannelsSheetPresented = false - var isNoticesSheetPresented = false - var isImagePreviewPresented = false - var isVerificationSheetPresented = false - var isVoiceAlertPresented = false - var isScreenshotPrivacyAlertPresented = false - var isMediaPickerPresented = false - - var hasPresentation: Bool { - isPeopleSheetPresented - || isAppInfoPresented - || isFingerprintPresented - || isLocationChannelsSheetPresented - || isNoticesSheetPresented - || isImagePreviewPresented - || isVerificationSheetPresented - || isVoiceAlertPresented - || isScreenshotPrivacyAlertPresented - || isMediaPickerPresented - } -} - -extension ContentRootModalPresentationState { - @MainActor - init( - appChromeModel: AppChromeModel, - isPeopleSheetPresented: Bool = false, - isImagePreviewPresented: Bool = false, - isVerificationSheetPresented: Bool = false, - isVoiceAlertPresented: Bool = false, - isMediaPickerPresented: Bool = false - ) { - self.init( - isPeopleSheetPresented: isPeopleSheetPresented, - isAppInfoPresented: appChromeModel.isAppInfoPresented, - isFingerprintPresented: - appChromeModel.showingFingerprintFor != nil, - isLocationChannelsSheetPresented: - appChromeModel.isLocationChannelsSheetPresented, - isNoticesSheetPresented: - appChromeModel.isNoticesSheetPresented, - isImagePreviewPresented: isImagePreviewPresented, - isVerificationSheetPresented: isVerificationSheetPresented, - isVoiceAlertPresented: isVoiceAlertPresented, - isScreenshotPrivacyAlertPresented: - appChromeModel.showScreenshotPrivacyWarning, - isMediaPickerPresented: isMediaPickerPresented - ) - } -} - /// On macOS 14+, disables the default system focus ring on TextFields. /// On earlier macOS versions and on iOS this is a no-op. -struct FocusEffectDisabledModifier: ViewModifier { +private struct FocusEffectDisabledModifier: ViewModifier { func body(content: Content) -> some View { #if os(macOS) if #available(macOS 14.0, *) { @@ -85,278 +33,185 @@ struct FocusEffectDisabledModifier: ViewModifier { } } -struct ContentView: View { - @EnvironmentObject private var appChromeModel: AppChromeModel - @EnvironmentObject private var privateConversationModel: PrivateConversationModel - @EnvironmentObject private var verificationModel: VerificationModel - @EnvironmentObject private var conversationUIModel: ConversationUIModel - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @EnvironmentObject private var sharedContentImportModel: SharedContentImportModel - @EnvironmentObject private var peerListModel: PeerListModel - @EnvironmentObject private var publicChatModel: PublicChatModel - @EnvironmentObject private var privateInboxModel: PrivateInboxModel +// MARK: - Main Content View +struct ContentView: View { + // MARK: - Properties + + @EnvironmentObject var viewModel: ChatViewModel @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() + @ObservedObject private var locationManager = LocationChannelManager.shared + @ObservedObject private var bookmarks = GeohashBookmarksStore.shared @State private var messageText = "" @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme - @Environment(\.appTheme) private var appTheme - @Environment(\.scenePhase) private var scenePhase + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var showSidebar = false + @State private var showAppInfo = false @State private var selectedMessageSender: String? @State private var selectedMessageSenderID: PeerID? @FocusState private var isNicknameFieldFocused: Bool - @State private var isAtBottomPublic = true - @State private var isAtBottomPrivate = true + @State private var isAtBottomPublic: Bool = true + @State private var isAtBottomPrivate: Bool = true @State private var autocompleteDebounceTimer: Timer? + @State private var showLocationChannelsSheet = false @State private var showVerifySheet = false - @State private var imagePreviewURL: URL? - #if os(iOS) + @State private var showLocationNotes = false + @State private var notesGeohash: String? = nil + @State private var imagePreviewURL: URL? = nil +#if os(iOS) @State private var showImagePicker = false @State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera - #else +#else @State private var showMacImagePicker = false - #endif +#endif @ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44 @ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11 @ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12 + // Timer-based refresh removed; use LocationChannelManager live updates instead + // Window sizes for rendering (infinite scroll up) @State private var windowCountPublic: Int = 300 @State private var windowCountPrivate: [PeerID: Int] = [:] - - @ThemedPalette private var palette - - private var selectedPrivatePeerID: PeerID? { - privateConversationModel.selectedPeerID + + // MARK: - Computed Properties + + private var backgroundColor: Color { + colorScheme == .dark ? Color.black : Color.white } - private var sharedContentDestination: SharedContentDestination { - SharedContentDestination.resolve( - selectedPrivatePeerID: selectedPrivatePeerID, - privateDisplayName: privateConversationModel.selectedHeaderState?.displayName, - activeChannel: locationChannelsModel.selectedChannel - ) + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } - private var usesGlassLayout: Bool { appTheme.usesGlassChrome } - - private var isPeopleSheetPresented: Bool { - showSidebar || selectedPrivatePeerID != nil + private var secondaryTextColor: Color { + colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) } - private func rootModalPresentationState( - includingVoiceAlert: Bool - ) -> ContentRootModalPresentationState { - #if os(iOS) - let isMediaPickerPresented = showImagePicker - #else - let isMediaPickerPresented = showMacImagePicker - #endif - - return ContentRootModalPresentationState( - appChromeModel: appChromeModel, - isPeopleSheetPresented: isPeopleSheetPresented, - isImagePreviewPresented: imagePreviewURL != nil, - isVerificationSheetPresented: showVerifySheet, - isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert, - isMediaPickerPresented: isMediaPickerPresented - ) + private var headerLineLimit: Int? { + dynamicTypeSize.isAccessibilitySize ? 2 : 1 } - private var hasRootModalPresentation: Bool { - rootModalPresentationState(includingVoiceAlert: true).hasPresentation + private var peopleSheetTitle: String { + String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased() } - /// The voice alert cannot defer to itself: its own binding must keep - /// reporting `true` while it is the presented modal. - private var hasRootModalPresentationBesidesVoiceAlert: Bool { - rootModalPresentationState(includingVoiceAlert: false).hasPresentation + private var peopleSheetSubtitle: String? { + switch locationManager.selectedChannel { + case .mesh: + return "#mesh" + case .location(let channel): + return "#\(channel.geohash.lowercased())" + } } - private var rootBluetoothAlertBinding: Binding { - Binding( - get: { - scenePhase == .active - && appChromeModel.showBluetoothAlert - && !hasRootModalPresentation - }, - set: { isPresented in - guard !isPresented, - scenePhase == .active, - !hasRootModalPresentation else { - return - } - // SwiftUI can invoke this setter inside a view update (the - // alert dismisses when a scenePhase change re-evaluates the - // `get`); publishing synchronously there is undefined - // behavior, so defer the write one hop. - Task { @MainActor in - appChromeModel.showBluetoothAlert = false - } - } - ) + private var peopleSheetActiveCount: Int { + switch locationManager.selectedChannel { + case .mesh: + return viewModel.allPeers.filter { $0.peerID != viewModel.meshService.myPeerID }.count + case .location: + return viewModel.visibleGeohashPeople().count + } + } + + + private struct PrivateHeaderContext { + let headerPeerID: PeerID + let peer: BitchatPeer? + let displayName: String + let isNostrAvailable: Bool } - /// Voice recording errors can surface while the people/DM sheet is up - /// (recording happens inside the sheet). Presenting the root alert then - /// would force-dismiss the sheet, so the root copy defers to any other - /// root modal; the sheet presents its own copy. Mirrors the Bluetooth - /// alert treatment above. - private var rootVoiceAlertBinding: Binding { - Binding( - get: { - scenePhase == .active - && voiceRecordingVM.showAlert - && !hasRootModalPresentationBesidesVoiceAlert - }, - set: { isPresented in - guard !isPresented, - scenePhase == .active, - !hasRootModalPresentationBesidesVoiceAlert else { - return - } - // Same deferral as the Bluetooth alert above: the setter can - // run inside a view update when the sheet state changes. - Task { @MainActor in - voiceRecordingVM.showAlert = false - } - } - ) - } +// MARK: - Body var body: some View { - mainContent - .onAppear { - conversationUIModel.setCurrentColorScheme(colorScheme) - conversationUIModel.setCurrentTheme(appTheme) - voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in - conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession() + VStack(spacing: 0) { + mainHeaderView + .onAppear { + viewModel.currentColorScheme = colorScheme + #if os(macOS) + // Focus message input on macOS launch, not nickname field + DispatchQueue.main.async { + isNicknameFieldFocused = false + isTextFieldFocused = true + } + #endif } - appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in - voiceRecordingVM?.panicWipe() + .onChange(of: colorScheme) { newValue in + viewModel.currentColorScheme = newValue } - #if os(macOS) - DispatchQueue.main.async { - isNicknameFieldFocused = false - isTextFieldFocused = true + + Divider() + + GeometryReader { geometry in + VStack(spacing: 0) { + MessageListView( + privatePeer: nil, + isAtBottom: $isAtBottomPublic, + messageText: $messageText, + selectedMessageSender: $selectedMessageSender, + selectedMessageSenderID: $selectedMessageSenderID, + imagePreviewURL: $imagePreviewURL, + windowCountPublic: $windowCountPublic, + windowCountPrivate: $windowCountPrivate, + showSidebar: $showSidebar, + isTextFieldFocused: $isTextFieldFocused, + ) + .background(backgroundColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - #endif - sharedContentImportModel.updateDestination(sharedContentDestination) + .frame(width: geometry.size.width, height: geometry.size.height) } - .onChange(of: colorScheme) { newValue in - conversationUIModel.setCurrentColorScheme(newValue) + + Divider() + + if viewModel.selectedPrivateChatPeer == nil { + inputView } - .onChange(of: appTheme) { newValue in - conversationUIModel.setCurrentTheme(newValue) - } - .background(ThemedRootBackground()) - .foregroundColor(palette.primary) + } + .background(backgroundColor) + .foregroundColor(textColor) #if os(macOS) .frame(minWidth: 600, minHeight: 400) #endif - .onChange(of: selectedPrivatePeerID) { newValue in + .onChange(of: viewModel.selectedPrivateChatPeer) { newValue in if newValue != nil { showSidebar = true } - sharedContentImportModel.updateDestination(sharedContentDestination) - } - .onChange(of: locationChannelsModel.selectedChannel) { _ in - sharedContentImportModel.updateDestination(sharedContentDestination) } .sheet( isPresented: Binding( - get: { isPeopleSheetPresented }, + get: { showSidebar || viewModel.selectedPrivateChatPeer != nil }, set: { isPresented in if !isPresented { showSidebar = false - // Scene/background and alert-presentation - // reconciliation (Bluetooth-off, recording errors) - // are not user requests to leave the conversation. - // Keep the selected DM so the sheet remains live - // when the app returns from Settings. - if scenePhase == .active, - !appChromeModel.showBluetoothAlert, - !voiceRecordingVM.showAlert { - privateConversationModel.endConversation() - } + viewModel.endPrivateChat() } } ) ) { - #if os(iOS) - ContentPeopleSheetView( - showSidebar: $showSidebar, - messageText: $messageText, - selectedMessageSender: $selectedMessageSender, - selectedMessageSenderID: $selectedMessageSenderID, - imagePreviewURL: $imagePreviewURL, - windowCountPublic: $windowCountPublic, - windowCountPrivate: $windowCountPrivate, - isAtBottomPrivate: $isAtBottomPrivate, - isTextFieldFocused: $isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - headerHeight: headerHeight, - onSendMessage: sendMessage, - showImagePicker: $showImagePicker, - imagePickerSourceType: $imagePickerSourceType - ) - // Sheets + NavigationStack can drop inherited EnvironmentObjects on - // some iOS versions (#1558). Re-inject every model the sheet tree - // reads so ContentPeopleListView / MessageListView never crash. - .environmentObject(appChromeModel) - .environmentObject(privateConversationModel) - .environmentObject(verificationModel) - .environmentObject(conversationUIModel) - .environmentObject(locationChannelsModel) - .environmentObject(peerListModel) - .environmentObject(publicChatModel) - .environmentObject(privateInboxModel) - #else - ContentPeopleSheetView( - showSidebar: $showSidebar, - messageText: $messageText, - selectedMessageSender: $selectedMessageSender, - selectedMessageSenderID: $selectedMessageSenderID, - imagePreviewURL: $imagePreviewURL, - windowCountPublic: $windowCountPublic, - windowCountPrivate: $windowCountPrivate, - isAtBottomPrivate: $isAtBottomPrivate, - isTextFieldFocused: $isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - headerHeight: headerHeight, - onSendMessage: sendMessage, - showMacImagePicker: $showMacImagePicker - ) - .environmentObject(appChromeModel) - .environmentObject(privateConversationModel) - .environmentObject(verificationModel) - .environmentObject(conversationUIModel) - .environmentObject(locationChannelsModel) - .environmentObject(peerListModel) - .environmentObject(publicChatModel) - .environmentObject(privateInboxModel) - #endif + peopleSheetView + .environmentObject(viewModel) } - .sheet(isPresented: $appChromeModel.isAppInfoPresented) { - AppInfoView( - topologyProvider: { appChromeModel.meshTopologyDisplayModel() }, - onPanicWipe: { appChromeModel.panicClearAllData() } - ) - .environmentObject(locationChannelsModel) + .sheet(isPresented: $showAppInfo) { + AppInfoView() + .environmentObject(viewModel) + .onAppear { viewModel.isAppInfoPresented = true } + .onDisappear { viewModel.isAppInfoPresented = false } } .sheet(isPresented: Binding( - get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil }, - set: { _ in appChromeModel.clearFingerprint() } + get: { viewModel.showingFingerprintFor != nil && !showSidebar && viewModel.selectedPrivateChatPeer == nil }, + set: { _ in viewModel.showingFingerprintFor = nil } )) { - if let peerID = appChromeModel.showingFingerprintFor { - FingerprintView(peerID: peerID) - .environmentObject(verificationModel) + if let peerID = viewModel.showingFingerprintFor { + FingerprintView(viewModel: viewModel, peerID: peerID) + .environmentObject(viewModel) } } - #if os(iOS) +#if os(iOS) + // Only present image picker from main view when NOT in a sheet .fullScreenCover(isPresented: Binding( - get: { showImagePicker && !showSidebar && selectedPrivatePeerID == nil }, + get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil }, set: { newValue in if !newValue { showImagePicker = false @@ -365,14 +220,16 @@ struct ContentView: View { )) { ImagePickerView(sourceType: imagePickerSourceType) { image in showImagePicker = false - conversationUIModel.processSelectedImage(image) + viewModel.processThenSendImage(image) } + .environmentObject(viewModel) .ignoresSafeArea() } - #endif - #if os(macOS) +#endif +#if os(macOS) + // Only present Mac image picker from main view when NOT in a sheet .sheet(isPresented: Binding( - get: { showMacImagePicker && !showSidebar && selectedPrivatePeerID == nil }, + get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil }, set: { newValue in if !newValue { showMacImagePicker = false @@ -381,23 +238,21 @@ struct ContentView: View { )) { MacImagePickerView { url in showMacImagePicker = false - conversationUIModel.processSelectedImage(from: url) + viewModel.processThenSendImage(from: url) } + .environmentObject(viewModel) } - #endif +#endif .sheet(isPresented: Binding( get: { imagePreviewURL != nil }, - set: { presenting in - if !presenting { - imagePreviewURL = nil - } - } + set: { presenting in if !presenting { imagePreviewURL = nil } } )) { if let url = imagePreviewURL { ImagePreviewView(url: url) + .environmentObject(viewModel) } } - .alert(Text(String(localized: "voice.error.title", defaultValue: "recording error", comment: "Title of the voice recording error alert")), isPresented: rootVoiceAlertBinding, actions: { + .alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: { Button("common.ok", role: .cancel) {} if voiceRecordingVM.state == .permissionDenied { Button("location_channels.action.open_settings") { @@ -407,179 +262,964 @@ struct ContentView: View { }, message: { Text(voiceRecordingVM.state.alertMessage) }) - .alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) { + .alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) { Button("content.alert.bluetooth_required.settings") { - // Powered-off needs the radio controls, not the privacy pane. - (appChromeModel.bluetoothState == .poweredOff - ? SystemSettings.bluetoothPower - : SystemSettings.bluetooth).open() + SystemSettings.bluetooth.open() } Button("common.ok", role: .cancel) {} } message: { - Text(appChromeModel.bluetoothAlertMessage) - } - .alert( - String(localized: "share_import.review.title", comment: "Title for reviewing content received from the share extension"), - isPresented: Binding( - get: { sharedContentImportModel.offer != nil }, - set: { _ in } - ), - presenting: sharedContentImportModel.offer - ) { _ in - Button("common.cancel", role: .cancel) { - sharedContentImportModel.cancel(destination: sharedContentDestination) - } - Button("share_import.review.use_in_composer") { - guard let importedText = sharedContentImportModel.confirm( - destination: sharedContentDestination - ) else { return } - // Replacing is deliberate and called out in the prompt. It - // avoids combining a stale draft from another conversation - // with newly shared content. - messageText = importedText - isTextFieldFocused = true - } - } message: { offer in - let format = String( - localized: "share_import.review.message", - comment: "Explains that shared content will replace the named destination's composer and will not be sent automatically" - ) - Text(String(format: format, offer.destination.displayName) + "\n\n" + offer.payload.preview) + Text(viewModel.bluetoothAlertMessage) } .onDisappear { autocompleteDebounceTimer?.invalidate() - appChromeModel.setPanicPreparation(nil) } } + + // MARK: - Input View - /// Matrix: classic opaque bars with dividers. Glass: full-bleed message - /// list scrolling underneath floating chrome panels (safe-area insets), - /// so the translucency gains usable space instead of losing it. @ViewBuilder - private var mainContent: some View { - if usesGlassLayout { - publicMessageList - .safeAreaInset(edge: .top, spacing: 0) { - headerView - } - .safeAreaInset(edge: .bottom, spacing: 0) { - if selectedPrivatePeerID == nil { - composerView + private var inputView: some View { + VStack(alignment: .leading, spacing: 6) { + // @mentions autocomplete + if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(viewModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in + Button(action: { + _ = viewModel.completeNickname(suggestion, in: &messageText) + }) { + HStack { + Text(suggestion) + .font(.bitchatSystem(size: 11, design: .monospaced)) + .foregroundColor(textColor) + .fontWeight(.medium) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .background(Color.gray.opacity(0.1)) } } - } else { - VStack(spacing: 0) { - headerView - - Divider() - - GeometryReader { geometry in - VStack(spacing: 0) { - publicMessageList - .background(palette.background) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(width: geometry.size.width, height: geometry.size.height) - } - - Divider() - - if selectedPrivatePeerID == nil { - composerView - } + .background(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) + ) + .padding(.horizontal, 12) } - } - } - private var headerView: some View { - VStack(spacing: 0) { - ContentHeaderView( - showSidebar: $showSidebar, - showVerifySheet: $showVerifySheet, - isNicknameFieldFocused: $isNicknameFieldFocused, - headerHeight: headerHeight, - headerPeerIconSize: headerPeerIconSize, - headerPeerCountFontSize: headerPeerCountFontSize + CommandSuggestionsView( + messageText: $messageText, + textColor: textColor, + backgroundColor: backgroundColor, + secondaryTextColor: secondaryTextColor ) - // Failed-wipe outranks connectivity: "your data may still be - // here" matters more than "the radio is off". - if appChromeModel.panicWipeBlocked { - PanicWipeBlockedBanner() + if voiceRecordingVM.state.isActive { + recordingIndicator } - if let issue = ConnectivityIssue.resolve( - bluetoothState: appChromeModel.bluetoothState, - torBlocked: appChromeModel.torBlocked - ) { - ConnectivityStatusBanner(issue: issue) - } - } - // Hosted here rather than on the logo Text so the pending dialog - // survives whatever happens to the header chrome. - .confirmationDialog( - Text( - String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe") - ), - isPresented: $appChromeModel.showPanicConfirmation, - titleVisibility: .visible - ) { - Button(role: .destructive) { - appChromeModel.panicClearAllData() - } label: { - Text( - String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe") + HStack(alignment: .center, spacing: 4) { + TextField( + "", + text: $messageText, + prompt: Text( + String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer") + ) + .foregroundColor(secondaryTextColor.opacity(0.6)) ) + .textFieldStyle(.plain) + .font(.bitchatSystem(size: 15, design: .monospaced)) + .foregroundColor(textColor) + .focused($isTextFieldFocused) + .autocorrectionDisabled(true) +#if os(iOS) + .textInputAutocapitalization(.sentences) +#endif + .submitLabel(.send) + .onSubmit { sendMessage() } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7)) + ) + .modifier(FocusEffectDisabledModifier()) + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: messageText) { newValue in + autocompleteDebounceTimer?.invalidate() + autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak viewModel] _ in + let cursorPosition = newValue.count + Task { @MainActor in + viewModel?.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) + } + } + } + + HStack(alignment: .center, spacing: 4) { + if shouldShowMediaControls { + attachmentButton + } + + sendOrMicButton + } } - Button("common.cancel", role: .cancel) {} } + .padding(.horizontal, 6) + .padding(.top, 6) + .padding(.bottom, 8) + .background(backgroundColor.opacity(0.95)) } - private var publicMessageList: some View { - MessageListView( - privatePeer: nil, - isAtBottom: $isAtBottomPublic, - messageText: $messageText, - selectedMessageSender: $selectedMessageSender, - selectedMessageSenderID: $selectedMessageSenderID, - imagePreviewURL: $imagePreviewURL, - windowCountPublic: $windowCountPublic, - windowCountPrivate: $windowCountPrivate, - showSidebar: $showSidebar, - isTextFieldFocused: $isTextFieldFocused - ) - } - - private var composerView: some View { - #if os(iOS) - ContentComposerView( - messageText: $messageText, - isTextFieldFocused: $isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - onSendMessage: sendMessage, - showImagePicker: $showImagePicker, - imagePickerSourceType: $imagePickerSourceType - ) - #else - ContentComposerView( - messageText: $messageText, - isTextFieldFocused: $isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - onSendMessage: sendMessage, - showMacImagePicker: $showMacImagePicker - ) - #endif - } - + // MARK: - Actions + private func sendMessage() { guard let trimmed = messageText.trimmedOrNilIfEmpty else { return } + // Clear input immediately for instant feedback messageText = "" + // Defer actual send to next runloop to avoid blocking DispatchQueue.main.async { - self.conversationUIModel.sendMessage(trimmed) + self.viewModel.sendMessage(trimmed) } } + + // MARK: - Sheet Content + + private var peopleSheetView: some View { + NavigationStack { + Group { + if viewModel.selectedPrivateChatPeer != nil { + privateChatSheetView + } else { + peopleListSheetView + } + } + .navigationDestination(isPresented: Binding( + get: { viewModel.showingFingerprintFor != nil && (showSidebar || viewModel.selectedPrivateChatPeer != nil) }, + set: { isPresented in + if !isPresented { + viewModel.showingFingerprintFor = nil + } + } + )) { + if let peerID = viewModel.showingFingerprintFor { + FingerprintView(viewModel: viewModel, peerID: peerID) + .environmentObject(viewModel) + } + } + } + .background(backgroundColor) + .foregroundColor(textColor) + #if os(macOS) + .frame(minWidth: 420, minHeight: 520) + #endif + // Present image picker from sheet context when IN a sheet (parent-child pattern) + #if os(iOS) + .fullScreenCover(isPresented: Binding( + get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) }, + set: { newValue in + if !newValue { + showImagePicker = false + } + } + )) { + ImagePickerView(sourceType: imagePickerSourceType) { image in + showImagePicker = false + viewModel.processThenSendImage(image) + } + .environmentObject(viewModel) + .ignoresSafeArea() + } + #endif + #if os(macOS) + .sheet(isPresented: $showMacImagePicker) { + MacImagePickerView { url in + showMacImagePicker = false + viewModel.processThenSendImage(from: url) + } + .environmentObject(viewModel) + } + #endif + } + + // MARK: - People Sheet Views + + private var peopleListSheetView: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text(peopleSheetTitle) + .font(.bitchatSystem(size: 18, design: .monospaced)) + .foregroundColor(textColor) + Spacer() + if case .mesh = locationManager.selectedChannel { + Button(action: { showVerifySheet = true }) { + Image(systemName: "qrcode") + .font(.bitchatSystem(size: 14)) + } + .buttonStyle(.plain) + .help( + String(localized: "content.help.verification", comment: "Help text for verification button") + ) + } + Button(action: { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + dismiss() + showSidebar = false + showVerifySheet = false + viewModel.endPrivateChat() + } + }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + } + let activeText = String.localizedStringWithFormat( + String(localized: "%@ active", comment: "Count of active users in the people sheet"), + "\(peopleSheetActiveCount)" + ) + + if let subtitle = peopleSheetSubtitle { + let subtitleColor: Color = { + switch locationManager.selectedChannel { + case .mesh: + return Color.blue + case .location: + return Color.green + } + }() + HStack(spacing: 6) { + Text(subtitle) + .foregroundColor(subtitleColor) + Text(activeText) + .foregroundColor(.secondary) + } + .font(.bitchatSystem(size: 12, design: .monospaced)) + } else { + Text(activeText) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + .background(backgroundColor) + + ScrollView { + VStack(alignment: .leading, spacing: 6) { + if case .location = locationManager.selectedChannel { + GeohashPeopleList( + viewModel: viewModel, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onTapPerson: { + showSidebar = true + } + ) + } else { + MeshPeerList( + viewModel: viewModel, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onTapPeer: { peerID in + viewModel.startPrivateChat(with: peerID) + showSidebar = true + }, + onToggleFavorite: { peerID in + viewModel.toggleFavorite(peerID: peerID) + }, + onShowFingerprint: { peerID in + viewModel.showFingerprint(for: peerID) + } + ) + } + } + .padding(.top, 4) + .id(viewModel.allPeers.map { "\($0.peerID)-\($0.isConnected)" }.joined()) + } + } + } + + // MARK: - View Components + + private var privateChatSheetView: some View { + VStack(spacing: 0) { + if let privatePeerID = viewModel.selectedPrivateChatPeer { + let headerContext = makePrivateHeaderContext(for: privatePeerID) + + HStack(spacing: 12) { + Button(action: { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + viewModel.endPrivateChat() + } + }) { + Image(systemName: "chevron.left") + .font(.bitchatSystem(size: 12)) + .foregroundColor(textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat") + ) + + Spacer(minLength: 0) + + HStack(spacing: 8) { + privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID) + let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID) + + if !privatePeerID.isGeoDM { + Button(action: { + viewModel.toggleFavorite(peerID: headerContext.headerPeerID) + }) { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.bitchatSystem(size: 14)) + .foregroundColor(isFavorite ? Color.yellow : textColor) + } + .buttonStyle(.plain) + .accessibilityLabel( + isFavorite + ? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") + : String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") + ) + } + } + .frame(maxWidth: .infinity) + + Spacer(minLength: 0) + + Button(action: { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + viewModel.endPrivateChat() + showSidebar = true + } + }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .frame(width: 32, height: 32) + } + + .buttonStyle(.plain) + .accessibilityLabel("Close") + } + .frame(height: headerHeight) + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 12) + .background(backgroundColor) + } + + MessageListView( + privatePeer: viewModel.selectedPrivateChatPeer, + isAtBottom: $isAtBottomPrivate, + messageText: $messageText, + selectedMessageSender: $selectedMessageSender, + selectedMessageSenderID: $selectedMessageSenderID, + imagePreviewURL: $imagePreviewURL, + windowCountPublic: $windowCountPublic, + windowCountPrivate: $windowCountPrivate, + showSidebar: $showSidebar, + isTextFieldFocused: $isTextFieldFocused, + ) + .background(backgroundColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + inputView + } + .background(backgroundColor) + .foregroundColor(textColor) + .highPriorityGesture( + DragGesture(minimumDistance: 25, coordinateSpace: .local) + .onEnded { value in + let horizontal = value.translation.width + let vertical = abs(value.translation.height) + guard horizontal > 80, vertical < 60 else { return } + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + showSidebar = true + viewModel.endPrivateChat() + } + } + ) + } + + private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View { + Button(action: { + viewModel.showFingerprint(for: context.headerPeerID) + }) { + HStack(spacing: 6) { + if let connectionState = context.peer?.connectionState { + switch connectionState { + case .bluetoothConnected: + Image(systemName: "dot.radiowaves.left.and.right") + .font(.bitchatSystem(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) + case .meshReachable: + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.bitchatSystem(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) + case .nostrAvailable: + Image(systemName: "globe") + .font(.bitchatSystem(size: 14)) + .foregroundColor(.purple) + .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) + case .offline: + EmptyView() + } + } else if viewModel.meshService.isPeerReachable(context.headerPeerID) { + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.bitchatSystem(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) + } else if context.isNostrAvailable { + Image(systemName: "globe") + .font(.bitchatSystem(size: 14)) + .foregroundColor(.purple) + .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) + } else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.bitchatSystem(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) + } + + Text(context.displayName) + .font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced)) + .foregroundColor(textColor) + + if !privatePeerID.isGeoDM { + let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID) + let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) + if let icon = encryptionStatus.icon { + Image(systemName: icon) + .font(.bitchatSystem(size: 14)) + .foregroundColor(encryptionStatus == .noiseVerified ? textColor : + encryptionStatus == .noiseSecured ? textColor : + Color.red) + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"), + locale: .current, + encryptionStatus.accessibilityDescription + ) + ) + } + } + } + } + .buttonStyle(.plain) + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"), + locale: .current, + context.displayName + ) + ) + .accessibilityHint( + String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") + ) + .frame(height: headerHeight) + } + + private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext { + let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID) + let peer = viewModel.getPeer(byID: headerPeerID) + + let displayName: String = { + if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel { + let disp = viewModel.geohashDisplayName(for: privatePeerID) + return "#\(ch.geohash)/@\(disp)" + } + if let name = peer?.displayName { return name } + if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name } + if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()), + !fav.peerNickname.isEmpty { return fav.peerNickname } + if headerPeerID.id.count == 16 { + let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) + if let id = candidates.first, + let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) { + if let pet = social.localPetname, !pet.isEmpty { return pet } + if !social.claimedNickname.isEmpty { return social.claimedNickname } + } + } else if let keyData = headerPeerID.noiseKey { + let fp = keyData.sha256Fingerprint() + if let social = viewModel.identityManager.getSocialIdentity(for: fp) { + if let pet = social.localPetname, !pet.isEmpty { return pet } + if !social.claimedNickname.isEmpty { return social.claimedNickname } + } + } + return String(localized: "common.unknown", comment: "Fallback label for unknown peer") + }() + + let isNostrAvailable: Bool = { + guard let connectionState = peer?.connectionState else { + if let noiseKey = Data(hexString: headerPeerID.id), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + favoriteStatus.isMutual { + return true + } + return false + } + return connectionState == .nostrAvailable + }() + + return PrivateHeaderContext( + headerPeerID: headerPeerID, + peer: peer, + displayName: displayName, + isNostrAvailable: isNostrAvailable + ) + } + + // Compute channel-aware people count and color for toolbar (cross-platform) + private func channelPeopleCountAndColor() -> (Int, Color) { + switch locationManager.selectedChannel { + case .location: + let n = viewModel.geohashPeople.count + let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) + return (n, n > 0 ? standardGreen : Color.secondary) + case .mesh: + let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in + guard peer.peerID != viewModel.meshService.myPeerID else { return } + if peer.isConnected { counts.mesh += 1; counts.others += 1 } + else if peer.isReachable { counts.others += 1 } + } + let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) + let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary + return (counts.others, color) + } + } + + + private var mainHeaderView: some View { + HStack(spacing: 0) { + Text(verbatim: "bitchat/") + .font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced)) + .foregroundColor(textColor) + .onTapGesture(count: 3) { + // PANIC: Triple-tap to clear all data + viewModel.panicClearAllData() + } + .onTapGesture(count: 1) { + // Single tap for app info + showAppInfo = true + } + + HStack(spacing: 0) { + Text(verbatim: "@") + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + + TextField("content.input.nickname_placeholder", text: $viewModel.nickname) + .textFieldStyle(.plain) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .frame(maxWidth: 80) + .foregroundColor(textColor) + .focused($isNicknameFieldFocused) + .autocorrectionDisabled(true) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .modifier(FocusEffectDisabledModifier()) + .onChange(of: isNicknameFieldFocused) { isFocused in + if !isFocused { + // Only validate when losing focus + viewModel.validateAndSaveNickname() + } + } + .onSubmit { + viewModel.validateAndSaveNickname() + } + } + + Spacer() + + // Channel badge + dynamic spacing + people counter + // Precompute header count and color outside the ViewBuilder expressions + let cc = channelPeopleCountAndColor() + let headerCountColor: Color = cc.1 + let headerOtherPeersCount: Int = { + if case .location = locationManager.selectedChannel { + return viewModel.visibleGeohashPeople().count + } + return cc.0 + }() + + HStack(spacing: 10) { + // Unread icon immediately to the left of the channel badge (independent from channel button) + + // Unread indicator (now shown on iOS and macOS) + if viewModel.hasAnyUnreadMessages { + Button(action: { viewModel.openMostRelevantPrivateChat() }) { + Image(systemName: "envelope.fill") + .font(.bitchatSystem(size: 12)) + .foregroundColor(Color.orange) + } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button") + ) + } + // Notes icon (mesh only and when location is authorized), to the left of #mesh + if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized { + Button(action: { + // Kick a one-shot refresh and show the sheet immediately. + LocationChannelManager.shared.enableLocationChannels() + LocationChannelManager.shared.refreshChannels() + // If we already have a block geohash, pass it; otherwise wait in the sheet. + notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash + showLocationNotes = true + }) { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "note.text") + .font(.bitchatSystem(size: 12)) + .foregroundColor(Color.orange.opacity(0.8)) + .padding(.top, 1) + } + .fixedSize(horizontal: true, vertical: false) + } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button") + ) + } + + // Bookmark toggle (geochats): to the left of #geohash + if case .location(let ch) = locationManager.selectedChannel { + Button(action: { bookmarks.toggle(ch.geohash) }) { + Image(systemName: bookmarks.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark") + .font(.bitchatSystem(size: 12)) + } + .buttonStyle(.plain) + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"), + locale: .current, + ch.geohash + ) + ) + } + + // Location channels button '#' + Button(action: { showLocationChannelsSheet = true }) { + let badgeText: String = { + switch locationManager.selectedChannel { + case .mesh: return "#mesh" + case .location(let ch): return "#\(ch.geohash)" + } + }() + let badgeColor: Color = { + switch locationManager.selectedChannel { + case .mesh: + return Color(hue: 0.60, saturation: 0.85, brightness: 0.82) + case .location: + return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + }() + Text(badgeText) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(badgeColor) + .lineLimit(headerLineLimit) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + .accessibilityLabel( + String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button") + ) + } + .buttonStyle(.plain) + .padding(.leading, 4) + .padding(.trailing, 2) + + HStack(spacing: 4) { + // People icon with count + Image(systemName: "person.2.fill") + .font(.system(size: headerPeerIconSize, weight: .regular)) + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"), + locale: .current, + headerOtherPeersCount + ) + ) + Text("\(headerOtherPeersCount)") + .font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced)) + .accessibilityHidden(true) + } + .foregroundColor(headerCountColor) + .padding(.leading, 2) + .lineLimit(headerLineLimit) + .fixedSize(horizontal: true, vertical: false) + + // QR moved to the PEOPLE header in the sidebar when on mesh channel + } + .layoutPriority(3) + .onTapGesture { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + showSidebar.toggle() + } + } + .sheet(isPresented: $showVerifySheet) { + VerificationSheetView(isPresented: $showVerifySheet) + .environmentObject(viewModel) + } + } + .frame(height: headerHeight) + .padding(.horizontal, 12) + .sheet(isPresented: $showLocationChannelsSheet) { + LocationChannelsSheet(isPresented: $showLocationChannelsSheet) + .environmentObject(viewModel) + .onAppear { viewModel.isLocationChannelsSheetPresented = true } + .onDisappear { viewModel.isLocationChannelsSheetPresented = false } + } + .sheet(isPresented: $showLocationNotes, onDismiss: { + notesGeohash = nil + }) { + Group { + if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { + LocationNotesView(geohash: gh) + .environmentObject(viewModel) + } else { + VStack(spacing: 12) { + HStack { + Text("content.notes.title") + .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) + Spacer() + Button(action: { showLocationNotes = false }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons")) + } + .frame(height: headerHeight) + .padding(.horizontal, 12) + .background(backgroundColor.opacity(0.95)) + Text("content.notes.location_unavailable") + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + Button("content.location.enable") { + LocationChannelManager.shared.enableLocationChannels() + LocationChannelManager.shared.refreshChannels() + } + .buttonStyle(.bordered) + Spacer() + } + .background(backgroundColor) + .foregroundColor(textColor) + // per-sheet global onChange added below + } + } + .onAppear { + // Ensure we are authorized and start live location updates (distance-filtered) + LocationChannelManager.shared.enableLocationChannels() + LocationChannelManager.shared.beginLiveRefresh() + } + .onDisappear { + LocationChannelManager.shared.endLiveRefresh() + } + .onChange(of: locationManager.availableChannels) { channels in + if let current = channels.first(where: { $0.level == .building })?.geohash, + notesGeohash != current { + notesGeohash = current + #if os(iOS) + // Light taptic when geohash changes while the sheet is open + let generator = UIImpactFeedbackGenerator(style: .light) + generator.prepare() + generator.impactOccurred() + #endif + } + } + } + .onAppear { + if case .mesh = locationManager.selectedChannel, + locationManager.permissionState == .authorized, + LocationChannelManager.shared.availableChannels.isEmpty { + LocationChannelManager.shared.refreshChannels() + } + } + .onChange(of: locationManager.selectedChannel) { _ in + if case .mesh = locationManager.selectedChannel, + locationManager.permissionState == .authorized, + LocationChannelManager.shared.availableChannels.isEmpty { + LocationChannelManager.shared.refreshChannels() + } + } + .onChange(of: locationManager.permissionState) { _ in + if case .mesh = locationManager.selectedChannel, + locationManager.permissionState == .authorized, + LocationChannelManager.shared.availableChannels.isEmpty { + LocationChannelManager.shared.refreshChannels() + } + } + .alert("content.alert.screenshot.title", isPresented: $viewModel.showScreenshotPrivacyWarning) { + Button("common.ok", role: .cancel) {} + } message: { + Text("content.alert.screenshot.message") + } + .background(backgroundColor.opacity(0.95)) + } + +} + +// MARK: - Helper Views + +private extension ContentView { + var recordingIndicator: some View { + HStack(spacing: 12) { + Image(systemName: "waveform.circle.fill") + .foregroundColor(.red) + .font(.bitchatSystem(size: 20)) + TimelineView(.periodic(from: .now, by: 0.05)) { context in + Text( + "recording \(voiceRecordingVM.formattedDuration(for: context.date))", + comment: "Voice note recording duration indicator" + ) + .font(.bitchatSystem(size: 13, design: .monospaced)) + .foregroundColor(.red) + } + Spacer() + Button(action: voiceRecordingVM.cancel) { + Label("Cancel", systemImage: "xmark.circle") + .labelStyle(.iconOnly) + .font(.bitchatSystem(size: 18)) + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.red.opacity(0.15)) + ) + } + + private var shouldShowMediaControls: Bool { + if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) { + return true + } + switch locationManager.selectedChannel { + case .mesh: + return true + case .location: + return false + } + } + + private var shouldShowVoiceControl: Bool { + if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) { + return true + } + switch locationManager.selectedChannel { + case .mesh: + return true + case .location: + return false + } + } + + private var composerAccentColor: Color { + viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor + } + + var attachmentButton: some View { + #if os(iOS) + Image(systemName: "camera.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(composerAccentColor) + .frame(width: 36, height: 36) + .contentShape(Circle()) + .onTapGesture { + // Tap = Photo Library + imagePickerSourceType = .photoLibrary + showImagePicker = true + } + .onLongPressGesture(minimumDuration: 0.3) { + // Long press = Camera + imagePickerSourceType = .camera + showImagePicker = true + } + .accessibilityLabel("Tap for library, long press for camera") + #else + Button(action: { showMacImagePicker = true }) { + Image(systemName: "photo.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(composerAccentColor) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .accessibilityLabel("Choose photo") + #endif + } + + @ViewBuilder + var sendOrMicButton: some View { + let hasText = !messageText.trimmed.isEmpty + if shouldShowVoiceControl { + ZStack { + micButtonView + .opacity(hasText ? 0 : 1) + .allowsHitTesting(!hasText) + sendButtonView(enabled: hasText) + .opacity(hasText ? 1 : 0) + .allowsHitTesting(hasText) + } + .frame(width: 36, height: 36) + } else { + sendButtonView(enabled: hasText) + .frame(width: 36, height: 36) + } + } + + private var micButtonView: some View { + Image(systemName: "mic.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor) + .frame(width: 36, height: 36) + .contentShape(Circle()) + .overlay( + Color.clear + .contentShape(Circle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in voiceRecordingVM.start(shouldShow: shouldShowVoiceControl) } + .onEnded { _ in voiceRecordingVM.finish(completion: viewModel.sendVoiceNote) } + ) + ) + .accessibilityLabel("Hold to record a voice note") + } + + private func sendButtonView(enabled: Bool) -> some View { + let activeColor = composerAccentColor + return Button(action: sendMessage) { + Image(systemName: "arrow.up.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(enabled ? activeColor : Color.gray) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .disabled(!enabled) + .accessibilityLabel( + String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button") + ) + .accessibilityHint( + enabled + ? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message") + : String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message") + ) + } } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index bce7e89c..9c764e9e 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -10,14 +10,18 @@ import SwiftUI import BitFoundation struct FingerprintView: View { - @EnvironmentObject private var verificationModel: VerificationModel + @ObservedObject var viewModel: ChatViewModel let peerID: PeerID @Environment(\.dismiss) var dismiss - @ThemedPalette private var palette - @State private var aliasDraft: String = "" - @State private var didLoadAlias = false - - private var textColor: Color { palette.primary } + @Environment(\.colorScheme) var colorScheme + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + + private var backgroundColor: Color { + colorScheme == .dark ? Color.black : Color.white + } private enum Strings { static let title: LocalizedStringKey = "fingerprint.title" @@ -28,21 +32,6 @@ struct FingerprintView: View { static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified" static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified" static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified" - static let localAlias = String( - localized: "fingerprint.local_alias.label", - defaultValue: "local alias", - comment: "Label for the local-only alias field on the fingerprint sheet" - ) - static let localAliasPlaceholder = String( - localized: "fingerprint.local_alias.placeholder", - defaultValue: "name for this person", - comment: "Placeholder for the local alias field on the fingerprint sheet" - ) - static let localAliasHint = String( - localized: "fingerprint.local_alias.hint", - defaultValue: "only on this device. leave blank to use their claimed nickname.", - comment: "Explanation under the local alias field" - ) static func verifyHint(_ nickname: String) -> String { String( format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"), @@ -52,93 +41,88 @@ struct FingerprintView: View { } static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified" static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification" - static let vouchedBadge: LocalizedStringKey = "fingerprint.badge.vouched" - static func vouchedBy(_ count: Int) -> String { - String( - format: String(localized: "fingerprint.message.vouched_by", comment: "How many people the user verified have vouched for this peer"), - locale: .current, - count - ) + static func unknownPeer() -> String { + String(localized: "common.unknown", comment: "Label for an unknown peer") } } var body: some View { - let fingerprintState = verificationModel.fingerprintPresentation(for: peerID) - VStack(spacing: 20) { // Header HStack { Text(Strings.title) - .bitchatFont(size: 16, weight: .bold) + .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .foregroundColor(textColor) Spacer() - SheetCloseButton { dismiss() } - .foregroundColor(textColor) + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 14, weight: .semibold)) + } + .foregroundColor(textColor) } .padding() VStack(alignment: .leading, spacing: 16) { + // Prefer short mesh ID for session/encryption status + let statusPeerID = viewModel.getShortIDForNoiseKey(peerID) + // Resolve a friendly name + let peerNickname: String = { + if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName } + if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name } + if let data = peerID.noiseKey { + if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } + let fp = data.sha256Fingerprint() + if let social = viewModel.identityManager.getSocialIdentity(for: fp) { + if let pet = social.localPetname, !pet.isEmpty { return pet } + if !social.claimedNickname.isEmpty { return social.claimedNickname } + } + } + return Strings.unknownPeer() + }() + // Accurate encryption state based on short ID session + let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) + HStack { - if let icon = fingerprintState.encryptionStatus.icon { + if let icon = encryptionStatus.icon { Image(systemName: icon) .font(.bitchatSystem(size: 20)) - .foregroundColor(fingerprintState.encryptionStatus == .noiseVerified ? Color.green : textColor) + .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor) } VStack(alignment: .leading, spacing: 4) { - Text(fingerprintState.peerNickname) - .bitchatFont(size: 18, weight: .semibold) + Text(peerNickname) + .font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced)) .foregroundColor(textColor) - Text(fingerprintState.encryptionStatus.description) - .bitchatFont(size: 12) + Text(encryptionStatus.description) + .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) } Spacer() } .padding() - .background(palette.secondary.opacity(0.1)) + .background(Color.gray.opacity(0.1)) .cornerRadius(8) - - if fingerprintState.canEditLocalAlias { - VStack(alignment: .leading, spacing: 8) { - Text(verbatim: Strings.localAlias) - .bitchatFont(size: 12, weight: .bold) - .foregroundColor(textColor.opacity(0.7)) - - TextField(Strings.localAliasPlaceholder, text: $aliasDraft) - .bitchatFont(size: 14) - .foregroundColor(textColor) - .padding(10) - .background(palette.secondary.opacity(0.1)) - .cornerRadius(8) - .onSubmit { commitAlias() } - - Text(verbatim: Strings.localAliasHint) - .bitchatFont(size: 11) - .foregroundColor(textColor.opacity(0.6)) - } - } // Their fingerprint VStack(alignment: .leading, spacing: 8) { Text(Strings.theirFingerprint) - .bitchatFont(size: 12, weight: .bold) + .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) - if let fingerprint = fingerprintState.theirFingerprint { + if let fingerprint = viewModel.getFingerprint(for: statusPeerID) { Text(formatFingerprint(fingerprint)) - .bitchatFont(size: 14) + .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(textColor) .multilineTextAlignment(.leading) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .padding() .frame(maxWidth: .infinity) - .background(palette.secondary.opacity(0.1)) + .background(Color.gray.opacity(0.1)) .cornerRadius(8) .contextMenu { Button(Strings.copy) { @@ -152,7 +136,7 @@ struct FingerprintView: View { } } else { Text(Strings.handshakePending) - .bitchatFont(size: 14) + .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(Color.orange) .padding() } @@ -161,95 +145,63 @@ struct FingerprintView: View { // My fingerprint VStack(alignment: .leading, spacing: 8) { Text(Strings.yourFingerprint) - .bitchatFont(size: 12, weight: .bold) + .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) - Text(formatFingerprint(fingerprintState.myFingerprint)) - .bitchatFont(size: 14) + let myFingerprint = viewModel.getMyFingerprint() + Text(formatFingerprint(myFingerprint)) + .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(textColor) .multilineTextAlignment(.leading) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .padding() .frame(maxWidth: .infinity) - .background(palette.secondary.opacity(0.1)) + .background(Color.gray.opacity(0.1)) .cornerRadius(8) .contextMenu { Button(Strings.copy) { #if os(iOS) - UIPasteboard.general.string = fingerprintState.myFingerprint + UIPasteboard.general.string = myFingerprint #else NSPasteboard.general.clearContents() - NSPasteboard.general.setString(fingerprintState.myFingerprint, forType: .string) + NSPasteboard.general.setString(myFingerprint, forType: .string) #endif } } } - // Vouched (transitively verified) status: shown whenever the - // peer isn't explicitly verified but people I verified vouch - // for them, independent of the current session state. - if fingerprintState.isVouched && !fingerprintState.isVerified { - VStack(spacing: 8) { - HStack(spacing: 6) { - Image(systemName: "checkmark.seal") - .font(.bitchatSystem(size: 14)) - .foregroundColor(.teal) - Text(Strings.vouchedBadge) - .bitchatFont(size: 14, weight: .bold) - .foregroundColor(.teal) - } - .frame(maxWidth: .infinity) - - Text(Strings.vouchedBy(fingerprintState.voucherCount)) - .bitchatFont(size: 12) - .foregroundColor(textColor.opacity(0.7)) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - - if !fingerprintState.voucherNames.isEmpty { - Text(fingerprintState.voucherNames.joined(separator: ", ")) - .bitchatFont(size: 12) - .foregroundColor(textColor.opacity(0.7)) - .multilineTextAlignment(.center) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity) - } - } - .padding(.top, 8) - .accessibilityElement(children: .combine) - } - // Verification status - if fingerprintState.canToggleVerification { + if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified { + let isVerified = encryptionStatus == .noiseVerified + VStack(spacing: 12) { - Text(fingerprintState.isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge) - .bitchatFont(size: 14, weight: .bold) - .foregroundColor(fingerprintState.isVerified ? Color.green : Color.orange) + Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge) + .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) + .foregroundColor(isVerified ? Color.green : Color.orange) .frame(maxWidth: .infinity) Group { - if fingerprintState.isVerified { + if isVerified { Text(Strings.verifiedMessage) } else { - Text(Strings.verifyHint(fingerprintState.peerNickname)) + Text(Strings.verifyHint(peerNickname)) } } - .bitchatFont(size: 12) + .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) .multilineTextAlignment(.center) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) - if !fingerprintState.isVerified { + if !isVerified { Button(action: { - verificationModel.verifyFingerprint(for: peerID) + viewModel.verifyFingerprint(for: peerID) dismiss() }) { Text(Strings.markVerified) - .bitchatFont(size: 14, weight: .bold) + .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 10) @@ -259,11 +211,11 @@ struct FingerprintView: View { .buttonStyle(PlainButtonStyle()) } else { Button(action: { - verificationModel.unverifyFingerprint(for: peerID) + viewModel.unverifyFingerprint(for: peerID) dismiss() }) { Text(Strings.removeVerification) - .bitchatFont(size: 14, weight: .bold) + .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 10) @@ -284,38 +236,7 @@ struct FingerprintView: View { } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) - .themedSheetBackground() - .onAppear { - syncAliasDraft(from: fingerprintState, force: true) - } - .onChange(of: fingerprintState.theirFingerprint) { _ in - // Fingerprint can arrive after the sheet opens; load (or reload) - // the saved alias then, otherwise an empty draft looks like a clear. - syncAliasDraft(from: fingerprintState, force: false) - } - .onDisappear { - commitAlias() - } - } - - /// Populate `aliasDraft` from the persisted petname once we know the - /// fingerprint. `force` reloads even if we already loaded (onAppear). - private func syncAliasDraft(from state: FingerprintPresentationState, force: Bool) { - guard state.canEditLocalAlias else { return } - if didLoadAlias && !force { return } - aliasDraft = state.localPetname ?? "" - didLoadAlias = true - } - - private func commitAlias() { - let fingerprintState = verificationModel.fingerprintPresentation(for: peerID) - guard fingerprintState.canEditLocalAlias else { return } - // Don't treat "never loaded a draft" as an intentional clear. - guard didLoadAlias else { return } - let current = fingerprintState.localPetname ?? "" - let draft = aliasDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard draft != current else { return } - verificationModel.setLocalPetname(draft.isEmpty ? nil : draft, for: peerID) + .background(backgroundColor) } private func formatFingerprint(_ fingerprint: String) -> String { diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 9613f227..dabdcc02 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -1,8 +1,9 @@ import SwiftUI struct GeohashPeopleList: View { - @EnvironmentObject private var peerListModel: PeerListModel - @ThemedPalette private var palette + @ObservedObject var viewModel: ChatViewModel + let textColor: Color + let secondaryTextColor: Color let onTapPerson: () -> Void @Environment(\.colorScheme) var colorScheme @State private var orderedIDs: [String] = [] @@ -13,35 +14,38 @@ struct GeohashPeopleList: View { static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels") static let unblock: LocalizedStringKey = "geohash_people.action.unblock" static let block: LocalizedStringKey = "geohash_people.action.block" - static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person") - static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person") - static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere") - static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area") - static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer") - static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list") - static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat") } var body: some View { - if peerListModel.geohashPeople.isEmpty { + if viewModel.visibleGeohashPeople().isEmpty { VStack(alignment: .leading, spacing: 0) { Text(Strings.noneNearby) - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) .padding(.horizontal) .padding(.top, 12) } } else { - let people = peerListModel.geohashPeople - let currentIDs = people.map(\.id) + let myHex: String? = { + if case .location(let ch) = LocationChannelManager.shared.selectedChannel, + let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) { + return id.publicKeyHex.lowercased() + } + return nil + }() + let people = viewModel.visibleGeohashPeople() + let currentIDs = people.map { $0.id } + + let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() }) + let isTeleportedID: (String) -> Bool = { id in + if teleportedSet.contains(id.lowercased()) { return true } + if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true } + return false + } let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } - let nonTele = displayIDs.filter { id in - !(people.first(where: { $0.id == id })?.isTeleported ?? false) - } - let tele = displayIDs.filter { id in - people.first(where: { $0.id == id })?.isTeleported ?? false - } + let nonTele = displayIDs.filter { !isTeleportedID($0) } + let tele = displayIDs.filter { isTeleportedID($0) } let finalOrder: [String] = nonTele + tele let firstID = finalOrder.first let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) }) @@ -50,41 +54,38 @@ struct GeohashPeopleList: View { ForEach(finalOrder.filter { personByID[$0] != nil }, id: \.self) { pid in let person = personByID[pid]! HStack(spacing: 4) { - let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse" - let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark) - let rowColor: Color = person.isMe ? .orange : assignedColor - Image(systemName: icon) - // Size 10 to match the mesh rows' leading glyphs — - // both lists share the sidebar. - .font(.bitchatSystem(size: 10)) - .foregroundColor(rowColor) - .help(person.isTeleported ? Strings.teleported : Strings.nearby) + let isMe = (person.id == myHex) + let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported) + let icon = teleported ? "face.dashed" : "mappin.and.ellipse" + let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark) + let rowColor: Color = isMe ? .orange : assignedColor + Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor) let (base, suffix) = person.displayName.splitSuffix() HStack(spacing: 0) { Text(base) - .bitchatFont(size: 14) - .fontWeight(person.isMe ? .bold : .regular) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .fontWeight(isMe ? .bold : .regular) .foregroundColor(rowColor) - .lineLimit(1) - .truncationMode(.tail) if !suffix.isEmpty { - let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) + let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) Text(suffix) - .bitchatFont(size: 14) + .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(suffixColor) } - if person.isMe { + if isMe { Text(Strings.youSuffix) - .bitchatFont(size: 14) + .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(rowColor) } } - if person.isBlocked { - Image(systemName: "nosign") - .font(.bitchatSystem(size: 10)) - .foregroundColor(.red) - .help(Strings.blockedTooltip) + if let me = myHex, person.id != me { + if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) { + Image(systemName: "nosign") + .font(.bitchatSystem(size: 10)) + .foregroundColor(.red) + .help(Strings.blockedTooltip) + } } Spacer() } @@ -93,50 +94,20 @@ struct GeohashPeopleList: View { .padding(.top, person.id == firstID ? 10 : 0) .contentShape(Rectangle()) .onTapGesture { - if !person.isMe { - peerListModel.openGeohashDirectMessage(with: person.id) + if person.id != myHex { + viewModel.startGeohashDM(withPubkeyHex: person.id) onTapPerson() } } .contextMenu { - if person.isMe { + if let me = myHex, person.id == me { EmptyView() } else { - if person.isBlocked { - Button(Strings.unblock) { - peerListModel.unblockGeohashUser( - pubkeyHexLowercased: person.id, - displayName: person.displayName - ) - } + let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) + if blocked { + Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) } } else { - Button(Strings.block) { - peerListModel.blockGeohashUser( - pubkeyHexLowercased: person.id, - displayName: person.displayName - ) - } - } - } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityDescription(for: person)) - .accessibilityAddTraits(person.isMe ? [] : .isButton) - .accessibilityHint(person.isMe ? "" : Strings.openDMHint) - .accessibilityActions { - if !person.isMe { - Button(person.isBlocked ? Strings.unblockText : Strings.blockText) { - if person.isBlocked { - peerListModel.unblockGeohashUser( - pubkeyHexLowercased: person.id, - displayName: person.displayName - ) - } else { - peerListModel.blockGeohashUser( - pubkeyHexLowercased: person.id, - displayName: person.displayName - ) - } + Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) } } } } @@ -154,13 +125,4 @@ struct GeohashPeopleList: View { } } } - - /// One spoken sentence per row: name, presence type, and block state. - private func accessibilityDescription(for person: GeohashPersonRow) -> String { - var parts: [String] = [person.displayName] - if person.isMe { parts.append(Strings.youState) } - parts.append(person.isTeleported ? Strings.teleported : Strings.nearby) - if person.isBlocked { parts.append(Strings.blockedState) } - return parts.joined(separator: ", ") - } } diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift deleted file mode 100644 index 4dff5c1d..00000000 --- a/bitchat/Views/GroupChatList.swift +++ /dev/null @@ -1,81 +0,0 @@ -import BitFoundation -import SwiftUI - -/// Compact "groups" section for the people sheet: one row per private group -/// this device belongs to, tappable to open the group chat window. -struct GroupChatList: View { - @ThemedPalette private var palette - - let groups: [GroupChatRow] - let onTapGroup: (PeerID) -> Void - - private enum Strings { - static let header = String(localized: "groups.section.header", comment: "Section header above the private groups list") - static let creator = String(localized: "groups.state.creator", comment: "State label for a group the user created") - static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") - static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") - static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", comment: "Accessibility hint on a group row explaining activation opens the group chat") - static let memberCountFormat = String(localized: "groups.member_count %@", comment: "Member count shown next to a group name; placeholder is the count") - } - - var body: some View { - if !groups.isEmpty { - VStack(alignment: .leading, spacing: 0) { - // Same glyph+label header shape as #mesh / across the bridge. - PeopleSectionHeader( - icon: "person.3.fill", - iconColor: palette.primary, - title: Strings.header - ) - - ForEach(groups) { group in - HStack(spacing: 4) { - Text("#\(group.name)") - .bitchatFont(size: 14) - .foregroundColor(palette.primary) - .lineLimit(1) - .truncationMode(.tail) - - Text(String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)")) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - - if group.isCreator { - Image(systemName: "crown.fill") - .font(.bitchatSystem(size: 9)) - .foregroundColor(.yellow) - .help(Strings.creator) - } - - Spacer() - - if group.hasUnread { - Image(systemName: "envelope.fill") - .font(.bitchatSystem(size: 10)) - .foregroundColor(.orange) - .help(Strings.newMessagesTooltip) - } - } - .padding(.horizontal) - .padding(.vertical, 6) - .contentShape(Rectangle()) - .onTapGesture { onTapGroup(group.peerID) } - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityDescription(for: group)) - .accessibilityAddTraits(.isButton) - .accessibilityHint(Strings.openGroupHint) - } - } - } - } - - private func accessibilityDescription(for group: GroupChatRow) -> String { - var parts: [String] = [ - group.name, - String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)") - ] - if group.isCreator { parts.append(Strings.creator) } - if group.hasUnread { parts.append(Strings.unread) } - return parts.joined(separator: ", ") - } -} diff --git a/bitchat/Views/Image Viewers/ImagePickerView.swift b/bitchat/Views/Image Viewers/ImagePickerView.swift index d4aa9cfe..defc81e4 100644 --- a/bitchat/Views/Image Viewers/ImagePickerView.swift +++ b/bitchat/Views/Image Viewers/ImagePickerView.swift @@ -64,7 +64,7 @@ struct ImagePickerView: UIViewControllerRepresentable { .resizable() .scaledToFit() } else { - Text(String(localized: "image_picker.none_selected", defaultValue: "no image selected", comment: "Placeholder shown in the image picker before a selection")) + Text("No image selected") } Button("Show") { isPresented = true } } diff --git a/bitchat/Views/Image Viewers/MacImagePickerView.swift b/bitchat/Views/Image Viewers/MacImagePickerView.swift index 2f467d6c..805d7495 100644 --- a/bitchat/Views/Image Viewers/MacImagePickerView.swift +++ b/bitchat/Views/Image Viewers/MacImagePickerView.swift @@ -14,25 +14,18 @@ struct MacImagePickerView: View { let completion: (URL?) -> Void @Environment(\.dismiss) private var dismiss - private enum Strings { - static let title: LocalizedStringKey = "mac.image_picker.title" - static let select = String(localized: "mac.image_picker.select", comment: "Button that opens the macOS open-panel to pick an image") - static let panelMessage = String(localized: "mac.image_picker.panel_message", comment: "Message shown in the macOS NSOpenPanel when picking an image") - static let cancel = String(localized: "mac.image_picker.cancel", comment: "Cancel button for the macOS image picker sheet") - } - var body: some View { VStack(spacing: 16) { - Text(Strings.title) + Text("Choose an image") .font(.headline) - Button(Strings.select) { + Button("Select Image") { let panel = NSOpenPanel() panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowedContentTypes = [.image, .png, .jpeg, .heic] - panel.message = Strings.panelMessage + panel.message = "Choose an image to send" if panel.runModal() == .OK { completion(panel.url) @@ -42,7 +35,7 @@ struct MacImagePickerView: View { } .buttonStyle(.borderedProminent) - Button(Strings.cancel) { + Button("Cancel") { completion(nil) } .buttonStyle(.bordered) @@ -63,7 +56,7 @@ struct MacImagePickerView: View { .resizable() .scaledToFit() } else { - Text(String(localized: "image_picker.none_selected", defaultValue: "no image selected", comment: "Placeholder shown in the image picker before a selection")) + Text("No image selected") } Button("Show") { isPresented = true } } diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index dcc2bc84..a54fb32e 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -7,15 +7,15 @@ import AppKit #endif struct LocationChannelsSheet: View { @Binding var isPresented: Bool - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @EnvironmentObject private var peerListModel: PeerListModel - @ThemedPalette private var palette + @ObservedObject private var manager = LocationChannelManager.shared + @ObservedObject private var bookmarks = GeohashBookmarksStore.shared + @ObservedObject private var network = NetworkActivationService.shared + @EnvironmentObject var viewModel: ChatViewModel + @Environment(\.colorScheme) var colorScheme @State private var customGeohash: String = "" @State private var customError: String? = nil - /// Geohash waiting on the fine-precision OpSec confirmation before share. - @State private var pendingShareGeohash: String? - @State private var showSharePrecisionWarning = false - @State private var activeSharePayload: ChannelSharePayload? + + private var backgroundColor: Color { colorScheme == .dark ? .black : .white } private enum Strings { static let title: LocalizedStringKey = "location_channels.title" @@ -24,37 +24,15 @@ struct LocationChannelsSheet: View { static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied" static let openSettings: LocalizedStringKey = "location_channels.action.open_settings" static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby" - static let grantToFind: LocalizedStringKey = "location_channels.grant_to_find" static let teleport: LocalizedStringKey = "location_channels.action.teleport" static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title" - // Same string the settings pane shows under the tor toggle — the - // warning belongs wherever the exposure is about to happen. - static let torOffWarning = String(localized: "app_info.settings.tor.off_warning", defaultValue: "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages.", comment: "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address") - - static let quickJoinTitle = String(localized: "location_channels.quick_join.title", defaultValue: "quick join", comment: "Section header in the location channels sheet for the one-tap suggestion of the region channel derived from the device region") - static func quickJoinDescription(_ regionName: String) -> String { - String( - format: String(localized: "location_channels.quick_join.description", defaultValue: "the region channel where people from %@ tend to gather — the wide cell around the main population center, not your location. it's public and well-known, so assume it's watched: quick join saves typing a geohash; it doesn't hide you or bypass blocks.", comment: "Caption under the quick join row; %@ is the localized country/region name. States plainly that the cell is the main population center's (not the person's location), that the channel must be assumed watched, and that quick join is discovery, not circumvention"), - locale: .current, - regionName - ) - } - static func quickJoinLabel(_ regionName: String) -> String { - String( - format: String(localized: "location_channels.quick_join.join_label", defaultValue: "join the %@ region channel", comment: "Accessibility label for the quick join row; %@ is the localized country/region name"), - locale: .current, - regionName - ) - } + static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access" + static let torTitle: LocalizedStringKey = "location_channels.tor.title" + static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" + static let toggleOn: LocalizedStringKey = "common.toggle.on" + static let toggleOff: LocalizedStringKey = "common.toggle.off" static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid") - static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it") - static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel") - static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark") - static let shareChannel = String(localized: "channel.share.action", defaultValue: "share channel", comment: "Context-menu / accessibility action that shares a location-channel invite") - static let sharePrecisionTitle = String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite") - static let sharePrecisionMessage = String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite") - static let shareAnyway = String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning") static func meshTitle(_ count: Int) -> String { let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row") @@ -121,30 +99,20 @@ struct LocationChannelsSheet: View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 12) { Text(Strings.title) - .bitchatFont(size: 18) + .font(.bitchatSystem(size: 18, design: .monospaced)) Spacer() closeButton } Text(Strings.description) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - - // The description's tor claim is only true while tor is on; - // when it's off, say what that exposes right where the person - // is about to join a channel, not just in settings. - if !locationChannelsModel.userTorEnabled { - Text(verbatim: Strings.torOffWarning) - .bitchatFont(size: 11) - .foregroundColor(palette.alertRed) - .fixedSize(horizontal: false, vertical: true) - } + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) Group { - switch locationChannelsModel.permissionState { - case .notDetermined: - Button(action: { locationChannelsModel.enableLocationChannels() }) { + switch manager.permissionState { + case LocationChannelManager.PermissionState.notDetermined: + Button(action: { manager.enableLocationChannels() }) { Text(Strings.requestPermissions) - .bitchatFont(size: 12) + .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(standardGreen) .frame(maxWidth: .infinity) .padding(.vertical, 6) @@ -152,15 +120,15 @@ struct LocationChannelsSheet: View { .cornerRadius(6) } .buttonStyle(.plain) - case .denied, .restricted: + case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted: VStack(alignment: .leading, spacing: 8) { Text(Strings.permissionDenied) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) Button(Strings.openSettings, action: SystemSettings.location.open) .buttonStyle(.plain) } - case .authorized: + case LocationChannelManager.PermissionState.authorized: EmptyView() } } @@ -170,7 +138,7 @@ struct LocationChannelsSheet: View { } .padding(.horizontal, 16) .padding(.vertical, 12) - .themedSurface() + .background(backgroundColor) #if os(iOS) .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(true) @@ -181,81 +149,54 @@ struct LocationChannelsSheet: View { #if os(macOS) .frame(minWidth: 420, minHeight: 520) #endif - .themedSheetBackground() + .background(backgroundColor) .onAppear { // Refresh channels when opening - if locationChannelsModel.permissionState == .authorized { - locationChannelsModel.refreshChannels() + if manager.permissionState == LocationChannelManager.PermissionState.authorized { + manager.refreshChannels() } // Begin periodic refresh while sheet is open - locationChannelsModel.beginLiveRefresh() + manager.beginLiveRefresh() // Geohash sampling is now managed by ChatViewModel globally } .onDisappear { - locationChannelsModel.endLiveRefresh() + manager.endLiveRefresh() } - .onChange(of: locationChannelsModel.permissionState) { newValue in - if newValue == .authorized { - locationChannelsModel.refreshChannels() + .onChange(of: manager.permissionState) { newValue in + if newValue == LocationChannelManager.PermissionState.authorized { + manager.refreshChannels() } } - .onChange(of: locationChannelsModel.availableChannels) { _ in } - .confirmationDialog( - Strings.sharePrecisionTitle, - isPresented: $showSharePrecisionWarning, - titleVisibility: .visible - ) { - Button(Strings.shareAnyway) { - if let gh = pendingShareGeohash { - presentShare(forGeohash: gh) - } - pendingShareGeohash = nil - } - Button("common.cancel", role: .cancel) { - pendingShareGeohash = nil - } - } message: { - Text(Strings.sharePrecisionMessage) - } - .sheet(item: $activeSharePayload) { payload in - ShareActivityView(text: payload.text) - } - } - - private func requestShare(forGeohash geohash: String) { - if ChannelShare.shouldWarn(forGeohash: geohash) { - pendingShareGeohash = geohash - showSharePrecisionWarning = true - } else { - presentShare(forGeohash: geohash) - } - } - - private func presentShare(forGeohash geohash: String) { - activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash)) + .onChange(of: manager.availableChannels) { _ in } } private var closeButton: some View { - SheetCloseButton { isPresented = false } + Button(action: { isPresented = false }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") } private var channelList: some View { ScrollView { LazyVStack(spacing: 0) { - channelRow(title: Strings.meshTitle(peerListModel.reachableMeshPeerCount), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: peerListModel.reachableMeshPeerCount > 0) { - locationChannelsModel.select(ChannelID.mesh) + channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) { + manager.select(ChannelID.mesh) isPresented = false } .padding(.vertical, 6) - let nearby = locationChannelsModel.availableChannels.filter { $0.level != .building } + let nearby = manager.availableChannels.filter { $0.level != .building } if !nearby.isEmpty { ForEach(nearby) { channel in sectionDivider let coverage = coverageString(forPrecision: channel.geohash.count) let nameBase = locationName(for: channel.level) let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 } - let participantCount = peerListModel.participantCount(for: channel.geohash) + let participantCount = viewModel.geohashParticipantCount(for: channel.geohash) let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage) let highlight = participantCount > 0 channelRow( @@ -265,75 +206,64 @@ struct LocationChannelsSheet: View { isSelected: isSelected(channel), titleBold: highlight, trailingAccessory: { - Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) { - Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") + Button(action: { bookmarks.toggle(channel.geohash) }) { + Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") .font(.bitchatSystem(size: 14)) } .buttonStyle(.plain) .padding(.leading, 8) - .accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark) - }, - accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark, - accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }, - shareGeohash: channel.geohash, - onShare: { requestShare(forGeohash: channel.geohash) } - ) { - locationChannelsModel.markTeleported(for: channel.geohash, false) - locationChannelsModel.select(ChannelID.location(channel)) - isPresented = false - } - .contextMenu { - Button { - requestShare(forGeohash: channel.geohash) - } label: { - Label(Strings.shareChannel, systemImage: "square.and.arrow.up") } + ) { + manager.markTeleported(for: channel.geohash, false) + manager.select(ChannelID.location(channel)) + isPresented = false } .padding(.vertical, 6) } - } else if locationChannelsModel.permissionState == .authorized { + } else { sectionDivider HStack(spacing: 8) { ProgressView() Text(Strings.loadingNearby) - .bitchatFont(size: 12) + .font(.bitchatSystem(size: 12, design: .monospaced)) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 10) - } else { - // No permission means no fix is coming: an honest hint - // beats a spinner that would never finish. - sectionDivider - Text(Strings.grantToFind) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 10) } sectionDivider customTeleportSection .padding(.vertical, 8) - if QuickJoinSuggestion.current() != nil { - sectionDivider - quickJoinSection - .padding(.vertical, 8) - } - - let bookmarkedList = locationChannelsModel.bookmarks + let bookmarkedList = bookmarks.bookmarks if !bookmarkedList.isEmpty { sectionDivider bookmarkedSection(bookmarkedList) .padding(.vertical, 8) } + if manager.permissionState == LocationChannelManager.PermissionState.authorized { + sectionDivider + torToggleSection + .padding(.top, 12) + Button(action: SystemSettings.location.open) { + Text(Strings.removeAccess) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.red.opacity(0.08)) + .cornerRadius(6) + } + .buttonStyle(.plain) + .padding(.vertical, 8) + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 6) - .themedSurface() + .background(backgroundColor) } - .themedSurface() + .background(backgroundColor) } private var sectionDivider: some View { @@ -342,21 +272,23 @@ struct LocationChannelsSheet: View { .frame(height: 1) } - private var dividerColor: Color { palette.divider } + private var dividerColor: Color { + colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08) + } private var customTeleportSection: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 2) { Text(verbatim: "#") - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(.secondary) TextField("geohash", text: $customGeohash) #if os(iOS) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .keyboardType(.asciiCapable) #endif - .bitchatFont(size: 14) + .font(.bitchatSystem(size: 14, design: .monospaced)) .onChange(of: customGeohash) { newValue in let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let filtered = newValue @@ -377,123 +309,74 @@ struct LocationChannelsSheet: View { Button(action: { let gh = normalized guard isValid else { customError = Strings.invalidGeohash; return } - locationChannelsModel.teleport(to: gh) + let level = levelForLength(gh.count) + let ch = GeohashChannel(level: level, geohash: gh) + manager.markTeleported(for: ch.geohash, true) + manager.select(ChannelID.location(ch)) isPresented = false }) { HStack(spacing: 6) { Text(Strings.teleport) - .bitchatFont(size: 14) + .font(.bitchatSystem(size: 14, design: .monospaced)) Image(systemName: "face.dashed") .font(.bitchatSystem(size: 14)) } } .buttonStyle(.plain) - .bitchatFont(size: 14) + .font(.bitchatSystem(size: 14, design: .monospaced)) .padding(.vertical, 6) .padding(.horizontal, 10) - .background(palette.secondary.opacity(0.12)) + .background(Color.secondary.opacity(0.12)) .cornerRadius(6) .opacity(isValid ? 1.0 : 0.4) .disabled(!isValid) } if let err = customError { Text(err) - .bitchatFont(size: 12) + .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.red) } } } - /// One tap into the region channel around the device region's main - /// population center — derived from the locale, no location access, no - /// roster (see QuickJoinSuggestion). The caption is deliberately blunt - /// that the cell is public and watched: discovery, not circumvention. - @ViewBuilder - private var quickJoinSection: some View { - if let suggestion = QuickJoinSuggestion.current() { - VStack(alignment: .leading, spacing: 8) { - Text(Strings.quickJoinTitle) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - - Button(action: { - locationChannelsModel.teleport(to: suggestion.geohash) - isPresented = false - }) { - HStack { - Text(verbatim: "\(suggestion.flag) \(suggestion.localizedName)") - .bitchatFont(size: 14) - .foregroundColor(palette.primary) - Spacer() - Text(verbatim: "#\(suggestion.geohash)") - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - } - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(Strings.quickJoinLabel(suggestion.localizedName)) - .accessibilityHint(Strings.switchChannelHint) - - Text(Strings.quickJoinDescription(suggestion.localizedName)) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - private func bookmarkedSection(_ entries: [String]) -> some View { VStack(alignment: .leading, spacing: 8) { Text(Strings.bookmarked) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) LazyVStack(spacing: 0) { ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in let level = levelForLength(gh.count) let channel = GeohashChannel(level: level, geohash: gh) let coverage = coverageString(forPrecision: gh.count) let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage) - let name = locationChannelsModel.bookmarkNames[gh] - let participantCount = peerListModel.participantCount(for: gh) + let name = bookmarks.bookmarkNames[gh] + let participantCount = viewModel.geohashParticipantCount(for: gh) channelRow( title: Strings.bookmarkTitle(geohash: gh, count: participantCount), subtitlePrefix: subtitle, subtitleName: name.map { formattedNamePrefix(for: level) + $0 }, - isSelected: locationChannelsModel.isSelected(channel), + isSelected: isSelected(channel), trailingAccessory: { - Button(action: { locationChannelsModel.toggleBookmark(gh) }) { - Image(systemName: locationChannelsModel.isBookmarked(gh) ? "bookmark.fill" : "bookmark") + Button(action: { bookmarks.toggle(gh) }) { + Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark") .font(.bitchatSystem(size: 14)) } .buttonStyle(.plain) .padding(.leading, 8) - .accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark) - }, - accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark, - accessoryAction: { locationChannelsModel.toggleBookmark(gh) }, - shareGeohash: gh, - onShare: { requestShare(forGeohash: gh) } - ) { - let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh } - if !inRegional && !locationChannelsModel.availableChannels.isEmpty { - locationChannelsModel.markTeleported(for: gh, true) - } else { - locationChannelsModel.markTeleported(for: gh, false) } - locationChannelsModel.select(ChannelID.location(channel)) + ) { + let inRegional = manager.availableChannels.contains { $0.geohash == gh } + if !inRegional && !manager.availableChannels.isEmpty { + manager.markTeleported(for: gh, true) + } else { + manager.markTeleported(for: gh, false) + } + manager.select(ChannelID.location(channel)) isPresented = false } - .contextMenu { - Button { - requestShare(forGeohash: gh) - } label: { - Label(Strings.shareChannel, systemImage: "square.and.arrow.up") - } - } .padding(.vertical, 6) - .onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) } + .onAppear { bookmarks.resolveBookmarkNameIfNeeded(for: gh) } if index < entries.count - 1 { sectionDivider @@ -505,11 +388,14 @@ struct LocationChannelsSheet: View { private func isSelected(_ channel: GeohashChannel) -> Bool { - locationChannelsModel.isSelected(channel) + if case .location(let ch) = manager.selectedChannel { + return ch == channel + } + return false } private var isMeshSelected: Bool { - if case .mesh = locationChannelsModel.selectedChannel { return true } + if case .mesh = manager.selectedChannel { return true } return false } @@ -518,15 +404,11 @@ struct LocationChannelsSheet: View { title: String, subtitlePrefix: String, subtitleName: String? = nil, - subtitleNameBold _: Bool = false, + subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, @ViewBuilder trailingAccessory: () -> some View = { EmptyView() }, - accessoryActionTitle: String? = nil, - accessoryAction: (() -> Void)? = nil, - shareGeohash: String? = nil, - onShare: (() -> Void)? = nil, action: @escaping () -> Void ) -> some View { HStack(alignment: .center, spacing: 8) { @@ -535,26 +417,26 @@ struct LocationChannelsSheet: View { let parts = splitTitleAndCount(title) HStack(spacing: 4) { Text(parts.base) - .bitchatFont(size: 14) + .font(.bitchatSystem(size: 14, design: .monospaced)) .fontWeight(titleBold ? .bold : .regular) - .foregroundColor(titleColor ?? palette.primary) + .foregroundColor(titleColor ?? Color.primary) if let count = parts.countSuffix, !count.isEmpty { Text(count) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) + .font(.bitchatSystem(size: 11, design: .monospaced)) + .foregroundColor(.secondary) } } let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName) Text(subtitleFull) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.tail) } Spacer() if isSelected { Text(verbatim: "✔︎") - .bitchatFont(size: 16) + .font(.bitchatSystem(size: 16, design: .monospaced)) .foregroundColor(standardGreen) } trailingAccessory() @@ -562,22 +444,6 @@ struct LocationChannelsSheet: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture(perform: action) - // The row is a plain HStack with a tap gesture, which VoiceOver reads - // as disconnected static text. Expose it as one activatable button; - // the visible bookmark accessory is mirrored as a named action. - .accessibilityElement(children: .ignore) - .accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))")) - .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton]) - .accessibilityHint(Strings.switchChannelHint) - .accessibilityAction(.default, action) - .accessibilityActions { - if let accessoryActionTitle, let accessoryAction { - Button(accessoryActionTitle, action: accessoryAction) - } - if shareGeohash != nil, let onShare { - Button(Strings.shareChannel, action: onShare) - } - } } // Split a title like "#mesh [3 people]" into base and suffix "[3 people]" @@ -587,6 +453,17 @@ struct LocationChannelsSheet: View { let suffix = String(s[idx...]) return (prefix, suffix) } + + // MARK: - Helpers for counts + private func meshCount() -> Int { + // Count mesh-connected OR mesh-reachable peers (exclude self) + let myID = viewModel.meshService.myPeerID + return viewModel.allPeers.reduce(0) { acc, peer in + if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 } + return acc + } + } + private func validateGeohash(_ s: String) -> Bool { let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") guard !s.isEmpty, s.count <= 12 else { return false } @@ -606,12 +483,70 @@ struct LocationChannelsSheet: View { } } -// MARK: - Standardized Colors -// (The tor and internet-gateway toggles moved to AppInfoView's Settings pane; -// IRCToggleStyle now lives in Views/Components.) +// MARK: - TOR Toggle & Standardized Colors extension LocationChannelsSheet { - private var standardGreen: Color { palette.primary } - private var standardBlue: Color { palette.accentBlue } + private var torToggleBinding: Binding { + Binding( + get: { network.userTorEnabled }, + set: { network.setUserTorEnabled($0) } + ) + } + + private var torToggleSection: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: torToggleBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(Strings.torTitle) + .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(.primary) + Text(Strings.torSubtitle) + .font(.bitchatSystem(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) + } + .padding(12) + .background(Color.secondary.opacity(0.12)) + .cornerRadius(8) + } + + private var standardGreen: Color { + (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + private var standardBlue: Color { + Color(red: 0.0, green: 0.478, blue: 1.0) + } +} + +private struct IRCToggleStyle: ToggleStyle { + let accent: Color + let onLabel: LocalizedStringKey + let offLabel: LocalizedStringKey + + func makeBody(configuration: Configuration) -> some View { + Button(action: { configuration.isOn.toggle() }) { + HStack(spacing: 12) { + configuration.label + Spacer() + Text(configuration.isOn ? onLabel : offLabel) + .textCase(.uppercase) + .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(configuration.isOn ? accent : .secondary) + .padding(.vertical, 4) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(accent.opacity(configuration.isOn ? 0.18 : 0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1) + ) + } + } + .buttonStyle(.plain) + } } // MARK: - Coverage helpers @@ -673,7 +608,7 @@ extension LocationChannelsSheet { } private func locationName(for level: GeohashChannelLevel) -> String? { - locationChannelsModel.locationName(for: level) + manager.locationNames[level] } private func formattedNamePrefix(for level: GeohashChannelLevel) -> String { diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift new file mode 100644 index 00000000..4d302524 --- /dev/null +++ b/bitchat/Views/LocationNotesView.swift @@ -0,0 +1,309 @@ +import SwiftUI + +struct LocationNotesView: View { + @EnvironmentObject var viewModel: ChatViewModel + @StateObject private var manager: LocationNotesManager + let geohash: String + let onNotesCountChanged: ((Int) -> Void)? + + @Environment(\.colorScheme) var colorScheme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ObservedObject private var locationManager = LocationChannelManager.shared + @Environment(\.dismiss) private var dismiss + @State private var draft: String = "" + + init( + geohash: String, + onNotesCountChanged: ((Int) -> Void)? = nil, + manager: LocationNotesManager? = nil + ) { + let gh = geohash.lowercased() + self.geohash = gh + self.onNotesCountChanged = onNotesCountChanged + _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) + } + + private var backgroundColor: Color { colorScheme == .dark ? .black : .white } + private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) } + private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } + + private enum Strings { + static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons") + static let description: LocalizedStringKey = "location_notes.description" + static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent" + static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused" + static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" + static let retry: LocalizedStringKey = "location_notes.action.retry" + static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint" + static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" + static let emptyTitle: LocalizedStringKey = "location_notes.empty_title" + static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle" + static let dismissError: LocalizedStringKey = "location_notes.action.dismiss" + static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder" + } + + var body: some View { +#if os(macOS) + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + headerSection + notesContent + } + } + .background(backgroundColor) + inputSection + } + .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) + .background(backgroundColor) + .onDisappear { manager.cancel() } + .onChange(of: geohash) { newValue in + manager.setGeohash(newValue) + } + .onAppear { onNotesCountChanged?(manager.notes.count) } + .onChange(of: manager.notes.count) { newValue in + onNotesCountChanged?(newValue) + } +#else + NavigationView { + VStack(spacing: 0) { + headerSection + ScrollView { + notesContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + inputSection + } + .background(backgroundColor) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(true) + #else + .navigationTitle("") + #endif + } + .background(backgroundColor) + .onDisappear { manager.cancel() } + .onChange(of: geohash) { newValue in + manager.setGeohash(newValue) + } + .onAppear { onNotesCountChanged?(manager.notes.count) } + .onChange(of: manager.notes.count) { newValue in + onNotesCountChanged?(newValue) + } +#endif + } + + private var closeButton: some View { + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.closeAccessibility) + } + + private var headerSection: some View { + let count = manager.notes.count + return VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text(headerTitle(for: count)) + .font(.bitchatSystem(size: 18, design: .monospaced)) + Spacer() + closeButton + } + if let building = locationManager.locationNames[.building], !building.isEmpty { + Text(building) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(accentGreen) + } else if let block = locationManager.locationNames[.block], !block.isEmpty { + Text(block) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(accentGreen) + } + Text(Strings.description) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + if manager.state == .noRelays { + Text(Strings.relaysPaused) + .font(.bitchatSystem(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + .background(backgroundColor) + } + + private func headerTitle(for count: Int) -> String { + String( + format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"), + locale: .current, + "\(geohash) ± 1", count + ) + } + + private var notesContent: some View { + LazyVStack(alignment: .leading, spacing: 12) { + if manager.state == .noRelays { + noRelaysRow + } else if manager.state == .loading && !manager.initialLoadComplete { + loadingRow + } else if manager.notes.isEmpty { + emptyRow + } else { + ForEach(manager.notes) { note in + noteRow(note) + } + } + + if let error = manager.errorMessage, manager.state != .noRelays { + errorRow(message: error) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private func noteRow(_ note: LocationNotesManager.Note) -> some View { + let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName + let ts = timestampText(for: note.createdAt) + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(verbatim: "@\(baseName)") + .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + if !ts.isEmpty { + Text(ts) + .font(.bitchatSystem(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + Spacer() + } + Text(note.content) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + } + + private var noRelaysRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.noRelaysNearby) + .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + Text(Strings.relaysRetryHint) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + Button(Strings.retry) { manager.refresh() } + .font(.bitchatSystem(size: 12, design: .monospaced)) + .buttonStyle(.plain) + } + .padding(.vertical, 6) + } + + private var loadingRow: some View { + HStack(spacing: 10) { + ProgressView() + Text(Strings.loadingNotes) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + Spacer() + } + .padding(.vertical, 8) + } + + private var emptyRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.emptyTitle) + .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + Text(Strings.emptySubtitle) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + } + .padding(.vertical, 6) + } + + private func errorRow(message: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.bitchatSystem(size: 12, design: .monospaced)) + Text(message) + .font(.bitchatSystem(size: 12, design: .monospaced)) + Spacer() + } + Button(Strings.dismissError) { manager.clearError() } + .font(.bitchatSystem(size: 12, design: .monospaced)) + .buttonStyle(.plain) + } + .padding(.vertical, 6) + } + + private var inputSection: some View { + HStack(alignment: .top, spacing: 10) { + TextField(Strings.addPlaceholder, text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .lineLimit(maxDraftLines, reservesSpace: true) + .padding(.vertical, 6) + Button(action: send) { + Image(systemName: "arrow.up.circle.fill") + .font(.bitchatSystem(size: 20)) + .foregroundColor(sendButtonEnabled ? accentGreen : .secondary) + } + .padding(.top, 2) + .buttonStyle(.plain) + .disabled(!sendButtonEnabled) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(backgroundColor) + .overlay(Divider(), alignment: .top) + } + + private func send() { + guard let content = draft.trimmedOrNilIfEmpty else { return } + manager.send(content: content, nickname: viewModel.nickname) + draft = "" + } + + private var sendButtonEnabled: Bool { + !draft.trimmed.isEmpty && manager.state != .noRelays + } + + // MARK: - Timestamp Formatting + private func timestampText(for date: Date) -> String { + let now = Date() + if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { + let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" + return rel.isEmpty ? "" : "\(rel) ago" + } else { + let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) + let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter + return fmt.string(from: date) + } + } + + private static let relativeFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.allowedUnits = [.day, .hour, .minute] + f.maximumUnitCount = 1 + f.unitsStyle = .abbreviated + f.collapsesLargestUnit = true + return f + }() + + private static let absDateFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d") + return f + }() + + private static let absDateYearFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d, y") + return f + }() +} diff --git a/bitchat/Views/Media/BlockRevealImageView.swift b/bitchat/Views/Media/BlockRevealImageView.swift index 01b5d8cb..5801888c 100644 --- a/bitchat/Views/Media/BlockRevealImageView.swift +++ b/bitchat/Views/Media/BlockRevealImageView.swift @@ -9,7 +9,6 @@ private typealias PlatformImage = NSImage #endif struct BlockRevealImageView: View { - @ThemedPalette private var palette private let url: URL private let revealProgress: Double? private let isSending: Bool @@ -21,25 +20,6 @@ struct BlockRevealImageView: View { @State private var platformImage: PlatformImage? @State private var aspectRatio: CGFloat = 1 @State private var isBlurred: Bool = false - @State private var showDeleteConfirmation = false - @State private var loadFailed = false - - private enum Strings { - static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it") - static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen") - static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image") - static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image") - static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image") - static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image") - static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image") - static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image") - static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image") - static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image") - static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen") - static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending") - static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded") - static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send") - } init( url: URL, @@ -65,19 +45,46 @@ struct BlockRevealImageView: View { } var body: some View { - // The DM sheet wraps the conversation in a high-priority - // swipe-to-close DragGesture (ContentSheetViews). An ancestor - // high-priority gesture starves descendant TapGestures, but Button - // actions still fire — the reveal/open tap must stay a Button or - // received DM images become untappable. - Button(action: handleTap) { - imageContent - } - .buttonStyle(.plain) - // The cancel control must sit outside the Button label: nested - // buttons don't get reliable independent hit testing, and the outer - // tap is a no-op while sending — the x could become untappable. - .overlay(alignment: .topTrailing) { + ZStack(alignment: .topTrailing) { + if let image = platformImage { + Image(platformImage: image) + .resizable() + .aspectRatio(aspectRatio, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + .mask( + BlockRevealMask( + fraction: fraction, + columns: 24, + rows: 16 + ) + .animation(.easeOut(duration: 0.2), value: fraction) + ) + .blur(radius: isBlurred ? 20 : 0) + .overlay { + if isBlurred { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.35)) + .overlay( + Image(systemName: "eye.slash.fill") + .font(.bitchatSystem(size: 24, weight: .semibold)) + .foregroundColor(.white.opacity(0.85)) + ) + } + } + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.gray.opacity(0.2)) + .frame(height: 200) + .overlay( + ProgressView() + .progressViewStyle(.circular) + ) + } + if let onCancel = onCancel, isSending { Button(action: onCancel) { Image(systemName: "xmark") @@ -88,10 +95,8 @@ struct BlockRevealImageView: View { .padding(8) } .buttonStyle(.plain) - .accessibilityLabel(Strings.cancelSend) } } - .simultaneousGesture(hideSwipe) .onAppear { isBlurred = initiallyBlurred loadImage() @@ -100,170 +105,41 @@ struct BlockRevealImageView: View { isBlurred = initiallyBlurred loadImage() } - .contextMenu { - if isSending { - cancelSendAction - } else { - imageActions - } - } - .confirmationDialog( - Strings.deleteConfirmTitle, - isPresented: $showDeleteConfirmation, - titleVisibility: .visible - ) { - Button(Strings.delete, role: .destructive) { - onDelete?() - } - Button("common.cancel", role: .cancel) {} - } message: { - Text(verbatim: Strings.deleteConfirmMessage) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabelText) - .accessibilityHint(accessibilityHintText) - .accessibilityAddTraits(isSending || loadFailed ? [] : .isButton) - .accessibilityActions { - if isSending { - // children: .ignore collapses the visible cancel button, so - // expose it as an action while the send is in flight. - cancelSendAction - } else { - imageActions - } - } - } - - @ViewBuilder - private var imageContent: some View { - if let image = platformImage { - Image(platformImage: image) - .resizable() - .aspectRatio(aspectRatio, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.gray.opacity(0.2), lineWidth: 1) - ) - .mask( - BlockRevealMask( - fraction: fraction, - columns: 24, - rows: 16 - ) - .animation(.easeOut(duration: 0.2), value: fraction) - ) - .blur(radius: isBlurred ? 20 : 0) - .overlay { - if isBlurred { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.black.opacity(0.35)) - .overlay( - VStack(spacing: 6) { - Image(systemName: "eye.slash.fill") - .font(.bitchatSystem(size: 24, weight: .semibold)) - Text(verbatim: Strings.tapToReveal) - // Themed: monospaced under matrix, - // system under liquid glass. - .bitchatFont(size: 12, weight: .medium) - } - .foregroundColor(.white.opacity(0.85)) - ) - } - } - } else { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(palette.secondary.opacity(0.2)) - .frame(height: 200) - .overlay { - if loadFailed { - Image(systemName: "photo") - .font(.bitchatSystem(size: 24, weight: .semibold)) - .foregroundColor(palette.secondary) - } else { - ProgressView() - .progressViewStyle(.circular) - } - } - } - } - - @ViewBuilder - private var cancelSendAction: some View { - if let onCancel { - Button(Strings.cancelSend, action: onCancel) - } - } - - @ViewBuilder - private var imageActions: some View { - // Open/reveal/hide would act on a file that failed to load, so only - // offer delete (when available) to let users clean up the attachment. - if !loadFailed { - if isBlurred { - Button(Strings.reveal) { - withAnimation(.easeOut(duration: 0.2)) { isBlurred = false } - } - } else { - Button(Strings.open) { onOpen?() } - Button(Strings.hide) { - withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true } - } - } - } - if onDelete != nil { - Button(Strings.delete, role: .destructive) { showDeleteConfirmation = true } - } - } - - private var accessibilityLabelText: String { - if isSending { return Strings.sendingImage } - if loadFailed { return Strings.unavailableImage } - return isBlurred ? Strings.hiddenImage : Strings.revealedImage - } - - private var accessibilityHintText: String { - if isSending || loadFailed { return "" } - return isBlurred ? Strings.revealHint : Strings.openHint + .gesture(mainGesture) } private func loadImage() { - loadFailed = false DispatchQueue.global(qos: .userInitiated).async { #if os(iOS) - let image = UIImage(contentsOfFile: url.path) + guard let image = UIImage(contentsOfFile: url.path) else { return } #else - let image = NSImage(contentsOf: url) + guard let image = NSImage(contentsOf: url) else { return } #endif + let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1 DispatchQueue.main.async { - guard let image else { - self.loadFailed = true - return - } self.platformImage = image - self.aspectRatio = image.size.height > 0 ? image.size.width / image.size.height : 1 + self.aspectRatio = ratio } } } - // Double-tap used to permanently delete the image — the most ingrained - // photo gesture on mobile, racing the reveal tap, with no confirmation - // and no way to get the file back. Delete now lives in the context menu - // behind a confirmation; taps only reveal and open. - private func handleTap() { - guard !isSending, !loadFailed else { return } - if isBlurred { - withAnimation(.easeOut(duration: 0.2)) { - isBlurred = false - } - } else { - onOpen?() + private var mainGesture: some Gesture { + let doubleTap = TapGesture(count: 2).onEnded { + guard !isSending else { return } + onDelete?() } - } - - private var hideSwipe: some Gesture { - DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in - guard !isSending, !loadFailed else { return } + let singleTap = TapGesture().onEnded { + guard !isSending else { return } + if isBlurred { + withAnimation(.easeOut(duration: 0.2)) { + isBlurred = false + } + } else { + onOpen?() + } + } + let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in + guard !isSending else { return } let horizontal = value.translation.width let vertical = value.translation.height guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return } @@ -273,6 +149,7 @@ struct BlockRevealImageView: View { } } } + return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe) } } diff --git a/bitchat/Views/Media/LiveVoiceBadge.swift b/bitchat/Views/Media/LiveVoiceBadge.swift deleted file mode 100644 index 709a170b..00000000 --- a/bitchat/Views/Media/LiveVoiceBadge.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// LiveVoiceBadge.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI - -/// Slow opacity pulse for live-voice indicators (composer HUD, bubble badge). -struct PulsingOpacityModifier: ViewModifier { - let active: Bool - @State private var dimmed = false - - func body(content: Content) -> some View { - content - .opacity(active && dimmed ? 0.35 : 1) - .animation(active ? .easeInOut(duration: 0.7).repeatForever(autoreverses: true) : .default, value: dimmed) - .onAppear { - if active { dimmed = true } - } - .onChange(of: active) { nowActive in - dimmed = nowActive - } - } -} - -/// The red pulsing "LIVE" chip shown on a voice bubble while its burst is -/// still streaming in. -struct LiveVoiceBadge: View { - var body: some View { - HStack(spacing: 4) { - Circle() - .fill(Color.red) - .frame(width: 6, height: 6) - Text("media.voice.live_badge", comment: "Badge on a voice message that is currently streaming in live") - .bitchatFont(size: 10, weight: .bold) - .foregroundColor(.red) - } - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background( - Capsule().fill(Color.red.opacity(0.15)) - ) - .modifier(PulsingOpacityModifier(active: true)) - .accessibilityLabel( - String(localized: "media.voice.accessibility.live", comment: "Accessibility label announcing a live incoming voice message") - ) - } -} diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 29711f38..76669489 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -6,161 +6,81 @@ // import SwiftUI -import BitFoundation struct MediaMessageView: View { @Environment(\.colorScheme) private var colorScheme - @Environment(\.appTheme) private var theme - @ThemedPalette private var palette - @EnvironmentObject private var conversationUIModel: ConversationUIModel + + @EnvironmentObject var viewModel: ChatViewModel let message: BitchatMessage let media: BitchatMessage.Media - /// Value snapshot of the message's mutable delivery status, captured at - /// construction (see `TextMessageView.deliveryStatus`): `BitchatMessage` - /// is a reference type mutated in place, and SwiftUI compares reference - /// fields by identity, so without the snapshot a status-only change - /// (send progress, delivered → read) would not re-render this row. - private let deliveryStatus: DeliveryStatus - @State private var showDeliveryDetail = false @Binding var imagePreviewURL: URL? - init(message: BitchatMessage, media: BitchatMessage.Media, imagePreviewURL: Binding) { - self.message = message - self.media = media - self.deliveryStatus = message.deliveryStatus - self._imagePreviewURL = imagePreviewURL - } - var body: some View { - let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) - let state = mediaSendState(for: deliveryStatus, isFromMe: isFromMe) - let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil + let state = mediaSendState(for: message) + let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID + let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil - // Baseline alignment (via the header text inside the VStack) keeps the - // lock on the header line; a fixed top padding left its solid body - // hanging below the line's visual center. - HStack(alignment: .firstTextBaseline, spacing: 0) { - if message.isPrivate { - Image(systemName: "lock.fill") - .font(.bitchatSystem(size: 8)) - .foregroundColor(Color.orange.opacity(0.75)) - .padding(.trailing, 4) - .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .center, spacing: 4) { + Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + if message.isPrivate && message.sender == viewModel.nickname, + let status = message.deliveryStatus { + DeliveryStatusView(status: status) + .padding(.leading, 4) + } } - if conversationUIModel.showsVerifiedSeal(for: message) { - Image(systemName: "checkmark.seal.fill") - .font(.bitchatSystem(size: 8)) - .foregroundColor(Color.green.opacity(0.85)) - .padding(.trailing, 4) - .accessibilityLabel( - String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message") + + Group { + switch media { + case .voice(let url): + VoiceNoteView( + url: url, + isSending: state.isSending, + sendProgress: state.progress, + onCancel: cancelAction ) - } - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .center, spacing: 4) { - Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - // Delivery status indicator for private messages. Tappable: - // .help() tooltips only exist on macOS, so iOS users get the - // explanation as a caption under the row instead. - if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - deliveryStatus != .notSentYet { - Button { - showDeliveryDetail.toggle() - } label: { - DeliveryStatusView(status: deliveryStatus) - .padding(.leading, 4) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityHint( - String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details") - ) - } - } - - // Failure reasons stay visible without a tap; other statuses - // reveal on demand. - if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - deliveryStatus != .notSentYet { - if case .failed = deliveryStatus { - Text(verbatim: deliveryStatus.bitchatDescription) - .bitchatFont(size: 11) - .foregroundColor(Color.red.opacity(0.9)) - .fixedSize(horizontal: false, vertical: true) - } else if showDeliveryDetail { - Text(verbatim: deliveryStatus.bitchatDescription) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - Group { - switch media { - case .voice(let url): - VoiceNoteView( - url: url, - isSending: state.isSending, - sendProgress: state.progress, - isLive: conversationUIModel.isLiveVoiceMessage(message), - onCancel: cancelAction - ) - case .image(let url): - BlockRevealImageView( - url: url, - revealProgress: state.progress, - isSending: state.isSending, - onCancel: cancelAction, - initiallyBlurred: !isFromMe, - onOpen: { - if !state.isSending { - imagePreviewURL = url - } - }, - onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil - ) - .frame(maxWidth: 280) - } + case .image(let url): + BlockRevealImageView( + url: url, + revealProgress: state.progress, + isSending: state.isSending, + onCancel: cancelAction, + initiallyBlurred: !isFromMe, + onOpen: { + if !state.isSending { + imagePreviewURL = url + } + }, + onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil + ) + .frame(maxWidth: 280) } } } .padding(.vertical, 4) - // Collapse the revealed caption when the status advances (e.g. - // sending → sent → delivered) so a detail opened for one state - // doesn't linger and silently morph into another. Guarded write: - // under a message storm many rows change status within one frame, - // and an unconditional state write per change trips SwiftUI's - // "tried to update multiple times per frame" re-entrancy warning. - .onChange(of: deliveryStatus) { _ in - if showDeliveryDetail { - showDeliveryDetail = false - } - } } - private func mediaSendState(for deliveryStatus: DeliveryStatus, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) { - // A received message is never in a send state: BitchatMessage defaults - // private messages to .sending, so an incoming message's status must - // not drive the reveal mask or disable the reveal tap. - guard isFromMe else { return (false, nil, false) } + private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) { var isSending = false var progress: Double? - switch deliveryStatus { - case .sending: - isSending = true - progress = 0 - case .partiallyDelivered(let reached, let total): - if total > 0 { + if let status = message.deliveryStatus { + switch status { + case .sending: isSending = true - progress = Double(reached) / Double(total) + progress = 0 + case .partiallyDelivered(let reached, let total): + if total > 0 { + isSending = true + progress = Double(reached) / Double(total) + } + case .sent, .read, .delivered, .failed: + break } - case .notSentYet, .sent, .carried, .read, .delivered, .failed: - break } - let canCancel = isSending && conversationUIModel.isSentByCurrentUser(message) + let canCancel = isSending && message.sender == viewModel.nickname let clamped = progress.map { max(0, min(1, $0)) } return (isSending, isSending ? clamped : nil, canCancel) } diff --git a/bitchat/Views/Media/VoiceNoteView.swift b/bitchat/Views/Media/VoiceNoteView.swift index 661d2193..7547893e 100644 --- a/bitchat/Views/Media/VoiceNoteView.swift +++ b/bitchat/Views/Media/VoiceNoteView.swift @@ -5,19 +5,16 @@ struct VoiceNoteView: View { private let url: URL private let isSending: Bool private let sendProgress: Double? - private let isLive: Bool private let onCancel: (() -> Void)? @Environment(\.colorScheme) private var colorScheme - @ThemedPalette private var palette @StateObject private var playback: VoiceNotePlaybackController @State private var waveform: [Float] = [] - init(url: URL, isSending: Bool, sendProgress: Double?, isLive: Bool = false, onCancel: (() -> Void)?) { + init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) { self.url = url self.isSending = isSending self.sendProgress = sendProgress - self.isLive = isLive self.onCancel = onCancel _playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url)) } @@ -30,13 +27,11 @@ struct VoiceNoteView: View { } private var backgroundColor: Color { - // Palette-based and slightly translucent so the card doesn't sit as - // an opaque white/black box over the glass gradient. - palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7) + colorScheme == .dark ? Color.black.opacity(0.6) : Color.white } private var borderColor: Color { - colorScheme == .dark ? palette.accent.opacity(0.3) : palette.accent.opacity(0.2) + colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2) } private var playbackLabel: String { @@ -51,15 +46,9 @@ struct VoiceNoteView: View { Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill") .foregroundColor(.white) .frame(width: 36, height: 36) - .background(Circle().fill(palette.accent)) + .background(Circle().fill(Color.green)) } .buttonStyle(.plain) - .accessibilityLabel( - playback.isPlaying - ? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback") - : String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note") - ) - .accessibilityValue(playbackLabel) WaveformView( samples: samples, @@ -71,13 +60,9 @@ struct VoiceNoteView: View { isInteractive: playback.isPlaying ) - if isLive { - LiveVoiceBadge() - } else { - Text(playbackLabel) - .bitchatFont(size: 13) - .foregroundColor(palette.secondary) - } + Text(playbackLabel) + .font(.bitchatSystem(size: 13, design: .monospaced)) + .foregroundColor(Color.secondary) if let onCancel = onCancel, isSending { Button(action: onCancel) { @@ -88,9 +73,6 @@ struct VoiceNoteView: View { .foregroundColor(.white) } .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send") - ) } } .padding(12) diff --git a/bitchat/Views/Media/WaveformView.swift b/bitchat/Views/Media/WaveformView.swift index b12ec6ee..063ef44d 100644 --- a/bitchat/Views/Media/WaveformView.swift +++ b/bitchat/Views/Media/WaveformView.swift @@ -6,7 +6,6 @@ struct WaveformView: View { let sendProgress: Double? let onSeek: ((Double) -> Void)? let isInteractive: Bool - @ThemedPalette private var palette private var clampedPlayback: Double { max(0, min(1, playbackProgress)) @@ -38,11 +37,11 @@ struct WaveformView: View { let binPosition = Double(index) / Double(samples.count) let color: Color if binPosition <= clampedPlayback { - color = palette.accent + color = Color.green } else if let send = clampedSend, binPosition <= send { - color = palette.accentBlue + color = Color.blue } else { - color = palette.secondary.opacity(0.35) + color = Color.gray.opacity(0.35) } context.fill(Path(rect), with: .color(color)) } diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 1d94af5f..4c785f5e 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -2,14 +2,12 @@ import SwiftUI import BitFoundation struct MeshPeerList: View { - @EnvironmentObject private var peerListModel: PeerListModel - @ThemedPalette private var palette + @ObservedObject var viewModel: ChatViewModel + let textColor: Color + let secondaryTextColor: Color let onTapPeer: (PeerID) -> Void let onToggleFavorite: (PeerID) -> Void let onShowFingerprint: (PeerID) -> Void - /// Optional so existing call sites (and previews/tests) keep compiling; - /// when absent the block/unblock context-menu entry is hidden. - var onToggleBlock: ((MeshPeerRow) -> Void)? = nil @Environment(\.colorScheme) var colorScheme @State private var orderedIDs: [String] = [] @@ -18,51 +16,40 @@ struct MeshPeerList: View { static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator") static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") - static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator") - static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator") - static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator") - static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable") - static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer") - static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") - static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer") - static let vouched = String(localized: "mesh_peers.state.vouched", comment: "State label for a peer vouched for by someone the user verified") - static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer") - static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") - static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") - static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen") - static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat") - static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person") - static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person") - static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person") } var body: some View { - let currentIDs = peerListModel.meshRows.map(\.id) - let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } - let peers: [MeshPeerRow] = displayIDs.compactMap { id in - peerListModel.meshRows.first(where: { $0.id == id }) + let myPeerID = viewModel.meshService.myPeerID + let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in + let isMe = peer.peerID == myPeerID + let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID) + let enc = viewModel.getEncryptionStatus(for: peer.peerID) + return (peer, isMe, hasUnread, enc) } - - if peerListModel.meshRows.isEmpty { - // Match the section's row rhythm (same size, indent, and vertical - // padding as a peer row) so the empty state reads as the list's - // only line, not a floating caption. - Text(Strings.noneNearby) - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) - .padding(.horizontal) - .padding(.vertical, 4) + // Stable visual order without mutating state here + let currentIDs = mapped.map { $0.peer.peerID.id } + let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } + let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in + mapped.first(where: { $0.peer.peerID.id == id }) + } + + if viewModel.allPeers.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Text(Strings.noneNearby) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + .padding(.horizontal) + .padding(.top, 12) + } } else { VStack(alignment: .leading, spacing: 0) { ForEach(0.. String { - var parts: [String] = [peer.displayName] - if !peer.isMe { - if peer.isConnected { - parts.append(Strings.connected) - } else if peer.isReachable { - parts.append(Strings.reachable) - } else if peer.isMutualFavorite { - parts.append(Strings.nostr) - } else { - parts.append(Strings.offline) - } - } - if peer.showsVouchedBadge { parts.append(Strings.vouched) } - if peer.isFavorite { parts.append(Strings.favorite) } - if peer.hasUnread { parts.append(Strings.unread) } - if peer.isBlocked { parts.append(Strings.blocked) } - return parts.joined(separator: ", ") - } } diff --git a/bitchat/Views/MeshTopologyView.swift b/bitchat/Views/MeshTopologyView.swift deleted file mode 100644 index a8bc4e3e..00000000 --- a/bitchat/Views/MeshTopologyView.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// MeshTopologyView.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI - -/// Display model for the mesh topology map: nodes are known mesh peers, -/// edges are gossiped `directNeighbors` claims. Built on the main actor from -/// a `MeshTopologySnapshot` plus the current nickname table. -struct MeshTopologyDisplayModel { - struct Node: Identifiable, Equatable { - let id: String - let label: String - let isSelf: Bool - } - - let nodes: [Node] - /// Pairs of `Node.id`; every id is present in `nodes`. - let edges: [(String, String)] - - static let empty = MeshTopologyDisplayModel(nodes: [], edges: []) -} - -/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in -/// the center), drawn with Canvas so it stays cheap at any peer count. -struct MeshTopologyView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appTheme) private var appTheme - @ThemedPalette private var palette - - /// Fetches a fresh model; called on appear and on manual refresh. - let provider: @MainActor () -> MeshTopologyDisplayModel - @State private var model: MeshTopologyDisplayModel = .empty - - var body: some View { - #if os(macOS) - VStack(spacing: 0) { - HStack { - Text("topology.title") - .bitchatFont(size: 16, weight: .bold) - .foregroundColor(palette.primary) - Spacer() - refreshButton - Button("app_info.done") { - dismiss() - } - .buttonStyle(.plain) - .foregroundColor(palette.primary) - } - .padding() - .themedSurface(opacity: 0.95) - - content - } - .frame(width: 500, height: 520) - .themedSheetBackground() - #else - NavigationView { - content - .themedSheetBackground() - .navigationTitle(Text("topology.title")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - refreshButton - } - ToolbarItem(placement: .navigationBarTrailing) { - SheetCloseButton { dismiss() } - .foregroundColor(palette.primary) - } - } - } - #endif - } - - private var refreshButton: some View { - Button { - model = provider() - } label: { - Image(systemName: "arrow.clockwise") - .font(.bitchatSystem(size: 14)) - .foregroundColor(palette.primary) - } - .buttonStyle(.plain) - .accessibilityLabel(Text("topology.refresh")) - } - - @ViewBuilder - private var content: some View { - VStack(spacing: 12) { - if model.nodes.count <= 1 { - Spacer() - Text("topology.empty") - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - Spacer() - } else { - graphCanvas - .padding(.horizontal, 8) - } - - VStack(spacing: 4) { - Text(summaryText) - .bitchatFont(size: 13, weight: .semibold) - .foregroundColor(palette.primary) - Text("topology.caption") - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal) - .padding(.bottom, 16) - } - .onAppear { model = provider() } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text(summaryText)) - } - - private var summaryText: String { - String( - format: String( - localized: "topology.summary", - comment: "Topology map summary: number of peers and links" - ), - locale: .current, - model.nodes.count, - model.edges.count - ) - } - - private var graphCanvas: some View { - Canvas { context, size in - let positions = Self.layout(nodes: model.nodes, in: size) - let fontDesign = appTheme.bodyFontDesign - - // Edges first so nodes draw on top. - for (fromID, toID) in model.edges { - guard let from = positions[fromID], let to = positions[toID] else { continue } - var path = Path() - path.move(to: from) - path.addLine(to: to) - context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1) - } - - for node in model.nodes { - guard let center = positions[node.id] else { continue } - let radius: CGFloat = node.isSelf ? 7 : 5 - let dot = Path(ellipseIn: CGRect( - x: center.x - radius, - y: center.y - radius, - width: radius * 2, - height: radius * 2 - )) - context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary)) - if node.isSelf { - let ring = Path(ellipseIn: CGRect( - x: center.x - radius - 3, - y: center.y - radius - 3, - width: (radius + 3) * 2, - height: (radius + 3) * 2 - )) - context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1) - } - context.draw( - Text(node.label) - .font(.system(size: 10, design: fontDesign)) - .foregroundColor(node.isSelf ? palette.accent : palette.secondary), - at: CGPoint(x: center.x, y: center.y + radius + 4), - anchor: .top - ) - } - } - .accessibilityHidden(true) // The combined summary label narrates the graph. - } - - /// Circular layout: self in the center, everyone else evenly spaced on a - /// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle. - static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] { - let center = CGPoint(x: size.width / 2, y: size.height / 2) - // Leave room for the label row under each ring node. - let radius = max(20, min(size.width, size.height) / 2 - 36) - var positions: [String: CGPoint] = [:] - - let ringNodes = nodes.filter { !$0.isSelf } - for node in nodes where node.isSelf { - positions[node.id] = center - } - for (index, node) in ringNodes.enumerated() { - let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2 - positions[node.id] = CGPoint( - x: center.x + radius * cos(angle), - y: center.y + radius * sin(angle) - ) - } - return positions - } -} - -#Preview("Topology") { - MeshTopologyView(provider: { - MeshTopologyDisplayModel( - nodes: [ - .init(id: "self", label: "me", isSelf: true), - .init(id: "a", label: "alice", isSelf: false), - .init(id: "b", label: "bob", isSelf: false), - .init(id: "c", label: "carol", isSelf: false) - ], - edges: [("self", "a"), ("a", "b"), ("self", "c")] - ) - }) -} diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 1894a347..d289efc6 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -6,7 +6,6 @@ // import BitFoundation -import CoreBluetooth import SwiftUI private struct MessageDisplayItem: Identifiable { @@ -15,16 +14,10 @@ private struct MessageDisplayItem: Identifiable { } struct MessageListView: View { - @EnvironmentObject private var publicChatModel: PublicChatModel - @EnvironmentObject private var privateInboxModel: PrivateInboxModel - @EnvironmentObject private var privateConversationModel: PrivateConversationModel - @EnvironmentObject private var conversationUIModel: ConversationUIModel - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @EnvironmentObject private var appChromeModel: AppChromeModel - @ObservedObject private var nearbyNotes = NearbyNotesCounter.shared + @EnvironmentObject private var viewModel: ChatViewModel + @ObservedObject private var locationManager = LocationChannelManager.shared @Environment(\.colorScheme) private var colorScheme - @Environment(\.appTheme) private var theme let privatePeer: PeerID? @Binding var isAtBottom: Bool @@ -39,20 +32,8 @@ struct MessageListView: View { var isTextFieldFocused: FocusState.Binding @State private var showMessageActions = false - @State private var showClearConfirmation = false @State private var lastScrollTime: Date = .distantPast @State private var scrollThrottleTimer: Timer? - @State private var unseenCount = 0 - @State private var lastSeenMessageCount = 0 - /// Context key the unseen counters were baselined against. Channel - /// switches swap the timeline wholesale, so a count delta is only a - /// "new messages" signal while the context is unchanged. - @State private var unseenBaselineKey = "" - /// Whether this instance holds the nearby-notes counter active (mesh - /// public timeline only); balanced against activate/deactivate. - @State private var holdsNotesCounter = false - - @ThemedPalette private var palette var body: some View { let currentWindowCount: Int = { @@ -62,14 +43,14 @@ struct MessageListView: View { return windowCountPublic }() - let messages = conversationMessages(for: privatePeer) + let messages = viewModel.getMessages(for: privatePeer) let windowedMessages = Array(messages.suffix(currentWindowCount)) let contextKey: String = { if let peer = privatePeer { "dm:\(peer)" } else { - locationChannelsModel.selectedChannel.contextKey + locationManager.selectedChannel.contextKey } }() @@ -78,20 +59,8 @@ struct MessageListView: View { return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message) } - VStack(spacing: 0) { - // Notes pinned to this place stay visible while chatting — a - // conversation starting must not hide what's left here. - if privatePeer == nil, - case .mesh = locationChannelsModel.selectedChannel, - nearbyNotes.noteCount > 0 { - notesHereStrip - } - GeometryReader { geometry in ScrollViewReader { proxy in ScrollView { - if messageItems.isEmpty && privatePeer == nil { - publicEmptyState(fillHeight: geometry.size.height) - } LazyVStack(alignment: .leading, spacing: 0) { ForEach(messageItems) { item in let message = item.message @@ -99,7 +68,6 @@ struct MessageListView: View { .onAppear { if message.id == windowedMessages.last?.id { isAtBottom = true - unseenCount = 0 } if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { @@ -117,32 +85,13 @@ struct MessageListView: View { } } .contentShape(Rectangle()) - .contextMenu { - let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message) - if showsUserActions { - // Mention and DM are redundant inside a 1:1 conversation: - // mentioning the only other participant is noise, and "DM" - // would just reopen the conversation that is already open. - if privatePeer == nil { - Button("content.actions.mention") { - insertMention(message.sender) - } - if let peerID = message.senderPeerID { - Button("content.actions.direct_message") { - privateConversationModel.openConversation(for: peerID) - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - showSidebar = true - } - } - } - } - Button("content.actions.hug") { - conversationUIModel.sendHug(to: message.sender) - } - Button("content.actions.slap") { - conversationUIModel.sendSlap(to: message.sender) - } + .onTapGesture { + if message.sender != "system" { + messageText = "@\(message.sender) " + isTextFieldFocused.wrappedValue = true } + } + .contextMenu { Button("content.message.copy") { #if os(iOS) UIPasteboard.general.string = message.content @@ -152,55 +101,17 @@ struct MessageListView: View { pb.setString(message.content, forType: .string) #endif } - if isResendableFailedMessage(message) { - Button("content.actions.resend") { - conversationUIModel.resendFailedPrivateMessage(message) - } - } - if showsUserActions { - Button("content.actions.block", role: .destructive) { - conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender) - } - } } .padding(.horizontal, 12) .padding(.vertical, 1) - // Archived echoes read as one tinted block, not - // just faded rows. - .background(message.isArchivedEcho ? palette.secondary.opacity(0.08) : Color.clear) } } - .transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } } + .transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } } .padding(.vertical, 2) - - // Only carried history on screen: the ambient layer (radar, - // sightings, live hints) stays visible below it instead of - // vanishing the moment echoes exist. - if privatePeer == nil, showsAmbientFooter(messageItems: messageItems) { - MeshEmptyStateView(compact: true, bluetoothAvailable: bluetoothCanScan) - .padding(.horizontal, 12) - .padding(.top, 20) - .padding(.bottom, 8) - } - } - .overlay(alignment: .bottomTrailing) { - if !isAtBottom && !messageItems.isEmpty { - jumpToLatestPill(proxy: proxy) - } } .onOpenURL(perform: handleOpenURL) .onTapGesture(count: 3) { - showClearConfirmation = true - } - .confirmationDialog( - "content.clear.confirm_title", - isPresented: $showClearConfirmation, - titleVisibility: .visible - ) { - Button("content.clear.confirm_action", role: .destructive) { - conversationUIModel.clearCurrentConversation() - } - Button("common.cancel", role: .cancel) {} + viewModel.sendMessage("/clear") } .onAppear { scrollToBottom(on: proxy) @@ -208,13 +119,13 @@ struct MessageListView: View { .onChange(of: privatePeer) { _ in scrollToBottom(on: proxy) } - .onChange(of: publicChatModel.messages.count) { _ in + .onChange(of: viewModel.messages.count) { _ in onMessagesChange(proxy: proxy) } - .onChange(of: privateMessageCount(for: privatePeer)) { _ in + .onChange(of: viewModel.privateChats) { _ in onPrivateChatsChange(proxy: proxy) } - .onChange(of: locationChannelsModel.selectedChannel) { newChannel in + .onChange(of: locationManager.selectedChannel) { newChannel in onSelectedChannelChange(newChannel, proxy: proxy) } .confirmationDialog( @@ -224,13 +135,21 @@ struct MessageListView: View { ) { Button("content.actions.mention") { if let sender = selectedMessageSender { - insertMention(sender) + // Pre-fill the input with an @mention and focus the field + messageText = "@\(sender) " + isTextFieldFocused.wrappedValue = true } } Button("content.actions.direct_message") { if let peerID = selectedMessageSenderID { - privateConversationModel.openConversation(for: peerID) + if peerID.isGeoChat { + if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) { + viewModel.startGeohashDM(withPubkeyHex: full) + } + } else { + viewModel.startPrivateChat(with: peerID) + } withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar = true } @@ -239,18 +158,25 @@ struct MessageListView: View { Button("content.actions.hug") { if let sender = selectedMessageSender { - conversationUIModel.sendHug(to: sender) + viewModel.sendMessage("/hug @\(sender)") } } Button("content.actions.slap") { if let sender = selectedMessageSender { - conversationUIModel.sendSlap(to: sender) + viewModel.sendMessage("/slap @\(sender)") } } Button("content.actions.block", role: .destructive) { - conversationUIModel.block(peerID: selectedMessageSenderID, displayName: selectedMessageSender) + // Prefer direct geohash block when we have a Nostr sender ID + if let peerID = selectedMessageSenderID, peerID.isGeoChat, + let full = viewModel.fullNostrHex(forSenderPeerID: peerID), + let sender = selectedMessageSender { + viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender) + } else if let sender = selectedMessageSender { + viewModel.sendMessage("/block \(sender)") + } } Button("common.cancel", role: .cancel) {} @@ -259,14 +185,14 @@ struct MessageListView: View { // Also check when view appears if let peerID = privatePeer { // Try multiple times to ensure read receipts are sent - privateConversationModel.markMessagesAsRead(from: peerID) + viewModel.markPrivateMessagesAsRead(from: peerID) DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { - privateConversationModel.markMessagesAsRead(from: peerID) + viewModel.markPrivateMessagesAsRead(from: peerID) } DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) { - privateConversationModel.markMessagesAsRead(from: peerID) + viewModel.markPrivateMessagesAsRead(from: peerID) } } } @@ -274,12 +200,6 @@ struct MessageListView: View { scrollThrottleTimer?.invalidate() } } - } - } - .onAppear { updateNotesCounterHold() } - .onDisappear { releaseNotesCounterHold() } - .onChange(of: locationChannelsModel.selectedChannel) { _ in updateNotesCounterHold() } - .onChange(of: privatePeer) { _ in updateNotesCounterHold() } .environment(\.openURL, OpenURLAction { url in // Intercept custom cashu: links created in attributed text if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" { @@ -297,234 +217,22 @@ struct MessageListView: View { } private extension MessageListView { - /// Whether the radio could be scanning at all — the empty-state radar - /// must not sweep over a switched-off or denied radio. - var bluetoothCanScan: Bool { - switch appChromeModel.bluetoothState { - case .poweredOff, .unauthorized, .unsupported: return false - default: return true - } - } - - var currentContextKey: String { - if let peer = privatePeer { - return "dm:\(peer)" - } - return locationChannelsModel.selectedChannel.contextKey - } - - /// Tappable strip above the mesh timeline while notes are pinned at this - /// place: opens the notices sheet on the geo tab. - var notesHereStrip: some View { - let text: String = nearbyNotes.noteCount == 1 - ? String(localized: "content.empty.notes_one", comment: "Hint when exactly one note was left at this place") - : String( - format: String(localized: "content.empty.notes_many", comment: "Hint counting notes left at this place"), - locale: .current, - nearbyNotes.noteCount - ) - - return Button { - appChromeModel.presentNotices(geoTab: true) - } label: { - HStack(spacing: 6) { - Text(verbatim: "📍 \(text)") - .bitchatFont(size: 12) - .foregroundColor(palette.primary) - Spacer() - Image(systemName: "chevron.right") - .font(.bitchatSystem(size: 10)) - .foregroundColor(palette.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(palette.secondary.opacity(0.08)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - /// The nearby-notes counter is held whenever the mesh public timeline is - /// showing — the strip needs a live count before it can decide to exist. - /// Holding is not subscribing: nothing hits the relays until an explicit - /// act reveals the counter (tap-to-reveal). - func updateNotesCounterHold() { - let shouldHold = privatePeer == nil && locationChannelsModel.selectedChannel.isMesh - guard shouldHold != holdsNotesCounter else { return } - holdsNotesCounter = shouldHold - if shouldHold { - NearbyNotesCounter.shared.activate() - } else { - NearbyNotesCounter.shared.deactivate() - } - } - - func releaseNotesCounterHold() { - guard holdsNotesCounter else { return } - holdsNotesCounter = false - NearbyNotesCounter.shared.deactivate() - } - - /// True when the mesh timeline holds nothing but archived echoes and - /// system lines — no live conversation yet, so the ambient layer still - /// applies. - private func showsAmbientFooter(messageItems: [MessageDisplayItem]) -> Bool { - guard case .mesh = locationChannelsModel.selectedChannel, - !messageItems.isEmpty else { return false } - return messageItems.allSatisfy { $0.message.isArchivedEcho || $0.message.sender == "system" } - } - - /// Terminal-styled narration for an empty public timeline: says which - /// channel this is, that the app is waiting for peers, and where to go - /// next. Rendered inside the ScrollView; disappears with the first row. - /// The mesh case fills the visible chat height so its radar can center - /// in the space below the text. - func publicEmptyState(fillHeight: CGFloat) -> some View { - VStack(alignment: .leading, spacing: 6) { - switch locationChannelsModel.selectedChannel { - case .mesh: - MeshEmptyStateView( - fillHeight: max(0, fillHeight - 24), - bluetoothAvailable: bluetoothCanScan - ) - case .location(let channel): - emptyStateLine( - String( - format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"), - locale: .current, - channel.geohash - ) - ) - emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")) - } - } - .padding(.horizontal, 12) - .padding(.top, 12) - .frame(maxWidth: .infinity, alignment: .leading) - } - - func emptyStateLine(_ text: String) -> some View { - // Non-breaking space before the closing asterisk so a tight wrap - // can't orphan a lone "*" onto its own line. - Text(verbatim: "* \(text)\u{00A0}*") - .bitchatFont(size: 13) - .foregroundColor(palette.secondary.opacity(0.9)) - .fixedSize(horizontal: false, vertical: true) - } - - /// Messages the unseen counters may book as "new": rows that render as - /// human messages. System lines render as narration and whitespace-only - /// content never renders at all, so neither belongs in the pill count. - func unseenEligibleCount(in messages: [BitchatMessage]) -> Int { - messages.filter { $0.sender != "system" && !$0.content.trimmed.isEmpty }.count - } - - /// Updates the unseen-count baseline for the current context and returns - /// how many messages were appended since the last observation. A context - /// change (timeline swapped wholesale) re-baselines and reports zero, so - /// cross-channel count differences are never booked as "new" messages. - func rebaselinedAppendedCount(newCount: Int) -> Int { - let key = currentContextKey - if unseenBaselineKey != key { - unseenBaselineKey = key - unseenCount = 0 - lastSeenMessageCount = newCount - return 0 - } - let appended = max(0, newCount - lastSeenMessageCount) - lastSeenMessageCount = newCount - return appended - } - - /// A failed private text message of our own can be resent through the - /// normal send path (the context menu removes the failed original and - /// re-submits its content). - func isResendableFailedMessage(_ message: BitchatMessage) -> Bool { - guard message.isPrivate, - conversationUIModel.isSentByCurrentUser(message), - conversationUIModel.mediaAttachment(for: message) == nil, - case .failed = message.deliveryStatus - else { return false } - return true - } - - /// Appends an @mention to the composer draft (never overwrites what the - /// user has already typed) and focuses the input field. - func insertMention(_ sender: String) { - let mention = "@\(sender) " - if messageText.isEmpty { - messageText = mention - } else if messageText.hasSuffix(" ") { - messageText += mention - } else { - messageText += " " + mention - } - isTextFieldFocused.wrappedValue = true - } - - /// Floating pill shown while scrolled up: re-presents the isAtBottom / - /// unseenCount state the view already tracks, and jumps to the newest - /// message via the existing scrollToBottom helper. - func jumpToLatestPill(proxy: ScrollViewProxy) -> some View { - Button { - scrollToBottom(on: proxy) - } label: { - HStack(spacing: 4) { - Image(systemName: "arrow.down") - .font(.bitchatSystem(size: 11, weight: .semibold)) - if unseenCount > 0 { - Text( - String( - format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"), - locale: .current, - unseenCount - ) - ) - .bitchatFont(size: 12, weight: .medium) - } - } - .foregroundColor(palette.primary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .themedOverlayPanel() - .padding(.trailing, 12) - .padding(.bottom, 10) - .accessibilityLabel(jumpToLatestAccessibilityLabel) - } - - var jumpToLatestAccessibilityLabel: String { - let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button") - guard unseenCount > 0 else { return base } - let count = String( - format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"), - locale: .current, - unseenCount - ) - return "\(base), \(count)" - } - @ViewBuilder func messageRow(for message: BitchatMessage) -> some View { Group { if message.sender == "system" { systemMessageRow(message) - } else if let media = conversationUIModel.mediaAttachment(for: message) { + } else if let media = message.mediaAttachment(for: viewModel.nickname) { MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL) } else { TextMessageView(message: message) } } - // Archived echoes ("heard here earlier") render dimmed: real history, - // visually distinct from the live conversation. - .opacity(message.isArchivedEcho ? 0.55 : 1) } @ViewBuilder func systemMessageRow(_ message: BitchatMessage) -> some View { - Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme)) + Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } @@ -538,7 +246,7 @@ private extension MessageListView { if let peer = privatePeer { "dm:\(peer)" } else { - locationChannelsModel.selectedChannel.contextKey + locationManager.selectedChannel.contextKey } }() let preserveID = "\(contextKey)|\(message.id)" @@ -570,12 +278,15 @@ private extension MessageListView { let peerID = PeerID(str: id.removingPercentEncoding ?? id) selectedMessageSenderID = peerID - selectedMessageSender = conversationUIModel.senderDisplayName( - for: peerID, - fallbackMessages: conversationMessages(for: privatePeer) - ) + if peerID.isGeoDM || peerID.isGeoChat { + selectedMessageSender = viewModel.geohashDisplayName(for: peerID) + } else if let name = viewModel.meshService.peerNickname(peerID: peerID) { + selectedMessageSender = name + } else { + selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender + } - if conversationUIModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) { + if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) { selectedMessageSender = nil selectedMessageSenderID = nil } else { @@ -586,7 +297,26 @@ private extension MessageListView { let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return } - locationChannelsModel.openLocationChannel(for: gh) + + func levelForLength(_ len: Int) -> GeohashChannelLevel { + switch len { + case 0...2: return .region + case 3...4: return .province + case 5: return .city + case 6: return .neighborhood + case 7: return .block + default: return .block + } + } + + let level = levelForLength(gh.count) + let channel = GeohashChannel(level: level, geohash: gh) + + let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh } + if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty { + LocationChannelManager.shared.markTeleported(for: gh, true) + } + LocationChannelManager.shared.select(ChannelID.location(channel)) default: return @@ -595,9 +325,6 @@ private extension MessageListView { func scrollToBottom(on proxy: ScrollViewProxy) { isAtBottom = true - unseenCount = 0 - lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer)) - unseenBaselineKey = currentContextKey if let targetPeerID { proxy.scrollTo(targetPeerID, anchor: .bottom) } @@ -610,39 +337,30 @@ private extension MessageListView { var targetPeerID: String? { if let peer = privatePeer, - let last = privateInboxModel.messages(for: peer).last?.id { + let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { return "dm:\(peer)|\(last)" } - if let last = publicChatModel.messages.last?.id { - return "\(locationChannelsModel.selectedChannel.contextKey)|\(last)" + if let last = viewModel.messages.suffix(300).last?.id { + return "\(locationManager.selectedChannel.contextKey)|\(last)" } return nil } func onMessagesChange(proxy: ScrollViewProxy) { - guard privatePeer == nil else { return } - let messages = publicChatModel.messages - let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages)) - guard let lastMsg = messages.last else { - // Timeline emptied (e.g. /clear): nothing below to jump to. - unseenCount = 0 - return - } + guard privatePeer == nil, let lastMsg = viewModel.messages.last else { return } // If the newest message is from me, always scroll to bottom - let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) + let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#") if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom - unseenCount += appendedCount return } else { // Ensure we consider ourselves at bottom for subsequent messages isAtBottom = true - unseenCount = 0 } func scrollIfNeeded(date: Date) { lastScrollTime = date - let contextKey = locationChannelsModel.selectedChannel.contextKey - if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) { + let contextKey = locationManager.selectedChannel.contextKey + if let target = viewModel.messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) { proxy.scrollTo(target, anchor: .bottom) } } @@ -664,29 +382,23 @@ private extension MessageListView { } func onPrivateChatsChange(proxy: ScrollViewProxy) { - guard let peerID = privatePeer else { return } - let messages = privateInboxModel.messages(for: peerID) - let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages)) - guard let lastMsg = messages.last else { - // Timeline emptied (e.g. /clear): nothing below to jump to. - unseenCount = 0 + guard let peerID = privatePeer, let messages = viewModel.privateChats[peerID], let lastMsg = messages.last else { return } // If the newest private message is from me, always scroll - let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) + let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#") if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom - unseenCount += appendedCount return } else { isAtBottom = true - unseenCount = 0 } func scrollIfNeeded(date: Date) { lastScrollTime = date let contextKey = "dm:\(peerID)" - if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) { + let count = windowCountPrivate[peerID] ?? 300 + if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){ proxy.scrollTo(target, anchor: .bottom) } } @@ -708,39 +420,18 @@ private extension MessageListView { func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) { // When switching to a new geohash channel, scroll to the bottom guard privatePeer == nil else { return } - // Invalidate the unseen baseline: the timeline is about to swap (or - // already has — the ordering of this onChange vs the count onChange - // is not guaranteed), so the next count observation re-baselines - // instead of booking the cross-channel difference as "new". - unseenCount = 0 - unseenBaselineKey = "" - // Entering any public channel shows its latest messages: a channel - // switch swaps the timeline wholesale, so the prior scroll offset is - // meaningless. Landing at the bottom keeps isAtBottom honest (no - // stale jump-to-latest pill) and matches standard chat behavior. - isAtBottom = true - windowCountPublic = TransportConfig.uiWindowInitialCountPublic - let contextKey: String switch channel { case .mesh: - contextKey = "mesh" + break case .location(let ch): - contextKey = "geo:\(ch.geohash)" + // Reset window size + isAtBottom = true + windowCountPublic = TransportConfig.uiWindowInitialCountPublic + let contextKey = "geo:\(ch.geohash)" + if let target = viewModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) { + proxy.scrollTo(target, anchor: .bottom) + } } - if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) { - proxy.scrollTo(target, anchor: .bottom) - } - } - - func conversationMessages(for privatePeer: PeerID?) -> [BitchatMessage] { - if let privatePeer { - return privateInboxModel.messages(for: privatePeer) - } - return publicChatModel.messages - } - - func privateMessageCount(for privatePeer: PeerID?) -> Int { - conversationMessages(for: privatePeer).count } } @@ -753,6 +444,6 @@ private extension ChannelID { } } -// #Preview { +//#Preview { // MessageListView() -// } +//} diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift index fd438f10..bd653ec9 100644 --- a/bitchat/Views/MessageTextHelpers.swift +++ b/bitchat/Views/MessageTextHelpers.swift @@ -21,40 +21,17 @@ extension String { return current >= threshold } - /// True when the message should collapse behind Show more in the UI. - /// Length alone decides this — embedding a Cashu-looking token must not - /// disable the guard (remote DoS via unbounded layout). - func isLongForDisplay( - lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold, - tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold - ) -> Bool { - count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold) - } - - /// True when rich formatting (regex / link detectors) should be skipped. - /// Cashu presence used to exempt oversized content from the plain path; - /// that let untrusted input force expensive formatting work. - func isOversizedForRichFormatting( - lengthThreshold: Int = 4000, - tokenThreshold: Int = 1024 - ) -> Bool { - count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold) - } - - // Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare - // bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI - // form matches too — the token embedded after the scheme is the match. + // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. func extractCashuLinks(max: Int = 3) -> [String] { let regex = MessageFormattingEngine.Patterns.cashu let ns = self as NSString let range = NSRange(location: 0, length: ns.length) var found: [String] = [] - for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 { - let token = ns.substring(with: m.range(at: 0)) - // Dedup: repeated tokens are one bearer instrument (and duplicate - // ForEach IDs) — one chip is enough. - if !found.contains(token) { - found.append(token) + for m in regex.matches(in: self, range: range) { + if m.numberOfRanges > 0 { + let token = ns.substring(with: m.range(at: 0)) + let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token + found.append("cashu:\(enc)") if found.count >= max { break } } } diff --git a/bitchat/Views/NoticesView.swift b/bitchat/Views/NoticesView.swift deleted file mode 100644 index 51edd184..00000000 --- a/bitchat/Views/NoticesView.swift +++ /dev/null @@ -1,828 +0,0 @@ -// -// NoticesView.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI - -/// The unified notices sheet behind the header's pin icon: one place for -/// everything pinned around you, with a scope toggle. -/// -/// - geo: the current geohash's notices — mesh-synced board posts merged and -/// deduped with Nostr kind-1 location notes, so you also see notices from -/// people who aren't on your mesh. -/// - mesh: the mesh-local board only (empty geohash, fully offline). -struct NoticesView: View { - enum Tab: Hashable { - case geo - case mesh - } - - let senderNickname: String - @ObservedObject var board: BoardManager - - @ThemedPalette private var palette - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @State private var tab: Tab - @State private var draft: String = "" - @State private var urgent = false - /// Days until the notice fades; `permanentExpiry` (geo default) means no - /// NIP-40 tag — the note stays until its relay drops it. - @State private var expiryDays: Int - /// Mirrors the app-info kill switch so its notification produces an - /// immediate presentation update as well as tearing down subscriptions. - @State private var locationNotesEnabled = LocationNotesSettings.enabled - - /// Sentinel picker tag for the ∞ option (geo tab only). - private static let permanentExpiry = 0 - - /// Injected notes manager for tests; live use derives one per geohash. - private let notesManager: LocationNotesManager? - /// Pooled manager held by the sheet so the composer can post pure Nostr - /// notes (∞ expiry has no mesh-board copy) and the list can render them. - /// Acquired from `LocationNotesPool` (shared with the nearby-notes - /// counter, one REQ per geohash) and released on dismissal. - @State private var liveGeoManager: LocationNotesManager? - /// Tracks only this sheet's high-accuracy refresh ownership, so repeated - /// Combine/SwiftUI invalidations do not restart CoreLocation and a - /// revocation or kill-switch transition balances the begin call once. - @State private var ownsLiveGeoRefresh = false - - init( - senderNickname: String, - board: BoardManager, - initialTab: Tab, - notesManager: LocationNotesManager? = nil - ) { - self.senderNickname = senderNickname - self.board = board - self.notesManager = notesManager - _tab = State(initialValue: initialTab) - _expiryDays = State(initialValue: initialTab == .geo ? Self.permanentExpiry : 7) - } - - private var activeNotesManager: LocationNotesManager? { - notesManager ?? liveGeoManager - } - - /// The one explicit act inside the sheet that unlocks the passive - /// nearby-notes counter: the person actively picking the geo segment - /// while the sheet has a geo scope. Landing on the geo tab via the - /// sheet's initial selection (auto-derived from the current channel — - /// e.g. browsing a remote geohash) is not an act toward the LOCAL - /// building cell and must not reveal it. - static func revealsNearbyNotes(onSwitchingTo tab: Tab, geoGeohash: String?) -> Bool { - tab == .geo && geoGeohash != nil - } - - struct GeoSessionState { - let manager: LocationNotesManager? - let ownsLiveRefresh: Bool - } - - enum GeoPresentationState: Equatable { - case disabled - case locationUnavailable - case available(String) - } - - static func geoPresentationState(notesEnabled: Bool, geohash: String?) -> GeoPresentationState { - guard notesEnabled else { return .disabled } - guard let geohash else { return .locationUnavailable } - return .available(geohash) - } - - static func composerGeohash(tab: Tab, notesEnabled: Bool, geoGeohash: String?) -> String? { - switch tab { - case .geo: - guard case .available(let geohash) = geoPresentationState( - notesEnabled: notesEnabled, - geohash: geoGeohash - ) else { - return nil - } - return geohash - case .mesh: - return "" - } - } - - /// Reconciles both privacy-sensitive resources owned by the sheet: the - /// high-accuracy CoreLocation refresh and the precise notes REQ. Kept as - /// a callback-driven function so permission and kill-switch transitions - /// can be regression tested without presenting SwiftUI. - @MainActor - static func reconcileGeoSession( - tab: Tab, - needsDeviceLocation: Bool, - permissionState: LocationChannelManager.PermissionState, - notesEnabled: Bool, - geohash: String?, - manager: LocationNotesManager?, - ownsLiveRefresh: Bool, - beginLiveRefresh: () -> Void, - endLiveRefresh: () -> Void, - acquire: (String) -> LocationNotesManager, - release: (LocationNotesManager?) -> Void - ) -> GeoSessionState { - let geoTabActive = tab == .geo && notesEnabled - let wantsLiveRefresh = geoTabActive && needsDeviceLocation && permissionState == .authorized - - if wantsLiveRefresh != ownsLiveRefresh { - if wantsLiveRefresh { - beginLiveRefresh() - } else { - endLiveRefresh() - } - } - - // A selected location channel is an explicit remote/teleported scope - // and remains usable without device permission. Only device-derived - // scope requires current authorization. - let mayUseNotes = geoTabActive && - (!needsDeviceLocation || permissionState == .authorized) - guard mayUseNotes, let geohash else { - if manager != nil { - release(manager) - } - return GeoSessionState(manager: nil, ownsLiveRefresh: wantsLiveRefresh) - } - - if let manager { - if manager.geohash != geohash.lowercased() { - // Pooled managers are shared; never retarget one in place. - release(manager) - return GeoSessionState( - manager: acquire(geohash), - ownsLiveRefresh: wantsLiveRefresh - ) - } - if manager.state == .idle { - manager.refresh() - } - return GeoSessionState(manager: manager, ownsLiveRefresh: wantsLiveRefresh) - } - - return GeoSessionState( - manager: acquire(geohash), - ownsLiveRefresh: wantsLiveRefresh - ) - } - - private func reconcileGeoSession(notesEnabled: Bool? = nil) { - let notesEnabled = notesEnabled ?? locationNotesEnabled - let next = Self.reconcileGeoSession( - tab: tab, - needsDeviceLocation: geoTabNeedsDeviceLocation, - permissionState: locationChannelsModel.permissionState, - notesEnabled: notesEnabled, - geohash: geoGeohash, - manager: liveGeoManager, - ownsLiveRefresh: ownsLiveGeoRefresh, - beginLiveRefresh: { - locationChannelsModel.enableLocationChannels() - locationChannelsModel.beginLiveRefresh() - }, - endLiveRefresh: { locationChannelsModel.endLiveRefresh() }, - acquire: { geohash in - notesManager ?? LocationNotesPool.shared.acquire(geohash) - }, - release: { manager in - guard notesManager == nil else { return } - LocationNotesPool.shared.release(manager) - } - ) - // A test-injected manager is owned by the caller, not by the pool or - // this view's lifecycle state. - liveGeoManager = notesManager == nil ? next.manager : nil - ownsLiveGeoRefresh = next.ownsLiveRefresh - } - - private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } - - /// The geohash the geo tab is scoped to: the selected location channel, - /// or the device's building geohash when chatting on mesh. - private var geoGeohash: String? { - if case .location(let channel) = locationChannelsModel.selectedChannel { - return channel.geohash - } - guard locationChannelsModel.permissionState == .authorized else { - return nil - } - return locationChannelsModel.currentBuildingGeohash - } - - /// The geo scope comes from device location only when no location channel - /// is selected; that's the case that needs the location machinery. - private var geoTabNeedsDeviceLocation: Bool { - if case .location = locationChannelsModel.selectedChannel { return false } - return true - } - - private var activeGeohash: String? { - Self.composerGeohash( - tab: tab, - notesEnabled: locationNotesEnabled, - geoGeohash: geoGeohash - ) - } - - enum Strings { - static let title = String(localized: "notices.title", defaultValue: "notices", comment: "Title prefix of the unified notices sheet") - static let geoTab = String(localized: "notices.tab.geo", defaultValue: "geo", comment: "Segmented control label for geohash-scoped notices") - static let meshTab = String(localized: "notices.tab.mesh", defaultValue: "mesh", comment: "Segmented control label for mesh-local notices") - static let scopePicker = String(localized: "notices.accessibility.scope", defaultValue: "Notices scope", comment: "Accessibility label for the geo/mesh scope toggle") - // The pre-merge location-notes explainer, reused so its existing - // translations carry over. - static let geoDescription = String(localized: "location_notes.description", comment: "Explainer for the geo tab of the notices sheet") - static let meshDescription = String(localized: "notices.description.mesh", defaultValue: "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.", comment: "Explainer for the mesh tab of the notices sheet") - static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts") - static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts") - static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts") - static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer") - static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field") - static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button") - static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts") - static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker") - static let permanentOption = String(localized: "notices.expiry.permanent", defaultValue: "permanent", comment: "Accessibility label for the ∞ (never expires) option in the geo notes expiry picker") - static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button") - static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh") - static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays") - static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices") - static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices") - static let locationNotesTitle: LocalizedStringKey = "app_info.location.notes.title" - static let locationNotesDescription: LocalizedStringKey = "app_info.location.notes.description" - static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" - static let connectingRelays: LocalizedStringKey = "location_notes.connecting_relays" - static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" - static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint" - static let retry: LocalizedStringKey = "location_notes.action.retry" - static let dismissError: LocalizedStringKey = "location_notes.action.dismiss" - - static func expiryDaysOption(_ days: Int) -> String { - String( - format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"), - locale: .current, - days - ) - } - - static func fades(_ expiresAt: Date) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return String( - format: String(localized: "notices.fades", defaultValue: "fades %@", comment: "Shown on notices with an expiry; placeholder is a localized relative time like 'in 23h'"), - locale: .current, - formatter.localizedString(for: expiresAt, relativeTo: Date()) - ) - } - - static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String { - let base = String( - format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"), - locale: .current, - author, content - ) - return urgent ? "\(urgentBadge), \(base)" : base - } - } - - var body: some View { - VStack(spacing: 0) { - headerSection - contentSection - if activeGeohash != nil { - composer - } - } - .themedSurface() - #if os(macOS) - .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) - #endif - .themedSheetBackground() - .onAppear { - reconcileGeoSession() - } - .onChange(of: tab) { newTab in - if newTab == .geo { - if Self.revealsNearbyNotes(onSwitchingTo: newTab, geoGeohash: geoGeohash) { - NearbyNotesCounter.shared.reveal() - } - } - reconcileGeoSession() - // Each tab keeps its natural default: geo notes stay until - // deleted (∞), mesh board posts fade within a week. - expiryDays = newTab == .geo ? Self.permanentExpiry : 7 - urgent = false - } - // Catches both grant and revocation. Revocation must balance the live - // refresh and release a device-derived building REQ immediately. - .onChange(of: locationChannelsModel.permissionState) { _ in - reconcileGeoSession() - } - .onChange(of: geoGeohash) { _ in - reconcileGeoSession() - } - .onChange(of: geoTabNeedsDeviceLocation) { _ in - reconcileGeoSession() - } - .onReceive(NotificationCenter.default.publisher(for: LocationNotesSettings.didChangeNotification)) { _ in - let enabled = LocationNotesSettings.enabled - locationNotesEnabled = enabled - reconcileGeoSession(notesEnabled: enabled) - } - .onDisappear { - if ownsLiveGeoRefresh { - locationChannelsModel.endLiveRefresh() - ownsLiveGeoRefresh = false - } - if notesManager == nil { - LocationNotesPool.shared.release(liveGeoManager) - } - liveGeoManager = nil - } - } - - private var headerSection: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Text(verbatim: scopeTitle) - .bitchatFont(size: 18) - Spacer() - SheetCloseButton { dismiss() } - .accessibilityLabel(Strings.closeHint) - } - Picker(Strings.scopePicker, selection: $tab) { - Text(Strings.geoTab).tag(Tab.geo) - Text(Strings.meshTab).tag(Tab.mesh) - } - .pickerStyle(.segmented) - .accessibilityLabel(Strings.scopePicker) - Text(tab == .geo ? Strings.geoDescription : Strings.meshDescription) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) - .themedSurface() - } - - private var scopeTitle: String { - switch tab { - case .mesh: - return "\(Strings.title) @ #mesh" - case .geo: - if let geohash = geoGeohash { - return "\(Strings.title) @ #\(geohash)" - } - return Strings.title - } - } - - @ViewBuilder - private var contentSection: some View { - switch tab { - case .mesh: - NoticesList( - items: UnifiedNotices.merge(posts: board.posts(forGeohash: ""), notes: []), - showsSource: false, - board: board, - notesManager: nil - ) - case .geo: - switch Self.geoPresentationState( - notesEnabled: locationNotesEnabled, - geohash: geoGeohash - ) { - case .disabled: - locationNotesDisabledSection - case .available(let geohash): - if let manager = activeNotesManager { - GeoNoticesList(geohash: geohash, board: board, manager: manager) - } else { - // Manager is created on appear; visible for one frame. - Color.clear - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { reconcileGeoSession() } - } - case .locationUnavailable: - locationUnavailableSection - } - } - } - - private var locationNotesDisabledSection: some View { - ScrollView { - Toggle( - isOn: Binding( - get: { locationNotesEnabled }, - set: { enabled in - locationNotesEnabled = enabled - LocationNotesSettings.enabled = enabled - } - ) - ) { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.locationNotesTitle) - .bitchatFont(size: 14) - Text(Strings.locationNotesDescription) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .toggleStyle(.switch) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .themedSurface() - } - - private var locationUnavailableSection: some View { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - Text(Strings.locationUnavailable) - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) - .fixedSize(horizontal: false, vertical: true) - Button(Strings.enableLocation) { - locationChannelsModel.enableAndRefresh() - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .themedSurface() - } - - private var composer: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 10) { - TextField(Strings.placeholder, text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .bitchatFont(size: 14) - .lineLimit(maxDraftLines, reservesSpace: true) - .padding(.vertical, 6) - Button(action: send) { - Image(systemName: "arrow.up.circle.fill") - .font(.bitchatSystem(size: 20)) - .foregroundColor(sendEnabled ? palette.accent : .secondary) - } - .padding(.top, 2) - .buttonStyle(.plain) - .disabled(!sendEnabled) - .accessibilityLabel(Strings.send) - } - // Both tabs pick an expiry (geo notes may be ∞); urgency is a - // mesh-board concept — notes are ambient by nature. - HStack(spacing: 12) { - if tab == .mesh { - Toggle(isOn: $urgent) { - Text(Strings.urgentToggle) - .bitchatFont(size: 12) - .foregroundColor(urgent ? palette.alertRed : palette.secondary) - } - .toggleStyle(.switch) - .fixedSize() - .accessibilityLabel(Strings.urgentToggle) - } - Spacer() - Text(Strings.expiryLabel) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Picker(Strings.expiryLabel, selection: $expiryDays) { - // Mesh board posts must fade (the wire caps their - // lifetime); only relay-backed geo notes can be ∞. - if tab == .geo { - Text(verbatim: "∞") - .accessibilityLabel(Strings.permanentOption) - .tag(Self.permanentExpiry) - } - ForEach([1, 3, 7], id: \.self) { days in - Text(Strings.expiryDaysOption(days)).tag(days) - } - } - .pickerStyle(.segmented) - // macOS segmented pickers render their own label; the themed - // Text alongside already carries it (and accessibility keeps - // the explicit label below). - .labelsHidden() - .fixedSize() - .accessibilityLabel(Strings.expiryLabel) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .themedSurface() - .overlay(Divider(), alignment: .top) - } - - private var sendEnabled: Bool { - let trimmed = draft.trimmed - return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes - } - - private func send() { - guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return } - - // ∞ (geo default): a pure relay note with no NIP-40 tag. It skips - // the mesh board deliberately — a board copy must fade within days, - // which would contradict the permanence the user just picked. - if tab == .geo, expiryDays == Self.permanentExpiry { - guard let manager = activeNotesManager else { return } - manager.send(content: content, nickname: senderNickname, expiresAt: nil) - draft = "" - urgent = false - return - } - - // Expiring posts go to the board and are bridged to Nostr by - // BoardManager, so mesh and internet see the same notice with the - // chosen expiry (expiresAt on mesh, NIP-40 on the bridged note). - // Urgency is mesh-only. - let sent = board.createPost( - content: content, - geohash: geohash, - urgent: tab == .mesh && urgent, - expiryDays: expiryDays, - nickname: senderNickname - ) - if sent { - draft = "" - urgent = false - } - } -} - -/// The geo tab's list: renders the sheet-owned Nostr notes subscription -/// merged with the board posts for the same geohash. The manager lives on -/// `NoticesView` so the composer can post through the same instance (∞ -/// notes local-echo into this list). -private struct GeoNoticesList: View { - let geohash: String - @ObservedObject var board: BoardManager - @ObservedObject var notesManager: LocationNotesManager - - init(geohash: String, board: BoardManager, manager: LocationNotesManager) { - self.geohash = geohash.lowercased() - self.board = board - self.notesManager = manager - } - - var body: some View { - NoticesList( - items: UnifiedNotices.merge( - posts: board.posts(forGeohash: geohash), - notes: notesManager.notes - ), - showsSource: true, - board: board, - notesManager: notesManager - ) - } -} - -/// Renders merged notices with per-source affordances: swipe-delete for own -/// items and a mesh/net badge when sources mix. -private struct NoticesList: View { - let items: [NoticeItem] - let showsSource: Bool - let board: BoardManager - let notesManager: LocationNotesManager? - - @ThemedPalette private var palette - - private typealias Strings = NoticesView.Strings - - var body: some View { - Group { - if items.isEmpty { - ScrollView { - VStack(alignment: .leading, spacing: 4) { - statusRows - if showEmptyState { - Text(Strings.emptyTitle) - .bitchatFont(size: 13, weight: .semibold) - Text(Strings.emptySubtitle) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - } else { - List { - statusRows - .listRowBackground(palette.background) - .listRowSeparatorTint(palette.divider) - ForEach(items) { item in - row(item) - .listRowBackground(palette.background) - .listRowSeparatorTint(palette.divider) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .themedSurface() - } - - /// Notes may still be loading or unreachable; only claim "no notices yet" - /// once the sources settled. - private var showEmptyState: Bool { - guard let notesManager else { return true } - return notesManager.initialLoadComplete - && notesManager.state != .loading - && notesManager.state != .connecting - } - - @ViewBuilder - private var statusRows: some View { - if let notesManager { - if notesManager.state == .loading && !notesManager.initialLoadComplete { - HStack(spacing: 10) { - ProgressView() - Text(Strings.loadingNotes) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Spacer() - } - .padding(.vertical, 8) - } else if notesManager.state == .connecting { - HStack(spacing: 10) { - ProgressView() - Text(Strings.connectingRelays) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Spacer() - } - .padding(.vertical, 8) - } else if notesManager.state == .noRelays { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.noRelaysNearby) - .bitchatFont(size: 13, weight: .semibold) - Text(Strings.relaysRetryHint) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Button(Strings.retry) { notesManager.refresh() } - .bitchatFont(size: 12) - .buttonStyle(.plain) - } - .padding(.bottom, 8) - } else if let error = notesManager.errorMessage { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .bitchatFont(size: 12) - Text(error) - .bitchatFont(size: 12) - Spacer() - } - Button(Strings.dismissError) { notesManager.clearError() } - .bitchatFont(size: 12) - .buttonStyle(.plain) - } - .padding(.bottom, 8) - } - } - } - - private func canDelete(_ item: NoticeItem) -> Bool { - switch item.source { - case .board(let post): - return board.isOwnPost(post) - case .nostr(let note): - return notesManager?.isOwnNote(note) ?? false - } - } - - private func delete(_ item: NoticeItem) { - switch item.source { - case .board(let post): - // Tombstones the board post and retracts the bridged Nostr copy. - board.deletePost(post) - case .nostr(let note): - notesManager?.delete(note: note) - } - } - - private func row(_ item: NoticeItem) -> some View { - let isOwn = canDelete(item) - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - if item.isUrgent { - Image(systemName: "exclamationmark.triangle.fill") - .font(.bitchatSystem(size: 11)) - .foregroundColor(palette.alertRed) - Text(Strings.urgentBadge) - .bitchatFont(size: 11, weight: .semibold) - .foregroundColor(palette.alertRed) - } - Text(verbatim: "@\(item.author)") - .bitchatFont(size: 12, weight: .semibold) - Text(Self.timestampText(for: item.createdAt)) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - if let expiresAt = item.expiresAt, expiresAt > Date() { - Text(Strings.fades(expiresAt)) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary.opacity(0.8)) - } - Spacer() - if showsSource { - sourceBadge(item) - } - if isOwn { - Button { - delete(item) - } label: { - Image(systemName: "trash") - .font(.bitchatSystem(size: 12)) - .foregroundColor(palette.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(Strings.deleteAction) - } - } - Text(item.content) - .bitchatFont(size: 14) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 4) - .accessibilityElement(children: .ignore) - .accessibilityLabel(Strings.rowAccessibilityLabel(author: item.author, content: item.content, urgent: item.isUrgent)) - .accessibilityActions { - if isOwn { - Button(Strings.deleteAction) { delete(item) } - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if isOwn { - Button(role: .destructive) { - delete(item) - } label: { - Label(Strings.deleteAction, systemImage: "trash") - } - } - } - } - - private func sourceBadge(_ item: NoticeItem) -> some View { - HStack(spacing: 3) { - Image(systemName: item.isBoardPost ? "antenna.radiowaves.left.and.right" : "globe") - .font(.bitchatSystem(size: 10)) - Text(item.isBoardPost ? Strings.meshSource : Strings.nostrSource) - .bitchatFont(size: 10) - } - .foregroundColor(palette.secondary.opacity(0.8)) - .accessibilityLabel(item.isBoardPost ? Strings.meshSource : Strings.nostrSource) - } - - // MARK: - Timestamp Formatting - - private static func timestampText(for date: Date) -> String { - let now = Date() - if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { - // The whole "3 hr ago" phrase must come from the formatter — - // gluing an English "ago" onto a localized duration ships the - // wrong word order to most locales ("hace 3 h", "vor 3 Std"). - return relativeFormatter.localizedString(for: date, relativeTo: now) - } - let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) - return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date) - } - - private static let relativeFormatter: RelativeDateTimeFormatter = { - let f = RelativeDateTimeFormatter() - f.unitsStyle = .abbreviated - return f - }() - - private static let absDateFormatter: DateFormatter = { - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("MMM d") - return f - }() - - private static let absDateYearFormatter: DateFormatter = { - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("MMM d, y") - return f - }() -} diff --git a/bitchat/Views/RecentChatList.swift b/bitchat/Views/RecentChatList.swift deleted file mode 100644 index 85180eb4..00000000 --- a/bitchat/Views/RecentChatList.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// RecentChatList.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import SwiftUI - -/// "chats" section for the people sheet: direct conversations with people -/// who are not in any roster above it (offline passersby, geoDMs from a -/// channel since left). Before this section existed, those threads were -/// unreachable the moment the unread envelope cleared — still in memory, -/// no row anywhere in the UI. Renders nothing when there are none. -struct RecentChatList: View { - @ThemedPalette private var palette - - let chats: [RecentChatRow] - let onTapChat: (PeerID) -> Void - - private enum Strings { - static let header = String(localized: "chats.section.header", defaultValue: "chats", comment: "Section header above recent direct conversations in the people sheet") - static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") - static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") - static let openChatHint = String(localized: "chats.accessibility.open_hint", defaultValue: "opens this conversation", comment: "Accessibility hint on a recent chat row explaining activation opens the direct conversation") - } - - /// Relative "5 min ago" stamps; the formatter is locale-aware. - private static let relativeFormatter: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .short - return formatter - }() - - var body: some View { - if !chats.isEmpty { - VStack(alignment: .leading, spacing: 0) { - // Same glyph+label header shape as #mesh / groups. - PeopleSectionHeader( - icon: "bubble.left.and.bubble.right", - iconColor: palette.secondary, - title: Strings.header - ) - - ForEach(chats) { chat in - HStack(spacing: 4) { - Text(verbatim: chat.displayName) - .bitchatFont(size: 14) - .foregroundColor(palette.primary) - .lineLimit(1) - .truncationMode(.tail) - - Text(verbatim: Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date())) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary.opacity(0.8)) - - Spacer() - - if chat.hasUnread { - Image(systemName: "envelope.fill") - .font(.bitchatSystem(size: 10)) - .foregroundColor(.orange) - .help(Strings.newMessagesTooltip) - } - } - .padding(.horizontal) - .padding(.vertical, 6) - .contentShape(Rectangle()) - .onTapGesture { onTapChat(chat.peerID) } - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityDescription(for: chat)) - .accessibilityAddTraits(.isButton) - .accessibilityHint(Strings.openChatHint) - } - } - } - } - - private func accessibilityDescription(for chat: RecentChatRow) -> String { - var parts: [String] = [ - chat.displayName, - Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date()) - ] - if chat.hasUnread { parts.append(Strings.unread) } - return parts.joined(separator: ", ") - } -} diff --git a/bitchat/Views/ShareActivityView.swift b/bitchat/Views/ShareActivityView.swift deleted file mode 100644 index 7b5ea947..00000000 --- a/bitchat/Views/ShareActivityView.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// ShareActivityView.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI - -/// Hosts the system share UI after an optional OpSec confirmation (#1497). -struct ShareActivityView: View { - let text: String - @Environment(\.dismiss) private var dismiss - - var body: some View { - #if os(iOS) - ShareActivityController(items: [text]) - .ignoresSafeArea() - #elseif os(macOS) - VStack(alignment: .leading, spacing: 16) { - Text(text) - .font(.body) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - HStack { - Spacer() - ShareLink(item: text) { - Label( - String(localized: "channel.share.action", defaultValue: "share channel", comment: "Button that opens the system share sheet for a location channel invite"), - systemImage: "square.and.arrow.up" - ) - } - Button(String(localized: "common.done", defaultValue: "done", comment: "Dismisses a sheet")) { - dismiss() - } - .keyboardShortcut(.cancelAction) - } - } - .padding() - .frame(minWidth: 360) - #endif - } -} - -#if os(iOS) -import UIKit - -private struct ShareActivityController: UIViewControllerRepresentable { - let items: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: items, applicationActivities: nil) - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} -} -#endif diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index a3f9d82b..180e2b46 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -1,7 +1,6 @@ import SwiftUI import CoreImage import CoreImage.CIFilterBuiltins -import AVFoundation #if os(iOS) import UIKit #else @@ -12,10 +11,7 @@ import AppKit struct MyQRView: View { let qrString: String @Environment(\.colorScheme) var colorScheme - @ThemedPalette private var palette - // Palette-tinted so the box follows the theme (green under matrix) - // instead of a fixed gray band over the glass gradient. - private var boxColor: Color { palette.secondary.opacity(0.1) } + private var boxColor: Color { Color.gray.opacity(0.1) } private enum Strings { static let title: LocalizedStringKey = "verification.my_qr.title" @@ -25,7 +21,7 @@ struct MyQRView: View { var body: some View { VStack(spacing: 12) { Text(Strings.title) - .bitchatFont(size: 16, weight: .bold) + .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) VStack(spacing: 10) { QRCodeImage(data: qrString, size: 240) @@ -33,7 +29,7 @@ struct MyQRView: View { // Non-scrolling, fully visible URL (wraps across lines) Text(qrString) - .bitchatFont(size: 11) + .font(.bitchatSystem(size: 11, design: .monospaced)) .textSelection(.enabled) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) @@ -54,7 +50,6 @@ struct MyQRView: View { struct QRCodeImage: View { let data: String let size: CGFloat - @ThemedPalette private var palette private let context = CIContext() private let filter = CIFilter.qrCodeGenerator() @@ -70,12 +65,12 @@ struct QRCodeImage: View { .frame(width: size, height: size) } else { RoundedRectangle(cornerRadius: 8) - .stroke(palette.secondary.opacity(0.5), lineWidth: 1) + .stroke(Color.gray.opacity(0.5), lineWidth: 1) .frame(width: size, height: size) .overlay( Text(Strings.unavailable) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.gray) ) } } @@ -110,27 +105,18 @@ struct ImageWrapper: View { } } -/// Peer verification QR scanner. Uses the camera on iOS and macOS; macOS also -/// keeps a paste/validate fallback for machines without a usable camera. +/// Placeholder scanner UI; real camera scanning will be added later. struct QRScanView: View { - @EnvironmentObject private var verificationModel: VerificationModel - @ThemedPalette private var palette + @EnvironmentObject var viewModel: ChatViewModel var isActive: Bool = true var onSuccess: (() -> Void)? = nil // Called when verification succeeds @State private var input = "" - @State private var result: String = "" + @State private var result: String = "" // not shown for iOS scanner @State private var lastValid: String = "" - @State private var cameraUnavailable = false - private enum Strings { static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt" static let validate: LocalizedStringKey = "verification.scan.validate" - static let cameraUnavailable = String( - localized: "verification.scan.camera_unavailable", - defaultValue: "Camera unavailable — paste a QR below.", - comment: "Shown over the scanner preview when no camera is available or permission was denied" - ) static func requested(_ nickname: String) -> String { String( format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"), @@ -144,83 +130,73 @@ struct QRScanView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - ZStack { - CameraScannerView(isActive: isActive, onUnavailable: { cameraUnavailable = true }) { code in - handleScannedCode(code, announceResult: false) - } - if cameraUnavailable { - Text(Strings.cameraUnavailable) - .bitchatFont(size: 13, weight: .medium) - .foregroundColor(palette.secondary) - .multilineTextAlignment(.center) - .padding(16) + #if os(iOS) + CameraScannerView(isActive: isActive) { code in + // Deduplicate: ignore if we just processed this exact QR code + guard code != lastValid else { return } + + if let qr = VerificationService.shared.verifyScannedQR(code) { + let ok = viewModel.beginQRVerification(with: qr) + if ok { + // Successfully initiated verification; remember this QR to prevent re-scanning + lastValid = code + // Close scanner and return to "My QR" view + onSuccess?() + } + // If !ok, peer not found or already pending - don't set lastValid so user can retry + } else { + // ignore invalid reads; continue scanning } } .frame(height: 260) .clipShape(RoundedRectangle(cornerRadius: 8)) - - #if os(macOS) + #else Text(Strings.pastePrompt) - .bitchatFont(size: 14, weight: .medium) + .font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced)) TextEditor(text: $input) .frame(height: 100) - .border(palette.secondary.opacity(0.4)) + .border(Color.gray.opacity(0.4)) Button(Strings.validate) { - handleScannedCode(input, announceResult: true) + // Deduplicate: ignore if we just processed this exact QR + guard input != lastValid else { + result = Strings.requested("") // Already processed + return + } + + if let qr = VerificationService.shared.verifyScannedQR(input) { + let ok = viewModel.beginQRVerification(with: qr) + if ok { + result = Strings.requested(qr.nickname) + lastValid = input + // Close scanner and return to "My QR" view + onSuccess?() + } else { + result = Strings.notFound + } + } else { + result = Strings.invalid + } } .buttonStyle(.bordered) - if !result.isEmpty { - Text(result) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - } #endif + // No status text under camera per design Spacer() } .padding() } - - private func handleScannedCode(_ code: String, announceResult: Bool) { - guard code != lastValid else { - if announceResult { - result = Strings.requested("") - } - return - } - - switch verificationModel.verifyScannedPayload(code) { - case .requested(let nickname): - lastValid = code - if announceResult { - result = Strings.requested(nickname) - } - onSuccess?() - case .notFound: - if announceResult { - result = Strings.notFound - } - case .invalid: - if announceResult { - result = Strings.invalid - } - } - } } #if os(iOS) +import AVFoundation + struct CameraScannerView: UIViewRepresentable { typealias UIViewType = PreviewView var isActive: Bool - var onUnavailable: (() -> Void)? = nil var onCode: (String) -> Void func makeUIView(context: Context) -> PreviewView { let view = PreviewView() - context.coordinator.setup( - previewLayer: view.videoPreviewLayer, - onCode: onCode, - onUnavailable: onUnavailable - ) + context.coordinator.setup(sessionOwner: view, onCode: onCode) context.coordinator.setActive(isActive) return view } @@ -229,10 +205,71 @@ struct CameraScannerView: UIViewRepresentable { context.coordinator.setActive(isActive) } - func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() } + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + private var onCode: ((String) -> Void)? + private weak var owner: PreviewView? + private let session = AVCaptureSession() + private var isRunning = false + private var permissionGranted = false + private var desiredActive = false + + func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) { + self.owner = sessionOwner + self.onCode = onCode + session.beginConfiguration() + session.sessionPreset = .high + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + if output.availableMetadataObjectTypes.contains(.qr) { + output.metadataObjectTypes = [.qr] + } + session.commitConfiguration() + sessionOwner.videoPreviewLayer.session = session + // Request permission and start + AVCaptureDevice.requestAccess(for: .video) { granted in + self.permissionGranted = granted + if granted && self.desiredActive && !self.isRunning { + self.setActive(true) + } + } + } + + func setActive(_ active: Bool) { + desiredActive = active + guard permissionGranted else { return } + if active && !isRunning { + isRunning = true + DispatchQueue.global(qos: .userInitiated).async { + if !self.session.isRunning { self.session.startRunning() } + } + } else if !active && isRunning { + isRunning = false + DispatchQueue.global(qos: .userInitiated).async { + if self.session.isRunning { self.session.stopRunning() } + } + } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + for obj in metadataObjects { + guard let m = obj as? AVMetadataMachineReadableCodeObject, + m.type == .qr, + let str = m.stringValue else { continue } + onCode?(str) + } + } + } final class PreviewView: UIView { - override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } override init(frame: CGRect) { super.init(frame: frame) @@ -241,189 +278,41 @@ struct CameraScannerView: UIViewRepresentable { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } } -#elseif os(macOS) -struct CameraScannerView: NSViewRepresentable { - typealias NSViewType = PreviewView - var isActive: Bool - var onUnavailable: (() -> Void)? = nil - var onCode: (String) -> Void - - func makeNSView(context: Context) -> PreviewView { - let view = PreviewView() - context.coordinator.setup( - previewLayer: view.videoPreviewLayer, - onCode: onCode, - onUnavailable: onUnavailable - ) - context.coordinator.setActive(isActive) - return view - } - - func updateNSView(_ nsView: PreviewView, context: Context) { - context.coordinator.setActive(isActive) - } - - func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() } - - final class PreviewView: NSView { - let videoPreviewLayer = AVCaptureVideoPreviewLayer() - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - videoPreviewLayer.videoGravity = .resizeAspectFill - layer = CALayer() - layer?.addSublayer(videoPreviewLayer) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func layout() { - super.layout() - videoPreviewLayer.frame = bounds - } - } -} #endif -final class CameraScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { - private var onCode: ((String) -> Void)? - private var onUnavailable: (() -> Void)? - private let session = AVCaptureSession() - private var isRunning = false - private var permissionGranted = false - private var desiredActive = false - private var didConfigureSession = false - private weak var previewLayer: AVCaptureVideoPreviewLayer? - - func setup( - previewLayer: AVCaptureVideoPreviewLayer, - onCode: @escaping (String) -> Void, - onUnavailable: (() -> Void)? = nil - ) { - self.onCode = onCode - self.onUnavailable = onUnavailable - self.previewLayer = previewLayer - previewLayer.session = session - - // Check authorization before creating AVCaptureDeviceInput so tests and - // cold launches do not trigger a TCC prompt just by constructing input. - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: - permissionGranted = true - if !configureSessionIfNeeded() { - reportUnavailable() - } - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video) { granted in - DispatchQueue.main.async { - self.permissionGranted = granted - if granted { - if !self.configureSessionIfNeeded() { - self.reportUnavailable() - return - } - if self.desiredActive && !self.isRunning { - self.setActive(true) - } - } else { - self.reportUnavailable() - } - } - } - default: - permissionGranted = false - reportUnavailable() - } - } - - @discardableResult - private func configureSessionIfNeeded() -> Bool { - guard !didConfigureSession else { return true } - session.beginConfiguration() - session.sessionPreset = .high - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { - session.commitConfiguration() - return false - } - session.addInput(input) - let output = AVCaptureMetadataOutput() - guard session.canAddOutput(output) else { - session.commitConfiguration() - return false - } - session.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) - if output.availableMetadataObjectTypes.contains(.qr) { - output.metadataObjectTypes = [.qr] - } - session.commitConfiguration() - previewLayer?.session = session - didConfigureSession = true - return true - } - - private func reportUnavailable() { - DispatchQueue.main.async { - self.onUnavailable?() - } - } - - func setActive(_ active: Bool) { - desiredActive = active - guard permissionGranted, didConfigureSession else { return } - if active && !isRunning { - isRunning = true - DispatchQueue.global(qos: .userInitiated).async { - if !self.session.isRunning { self.session.startRunning() } - } - } else if !active && isRunning { - isRunning = false - DispatchQueue.global(qos: .userInitiated).async { - if self.session.isRunning { self.session.stopRunning() } - } - } - } - - func metadataOutput( - _ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection - ) { - for obj in metadataObjects { - guard let m = obj as? AVMetadataMachineReadableCodeObject, - m.type == .qr, - let str = m.stringValue else { continue } - onCode?(str) - } - } -} - // Combined sheet: shows my QR by default with a button to scan instead struct VerificationSheetView: View { - @EnvironmentObject private var verificationModel: VerificationModel + @EnvironmentObject var viewModel: ChatViewModel @Binding var isPresented: Bool @State private var showingScanner = false - @ThemedPalette private var palette + @Environment(\.colorScheme) var colorScheme - private var accentColor: Color { palette.accent } - private var boxColor: Color { palette.secondary.opacity(0.1) } + private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white } + private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } + private var boxColor: Color { Color.gray.opacity(0.1) } + + private func myQRString() -> String { + let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub + return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? "" + } var body: some View { VStack(spacing: 0) { // Top header (always at top) HStack { Text("verification.sheet.title") - .bitchatFont(size: 14, weight: .bold) + .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(accentColor) Spacer() - SheetCloseButton { + Button(action: { showingScanner = false isPresented = false + }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 14, weight: .semibold)) + .foregroundColor(accentColor) } - .foregroundColor(accentColor) + .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.top, 12) @@ -436,23 +325,31 @@ struct VerificationSheetView: View { if showingScanner { VStack(alignment: .leading, spacing: 12) { Text("verification.scan.prompt_friend") - .bitchatFont(size: 16, weight: .bold) + .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) .foregroundColor(accentColor) + #if os(iOS) QRScanView(isActive: showingScanner, onSuccess: { showingScanner = false }) - .environmentObject(verificationModel) - .frame(minHeight: 280) + .environmentObject(viewModel) + .frame(height: 280) .clipShape(RoundedRectangle(cornerRadius: 10)) + #else + QRScanView(onSuccess: { + showingScanner = false + }) + .environmentObject(viewModel) + #endif } .padding() .frame(maxWidth: .infinity) .background(boxColor) .cornerRadius(8) } else { - MyQRView(qrString: verificationModel.myQRString()) + let qr = myQRString() + MyQRView(qrString: qr) } } .padding(16) @@ -463,24 +360,25 @@ struct VerificationSheetView: View { if showingScanner { Button(action: { showingScanner = false }) { Label("show my qr", systemImage: "qrcode") - .bitchatFont(size: 13) + .font(.bitchatSystem(size: 13, design: .monospaced)) } .buttonStyle(.bordered) } else { Button(action: { showingScanner = true }) { Label("scan someone else's qr", systemImage: "camera.viewfinder") - .bitchatFont(size: 13, weight: .medium) + .font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced)) } .buttonStyle(.bordered) .tint(.gray) } // Optional: Remove verification for selected peer (if verified) - if let peerID = verificationModel.selectedPeerID, - verificationModel.isVerified(peerID: peerID) { - Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) { + if let pid = viewModel.selectedPrivateChatPeer, + let fp = viewModel.getFingerprint(for: pid), + viewModel.verifiedFingerprints.contains(fp) { + Button(action: { viewModel.unverifyFingerprint(for: pid) }) { Label("remove verification", systemImage: "minus.circle") - .bitchatFont(size: 12) + .font(.bitchatSystem(size: 12, design: .monospaced)) } .buttonStyle(.bordered) .tint(.gray) @@ -489,7 +387,7 @@ struct VerificationSheetView: View { .frame(maxWidth: .infinity) .padding(.vertical, 14) } - .themedSheetBackground() + .background(backgroundColor) .onDisappear { showingScanner = false } } } diff --git a/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift b/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift index e57a1471..25a530cd 100644 --- a/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift +++ b/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift @@ -6,7 +6,6 @@ // For more information, see // -import BitFoundation import Foundation extension BitchatMessage { diff --git a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift index 17bc9ead..464eaa3d 100644 --- a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift +++ b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift @@ -6,57 +6,28 @@ // For more information, see // -import BitFoundation import Foundation final class PreviewKeychainManager: KeychainManagerProtocol { - // Locked: KeychainManager.makeDefault() hands one shared instance to - // every default-constructed component under test, which access it from - // arbitrary threads. - private let lock = NSLock() - private let installAccessGate: KeychainInstallAccessGate - private let reconcileInstallAccess: () -> Bool private var storage: [String: Data] = [:] private var serviceStorage: [String: [String: Data]] = [:] - - init( - installAccessGate: KeychainInstallAccessGate = KeychainInstallAccessGate(), - reconcileInstallAccess: @escaping () -> Bool = { true } - ) { - self.installAccessGate = installAccessGate - self.reconcileInstallAccess = reconcileInstallAccess - } - - private func installAccessAllowed() -> Bool { - installAccessGate.allowsAccess(reconcile: reconcileInstallAccess) - } + init() {} func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { - guard installAccessAllowed() else { return false } - lock.lock() - defer { lock.unlock() } storage[key] = keyData return true } func getIdentityKey(forKey key: String) -> Data? { - guard installAccessAllowed() else { return nil } - lock.lock() - defer { lock.unlock() } - return storage[key] + storage[key] } func deleteIdentityKey(forKey key: String) -> Bool { - guard installAccessAllowed() else { return false } - lock.lock() - defer { lock.unlock() } storage.removeValue(forKey: key) return true } func deleteAllKeychainData() -> Bool { - lock.lock() - defer { lock.unlock() } storage.removeAll() serviceStorage.removeAll() return true @@ -67,17 +38,11 @@ final class PreviewKeychainManager: KeychainManagerProtocol { func secureClear(_ string: inout String) {} func verifyIdentityKeyExists() -> Bool { - guard installAccessAllowed() else { return false } - lock.lock() - defer { lock.unlock() } - return storage["identity_noiseStaticKey"] != nil + storage["identity_noiseStaticKey"] != nil } // BCH-01-009: New methods with proper error classification func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { - guard installAccessAllowed() else { return .accessDenied } - lock.lock() - defer { lock.unlock() } if let data = storage[key] { return .success(data) } @@ -85,9 +50,6 @@ final class PreviewKeychainManager: KeychainManagerProtocol { } func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { - guard installAccessAllowed() else { return .accessDenied } - lock.lock() - defer { lock.unlock() } storage[key] = keyData return .success } @@ -95,40 +57,17 @@ final class PreviewKeychainManager: KeychainManagerProtocol { // MARK: - Generic Data Storage (consolidated from KeychainHelper) func save(key: String, data: Data, service: String, accessible: CFString?) { - guard installAccessAllowed() else { return } - lock.lock() - defer { lock.unlock() } - serviceStorage[service, default: [:]][key] = data + if serviceStorage[service] == nil { + serviceStorage[service] = [:] + } + serviceStorage[service]?[key] = data } func load(key: String, service: String) -> Data? { - guard case .success(let data) = loadWithResult(key: key, service: service) else { - return nil - } - return data - } - - func loadWithResult(key: String, service: String) -> KeychainReadResult { - guard installAccessAllowed() else { return .accessDenied } - lock.lock() - defer { lock.unlock() } - guard let data = serviceStorage[service]?[key] else { - return .itemNotFound - } - return .success(data) + serviceStorage[service]?[key] } func delete(key: String, service: String) { - guard installAccessAllowed() else { return } - lock.lock() - defer { lock.unlock() } serviceStorage[service]?.removeValue(forKey: key) } - - func deleteAll(service: String) { - guard installAccessAllowed() else { return } - lock.lock() - defer { lock.unlock() } - serviceStorage.removeValue(forKey: service) - } } diff --git a/bitchat/bitchat-macOS.entitlements b/bitchat/bitchat-macOS.entitlements index f3310f75..2369c18b 100644 --- a/bitchat/bitchat-macOS.entitlements +++ b/bitchat/bitchat-macOS.entitlements @@ -10,8 +10,6 @@ com.apple.security.device.bluetooth - com.apple.security.device.camera - com.apple.security.device.microphone com.apple.security.personal-information.location diff --git a/bitchatShareExtension/Info.plist b/bitchatShareExtension/Info.plist index 62d9b453..a9c29c8d 100644 --- a/bitchatShareExtension/Info.plist +++ b/bitchatShareExtension/Info.plist @@ -28,6 +28,8 @@ NSExtensionActivationRule + NSExtensionActivationSupportsImageWithMaxCount + 1 NSExtensionActivationSupportsText NSExtensionActivationSupportsWebURLWithMaxCount diff --git a/bitchatShareExtension/Localization/Localizable.xcstrings b/bitchatShareExtension/Localization/Localizable.xcstrings index 23c8fab2..20a68af4 100644 --- a/bitchatShareExtension/Localization/Localizable.xcstrings +++ b/bitchatShareExtension/Localization/Localizable.xcstrings @@ -1,681 +1,708 @@ { - "sourceLanguage" : "en", - "strings" : { - "share.fallback.shared_link_title" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "رابط مشترك", - "comment" : "Fallback title when saving a shared link" + "sourceLanguage": "en", + "strings": { + "share.fallback.shared_link_title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "shared Link", + "comment": "Fallback title when saving a shared link" } }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "শেয়ার করা লিঙ্ক" + "ar": { + "stringUnit": { + "state": "translated", + "value": "رابط مشترك", + "comment": "Fallback title when saving a shared link" } }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "geteilter link", - "comment" : "Fallback title when saving a shared link" + "de": { + "stringUnit": { + "state": "translated", + "value": "geteilter link", + "comment": "Fallback title when saving a shared link" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "shared Link", - "comment" : "Fallback title when saving a shared link" + "es": { + "stringUnit": { + "state": "translated", + "value": "enlace compartido", + "comment": "Fallback title when saving a shared link" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "enlace compartido", - "comment" : "Fallback title when saving a shared link" + "fr": { + "stringUnit": { + "state": "translated", + "value": "lien partagé", + "comment": "Fallback title when saving a shared link" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیوند اشتراک‌گذاری‌شده" + "he": { + "stringUnit": { + "state": "translated", + "value": "קישור משותף", + "comment": "Fallback title when saving a shared link" } }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "naibahaging link" + "id": { + "stringUnit": { + "state": "translated", + "value": "tautan dibagikan", + "comment": "Fallback title when saving a shared link" } }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "lien partagé", - "comment" : "Fallback title when saving a shared link" + "it": { + "stringUnit": { + "state": "translated", + "value": "link condiviso", + "comment": "Fallback title when saving a shared link" } }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "קישור משותף", - "comment" : "Fallback title when saving a shared link" + "ja": { + "stringUnit": { + "state": "translated", + "value": "共有リンク", + "comment": "Fallback title when saving a shared link" } }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "शेयर किया गया लिंक" + "ne": { + "stringUnit": { + "state": "translated", + "value": "साझा गरिएको लिङ्क", + "comment": "Fallback title when saving a shared link" } }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tautan dibagikan", - "comment" : "Fallback title when saving a shared link" + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "link compartilhado", + "comment": "Fallback title when saving a shared link" } }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "link condiviso", - "comment" : "Fallback title when saving a shared link" + "ru": { + "stringUnit": { + "state": "translated", + "value": "поделился ссылкой", + "comment": "Fallback title when saving a shared link" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "共有リンク", - "comment" : "Fallback title when saving a shared link" + "uk": { + "stringUnit": { + "state": "translated", + "value": "спільне посилання", + "comment": "Fallback title when saving a shared link" } }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공유된 링크", - "comment" : "Fallback title when saving a shared link" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分享的链接", + "comment": "Fallback title when saving a shared link" } }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pautan dikongsi" + "ko": { + "stringUnit": { + "state": "translated", + "value": "공유된 링크", + "comment": "Fallback title when saving a shared link" } }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "साझा गरिएको लिङ्क", - "comment" : "Fallback title when saving a shared link" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gedeelde link" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "udostępniony link" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ligação partilhada" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "link compartilhado", - "comment" : "Fallback title when saving a shared link" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "поделился ссылкой", - "comment" : "Fallback title when saving a shared link" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "delad länk" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "பகிரப்பட்ட இணைப்பு" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ลิงก์ที่แชร์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "paylaşılan bağlantı", - "comment" : "Fallback title when saving a shared link" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "спільне посилання", - "comment" : "Fallback title when saving a shared link" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "شیئر کردہ لنک" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "liên kết đã chia sẻ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "分享的链接", - "comment" : "Fallback title when saving a shared link" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "分享的連結" + "tr": { + "stringUnit": { + "state": "translated", + "value": "paylaşılan bağlantı", + "comment": "Fallback title when saving a shared link" } } } }, - "share.status.no_shareable_content" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا محتوى قابلاً للمشاركة", - "comment" : "Shown when provided content cannot be shared" + "share.status.failed_to_encode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "failed to encode link", + "comment": "Shown when the share payload cannot be encoded" } }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "শেয়ার করার মতো কোনো কনটেন্ট নেই" + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر ترميز الرابط", + "comment": "Shown when the share payload cannot be encoded" } }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "kein teilbarer inhalt", - "comment" : "Shown when provided content cannot be shared" + "de": { + "stringUnit": { + "state": "translated", + "value": "link konnte nicht codiert werden", + "comment": "Shown when the share payload cannot be encoded" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no shareable content", - "comment" : "Shown when provided content cannot be shared" + "es": { + "stringUnit": { + "state": "translated", + "value": "no se pudo codificar el enlace", + "comment": "Shown when the share payload cannot be encoded" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "sin contenido que se pueda compartir", - "comment" : "Shown when provided content cannot be shared" + "fr": { + "stringUnit": { + "state": "translated", + "value": "échec de l'encodage du lien", + "comment": "Shown when the share payload cannot be encoded" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "محتوای قابل اشتراک‌گذاری وجود ندارد" + "he": { + "stringUnit": { + "state": "translated", + "value": "לא ניתן לקודד את הקישור", + "comment": "Shown when the share payload cannot be encoded" } }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "walang maibabahaging nilalaman" + "id": { + "stringUnit": { + "state": "translated", + "value": "gagal mengodekan tautan", + "comment": "Shown when the share payload cannot be encoded" } }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "aucun contenu partageable", - "comment" : "Shown when provided content cannot be shared" + "it": { + "stringUnit": { + "state": "translated", + "value": "impossibile codificare il link", + "comment": "Shown when the share payload cannot be encoded" } }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין תוכן שניתן לשתף", - "comment" : "Shown when provided content cannot be shared" + "ja": { + "stringUnit": { + "state": "translated", + "value": "リンクのエンコードに失敗しました", + "comment": "Shown when the share payload cannot be encoded" } }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "शेयर करने योग्य कोई सामग्री नहीं" + "ne": { + "stringUnit": { + "state": "translated", + "value": "लिङ्क सङ्केत गर्न सकेन", + "comment": "Shown when the share payload cannot be encoded" } }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak ada konten yang bisa dibagikan", - "comment" : "Shown when provided content cannot be shared" + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "falha ao codificar link", + "comment": "Shown when the share payload cannot be encoded" } }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "nessun contenuto condivisibile", - "comment" : "Shown when provided content cannot be shared" + "ru": { + "stringUnit": { + "state": "translated", + "value": "не удалось закодировать ссылку", + "comment": "Shown when the share payload cannot be encoded" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "共有可能なコンテンツがありません", - "comment" : "Shown when provided content cannot be shared" + "uk": { + "stringUnit": { + "state": "translated", + "value": "не вдалося закодувати посилання", + "comment": "Shown when the share payload cannot be encoded" } }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공유할 수 있는 내용이 없습니다", - "comment" : "Shown when provided content cannot be shared" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法编码链接", + "comment": "Shown when the share payload cannot be encoded" } }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tiada kandungan yang boleh dikongsi" + "ko": { + "stringUnit": { + "state": "translated", + "value": "링크를 인코딩하는 데 실패했습니다", + "comment": "Shown when the share payload cannot be encoded" } }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "बाँड्न मिल्ने सामग्री छैन", - "comment" : "Shown when provided content cannot be shared" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "geen deelbare inhoud" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "brak treści do udostępnienia" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sem conteúdo partilhável" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "nenhum conteúdo compartilhável", - "comment" : "Shown when provided content cannot be shared" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "нет подходящего контента", - "comment" : "Shown when provided content cannot be shared" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inget delbart innehåll" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "பகிரக்கூடிய உள்ளடக்கம் இல்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่มีเนื้อหาที่แชร์ได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "paylaşılabilir içerik yok", - "comment" : "Shown when provided content cannot be shared" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "нема відповідного контенту", - "comment" : "Shown when provided content cannot be shared" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "شیئر کرنے کے قابل کوئی مواد نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không có nội dung có thể chia sẻ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "没有可分享的素材", - "comment" : "Shown when provided content cannot be shared" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "沒有可分享的素材" + "tr": { + "stringUnit": { + "state": "translated", + "value": "bağlantı kodlanamadı", + "comment": "Shown when the share payload cannot be encoded" } } } }, - "share.status.nothing_to_share" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا شيء لمشاركته", - "comment" : "Shown when the share extension receives no content" + "share.status.no_shareable_content": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "no shareable content", + "comment": "Shown when provided content cannot be shared" } }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "শেয়ার করার কিছু নেই" + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا محتوى قابلاً للمشاركة", + "comment": "Shown when provided content cannot be shared" } }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "nichts zum teilen", - "comment" : "Shown when the share extension receives no content" + "de": { + "stringUnit": { + "state": "translated", + "value": "kein teilbarer inhalt", + "comment": "Shown when provided content cannot be shared" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "nothing to share", - "comment" : "Shown when the share extension receives no content" + "es": { + "stringUnit": { + "state": "translated", + "value": "sin contenido que se pueda compartir", + "comment": "Shown when provided content cannot be shared" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "nada que compartir", - "comment" : "Shown when the share extension receives no content" + "fr": { + "stringUnit": { + "state": "translated", + "value": "aucun contenu partageable", + "comment": "Shown when provided content cannot be shared" } }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "چیزی برای اشتراک‌گذاری نیست" + "he": { + "stringUnit": { + "state": "translated", + "value": "אין תוכן שניתן לשתף", + "comment": "Shown when provided content cannot be shared" } }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "walang maibabahagi" + "id": { + "stringUnit": { + "state": "translated", + "value": "tidak ada konten yang bisa dibagikan", + "comment": "Shown when provided content cannot be shared" } }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "rien à partager", - "comment" : "Shown when the share extension receives no content" + "it": { + "stringUnit": { + "state": "translated", + "value": "nessun contenuto condivisibile", + "comment": "Shown when provided content cannot be shared" } }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין מה לשתף", - "comment" : "Shown when the share extension receives no content" + "ja": { + "stringUnit": { + "state": "translated", + "value": "共有可能なコンテンツがありません", + "comment": "Shown when provided content cannot be shared" } }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "शेयर करने के लिए कुछ नहीं" + "ne": { + "stringUnit": { + "state": "translated", + "value": "बाँड्न मिल्ने सामग्री छैन", + "comment": "Shown when provided content cannot be shared" } }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "tidak ada yang bisa dibagikan", - "comment" : "Shown when the share extension receives no content" + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "nenhum conteúdo compartilhável", + "comment": "Shown when provided content cannot be shared" } }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "niente da condividere", - "comment" : "Shown when the share extension receives no content" + "ru": { + "stringUnit": { + "state": "translated", + "value": "нет подходящего контента", + "comment": "Shown when provided content cannot be shared" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "共有できるものがありません", - "comment" : "Shown when the share extension receives no content" + "uk": { + "stringUnit": { + "state": "translated", + "value": "нема відповідного контенту", + "comment": "Shown when provided content cannot be shared" } }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "공유할 내용이 없습니다", - "comment" : "Shown when the share extension receives no content" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有可分享的素材", + "comment": "Shown when provided content cannot be shared" } }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tiada apa-apa untuk dikongsi" + "ko": { + "stringUnit": { + "state": "translated", + "value": "공유할 수 있는 내용이 없습니다", + "comment": "Shown when provided content cannot be shared" } }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "बाँड्ने केही छैन", - "comment" : "Shown when the share extension receives no content" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "niets om te delen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie ma nic do udostępnienia" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nada para partilhar" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "nada para compartilhar", - "comment" : "Shown when the share extension receives no content" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "нечем поделиться", - "comment" : "Shown when the share extension receives no content" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inget att dela" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "பகிர எதுவும் இல்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่มีอะไรให้แชร์" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "paylaşılacak bir şey yok", - "comment" : "Shown when the share extension receives no content" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "нема чим ділитися", - "comment" : "Shown when the share extension receives no content" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "شیئر کرنے کے لیے کچھ نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không có gì để chia sẻ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "没有可分享的内容", - "comment" : "Shown when the share extension receives no content" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "沒有可分享的內容" + "tr": { + "stringUnit": { + "state": "translated", + "value": "paylaşılabilir içerik yok", + "comment": "Shown when provided content cannot be shared" } } } }, - "share.status.failed_to_save" : { - "comment" : "Shown when content cannot be staged for the main app", - "extractionState" : "manual", - "localizations" : { - "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذر الحفظ في bitchat" } }, - "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-এ সংরক্ষণ করা যায়নি" } }, - "de" : { "stringUnit" : { "state" : "needs_review", "value" : "Konnte nicht in bitchat gespeichert werden" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Could not save to bitchat" } }, - "es" : { "stringUnit" : { "state" : "needs_review", "value" : "No se pudo guardar en bitchat" } }, - "fa" : { "stringUnit" : { "state" : "needs_review", "value" : "ذخیره در bitchat ممکن نشد" } }, - "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Hindi ma-save sa bitchat" } }, - "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossible d’enregistrer dans bitchat" } }, - "he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } }, - "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } }, - "id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } }, - "it" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossibile salvare in bitchat" } }, - "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat に保存できませんでした" } }, - "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat에 저장할 수 없습니다" } }, - "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } }, - "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } }, - "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } }, - "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } }, - "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível guardar no bitchat" } }, - "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível salvar no bitchat" } }, - "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Не удалось сохранить в bitchat" } }, - "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } }, - "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } }, - "th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } }, - "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat’e kaydedilemedi" } }, - "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Не вдалося зберегти в bitchat" } }, - "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat میں محفوظ نہیں ہو سکا" } }, - "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Không thể lưu vào bitchat" } }, - "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } }, - "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } } + "share.status.nothing_to_share": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "nothing to share", + "comment": "Shown when the share extension receives no content" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا شيء لمشاركته", + "comment": "Shown when the share extension receives no content" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "nichts zum teilen", + "comment": "Shown when the share extension receives no content" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "nada que compartir", + "comment": "Shown when the share extension receives no content" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "rien à partager", + "comment": "Shown when the share extension receives no content" + } + }, + "he": { + "stringUnit": { + "state": "translated", + "value": "אין מה לשתף", + "comment": "Shown when the share extension receives no content" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "tidak ada yang bisa dibagikan", + "comment": "Shown when the share extension receives no content" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "niente da condividere", + "comment": "Shown when the share extension receives no content" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共有できるものがありません", + "comment": "Shown when the share extension receives no content" + } + }, + "ne": { + "stringUnit": { + "state": "translated", + "value": "बाँड्ने केही छैन", + "comment": "Shown when the share extension receives no content" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "nada para compartilhar", + "comment": "Shown when the share extension receives no content" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "нечем поделиться", + "comment": "Shown when the share extension receives no content" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "нема чим ділитися", + "comment": "Shown when the share extension receives no content" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有可分享的内容", + "comment": "Shown when the share extension receives no content" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공유할 내용이 없습니다", + "comment": "Shown when the share extension receives no content" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "paylaşılacak bir şey yok", + "comment": "Shown when the share extension receives no content" + } + } } }, - "share.status.saved_for_review" : { - "comment" : "Shown after content is staged for review in the main app", - "extractionState" : "manual", - "localizations" : { - "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ حُفظ في bitchat — افتح التطبيق للمراجعة" } }, - "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-এ সংরক্ষিত — পর্যালোচনার জন্য অ্যাপটি খুলুন" } }, - "de" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ In bitchat gespeichert — App zum Prüfen öffnen" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "✓ Saved in bitchat — open the app to review" } }, - "es" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado en bitchat — abre la app para revisarlo" } }, - "fa" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ در bitchat ذخیره شد — برای بازبینی، برنامه را باز کنید" } }, - "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Na-save sa bitchat — buksan ang app para suriin" } }, - "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Enregistré dans bitchat — ouvrez l’app pour vérifier" } }, - "he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } }, - "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } }, - "id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } }, - "it" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvato in bitchat — apri l’app per controllare" } }, - "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat に保存しました — アプリを開いて確認してください" } }, - "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat에 저장됨 — 앱을 열어 검토하세요" } }, - "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } }, - "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } }, - "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } }, - "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } }, - "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado no bitchat — abra a app para rever" } }, - "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvo no bitchat — abra o app para revisar" } }, - "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Сохранено в bitchat — откройте приложение для проверки" } }, - "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } }, - "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } }, - "th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } }, - "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat’e kaydedildi — incelemek için uygulamayı açın" } }, - "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Збережено в bitchat — відкрийте застосунок для перегляду" } }, - "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat میں محفوظ ہو گیا — جائزے کے لیے ایپ کھولیں" } }, - "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Đã lưu trong bitchat — mở ứng dụng để xem lại" } }, - "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已保存在 bitchat 中 — 打开应用查看" } }, - "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已儲存在 bitchat 中 — 開啟 App 查看" } } + "share.status.shared_link": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "✓ shared link to bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "✓ تم إرسال الرابط إلى bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "✓ link zu bitchat geteilt", + "comment": "Confirmation after successfully sharing a link" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "✓ enlace compartido con bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "✓ lien partagé vers bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "he": { + "stringUnit": { + "state": "translated", + "value": "✓ הקישור נשלח אל bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "✓ tautan dikirim ke bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "✓ link inviato a bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "✓ bitchatにリンクを共有", + "comment": "Confirmation after successfully sharing a link" + } + }, + "ne": { + "stringUnit": { + "state": "translated", + "value": "✓ bitchat मा लिङ्क पठाइयो", + "comment": "Confirmation after successfully sharing a link" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "✓ link enviado para bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "✓ ссылка отправлена в bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "✓ посилання надіслано в bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "✓ 已将链接分享至 bitchat", + "comment": "Confirmation after successfully sharing a link" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "✓ bitchat으로 링크를 공유했습니다", + "comment": "Confirmation after successfully sharing a link" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "✓ bitchat'e bağlantı paylaşıldı", + "comment": "Confirmation after successfully sharing a link" + } + } + } + }, + "share.status.shared_text": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "✓ shared text to bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "✓ تم إرسال النص إلى bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "✓ text zu bitchat geteilt", + "comment": "Confirmation after successfully sharing text" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "✓ texto compartido con bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "✓ texte partagé vers bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "he": { + "stringUnit": { + "state": "translated", + "value": "✓ הטקסט נשלח אל bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "✓ teks dikirim ke bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "✓ testo inviato a bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "✓ bitchatにテキストを共有", + "comment": "Confirmation after successfully sharing text" + } + }, + "ne": { + "stringUnit": { + "state": "translated", + "value": "✓ bitchat मा पाठ पठाइयो", + "comment": "Confirmation after successfully sharing text" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "✓ texto enviado para bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "✓ текст отправлен в bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "✓ текст надіслано в bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "✓ 已将文本分享至 bitchat", + "comment": "Confirmation after successfully sharing text" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "✓ bitchat으로 텍스트를 공유했습니다", + "comment": "Confirmation after successfully sharing text" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "✓ bitchat'e metin paylaşıldı", + "comment": "Confirmation after successfully sharing text" + } + } } } }, - "version" : "1.0" + "version": "1.0" } diff --git a/bitchatShareExtension/PrivacyInfo.xcprivacy b/bitchatShareExtension/PrivacyInfo.xcprivacy deleted file mode 100644 index d773e0ef..00000000 --- a/bitchatShareExtension/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,23 +0,0 @@ - - - - - NSPrivacyTracking - - NSPrivacyTrackingDomains - - NSPrivacyCollectedDataTypes - - NSPrivacyAccessedAPITypes - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryUserDefaults - NSPrivacyAccessedAPITypeReasons - - 1C8F.1 - - - - - diff --git a/bitchatShareExtension/ShareViewController.swift b/bitchatShareExtension/ShareViewController.swift index d46f1c1f..a777720a 100644 --- a/bitchatShareExtension/ShareViewController.swift +++ b/bitchatShareExtension/ShareViewController.swift @@ -19,8 +19,9 @@ final class ShareViewController: UIViewController { static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content") static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared") static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link") - static let savedForReview = String(localized: "share.status.saved_for_review", comment: "Shown after content is staged for review in the main app") - static let failedToSave = String(localized: "share.status.failed_to_save", comment: "Shown when content cannot be staged for the main app") + static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link") + static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text") + static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded") } private let statusLabel: UILabel = { @@ -43,7 +44,9 @@ final class ShareViewController: UIViewController { statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor) ]) - processShare() + DispatchQueue.global().async { + self.processShare() + } } // MARK: - Processing @@ -81,7 +84,7 @@ final class ShareViewController: UIViewController { self.loadFirstPlainText(from: providers) { text in if let t = text, !t.isEmpty { // Treat as URL if parseable http(s), else plain text - if let u = URL(string: t), ["http", "https"].contains(u.scheme?.lowercased() ?? "") { + if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") { self.saveAndFinish(url: u, title: item.attributedTitle?.string) } else { self.saveAndFinish(text: t) @@ -104,87 +107,73 @@ final class ShareViewController: UIViewController { private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) { let identifiers = [UTType.url.identifier, "public.url", "public.file-url"] - for provider in providers { - guard let identifier = identifiers.first(where: { provider.hasItemConformingToTypeIdentifier($0) }) else { - continue - } - provider.loadItem(forTypeIdentifier: identifier, options: nil) { item, _ in - let result: URL? - if let url = item as? URL { - result = url - } else if let string = item as? String { - result = URL(string: string) - } else if let data = item as? Data, - let string = String(data: data, encoding: .utf8) { - result = URL(string: string) - } else { - result = nil + let grp = DispatchGroup() + var found: URL? + + for p in providers where found == nil { + for id in identifiers where p.hasItemConformingToTypeIdentifier(id) { + grp.enter() + p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in + defer { grp.leave() } + if let u = item as? URL { found = u; return } + if let s = item as? String, let u = URL(string: s) { found = u; return } + if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return } } - DispatchQueue.main.async { completion(result) } + break } - return } - DispatchQueue.main.async { completion(nil) } + grp.notify(queue: .main) { completion(found) } } private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) { - let identifier = UTType.plainText.identifier - guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(identifier) }) else { - DispatchQueue.main.async { completion(nil) } - return - } - provider.loadItem(forTypeIdentifier: identifier, options: nil) { item, _ in - let result: String? - if let string = item as? String { - result = string - } else if let data = item as? Data { - result = String(data: data, encoding: .utf8) - } else { - result = nil + let id = UTType.plainText.identifier + let grp = DispatchGroup() + var text: String? + for p in providers where p.hasItemConformingToTypeIdentifier(id) { + grp.enter() + p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in + defer { grp.leave() } + if let s = item as? String { text = s } + else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s } } - DispatchQueue.main.async { completion(result) } + break } + grp.notify(queue: .main) { completion(text) } } // MARK: - Save + Finish private func saveAndFinish(url: URL, title: String?) { - let payload = SharedContentPayload( - kind: .url, - content: url.absoluteString, - title: title ?? url.host ?? Strings.sharedLinkTitleFallback - ) - stageAndFinish(payload) + let payload: [String: String] = [ + "url": url.absoluteString, + "title": title ?? url.host ?? Strings.sharedLinkTitleFallback + ] + if let json = try? JSONSerialization.data(withJSONObject: payload), + let s = String(data: json, encoding: .utf8) { + saveToSharedDefaults(content: s, type: "url") + finishWithMessage(Strings.sharedLinkConfirmation) + } else { + finishWithMessage(Strings.failedToEncode) + } } private func saveAndFinish(text: String) { - stageAndFinish(.text(text)) + saveToSharedDefaults(content: text, type: "text") + finishWithMessage(Strings.sharedTextConfirmation) } - private func stageAndFinish(_ payload: SharedContentPayload) { - guard let defaults = UserDefaults(suiteName: Self.groupID) else { - finishWithMessage(Strings.failedToSave) - return - } - let store = SharedContentStore(defaults: defaults) - - do { - try store.stage(payload) - // Staging is not sending. The main app will require a second, - // destination-labelled confirmation before filling its composer. - finishWithMessage(Strings.savedForReview) - } catch { - finishWithMessage(Strings.failedToSave) - } + private func saveToSharedDefaults(content: String, type: String) { + guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return } + userDefaults.set(content, forKey: "sharedContent") + userDefaults.set(type, forKey: "sharedContentType") + userDefaults.set(Date(), forKey: "sharedContentDate") + // No need to force synchronize; the system persists changes } private func finishWithMessage(_ msg: String) { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.statusLabel.text = msg - // Complete shortly after showing status. - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { [weak self] in - self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) - } + statusLabel.text = msg + // Complete shortly after showing status + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { + self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } } } diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift deleted file mode 100644 index 747e737b..00000000 --- a/bitchatTests/AppArchitectureTests.swift +++ /dev/null @@ -1,904 +0,0 @@ -import BitFoundation -import Combine -import Foundation -import Testing -@testable import bitchat - -@MainActor -private func makeArchitectureViewModel( - locationManager: LocationChannelManager? = nil -) -> ChatViewModel { - let keychain = MockKeychain() - let keychainHelper = MockKeychainHelper() - let idBridge = NostrIdentityBridge(keychain: keychainHelper) - let identityManager = MockIdentityManager(keychain) - let locationManager = locationManager ?? makeArchitectureLocationManager() - - return ChatViewModel( - keychain: keychain, - idBridge: idBridge, - identityManager: identityManager, - transport: MockTransport(), - locationManager: locationManager - ) -} - -@MainActor -private func makeArchitectureLocationManager() -> LocationChannelManager { - let suiteName = "AppArchitectureTests.\(UUID().uuidString)" - let storage = UserDefaults(suiteName: suiteName) ?? .standard - storage.removePersistentDomain(forName: suiteName) - return LocationChannelManager(storage: storage) -} - -private func makeArchitectureSnapshot( - peerID: PeerID, - nickname: String, - connected: Bool, - noisePublicKey: Data -) -> TransportPeerSnapshot { - TransportPeerSnapshot( - peerID: peerID, - nickname: nickname, - isConnected: connected, - noisePublicKey: noisePublicKey, - lastSeen: Date() - ) -} - -@MainActor -private func makeArchitectureMessage( - id: String, - timestamp: TimeInterval = 0, - content: String? = nil, - isPrivate: Bool = false, - senderPeerID: PeerID = PeerID(str: "peer-a") -) -> BitchatMessage { - BitchatMessage( - id: id, - sender: "alice", - content: content ?? "message \(id)", - timestamp: Date(timeIntervalSince1970: timestamp), - isRelay: false, - originalSender: nil, - isPrivate: isPrivate, - recipientNickname: isPrivate ? "builder" : nil, - senderPeerID: senderPeerID - ) -} - -@MainActor -private func waitUntil( - // Settle deadline, not a latency budget (see TestConstants.settleTimeout): - // the old 3s default flaked on CI the moment a Combine hop through - // receive(on: .main) was starved. Every caller waits for a condition to - // become true, so passing runs return immediately. - timeoutNanoseconds: UInt64 = UInt64(TestConstants.settleTimeout * 1_000_000_000), - pollNanoseconds: UInt64 = 20_000_000, - _ condition: @escaping @MainActor () -> Bool -) async { - let timeout = Double(timeoutNanoseconds) / 1_000_000_000 - let deadline = Date().addingTimeInterval(timeout) - - while !condition(), Date() < deadline { - try? await Task.sleep(nanoseconds: pollNanoseconds) - } -} - -@Suite("App Architecture Tests", .serialized) -struct AppArchitectureTests { - - @Test("PeerIdentityStore owns fingerprint, mapping, and verification state") - @MainActor - func peerIdentityStoreOwnsIdentityState() { - let store = PeerIdentityStore() - let shortPeerID = PeerID(str: "peer-short") - let stablePeerID = PeerID(str: "peer-stable") - let canonicalPeerID = PeerID(str: "peer-canonical") - - store.setStablePeerID(stablePeerID, forShortID: shortPeerID) - store.setFingerprint("fp-1", for: shortPeerID) - store.setCachedEncryptionStatus(.noiseHandshaking, for: shortPeerID) - store.setEncryptionStatus(.noiseSecured, for: shortPeerID) - store.setVerified("fp-1", verified: true) - - let migratedFingerprint = store.migrateFingerprintMapping( - from: shortPeerID, - to: canonicalPeerID - ) - - #expect(store.stablePeerID(forShortID: shortPeerID) == stablePeerID) - #expect(store.shortPeerID(forStablePeerID: stablePeerID) == shortPeerID) - #expect(migratedFingerprint == "fp-1") - #expect(store.fingerprint(for: shortPeerID) == nil) - #expect(store.fingerprint(for: canonicalPeerID) == "fp-1") - #expect(store.selectedPrivateChatFingerprint == "fp-1") - #expect(store.encryptionStatus(for: shortPeerID) == .noiseSecured) - #expect(store.cachedEncryptionStatus(for: shortPeerID) == nil) - #expect(store.isVerified("fp-1")) - - store.clearAll() - - #expect(store.encryptionStatuses.isEmpty) - #expect(store.verifiedFingerprints.isEmpty) - #expect(store.peerFingerprintsByPeerID.isEmpty) - #expect(store.selectedPrivateChatFingerprint == nil) - #expect(store.stablePeerID(forShortID: shortPeerID) == nil) - } - - @Test("LocationPresenceStore normalizes and resets geohash presence state") - @MainActor - func locationPresenceStoreNormalizesPresenceState() { - let store = LocationPresenceStore() - - store.setCurrentGeohash("U4PRUY") - store.replaceGeoNicknames([ - "ABCDEF": "alice", - "123456": "bob" - ]) - store.markTeleported("ABCDEF") - store.replaceTeleportedGeo(Set(["FEDCBA", "123456"])) - - #expect(store.currentGeohash == "u4pruy") - #expect(store.geoNicknames["abcdef"] == "alice") - #expect(store.geoNicknames["123456"] == "bob") - #expect(store.teleportedGeo == Set(["fedcba", "123456"])) - - store.reset() - - #expect(store.currentGeohash == nil) - #expect(store.geoNicknames.isEmpty) - #expect(store.teleportedGeo.isEmpty) - } - - @Test("LocationPresenceStore bounds and prunes teleported geohash participants") - @MainActor - func locationPresenceStoreBoundsTeleportedParticipants() { - let store = LocationPresenceStore(teleportedGeoCapacity: 2) - - store.setCurrentGeohash("u4pruy") - store.markTeleported("AAAAAA") - store.markTeleported("BBBBBB") - store.markTeleported("CCCCCC") - - #expect(store.teleportedGeo == Set(["bbbbbb", "cccccc"])) - - store.retainTeleportedGeo(keeping: Set(["CCCCCC"])) - #expect(store.teleportedGeo == Set(["cccccc"])) - - store.setCurrentGeohash("u4pruz") - #expect(store.teleportedGeo.isEmpty) - } - - @Test("LocationPresenceStore bounds geohash nicknames and clears on channel switch") - @MainActor - func locationPresenceStoreBoundsGeoNicknames() { - let store = LocationPresenceStore(geoNicknameCapacity: 2) - - store.setCurrentGeohash("u4pruy") - store.setNickname("alice", for: "AAAAAA") - store.setNickname("bob", for: "BBBBBB") - store.setNickname("carol", for: "CCCCCC") - - #expect(store.geoNicknames == ["bbbbbb": "bob", "cccccc": "carol"]) - - store.retainGeoNicknames(keeping: Set(["CCCCCC"])) - #expect(store.geoNicknames == ["cccccc": "carol"]) - - store.setCurrentGeohash("u4pruz") - #expect(store.geoNicknames.isEmpty) - } - - @Test("PeerHandle equality and hashing use the canonical identity only") - func peerHandleEqualityUsesCanonicalIdentity() { - let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a")) - let second = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-b")) - - #expect(first == second) - #expect(Set([first, second]).count == 1) - } - - @Test("ConversationStore orders timelines and replaces duplicates by message ID") - @MainActor - func conversationStoreOrdersAndDedupsMessages() { - let store = ConversationStore() - let older = makeArchitectureMessage(id: "m1", timestamp: 1, content: "first") - let newer = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second") - let replacement = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second-updated") - - store.append(newer, to: .mesh) - store.append(older, to: .mesh) - store.upsertByID(replacement, in: .mesh) - - let messages = store.conversation(for: .mesh).messages - #expect(messages.map(\.id) == ["m1", "m2"]) - #expect(messages.last?.content == "second-updated") - } - - @Test("ConversationStore tracks unread direct conversations by routing peer ID") - @MainActor - func conversationStoreTracksUnreadDirectConversations() { - let store = ConversationStore() - let peerID = PeerID(str: "peer-1") - let message = makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: peerID) - - store.append(message, to: .directPeer(peerID)) - store.markUnread(.directPeer(peerID)) - - #expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["dm-1"]) - #expect(store.unreadDirectRoutingPeerIDs() == Set([peerID])) - #expect(store.conversation(for: .directPeer(peerID)).isUnread) - - store.markRead(.directPeer(peerID)) - #expect(store.unreadDirectRoutingPeerIDs().isEmpty) - #expect(!store.conversation(for: .directPeer(peerID)).isUnread) - } - - @Test("ConversationStore derives the selected conversation from channel and private peer") - @MainActor - func conversationStoreTracksSelectedConversationContext() { - let store = ConversationStore() - let peerID = PeerID(str: "0011223344556677") - let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy")) - - store.setActiveChannel(geohashChannel) - store.setSelectedPrivatePeer(peerID) - - #expect(store.activeChannel == geohashChannel) - #expect(store.selectedPrivatePeerID == peerID) - // The open private chat wins the derived selection. - #expect(store.selectedConversationID == ConversationID.directPeer(peerID)) - - store.setSelectedPrivatePeer(nil) - // Selection falls back to the active public channel. - #expect(store.selectedConversationID == ConversationID(channelID: geohashChannel)) - - store.setActiveChannel(.mesh) - #expect(store.activeChannel == ChannelID.mesh) - #expect(store.selectedPrivatePeerID == nil) - #expect(store.selectedConversationID == ConversationID.mesh) - } - - @Test("ConversationStore re-keys a direct conversation via the migrate intent") - @MainActor - func conversationStoreMigratesDirectConversationsBetweenPeerIDs() { - let store = ConversationStore() - let noiseKey = Data((0..<32).map(UInt8.init)) - let shortPeerID = PeerID(str: "0011223344556677") - let fullPeerID = PeerID(hexData: noiseKey) - - store.append( - makeArchitectureMessage(id: "dm-1", timestamp: 1, isPrivate: true, senderPeerID: shortPeerID), - to: .directPeer(shortPeerID) - ) - store.markUnread(.directPeer(shortPeerID)) - store.setSelectedPrivatePeer(shortPeerID) - - store.migrateConversation(from: .directPeer(shortPeerID), to: .directPeer(fullPeerID)) - - // Raw keying: the old peer's conversation is gone, the new peer's - // conversation holds the timeline, unread and selection carried over. - #expect(store.conversationsByID[.directPeer(shortPeerID)] == nil) - #expect(Set(store.directMessagesByRoutingPeerID().keys) == Set([fullPeerID])) - #expect(store.directMessagesByRoutingPeerID()[fullPeerID]?.map(\.id) == ["dm-1"]) - #expect(store.unreadDirectRoutingPeerIDs() == Set([fullPeerID])) - #expect(store.selectedPrivatePeerID == fullPeerID) - #expect(store.selectedConversationID == ConversationID.directPeer(fullPeerID)) - } - - @Test("PrivateInboxModel reads direct message state from the ConversationStore") - @MainActor - func privateInboxModelReadsDirectMessageStateFromConversationStore() { - let store = ConversationStore() - let inboxModel = PrivateInboxModel(conversations: store) - let messagePeerID = PeerID(str: "peer-1") - let unreadOnlyPeerID = PeerID(str: "peer-2") - let selectedOnlyPeerID = PeerID(str: "peer-3") - - store.append( - makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: messagePeerID), - to: .directPeer(messagePeerID) - ) - store.markUnread(.directPeer(messagePeerID)) - store.markUnread(.directPeer(unreadOnlyPeerID)) - store.setSelectedPrivatePeer(selectedOnlyPeerID) - - // Reads are synchronous against the single-writer store. - #expect(inboxModel.selectedPeerID == selectedOnlyPeerID) - #expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID])) - #expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"]) - #expect(inboxModel.messages(for: unreadOnlyPeerID).isEmpty) - #expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty) - } - - @Test("PrivateInboxModel republishes only for the selected conversation") - @MainActor - func privateInboxModelIsolatesBackgroundConversations() { - let store = ConversationStore() - let inboxModel = PrivateInboxModel(conversations: store) - let selectedPeerID = PeerID(str: "peer-selected") - let backgroundPeerID = PeerID(str: "peer-background") - store.setSelectedPrivatePeer(selectedPeerID) - - var emissions = 0 - let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 } - defer { cancellable.cancel() } - - let baseline = emissions - store.append( - makeArchitectureMessage(id: "dm-bg-1", isPrivate: true, senderPeerID: backgroundPeerID), - to: .directPeer(backgroundPeerID) - ) - // An append to a background chat does not republish the model. - #expect(emissions == baseline) - - store.append( - makeArchitectureMessage(id: "dm-sel-1", isPrivate: true, senderPeerID: selectedPeerID), - to: .directPeer(selectedPeerID) - ) - #expect(emissions == baseline + 1) - #expect(inboxModel.messages(for: selectedPeerID).map(\.id) == ["dm-sel-1"]) - } - - @Test("PrivateInboxModel republishes read receipts for the selected DM (ephemeral- and stable-keyed)") - @MainActor - func privateInboxModelRepublishesReadReceiptsForSelectedConversation() { - // A DM's messages can live under BOTH .directPeer(ephemeral) and - // .directPeer(stableKey) (mirroring shares one BitchatMessage - // instance); the view's read-receipt update must fire no matter - // which of the two keys the selection holds. - let ephemeralPeerID = PeerID(str: "abcdef1234567890") - let stablePeerID = PeerID(str: String(repeating: "ab", count: 32)) - - for selectedPeerID in [ephemeralPeerID, stablePeerID] { - let store = ConversationStore() - let inboxModel = PrivateInboxModel(conversations: store) - store.setSelectedPrivatePeer(selectedPeerID) - - // One shared instance mirrored into both direct conversations, - // exactly like `mirrorToEphemeralIfNeeded`. - let message = makeArchitectureMessage( - id: "dm-read-1", - isPrivate: true, - senderPeerID: ephemeralPeerID - ) - store.append(message, to: .directPeer(ephemeralPeerID)) - store.upsertByID(message, in: .directPeer(stablePeerID)) - - var emissions = 0 - let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 } - defer { cancellable.cancel() } - - // ID-only intent — the exact call `ChatDeliveryCoordinator` - // makes when a READ ack arrives. - let read = DeliveryStatus.read(by: "builder", at: Date(timeIntervalSince1970: 100)) - #expect(store.setDeliveryStatus(read, forMessageID: "dm-read-1")) - - // The fan-out emits .statusChanged for both containing - // conversations; exactly the selected one republishes the model. - #expect(emissions == 1) - #expect(inboxModel.messages(for: selectedPeerID).first?.deliveryStatus == read) - } - } - - @Test("PublicChatModel ignores appends to background conversations") - @MainActor - func publicChatModelIsolatesBackgroundConversations() { - let store = ConversationStore() - store.setActiveChannel(.mesh) - let model = PublicChatModel(conversations: store) - - var emissions = 0 - let cancellable = model.objectWillChange.sink { _ in emissions += 1 } - defer { cancellable.cancel() } - - store.append(makeArchitectureMessage(id: "mesh-1"), to: .mesh) - let afterActiveAppend = emissions - #expect(afterActiveAppend >= 1) - #expect(model.messages.map(\.id) == ["mesh-1"]) - - // Appends to a background geohash channel and to a private chat do - // not invalidate the observer of the active conversation. - store.append(makeArchitectureMessage(id: "geo-1"), to: .geohash("u4pruyd")) - store.append( - makeArchitectureMessage(id: "dm-1", isPrivate: true), - to: .directPeer(PeerID(str: "peer-1")) - ) - #expect(emissions == afterActiveAppend) - #expect(model.messages.map(\.id) == ["mesh-1"]) - - // Switching the channel retargets the observation. - store.setActiveChannel(.location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd"))) - #expect(model.messages.map(\.id) == ["geo-1"]) - store.append(makeArchitectureMessage(id: "geo-2", timestamp: 1), to: .geohash("u4pruyd")) - #expect(model.messages.map(\.id) == ["geo-1", "geo-2"]) - } - - @Test("AppChromeModel mirrors nickname and unread state through focused models") - @MainActor - func appChromeModelMirrorsNicknameAndUnreadState() async { - let viewModel = makeArchitectureViewModel() - let conversations = ConversationStore() - let privateInboxModel = PrivateInboxModel(conversations: conversations) - let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) - - chromeModel.setNickname("builder") - await waitUntil { - viewModel.nickname == "builder" && chromeModel.nickname == "builder" - } - - #expect(viewModel.nickname == "builder") - #expect(chromeModel.nickname == "builder") - #expect(!chromeModel.hasUnreadPrivateMessages) - - let peerID = PeerID(str: "peer-1") - conversations.markUnread(.directPeer(peerID)) - await waitUntil { - chromeModel.hasUnreadPrivateMessages - } - - #expect(chromeModel.hasUnreadPrivateMessages) - } - - @Test("AppChromeModel owns fingerprint and screenshot presentation state") - @MainActor - func appChromeModelOwnsPresentationState() { - let viewModel = makeArchitectureViewModel() - let privateInboxModel = PrivateInboxModel(conversations: ConversationStore()) - let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) - let peerID = PeerID(str: "peer-2") - - chromeModel.showFingerprint(for: peerID) - chromeModel.presentAppInfo() - chromeModel.isLocationChannelsSheetPresented = true - chromeModel.triggerScreenshotPrivacyWarning() - - #expect(chromeModel.showingFingerprintFor == peerID) - #expect(chromeModel.isAppInfoPresented) - #expect(chromeModel.shouldSuppressScreenshotNotification) - #expect(chromeModel.showScreenshotPrivacyWarning) - - chromeModel.clearFingerprint() - #expect(chromeModel.showingFingerprintFor == nil) - } - - @Test("Triple-tap panic entry point only raises the confirmation dialog") - @MainActor - func requestPanicWipeAsksBeforeDestroying() { - let viewModel = makeArchitectureViewModel() - let privateInboxModel = PrivateInboxModel(conversations: ConversationStore()) - let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) - viewModel.seedPublicMessages([ - BitchatMessage( - id: "keep-1", - sender: "Tester", - content: "still here", - timestamp: Date(), - isRelay: false - ) - ]) - - chromeModel.requestPanicWipe() - - // The gesture must never wipe directly — a fumbled tap on the logo - // (single-tap opens App Info) cannot be allowed to destroy identity. - #expect(chromeModel.showPanicConfirmation) - #expect(viewModel.messages.map(\.id) == ["keep-1"]) - } - - @Test("Panic wipe dismisses chrome sheets so its outcome is visible") - @MainActor - func panicWipeDismissesChromePresentation() { - let viewModel = makeArchitectureViewModel() - let privateInboxModel = PrivateInboxModel(conversations: ConversationStore()) - let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) - - // The App Info danger-zone path wipes while its own sheet is up; the - // success message and the failed-wipe banner both render on the root - // timeline, so any sheet left presented would hide the one signal - // that says whether the wipe worked. - chromeModel.presentAppInfo() - chromeModel.isLocationChannelsSheetPresented = true - chromeModel.presentNotices() - chromeModel.showFingerprint(for: PeerID(str: "peer-3")) - - chromeModel.panicClearAllData() - - #expect(!chromeModel.isAppInfoPresented) - #expect(!chromeModel.isLocationChannelsSheetPresented) - #expect(!chromeModel.isNoticesSheetPresented) - #expect(chromeModel.showingFingerprintFor == nil) - } - - @Test("Recent chats list direct conversations with people absent from the rosters") - @MainActor - func peerListModelSurfacesRecentChatsForAbsentPeers() async { - let viewModel = makeArchitectureViewModel() - let offlinePeerID = PeerID(str: "00000000000000aa") - let groupPeerID = PeerID(groupID: Data(repeating: 0xAB, count: 16)) - - // A DM thread from a passerby who has since gone offline: not in - // allPeers, not a favorite — previously unreachable once read. - viewModel.seedPrivateChat([ - BitchatMessage( - id: "dm-1", - sender: "passerby", - content: "hello from the train", - timestamp: Date(timeIntervalSince1970: 100), - isRelay: false, - isPrivate: true, - recipientNickname: "me", - senderPeerID: offlinePeerID - ) - ], for: offlinePeerID) - // Group threads have their own section and must not duplicate here. - viewModel.seedPrivateChat([ - BitchatMessage( - id: "gm-1", - sender: "member", - content: "group hello", - timestamp: Date(timeIntervalSince1970: 200), - isRelay: false, - isPrivate: true, - recipientNickname: "me", - senderPeerID: groupPeerID - ) - ], for: groupPeerID) - - let peerListModel = PeerListModel( - chatViewModel: viewModel, - conversations: viewModel.conversations - ) - - await waitUntil { - peerListModel.recentChatRows.contains { $0.peerID == offlinePeerID } - } - - #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID]) - #expect(peerListModel.recentChatRows.first?.lastActivity == Date(timeIntervalSince1970: 100)) - // The roster doesn't list this peer — that's exactly why the row exists. - #expect(!peerListModel.meshRows.contains { $0.peerID == offlinePeerID }) - - // A geoDM thread resolves through the Nostr mapping (bare-key - // fallback here), never resolveNickname's mesh-only anon fallback. - let pubkeyHex = String(repeating: "ab", count: 32) - let geoDMPeer = PeerID(nostr_: pubkeyHex) - viewModel.seedPrivateChat([ - BitchatMessage( - id: "geo-1", - sender: "stranger", - content: "hello from another cell", - timestamp: Date(timeIntervalSince1970: 300), - isRelay: false, - isPrivate: true, - recipientNickname: "me", - senderPeerID: geoDMPeer - ) - ], for: geoDMPeer) - - await waitUntil { - peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } - } - let geoRow = peerListModel.recentChatRows.first { $0.peerID == geoDMPeer } - #expect(geoRow?.displayName == geoDMPeer.bare) - - // While that person is visible in the geohash roster, the chat row - // collapses — same absent-from-rosters contract as mesh. - // - // Re-assert the tracker state on every poll: the view model's own - // channel binding delivers its initial .mesh selection asynchronously - // and resets the active participant geohash when it lands - // (GeohashSubscriptionManager.setActiveParticipantGeohash(nil)) — on - // a loaded parallel runner that reset arrives AFTER this setup and - // the dedup can never happen. Both calls are idempotent, so the - // interference heals on the next poll while a genuine dedup failure - // still times out. - await waitUntil { - viewModel.participantTracker.setActiveGeohash("u4pruy") - viewModel.participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: "u4pruy") - return !peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } - } - #expect(!peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer }) - #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID]) - } - - @Test("PrivateConversationModel resolves canonical header state for the selected DM") - @MainActor - func privateConversationModelResolvesSelectedHeaderState() async { - let viewModel = makeArchitectureViewModel() - guard let transport = viewModel.meshService as? MockTransport else { - Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") - return - } - let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager()) - let conversationModel = PrivateConversationModel( - chatViewModel: viewModel, - conversations: viewModel.conversations, - locationChannelsModel: locationChannelsModel - ) - - let noiseKey = Data((0..<32).map(UInt8.init)) - let shortPeerID = PeerID(str: "0011223344556677") - let fullPeerID = PeerID(hexData: noiseKey) - transport.peerNicknames[shortPeerID] = "alice" - transport.reachablePeers.insert(shortPeerID) - viewModel.allPeers = [ - BitchatPeer( - peerID: shortPeerID, - noisePublicKey: noiseKey, - nickname: "alice", - isConnected: false, - isReachable: true - ) - ] - - conversationModel.startConversation(with: fullPeerID) - await waitUntil { - conversationModel.selectedPeerID == fullPeerID - } - - #expect(conversationModel.selectedPeerID == fullPeerID) - #expect(conversationModel.selectedHeaderState?.headerPeerID == shortPeerID) - #expect(conversationModel.selectedHeaderState?.displayName == "alice") - #expect(conversationModel.selectedHeaderState?.availability == .meshReachable) - #expect(conversationModel.selectedHeaderState?.encryptionStatus == .noHandshake) - - conversationModel.endConversation() - await waitUntil { - conversationModel.selectedPeerID == nil - } - #expect(conversationModel.selectedPeerID == nil) - #expect(conversationModel.selectedHeaderState == nil) - } - - @Test("ConversationUIModel mirrors composer state and forwards sends") - @MainActor - func conversationUIModelMirrorsComposerStateAndForwardsSends() async { - let locationManager = makeArchitectureLocationManager() - let viewModel = makeArchitectureViewModel(locationManager: locationManager) - guard let transport = viewModel.meshService as? MockTransport else { - Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") - return - } - - locationManager.select(.mesh) - let locationChannelsModel = LocationChannelsModel(manager: locationManager) - let privateConversationModel = PrivateConversationModel( - chatViewModel: viewModel, - conversations: viewModel.conversations, - locationChannelsModel: locationChannelsModel - ) - let uiModel = ConversationUIModel( - chatViewModel: viewModel, - privateConversationModel: privateConversationModel, - conversations: viewModel.conversations - ) - let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy")) - defer { - locationManager.select(.mesh) - } - viewModel.nickname = "builder" - viewModel.autocompleteSuggestions = ["alice"] - viewModel.showAutocomplete = true - locationChannelsModel.select(geohashChannel) - - await waitUntil { - viewModel.activeChannel == geohashChannel && - uiModel.currentNickname == "builder" && - uiModel.showAutocomplete && - uiModel.autocompleteSuggestions == ["alice"] && - !uiModel.canSendMediaInCurrentContext - } - - #expect(viewModel.activeChannel == geohashChannel) - #expect(uiModel.currentNickname == "builder") - #expect(uiModel.showAutocomplete) - #expect(uiModel.autocompleteSuggestions == ["alice"]) - #expect(!uiModel.canSendMediaInCurrentContext) - - locationChannelsModel.select(ChannelID.mesh) - await waitUntil { - viewModel.activeChannel == ChannelID.mesh && - uiModel.canSendMediaInCurrentContext - } - - #expect(viewModel.activeChannel == ChannelID.mesh) - #expect(uiModel.canSendMediaInCurrentContext) - - uiModel.sendMessage("hello mesh") - - await waitUntil { - transport.sentMessages.last?.content == "hello mesh" - } - - #expect(transport.sentMessages.last?.content == "hello mesh") - } - - @Test("VerificationModel bridges selected conversation and fingerprint actions") - @MainActor - func verificationModelBridgesSelectedConversationAndFingerprintActions() async { - let viewModel = makeArchitectureViewModel() - guard let transport = viewModel.meshService as? MockTransport else { - Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") - return - } - - let peerID = PeerID(str: "0011223344556677") - let fingerprint = "verified-fingerprint" - let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager()) - let privateConversationModel = PrivateConversationModel( - chatViewModel: viewModel, - conversations: viewModel.conversations, - locationChannelsModel: locationChannelsModel - ) - let verificationModel = VerificationModel( - chatViewModel: viewModel, - privateConversationModel: privateConversationModel - ) - - transport.peerFingerprints[peerID] = fingerprint - transport.peerNicknames[peerID] = "alice" - viewModel.allPeers = [ - BitchatPeer( - peerID: peerID, - noisePublicKey: Data((0..<32).map(UInt8.init)), - nickname: "alice", - isConnected: true, - isReachable: true - ) - ] - - privateConversationModel.startConversation(with: peerID) - await waitUntil { - verificationModel.selectedPeerID == peerID - } - - let presentation = verificationModel.fingerprintPresentation(for: peerID) - #expect(verificationModel.selectedPeerID == peerID) - #expect(presentation.peerNickname == "alice") - #expect(presentation.theirFingerprint == fingerprint) - #expect(!presentation.myFingerprint.isEmpty) - #expect(!verificationModel.isVerified(peerID: peerID)) - - verificationModel.verifyFingerprint(for: peerID) - await waitUntil { - verificationModel.isVerified(peerID: peerID) - } - #expect(verificationModel.isVerified(peerID: peerID)) - - verificationModel.unverifyFingerprint(for: peerID) - await waitUntil { - !verificationModel.isVerified(peerID: peerID) - } - #expect(!verificationModel.isVerified(peerID: peerID)) - } - - @Test("VerificationModel refreshes when peer trust changes (vouch accepted)") - @MainActor - func verificationModelRefreshesOnPeerTrustChange() async { - let viewModel = makeArchitectureViewModel() - var privateConversationModel: PrivateConversationModel? = PrivateConversationModel( - chatViewModel: viewModel, - conversations: viewModel.conversations, - locationChannelsModel: LocationChannelsModel(manager: makeArchitectureLocationManager()) - ) - let verificationModel = VerificationModel( - chatViewModel: viewModel, - privateConversationModel: privateConversationModel! - ) - - // PrivateConversationModel happens to observe the same notification - // and re-assign its published selection, which would ripple into - // VerificationModel; release it so this test pins VerificationModel's - // own subscription rather than that incidental chain. - privateConversationModel = nil - - // The bound @Published sources replay their current values on - // subscription; let those initial main-queue emissions settle so the - // sink below observes only the trust-change signal. - try? await Task.sleep(nanoseconds: 100_000_000) - - // ChatVouchCoordinator.notifyPeerTrustChanged() signals accepted - // vouches via "peerStatusUpdated"; an open fingerprint sheet must - // re-render its vouched badge from that signal alone. - var refreshed = false - let cancellable = verificationModel.objectWillChange.sink { _ in - refreshed = true - } - defer { cancellable.cancel() } - - NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) - await waitUntil { refreshed } - #expect(refreshed) - } - - @Test("PeerListModel publishes mesh and geohash directory state") - @MainActor - func peerListModelPublishesDirectoryState() async { - let viewModel = makeArchitectureViewModel() - guard let transport = viewModel.meshService as? MockTransport else { - Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") - return - } - - let myPeerID = PeerID(str: "me-peer") - let otherPeerID = PeerID(str: "0011223344556677") - let geohash = "9q8yy" - let remoteGeoID = String(repeating: "b", count: 64) - let locationManager = makeArchitectureLocationManager() - let locationChannelsModel = LocationChannelsModel(manager: locationManager) - let otherNoiseKey = Data((0..<32).map(UInt8.init)) - let verifiedFingerprint = otherNoiseKey.sha256Fingerprint() - - transport.myPeerID = myPeerID - transport.peerFingerprints[otherPeerID] = verifiedFingerprint - transport.peerNicknames[otherPeerID] = "alice" - transport.reachablePeers.insert(otherPeerID) - viewModel.nickname = "builder" - viewModel.verifiedFingerprints.insert(verifiedFingerprint) - viewModel.markPrivateChatUnread(otherPeerID) - transport.updatePeerSnapshots([ - makeArchitectureSnapshot( - peerID: myPeerID, - nickname: "builder", - connected: true, - noisePublicKey: Data(repeating: 0, count: 32) - ), - makeArchitectureSnapshot( - peerID: otherPeerID, - nickname: "alice", - connected: false, - noisePublicKey: otherNoiseKey - ) - ]) - - locationManager.select(.location(GeohashChannel(level: .city, geohash: geohash))) - await waitUntil { - if case .location(let channel) = locationManager.selectedChannel { - return channel.geohash == geohash && !viewModel.allPeers.isEmpty - } - return false - } - - viewModel.participantTracker.setActiveGeohash(geohash) - viewModel.teleportedGeo = Set([remoteGeoID]) - viewModel.participantTracker.recordParticipant(pubkeyHex: remoteGeoID, geohash: geohash) - if let myGeoID = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased() { - viewModel.participantTracker.recordParticipant(pubkeyHex: myGeoID, geohash: geohash) - } - - let peerListModel = PeerListModel( - chatViewModel: viewModel, - conversations: viewModel.conversations, - locationChannelsModel: locationChannelsModel - ) - - await waitUntil { - peerListModel.reachableMeshPeerCount == 1 && - peerListModel.connectedMeshPeerCount == 0 && - peerListModel.meshRows.contains(where: { $0.peerID == otherPeerID && $0.hasUnread }) && - peerListModel.geohashPeople.contains(where: { $0.id == remoteGeoID && $0.isTeleported }) - } - - let meshRow = peerListModel.meshRows.first(where: { $0.peerID == otherPeerID }) - #expect(peerListModel.reachableMeshPeerCount == 1) - #expect(peerListModel.connectedMeshPeerCount == 0) - #expect(meshRow?.displayName == "alice") - #expect(meshRow?.showsVerifiedBadgeWhenOffline == true) - #expect(meshRow?.hasUnread == true) - #expect(peerListModel.visibleGeohashPeerCount >= 1) - #expect(peerListModel.participantCount(for: geohash) >= 1) - #expect(peerListModel.geohashPeople.contains(where: { $0.id == remoteGeoID && $0.isTeleported })) - - viewModel.participantTracker.clear() - viewModel.teleportedGeo = [] - locationManager.markTeleported(for: geohash, false) - locationManager.select(ChannelID.mesh) - await waitUntil { - if case ChannelID.mesh = locationManager.selectedChannel { - return true - } - return false - } - } -} diff --git a/bitchatTests/AudioSessionCoordinatorTests.swift b/bitchatTests/AudioSessionCoordinatorTests.swift deleted file mode 100644 index b9627c1a..00000000 --- a/bitchatTests/AudioSessionCoordinatorTests.swift +++ /dev/null @@ -1,510 +0,0 @@ -// -// AudioSessionCoordinatorTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -@testable import bitchat - -/// Thread-safe: the coordinator invokes it on its private serial queue (that -/// the calls happen off the main thread is itself under test) while the test -/// reads from the main actor. -private final class MockAudioSession: SessionApplying, @unchecked Sendable { - enum Call: Equatable { - case setCategory(AudioSessionCoordinator.Category) - case setActive(Bool, notifyOthers: Bool) - } - - private let lock = NSLock() - private var _calls: [Call] = [] - private var _callsOnMainThread: [Bool] = [] - private var _nextError: Error? - private var _nextActivationError: Error? - - var calls: [Call] { lock.withLock { _calls } } - /// Whether each recorded call ran on the main thread — the coordinator's - /// whole point is that none ever does (the real calls block on IPC to the - /// audio server). - var callsOnMainThread: [Bool] { lock.withLock { _callsOnMainThread } } - var nextError: Error? { - get { lock.withLock { _nextError } } - set { lock.withLock { _nextError = newValue } } - } - /// Fails only the next `setActive` (so `setCategory` can succeed first). - var nextActivationError: Error? { - get { lock.withLock { _nextActivationError } } - set { lock.withLock { _nextActivationError = newValue } } - } - - func setCategory(_ category: AudioSessionCoordinator.Category) throws { - try lock.withLock { - if let error = _nextError { - _nextError = nil - throw error - } - _calls.append(.setCategory(category)) - _callsOnMainThread.append(Thread.isMainThread) - } - } - - func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { - try lock.withLock { - if let error = _nextError { - _nextError = nil - throw error - } - if let error = _nextActivationError { - _nextActivationError = nil - throw error - } - _calls.append(.setActive(active, notifyOthers: notifyOthersOnDeactivation)) - _callsOnMainThread.append(Thread.isMainThread) - } - } - - var categoryCalls: [AudioSessionCoordinator.Category] { - calls.compactMap { if case .setCategory(let category) = $0 { category } else { nil } } - } - - var activationCalls: [Bool] { - calls.compactMap { if case .setActive(let active, _) = $0 { active } else { nil } } - } -} - -private struct MockSessionError: Error {} - -/// Async suspension gate used to force lifecycle races without sleeps. The -/// production operation announces that it reached the gated boundary, then -/// stays suspended until the test opens it. -private actor AsyncGate { - private var isOpen = false - private var hasWaiter = false - private var arrivalWaiters: [CheckedContinuation] = [] - private var gateWaiters: [CheckedContinuation] = [] - - func wait() async { - hasWaiter = true - let arrivals = arrivalWaiters - arrivalWaiters = [] - for continuation in arrivals { - continuation.resume() - } - guard !isOpen else { return } - await withCheckedContinuation { continuation in - gateWaiters.append(continuation) - } - } - - func waitUntilEntered() async { - guard !hasWaiter else { return } - await withCheckedContinuation { continuation in - arrivalWaiters.append(continuation) - } - } - - func open() { - isOpen = true - let waiters = gateWaiters - gateWaiters = [] - for continuation in waiters { - continuation.resume() - } - } -} - -@MainActor -struct AudioSessionCoordinatorTests { - // MARK: - Reference-counted activation - - @Test func activatesOnFirstAcquireAndDeactivatesOnLastRelease() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - let first = try await coordinator.acquire(.playback) {} - let second = try await coordinator.acquire(.playback) {} - #expect(session.activationCalls == [true]) - - coordinator.release(first) - await coordinator.drain() - #expect(session.activationCalls == [true]) - - coordinator.release(second) - await coordinator.drain() - #expect(session.activationCalls == [true, false]) - #expect(session.calls.last == .setActive(false, notifyOthers: true)) - } - - @Test func releasingOneOfTwoClientsDoesNotDeactivate() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - let playback = try await coordinator.acquire(.playback) {} - let capture = try await coordinator.acquire(.capture) {} - - coordinator.release(capture) - await coordinator.drain() - #expect(session.activationCalls == [true]) - - coordinator.release(playback) - await coordinator.drain() - #expect(session.activationCalls == [true, false]) - } - - @Test func doubleReleaseIsIdempotent() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - let first = try await coordinator.acquire(.playback) {} - let second = try await coordinator.acquire(.playback) {} - - coordinator.release(first) - coordinator.release(first) - await coordinator.drain() - // The stale second release must not tear the session out from under - // the remaining holder. - #expect(session.activationCalls == [true]) - - coordinator.release(second) - coordinator.release(second) - await coordinator.drain() - #expect(session.activationCalls == [true, false]) - } - - // MARK: - Off-main session calls - - @Test func sessionCallsNeverRunOnTheMainThread() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - // The real setCategory/setActive block on IPC to the audio server - // (>1 s observed under contention, tripping the system gesture gate - // on PTT press) — every call must land on the coordinator's queue. - let token = try await coordinator.acquire(.capture) {} - coordinator.release(token) - await coordinator.drain() - - #expect(session.calls.count == 3) // setCategory + activate + deactivate - #expect(session.callsOnMainThread == [false, false, false]) - } - - @Test func failedActivationDoesNotRegisterAHolder() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - session.nextError = MockSessionError() - await #expect(throws: MockSessionError.self) { - try await coordinator.acquire(.playback) {} - } - - // The failed acquire left no holder behind: the next one is 0->1 - // again and activates. - let token = try await coordinator.acquire(.playback) {} - #expect(session.activationCalls == [true]) - coordinator.release(token) - await coordinator.drain() - #expect(session.activationCalls == [true, false]) - } - - @Test func failedActivationRollsBackEscalatedCategory() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - // setCategory(.playAndRecord) succeeds, setActive throws (e.g. a - // phone call owns the hardware). - session.nextActivationError = MockSessionError() - await #expect(throws: MockSessionError.self) { - try await coordinator.acquire(.capture) {} - } - - // With no holder registered the escalated category must not stick: - // the next playback-only acquire runs under .playback, not the - // leftover .playAndRecord. - let token = try await coordinator.acquire(.playback) {} - #expect(session.categoryCalls == [.playAndRecord, .playback]) - // And the failed acquire left no holder behind: this one was 0->1. - #expect(session.activationCalls == [true]) - coordinator.release(token) - await coordinator.drain() - #expect(session.activationCalls == [true, false]) - } - - // MARK: - Category escalation - - @Test func captureWhilePlaybackEscalatesExactlyOnceAndNeverDowngrades() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - let playback = try await coordinator.acquire(.playback) {} - #expect(session.categoryCalls == [.playback]) - - let capture = try await coordinator.acquire(.capture) {} - #expect(session.categoryCalls == [.playback, .playAndRecord]) - - // More clients of either use don't touch the category again. - let secondCapture = try await coordinator.acquire(.capture) {} - let secondPlayback = try await coordinator.acquire(.playback) {} - #expect(session.categoryCalls == [.playback, .playAndRecord]) - - // Capture ending must not downgrade the route under live playback. - coordinator.release(capture) - coordinator.release(secondCapture) - await coordinator.drain() - #expect(session.categoryCalls == [.playback, .playAndRecord]) - - // Even a fresh playback acquire stays on playAndRecord while held. - let thirdPlayback = try await coordinator.acquire(.playback) {} - #expect(session.categoryCalls == [.playback, .playAndRecord]) - - coordinator.release(playback) - coordinator.release(secondPlayback) - coordinator.release(thirdPlayback) - await coordinator.drain() - } - - @Test func categoryResetsAfterAllHoldersRelease() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - let capture = try await coordinator.acquire(.capture) {} - coordinator.release(capture) - await coordinator.drain() - #expect(session.categoryCalls == [.playAndRecord]) - - // With no holders left the next playback-only session downgrades. - let playback = try await coordinator.acquire(.playback) {} - #expect(session.categoryCalls == [.playAndRecord, .playback]) - coordinator.release(playback) - await coordinator.drain() - } - - @Test func escalationNotifiesExistingHoldersSoEnginesCanRestart() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - var playbackInterruptions = 0 - var captureInterruptions = 0 - let playback = try await coordinator.acquire(.playback) { playbackInterruptions += 1 } - let capture = try await coordinator.acquire(.capture) { captureInterruptions += 1 } - - // The pre-existing playback holder was reconfigured underneath (the - // fan-out is delivered before acquire returns); the newly acquiring - // capture client was not. - #expect(playbackInterruptions == 1) - #expect(captureInterruptions == 0) - - // A second capture doesn't change the category — nobody is notified. - let secondCapture = try await coordinator.acquire(.capture) {} - #expect(playbackInterruptions == 1) - #expect(captureInterruptions == 0) - - coordinator.release(playback) - coordinator.release(capture) - coordinator.release(secondCapture) - await coordinator.drain() - } - - @Test func escalationPrefersCategoryChangeCallbackOverInterruption() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - var escalations = 0 - var interruptions = 0 - let playback = try await coordinator.acquire( - .playback, - onInterrupted: { interruptions += 1 }, - onCategoryEscalated: { escalations += 1 } - ) - - // Escalation reaches the dedicated callback (the holder restarts and - // keeps playing) — not onInterrupted (which would stop it for good). - let capture = try await coordinator.acquire(.capture) {} - #expect(escalations == 1) - #expect(interruptions == 0) - - // A real interruption still stops it. - await coordinator.handleInterruptionBegan() - #expect(escalations == 1) - #expect(interruptions == 1) - - coordinator.release(playback) - coordinator.release(capture) - await coordinator.drain() - } - - // MARK: - Interruptions and route changes - - @Test func interruptionDuringAcquireHandoffCancelsAcquire() async throws { - let session = MockAudioSession() - let handoffGate = AsyncGate() - let coordinator = AudioSessionCoordinator( - session: session, - testingHooks: .init(beforeAcquireHandoff: { - await handoffGate.wait() - }) - ) - - var interruptionCount = 0 - let acquireTask = Task { @MainActor in - try await coordinator.acquire(.capture) { - interruptionCount += 1 - } - } - - // The session-queue registration is complete, but the caller has not - // received its token. An interruption here used to invoke the callback - // immediately, when capture clients could not release the token yet. - await handoffGate.waitUntilEntered() - await coordinator.handleInterruptionBegan() - #expect(interruptionCount == 0) - - await handoffGate.open() - await #expect(throws: CancellationError.self) { - try await acquireTask.value - } - await coordinator.drain() - #expect(interruptionCount == 0) - // The OS already deactivated the interrupted session; removing the - // provisional token must not issue a redundant setActive(false). - #expect(session.activationCalls == [true]) - - // The canceled acquire left no holder behind and the now-open test gate - // does not affect a subsequent ownership handoff. - let replacement = try await coordinator.acquire(.playback) {} - #expect(session.activationCalls == [true, true]) - coordinator.release(replacement) - await coordinator.drain() - #expect(session.activationCalls == [true, true, false]) - } - - @Test func releasedSnapshotCannotInterruptReacquiredToken() async throws { - let session = MockAudioSession() - let deliveryGate = AsyncGate() - let coordinator = AudioSessionCoordinator( - session: session, - testingHooks: .init(beforeCallbackDelivery: { - await deliveryGate.wait() - }) - ) - - // Model a single client whose callback acts on whichever token it owns - // now. If the old snapshot is delivered after reacquisition, it would - // incorrectly release the new session. - var activeToken: AudioSessionCoordinator.Token? - var interruptionCount = 0 - let onInterrupted: @MainActor () -> Void = { - interruptionCount += 1 - activeToken.map(coordinator.release) - } - - let first = try await coordinator.acquire(.playback, onInterrupted: onInterrupted) - activeToken = first - let interruptionTask = Task { - await coordinator.handleInterruptionBegan() - } - - // The queue snapshot contains `first`, but main-actor delivery is held. - await deliveryGate.waitUntilEntered() - coordinator.release(first) - activeToken = nil - await coordinator.drain() - - let second = try await coordinator.acquire(.playback, onInterrupted: onInterrupted) - activeToken = second - #expect(session.activationCalls == [true, true]) - - await deliveryGate.open() - await interruptionTask.value - await coordinator.drain() - #expect(interruptionCount == 0) - // A stale callback would have released `second` and appended false. - #expect(session.activationCalls == [true, true]) - - coordinator.release(second) - activeToken = nil - await coordinator.drain() - #expect(session.activationCalls == [true, true, false]) - } - - @Test func interruptionFansOutToAllHoldersAndResetsActiveState() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - var playbackInterruptions = 0 - var captureInterruptions = 0 - // Capture first so no escalation fan-out muddies the counters. - let capture = try await coordinator.acquire(.capture) { captureInterruptions += 1 } - let playback = try await coordinator.acquire(.playback) { playbackInterruptions += 1 } - #expect(session.activationCalls == [true]) - - await coordinator.handleInterruptionBegan() - #expect(playbackInterruptions == 1) - #expect(captureInterruptions == 1) - // The OS deactivated the session; the coordinator must not issue its - // own setActive(false) on top of it. - #expect(session.activationCalls == [true]) - - // The active state was reset: the next acquire re-activates even - // though holders never released. - let resumed = try await coordinator.acquire(.playback) {} - #expect(session.activationCalls == [true, true]) - - coordinator.release(playback) - coordinator.release(capture) - coordinator.release(resumed) - await coordinator.drain() - #expect(session.activationCalls == [true, true, false]) - } - - @Test func interruptedHoldersReleasingDuringFanOutStaySafe() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - // Real clients release from within onInterrupted (stop() paths); - // release is fire-and-forget onto the coordinator's queue, so it is - // safe from inside the main-actor fan-out. - var tokens: [AudioSessionCoordinator.Token] = [] - for _ in 0..<2 { - var token: AudioSessionCoordinator.Token? - token = try await coordinator.acquire(.playback) { - token.map(coordinator.release) - } - tokens.append(token!) - } - - await coordinator.handleInterruptionBegan() - await coordinator.drain() - // Every holder released mid-fan-out; the session was already - // deactivated by the OS, so no redundant setActive(false). - #expect(session.activationCalls == [true]) - - // All holders are gone: a fresh acquire is 0->1 again. - let token = try await coordinator.acquire(.playback) {} - #expect(session.activationCalls == [true, true]) - coordinator.release(token) - await coordinator.drain() - #expect(session.activationCalls == [true, true, false]) - } - - @Test func routeDeviceUnavailableNotifiesHoldersButKeepsSessionActive() async throws { - let session = MockAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - - var interruptions = 0 - // Capture first so no escalation fan-out muddies the counter. - let capture = try await coordinator.acquire(.capture) { interruptions += 1 } - let playback = try await coordinator.acquire(.playback) { interruptions += 1 } - - await coordinator.handleRouteDeviceUnavailable() - #expect(interruptions == 2) - // Unlike an interruption, the session itself is still active — the - // last holder's release performs the deactivation. - coordinator.release(playback) - coordinator.release(capture) - await coordinator.drain() - #expect(session.activationCalls == [true, false]) - } -} diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 13f2bb25..8849f089 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -13,85 +13,27 @@ import BitFoundation struct BLEServiceCoreTests { - /// Records ping completions (delivered on the main actor) so the - /// injected-clock test can assert from its own thread. - private final class MeshPingResultCollector: @unchecked Sendable { - private let lock = NSLock() - private var recorded: [MeshPingResult?] = [] - var results: [MeshPingResult?] { lock.withLock { recorded } } - func record(_ result: MeshPingResult?) { - lock.withLock { recorded.append(result) } - } - } - - /// The ping deadline asserted on an injected clock: the real 10s - /// product constant, no wall-clock in the loop. This is the pattern - /// for every engine deadline — the timeout must not fire early, must - /// fire exactly once at the deadline, and must stay consumed after. @Test - func meshPingTimesOutOnTheInjectedClockExactlyOnce() async throws { - let scheduler = BLEEngineManualScheduler() - let ble = makeService(engineScheduler: scheduler) - let peer = PeerID(str: "aabbccdd00112233") - ble._test_seedConnectedPeer(peer, nickname: "Alice") - - let collector = MeshPingResultCollector() - ble.sendMeshPing(to: peer) { result in - collector.record(result) - } - // The probe registers and its deadline schedules on the engine; - // fence that submission before touching the clock. - await ble._test_drainNoiseMessagePipeline() - #expect(scheduler.pendingCount == 1) - - // A hair before the deadline nothing may fire. - scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds - 0.01) - await ble._test_drainNoiseMessagePipeline() - #expect(collector.results.isEmpty) - - // Crossing the deadline expires the probe: nil, exactly once, on - // the main actor. - scheduler.advance(by: 0.02) - let completed = await TestHelpers.waitUntil( - { collector.results.count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(completed) - #expect(collector.results == [nil]) - - // The deadline is consumed — more time cannot re-fire it. - scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds * 2) - await ble._test_drainNoiseMessagePipeline() - #expect(collector.results.count == 1) - } - - @Test - func duplicatePacket_isDeduped() async throws { + func duplicatePacket_isDeduped() async { let ble = makeService() let delegate = PublicCaptureDelegate() ble.delegate = delegate - // Public messages must carry a valid signature from the claimed sender; - // sign the packet and preseed the sender's signing key so the receiver - // can verify it (production `sendMessage` signs public broadcasts too). - let signer = NoiseEncryptionService(keychain: MockKeychain()) let sender = PeerID(str: "1122334455667788") let timestamp = UInt64(Date().timeIntervalSince1970 * 1000) - let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp) - let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message") - let signingKey = signer.getSigningPublicKeyData() + let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp) - ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey) + ble._test_handlePacket(packet, fromPeerID: sender) let receivedFirst = await TestHelpers.waitUntil( { delegate.publicMessagesSnapshot().count == 1 }, - timeout: TestConstants.longTimeout + timeout: TestConstants.defaultTimeout ) #expect(receivedFirst) - ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey) + ble._test_handlePacket(packet, fromPeerID: sender) let receivedDuplicate = await TestHelpers.waitUntil( { delegate.publicMessagesSnapshot().count > 1 }, - timeout: TestConstants.negativeWaitWindow + timeout: TestConstants.shortTimeout ) #expect(!receivedDuplicate) @@ -150,1385 +92,9 @@ struct BLEServiceCoreTests { _ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3) #expect(ble.currentPeerSnapshots().isEmpty) } - - @Test - func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws { - let ble = makeService() - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let mallory = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let outbound = OutboundPacketTap() - ble._test_onOutboundPacket = outbound.record - - let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned") - ble._test_handlePacket( - unsigned, - fromPeerID: alicePeerID, - signingPublicKey: alice.getSigningPublicKeyData() - ) - - let unsignedRelayed = await TestHelpers.waitUntil( - { outbound.count(ofType: .leave) > 0 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!unsignedRelayed) - #expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }) - - let badSignature = try #require( - mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature")) - ) - ble._test_handlePacket( - badSignature, - fromPeerID: alicePeerID, - signingPublicKey: alice.getSigningPublicKeyData() - ) - - let badSignatureRelayed = await TestHelpers.waitUntil( - { outbound.count(ofType: .leave) > 0 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!badSignatureRelayed) - #expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }) - } - - @Test - func validSignedLeaveEvictsSessionAndRelays() async throws { - let ble = makeService() - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - - // Establish a real session so the leave regression also verifies that - // stale secure-delivery state is retired, not just the peer-list row. - let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID) - let message2 = try #require( - try alice.processHandshakeMessage(from: ble.myPeerID, message: message1) - ) - let message3 = try #require( - try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2) - ) - _ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3) - #expect(ble.canDeliverSecurely(to: alicePeerID)) - let centralUUID = "central-valid-leave" - ble._test_bindCentral(centralUUID, to: alicePeerID) - ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID) - #expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)) - - let outbound = OutboundPacketTap() - ble._test_onOutboundPacket = outbound.record - let signedLeave = try #require( - alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid")) - ) - ble._test_handlePacket( - signedLeave, - fromPeerID: alicePeerID, - signingPublicKey: alice.getSigningPublicKeyData() - ) - - let evicted = await TestHelpers.waitUntil( - { - !ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID } - && !ble.canDeliverSecurely(to: alicePeerID) - && !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID) - }, - timeout: TestConstants.longTimeout - ) - #expect(evicted) - let relayed = await TestHelpers.waitUntil( - { outbound.count(ofType: .leave) == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(relayed) - } - - @Test - func ingressAllowsRelayedSenderOnBoundLink() async throws { - let ble = makeService() - let boundPeer = PeerID(str: "1122334455667788") - let relayedSender = PeerID(str: "8899aabbccddeeff") - let packet = makePublicPacket( - content: "Relayed", - sender: relayedSender, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000) - ) - - #expect(ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer)) - } - - @Test - func ingressAllowsDirectAnnounceThatConflictsWithBoundLink() async throws { - // Peer-ID rotation heal: the announce must reach signature - // verification, which decides whether the link rebinds. - let ble = makeService() - let boundPeer = PeerID(str: "1122334455667788") - let claimedPeer = PeerID(str: "8899aabbccddeeff") - let packet = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: claimedPeer.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data(), - signature: nil, - ttl: 7 - ) - - #expect(ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer)) - } - - @Test - func ingressRejectsRequestSyncThatConflictsWithBoundLink() async throws { - let ble = makeService() - let boundPeer = PeerID(str: "1122334455667788") - let claimedPeer = PeerID(str: "8899aabbccddeeff") - let packet = BitchatPacket( - type: MessageType.requestSync.rawValue, - senderID: Data(hexString: claimedPeer.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data(), - signature: nil, - ttl: 0 - ) - - #expect(!ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer)) - } - - @Test - func verifiedDirectAnnounceRebindsRotatedLinkAndRetiresOldPeer() async throws { - let ble = makeService() - let oldPeerID = PeerID(str: "1122334455667788") - let centralUUID = "central-rotation" - - // A connected peer whose link binding predates its relaunch. - ble._test_seedConnectedPeer(oldPeerID, nickname: "alice") - ble._test_bindCentral(centralUUID, to: oldPeerID) - - // The relaunched device re-announces its rotated identity over the - // still-open link. - let signer = NoiseEncryptionService(keychain: MockKeychain()) - let announcement = AnnouncementPacket( - nickname: "alice", - noisePublicKey: signer.getStaticPublicKeyData(), - signingPublicKey: signer.getSigningPublicKeyData(), - directNeighbors: nil - ) - let payload = try #require(announcement.encode(), "Failed to encode announcement") - let newPeerID = PeerID(publicKey: announcement.noisePublicKey) - let unsigned = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: newPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: 7 - ) - let packet = try #require(signer.signPacket(unsigned), "Failed to sign announce packet") - - #expect(ble._test_recordIngressIfNew(packet: packet, linkID: centralUUID)) - ble._test_handlePacket(packet, fromPeerID: newPeerID, preseedPeer: false) - - let rebound = await TestHelpers.waitUntil( - { ble._test_centralBinding(centralUUID) == newPeerID }, - timeout: TestConstants.longTimeout - ) - #expect(rebound) - - let retired = await TestHelpers.waitUntil( - { - let peerIDs = ble.currentPeerSnapshots().map(\.peerID) - return peerIDs.contains(newPeerID) && !peerIDs.contains(oldPeerID) - }, - timeout: TestConstants.longTimeout - ) - #expect(retired) - } - - @Test - func replayedDirectAnnounceCannotStealBoundIdentity() async throws { - let ble = makeService() - let attackerPeerID = PeerID(str: "1122334455667788") - let victimLink = "central-victim" - let attackerLink = "central-attacker" - - // The victim's identity, genuinely bound on its own link. - let victimSigner = NoiseEncryptionService(keychain: MockKeychain()) - let announcement = AnnouncementPacket( - nickname: "victim", - noisePublicKey: victimSigner.getStaticPublicKeyData(), - signingPublicKey: victimSigner.getSigningPublicKeyData(), - directNeighbors: nil - ) - let payload = try #require(announcement.encode(), "Failed to encode announcement") - let victimPeerID = PeerID(publicKey: announcement.noisePublicKey) - let courierStore = CourierStore(persistsToDisk: false) - ble.courierStore = courierStore - let carriedEnvelope = CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: announcement.noisePublicKey, - epochDay: CourierEnvelope.epochDay(for: Date()) - ), - expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), - ciphertext: Data(repeating: 0xA5, count: 128) - ) - #expect(courierStore.deposit( - carriedEnvelope, - from: Data(repeating: 0xC0, count: 32), - tier: .favorite - )) - let sprayRecipientKey = Data(repeating: 0xB4, count: 32) - let sprayEnvelope = CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: sprayRecipientKey, - epochDay: CourierEnvelope.epochDay(for: Date()) - ), - expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), - ciphertext: Data(repeating: 0xB5, count: 128), - copies: 4 - ) - #expect(courierStore.deposit( - sprayEnvelope, - from: Data(repeating: 0xC0, count: 32), - tier: .favorite - )) - ble._test_seedConnectedPeer(victimPeerID, nickname: "victim") - ble._test_bindCentral(victimLink, to: victimPeerID) - ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker") - ble._test_bindCentral(attackerLink, to: attackerPeerID) - - // Preserve the hard case: a valid victim session still exists on the - // victim's own physical link when the announce is replayed elsewhere. - let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID) - let message2 = try #require( - try victimSigner.processHandshakeMessage(from: ble.myPeerID, message: message1) - ) - let message3 = try #require( - try ble._test_noiseProcessHandshakeMessage(from: victimPeerID, message: message2) - ) - _ = try victimSigner.processHandshakeMessage(from: ble.myPeerID, message: message3) - #expect(ble.canDeliverSecurely(to: victimPeerID)) - ble._test_markNoiseAuthenticatedCentral(victimLink, to: victimPeerID) - - // The victim's fresh signed announce replayed on the attacker's bound - // link with its direct TTL restored (TTL is excluded from signing, so - // the signature still verifies). - let unsigned = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: victimPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: 7 - ) - let packet = try #require(victimSigner.signPacket(unsigned), "Failed to sign announce packet") - - #expect(ble._test_recordIngressIfNew(packet: packet, linkID: attackerLink)) - ble._test_handlePacket(packet, fromPeerID: victimPeerID, preseedPeer: false) - - // The rebind must be refused: the identity already owns a live link. - let stolen = await TestHelpers.waitUntil( - { ble._test_centralBinding(attackerLink) == victimPeerID }, - timeout: 0.3 - ) - #expect(!stolen) - #expect(ble._test_centralBinding(attackerLink) == attackerPeerID) - #expect(ble._test_centralBinding(victimLink) == victimPeerID) - // A valid signature authenticates the announce contents, not the - // unsigned direct TTL. Without a Noise-authenticated session on the - // ingress link, the replay must not retire mail or consume spray state. - #expect(!courierStore.isEmpty) - await Task.yield() - await Task.yield() - let stillEligibleForSpray = courierStore.takeSprayCopies(for: announcement.noisePublicKey) - #expect(stillEligibleForSpray.map(\.copies) == [2]) - #expect(courierStore.takeEnvelopes(for: announcement.noisePublicKey) == [carriedEnvelope]) - #expect(ble.canDeliverSecurely(to: victimPeerID)) - // And the replay must not retire the link's real bound peer. - #expect(ble.currentPeerSnapshots().map(\.peerID).contains(attackerPeerID)) - } - - @Test - func replayedDirectAnnounceForAbsentPeerNeverYieldsSecureDelivery() async throws { - // Residual heal-path gap: the victim has NO live link, so the - // identity-owns-a-link containment cannot refuse the rebind. The - // replay steals the link binding, and because a successful rebind - // promotes its new owner to connected (a legitimate rotation heal - // requires that), the absent victim may read as connected. That - // forged presence is display-only and accepted — the invariant that - // holds is that the stolen link can never produce an established - // Noise session, so MessageRouter's canDeliverSecurely gate routes - // DMs through retain + courier instead of trusting it outright. - let ble = makeService() - let attackerPeerID = PeerID(str: "1122334455667788") - let attackerLink = "central-attacker-absent-victim" - ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker") - ble._test_bindCentral(attackerLink, to: attackerPeerID) - - // The absent victim's fresh signed announce, replayed on the - // attacker's bound link with its direct TTL restored. - let victimSigner = NoiseEncryptionService(keychain: MockKeychain()) - let announcement = AnnouncementPacket( - nickname: "victim", - noisePublicKey: victimSigner.getStaticPublicKeyData(), - signingPublicKey: victimSigner.getSigningPublicKeyData(), - directNeighbors: nil - ) - let payload = try #require(announcement.encode(), "Failed to encode announcement") - let victimPeerID = PeerID(publicKey: announcement.noisePublicKey) - let unsigned = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: victimPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: 7 - ) - let packet = try #require(victimSigner.signPacket(unsigned), "Failed to sign announce packet") - - #expect(ble._test_recordIngressIfNew(packet: packet, linkID: attackerLink)) - ble._test_handlePacket(packet, fromPeerID: victimPeerID, preseedPeer: false) - - // The rebind steals the link (no live link owns the victim's - // identity, so containment cannot refuse) … - let rebound = await TestHelpers.waitUntil( - { ble._test_centralBinding(attackerLink) == victimPeerID }, - timeout: TestConstants.longTimeout - ) - #expect(rebound) - // … and the promote marks the absent victim connected: the accepted, - // display-only forged-presence residue (documented at - // BLEAnnounceHandler's linkBoundToOtherPeer check) … - let forgedPresence = await TestHelpers.waitUntil( - { ble.isPeerConnected(victimPeerID) }, - timeout: TestConstants.longTimeout - ) - #expect(forgedPresence) - // … but secure delivery stays impossible — the DM gate holds, and - // MessageRouter retains + couriers instead of trusting the link. - #expect(!ble.canDeliverSecurely(to: victimPeerID)) - } - - @Test - func replayedDirectAnnounceWithStaleVictimSessionCannotBridgeThroughForeignLink() async throws { - let ble = makeService() - // Keep announce handling out of the carried-mail path: the regression - // is specifically BridgeCourierService's direct delivery preflight. - ble.courierStore = CourierStore(persistsToDisk: false) - let attackerPeerID = PeerID(str: "1122334455667788") - let attackerLink = "central-attacker-stale-victim-session" - ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker") - ble._test_bindCentral(attackerLink, to: attackerPeerID) - - let victim = NoiseEncryptionService(keychain: MockKeychain()) - let announcement = AnnouncementPacket( - nickname: "victim", - noisePublicKey: victim.getStaticPublicKeyData(), - signingPublicKey: victim.getSigningPublicKeyData(), - directNeighbors: nil - ) - let victimPeerID = PeerID(publicKey: announcement.noisePublicKey) - - // Establish a real peer-level victim session without associating it - // with the attacker's physical link. This is the stale-session case - // that a plain `canDeliverSecurely` check cannot distinguish. - let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID) - let message2 = try #require( - try victim.processHandshakeMessage(from: ble.myPeerID, message: message1) - ) - let message3 = try #require( - try ble._test_noiseProcessHandshakeMessage(from: victimPeerID, message: message2) - ) - _ = try victim.processHandshakeMessage(from: ble.myPeerID, message: message3) - #expect(ble.canDeliverSecurely(to: victimPeerID)) - - let payload = try #require(announcement.encode(), "Failed to encode announcement") - let unsigned = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: victimPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: 7 - ) - let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce") - #expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink)) - ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false) - - // The rebind, its Noise-proof retirement, and the ordinary - // reconnect preparation are one engine slot: no observer can see - // the new binding while the victim's stale sending keys are still - // available. Once the binding is visible, the keys must already be - // gone. - let rebound = await TestHelpers.waitUntil( - { ble._test_centralBinding(attackerLink) == victimPeerID }, - timeout: TestConstants.longTimeout - ) - try #require(rebound) - #expect(!ble.canDeliverSecurely(to: victimPeerID)) - - let outbound = OutboundPacketTap() - ble._test_onOutboundPacket = { outbound.record($0) } - let envelope = CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: announcement.noisePublicKey, - epochDay: CourierEnvelope.epochDay(for: Date()) - ), - expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), - ciphertext: Data(repeating: 0xA5, count: 128) - ) - - #expect(!ble.deliverBridgedEnvelope(envelope, to: victimPeerID)) - // Reject before even entering the outbound pipeline: otherwise a - // real attacker CBCentral could accept the opaque courier packet and - // cause the relay drop's persisted seen ID to be consumed forever. - #expect(outbound.count(ofType: .courierEnvelope) == 0) - } - - @Test - func replacementXXMessageOneWithPayloadCannotAuthenticateIngressLink() async throws { - let ble = makeService() - let victim = NoiseEncryptionService(keychain: MockKeychain()) - let victimPeerID = PeerID(publicKey: victim.getStaticPublicKeyData()) - - // Preserve a working victim session while an unauthenticated - // replacement candidate arrives on a newly bound physical link. - // Establish BLE as responder so the replacement candidate below is - // not coalesced by the initiator-completion grace path. - let message1 = try victim.initiateHandshake(with: ble.myPeerID) - let message2 = try #require( - try ble._test_noiseProcessHandshakeMessage( - from: victimPeerID, - message: message1 - ) - ) - let message3 = try #require( - try victim.processHandshakeMessage( - from: ble.myPeerID, - message: message2 - ) - ) - _ = try ble._test_noiseProcessHandshakeMessage( - from: victimPeerID, - message: message3 - ) - await ble._test_drainNoiseMessagePipeline() - #expect(ble.canDeliverSecurely(to: victimPeerID)) - - let centralUUID = "central-replacement-xx-message-one" - ble._test_bindCentral(centralUUID, to: victimPeerID) - #expect( - !ble._test_isNoiseAuthenticatedCentral( - centralUUID, - for: victimPeerID - ) - ) - - // XX message one may legally carry a payload, so its length is not a - // reliable signal that the replacement handshake completed. - let unauthenticatedInitiator = NoiseHandshakeState( - role: .initiator, - pattern: .XX, - keychain: MockKeychain() - ) - let replacementMessage1 = try unauthenticatedInitiator.writeMessage( - payload: Data([0xA5]) - ) - #expect( - replacementMessage1.count - > NoiseSecurityConstants.xxInitialMessageSize - ) - - let packet = BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: Data(hexString: victimPeerID.id) ?? Data(), - recipientID: Data(hexString: ble.myPeerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: replacementMessage1, - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - #expect( - ble._test_recordIngressIfNew( - packet: packet, - linkID: centralUUID - ) - ) - - let outbound = OutboundPacketTap() - ble._test_onOutboundPacket = outbound.record - ble._test_handlePacket( - packet, - fromPeerID: victimPeerID, - preseedPeer: false - ) - - // Waiting for the responder's message two proves the candidate was - // processed before checking its exact authentication result. - let candidateProcessed = await TestHelpers.waitUntil( - { outbound.count(ofType: .noiseHandshake) == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(candidateProcessed) - #expect( - !ble._test_isNoiseAuthenticatedCentral( - centralUUID, - for: victimPeerID - ) - ) - // Ordinary reconnect hardening quarantines the cached transport while - // this candidate proves the claimed identity. It must be unavailable - // for sending as well as unable to authenticate this ingress link. - #expect(!ble.canDeliverSecurely(to: victimPeerID)) - } - - @Test - func failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws { - let ble = makeService() - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let mallory = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - - // Establish BLE as responder so the following inbound reconnect is - // not intentionally coalesced by the initiator-completion grace path. - let message1 = try alice.initiateHandshake(with: ble.myPeerID) - let message2 = try #require( - try ble._test_noiseProcessHandshakeMessage( - from: alicePeerID, - message: message1 - ) - ) - let message3 = try #require( - try alice.processHandshakeMessage( - from: ble.myPeerID, - message: message2 - ) - ) - _ = try ble._test_noiseProcessHandshakeMessage( - from: alicePeerID, - message: message3 - ) - await ble._test_drainNoiseMessagePipeline() - #expect(ble.canDeliverSecurely(to: alicePeerID)) - // The establishment transition enqueues its forced announce as a - // separate serialized phase; drain again so it lands before the tap - // and cannot masquerade as restore-driven announce traffic below. - await ble._test_drainNoiseMessagePipeline() - - let outbound = OutboundPacketTap() - ble._test_onOutboundPacket = outbound.record - let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID) - let firstPacket = BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: Data(hexString: alicePeerID.id) ?? Data(), - recipientID: Data(hexString: ble.myPeerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), - payload: forgedMessage1, - signature: nil, - ttl: 7 - ) - ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID) - - let responseReady = await TestHelpers.waitUntil( - { - outbound.snapshot().contains { - $0.type == MessageType.noiseHandshake.rawValue - && PeerID(hexData: $0.senderID) == ble.myPeerID - && $0.payload.count - != NoiseSecurityConstants.xxInitialMessageSize - } - }, - timeout: TestConstants.longTimeout - ) - try #require(responseReady) - let forgedMessage2 = try #require( - outbound.snapshot().first { - $0.type == MessageType.noiseHandshake.rawValue - && PeerID(hexData: $0.senderID) == ble.myPeerID - && $0.payload.count - != NoiseSecurityConstants.xxInitialMessageSize - }?.payload - ) - #expect(!ble.canDeliverSecurely(to: alicePeerID)) - - // Typed control traffic must queue behind the ordinary responder, - // rather than attempting encryption and disappearing. - let privateMessageID = "quarantine-pm-\(UUID().uuidString)" - ble.sendPrivateMessage( - "queued private message", - to: alicePeerID, - recipientNickname: "Alice", - messageID: privateMessageID - ) - ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID) - await ble._test_drainNoiseMessagePipeline() - #expect(outbound.count(ofType: .noiseEncrypted) == 0) - - let forgedMessage3 = try #require( - try mallory.processHandshakeMessage( - from: ble.myPeerID, - message: forgedMessage2 - ) - ) - let forgedEarlyPayload = try #require( - BLENoisePayloadFactory.privateMessage( - content: "forged early message", - messageID: "forged-early" - ) - ) - try #require( - mallory.hasEstablishedSession(with: ble.myPeerID), - "forged initiator did not establish after producing message three" - ) - let forgedEarlyCiphertext = try mallory.encrypt( - forgedEarlyPayload, - for: ble.myPeerID - ) - let earlyPacket = BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: alicePeerID.id) ?? Data(), - recipientID: Data(hexString: ble.myPeerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1, - payload: forgedEarlyCiphertext, - signature: nil, - ttl: 7 - ) - ble._test_handlePacket(earlyPacket, fromPeerID: alicePeerID) - await ble._test_drainNoiseMessagePipeline() - - let thirdPacket = BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: Data(hexString: alicePeerID.id) ?? Data(), - recipientID: Data(hexString: ble.myPeerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 2, - payload: forgedMessage3, - signature: nil, - ttl: 7 - ) - ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID) - - // Rollback restores the same generation. It retries the bounded early - // ciphertext and drains both outbound queues, but must not repeat a - // new-generation capability proof or forced announce. - let drained = await TestHelpers.waitUntil( - { outbound.count(ofType: .noiseEncrypted) >= 2 }, - timeout: TestConstants.longTimeout - ) - try #require(drained) - await ble._test_drainNoiseMessagePipeline() - let plaintexts = try outbound.snapshot() - .filter { $0.type == MessageType.noiseEncrypted.rawValue } - .map { try alice.decrypt($0.payload, from: ble.myPeerID) } - #expect(plaintexts.count == 2) - #expect( - plaintexts.filter { - $0.first == NoisePayloadType.authenticatedPeerState.rawValue - }.isEmpty - ) - #expect( - plaintexts.filter { - $0.first == NoisePayloadType.privateMessage.rawValue - }.count == 1 - ) - #expect( - plaintexts.filter { - $0.first == NoisePayloadType.groupInvite.rawValue - }.count == 1 - ) - #expect(outbound.count(ofType: .announce) == 0) - - // A duplicate ready callback cannot replay either buffer. - ble._test_reconcileCurrentNoiseSession(for: alicePeerID) - await ble._test_drainNoiseMessagePipeline() - #expect(outbound.count(ofType: .noiseEncrypted) == 2) - #expect(outbound.count(ofType: .announce) == 0) - } - - /// The message-loss interleaving behind a legitimate peer restart: the - /// remote completes a replacement handshake (discarding the old keys) - /// but its completion never arrives, so the local responder timeout - /// restores the quarantined OLD generation and requests one convergence - /// retry — both dispatched unordered. When the restore handler wins the - /// race, it must NOT drain the pending private-message/typed-payload - /// queues under the restored keys the remote no longer holds; the drain - /// has to wait for the convergence handshake and use its new session. - @Test - func timeoutRestoredSessionDefersQueueDrainUntilConvergence() async throws { - let ble = makeService(noiseResponderHandshakeTimeout: 0.3) - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let mallory = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - - // Establish BLE as responder so the inbound reconnect below is not - // coalesced by the initiator-completion grace path. - let message1 = try alice.initiateHandshake(with: ble.myPeerID) - let message2 = try #require( - try ble._test_noiseProcessHandshakeMessage( - from: alicePeerID, - message: message1 - ) - ) - let message3 = try #require( - try alice.processHandshakeMessage( - from: ble.myPeerID, - message: message2 - ) - ) - _ = try ble._test_noiseProcessHandshakeMessage( - from: alicePeerID, - message: message3 - ) - await ble._test_drainNoiseMessagePipeline() - #expect(ble.canDeliverSecurely(to: alicePeerID)) - - // The convergence retry only prepares for reachable peers. - ble._test_seedConnectedPeer(alicePeerID, nickname: "Alice") - - let reconciled = SessionReconcileCounter() - ble._test_onPrivateMediaSessionReconciled = reconciled.record - // Park the convergence-recovery callback on its global-queue thread - // before it can enqueue onto messageQueue: the restore handler - // deterministically wins the dispatch race this test exercises. - let recoveryGate = HandshakeRecoveryEnqueueGate() - defer { recoveryGate.release() } - ble._test_beforeHandshakeRecoveryEnqueued = { _ in recoveryGate.pause() } - let outbound = OutboundPacketTap() - ble._test_onOutboundPacket = outbound.record - - // Park the traffic the race would lose directly in the pending - // queues — the same place live sends land during quarantine — so no - // assertion below depends on outrunning the responder deadline under - // parallel test load. Nothing drains these queues while the current - // session stays untouched. - ble._test_enqueuePendingPrivateMessage( - content: "deferred private message", - messageID: "deferred-pm-\(UUID().uuidString)", - for: alicePeerID - ) - ble._test_enqueuePendingNoisePayload( - NoisePayload( - type: .groupInvite, - data: Data("queued-during-quarantine".utf8) - ).encode(), - transferId: "deferred-invite-\(UUID().uuidString)", - for: alicePeerID - ) - - // An unauthenticated message 1 quarantines the established transport. - // Its message 3 never arrives, modeling the restarted peer whose - // completion was lost after it already discarded the old keys. - let reconnectMessage1 = try mallory.initiateHandshake(with: ble.myPeerID) - let reconnectPacket = BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: Data(hexString: alicePeerID.id) ?? Data(), - recipientID: Data(hexString: ble.myPeerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), - payload: reconnectMessage1, - signature: nil, - ttl: 7 - ) - ble._test_handlePacket(reconnectPacket, fromPeerID: alicePeerID) - // The responder's message 2 is a monotonic quarantine signal; the - // secure-delivery dip itself only lasts until the responder deadline, - // which parallel test load can outrun. (The recovery gate keeps the - // convergence retry's message 1 out of the tap until released.) - let responderReady = await TestHelpers.waitUntil( - { outbound.count(ofType: .noiseHandshake) >= 1 }, - timeout: TestConstants.longTimeout - ) - try #require(responderReady) - #expect(outbound.count(ofType: .noiseEncrypted) == 0) - - // The responder timeout restores the quarantined generation; the - // gate guarantees its handler runs before the convergence retry. - let restoreRan = await TestHelpers.waitUntil( - { reconciled.count(for: alicePeerID) == 1 }, - timeout: TestConstants.longTimeout - ) - try #require(restoreRan) - #expect(ble.canDeliverSecurely(to: alicePeerID)) - await ble._test_drainNoiseMessagePipeline() - // The parked queues must not have been encrypted under the restored - // old generation the remote may no longer be able to read. - #expect(outbound.count(ofType: .noiseEncrypted) == 0) - - // The capability-proof watchdog armed at the original authentication - // is still live and can genuinely reach its real 5s deadline here on - // a stalled CI runner. Fire it deterministically: its drain must - // respect the deferred-until-convergence state instead of encrypting - // the parked queues under the restored keys (the exact silent loss - // the defer path exists to prevent). The retry below then still - // finds the queues parked. - ble._test_forcePrivateMediaProofTimeout(for: alicePeerID) - await ble._test_drainNoiseMessagePipeline() - #expect(outbound.count(ofType: .noiseEncrypted) == 0) - - // Release the mandatory convergence retry: it retires the restored - // session and starts a fresh XX exchange with the live peer. - recoveryGate.release() - let retryStarted = await TestHelpers.waitUntil( - { - outbound.snapshot().contains { - $0.type == MessageType.noiseHandshake.rawValue - && PeerID(hexData: $0.senderID) == ble.myPeerID - && $0.payload.count - == NoiseSecurityConstants.xxInitialMessageSize - } - }, - timeout: TestConstants.longTimeout - ) - try #require(retryStarted) - #expect(outbound.count(ofType: .noiseEncrypted) == 0) - let retryMessage1 = try #require( - outbound.snapshot().last { - $0.type == MessageType.noiseHandshake.rawValue - && PeerID(hexData: $0.senderID) == ble.myPeerID - && $0.payload.count - == NoiseSecurityConstants.xxInitialMessageSize - }?.payload - ) - - // Alice answers the retry as the restarted peer she models: her old - // session is gone, so the retry is a fresh responder exchange (and - // never the initiator-completion grace deferral, whose lower-peerID - // arm would otherwise coalesce the retry for random key orderings). - alice.clearSession(for: ble.myPeerID) - let retryMessage2 = try #require( - try alice.processHandshakeMessage( - from: ble.myPeerID, - message: retryMessage1 - ) - ) - let retryPacket = BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: Data(hexString: alicePeerID.id) ?? Data(), - recipientID: Data(hexString: ble.myPeerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1, - payload: retryMessage2, - signature: nil, - ttl: 7 - ) - ble._test_handlePacket(retryPacket, fromPeerID: alicePeerID) - let drained = await TestHelpers.waitUntil( - { outbound.count(ofType: .noiseEncrypted) >= 3 }, - timeout: TestConstants.longTimeout - ) - try #require(drained) - await ble._test_drainNoiseMessagePipeline() - - // Alice completes with message 3, then must be able to decrypt every - // drained payload — proving nothing left under the old generation. - let retryMessage3 = try #require( - outbound.snapshot().last { - $0.type == MessageType.noiseHandshake.rawValue - && PeerID(hexData: $0.senderID) == ble.myPeerID - && $0.payload.count - != NoiseSecurityConstants.xxInitialMessageSize - }?.payload - ) - _ = try alice.processHandshakeMessage( - from: ble.myPeerID, - message: retryMessage3 - ) - let plaintexts = try outbound.snapshot() - .filter { $0.type == MessageType.noiseEncrypted.rawValue } - .map { try alice.decrypt($0.payload, from: ble.myPeerID) } - #expect(plaintexts.count == 3) - #expect( - plaintexts.filter { - $0.first == NoisePayloadType.authenticatedPeerState.rawValue - }.count == 1 - ) - #expect( - plaintexts.filter { - $0.first == NoisePayloadType.privateMessage.rawValue - }.count == 1 - ) - #expect( - plaintexts.filter { - $0.first == NoisePayloadType.groupInvite.rawValue - }.count == 1 - ) - } - - /// A legitimate rotation announce necessarily arrives on a link still - /// bound to the OLD ID, so its registry upsert stores the new peer - /// disconnected. The successful rebind must promote it: a healed - /// rotation with a live link has to read as connected again for routing - /// and outbox flushes. - @Test - func rotationHealPromotesRotatedPeerToConnected() async throws { - let ble = makeService() - let oldPeerID = PeerID(str: "1122334455667788") - let centralUUID = "central-rotation-promote" - - ble._test_seedConnectedPeer(oldPeerID, nickname: "alice") - ble._test_bindCentral(centralUUID, to: oldPeerID) - - let signer = NoiseEncryptionService(keychain: MockKeychain()) - let announcement = AnnouncementPacket( - nickname: "alice", - noisePublicKey: signer.getStaticPublicKeyData(), - signingPublicKey: signer.getSigningPublicKeyData(), - directNeighbors: nil - ) - let payload = try #require(announcement.encode(), "Failed to encode announcement") - let newPeerID = PeerID(publicKey: announcement.noisePublicKey) - let unsigned = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: newPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: 7 - ) - let packet = try #require(signer.signPacket(unsigned), "Failed to sign announce packet") - - #expect(ble._test_recordIngressIfNew(packet: packet, linkID: centralUUID)) - ble._test_handlePacket(packet, fromPeerID: newPeerID, preseedPeer: false) - - let rebound = await TestHelpers.waitUntil( - { ble._test_centralBinding(centralUUID) == newPeerID }, - timeout: TestConstants.longTimeout - ) - #expect(rebound) - - let connected = await TestHelpers.waitUntil( - { ble.isPeerConnected(newPeerID) }, - timeout: TestConstants.longTimeout - ) - #expect(connected) - } - - /// Noise sessions are keyed by the short wire ID, but routers may key - /// sends by the full 64-hex Noise key (favorites resolution does). The - /// secure-delivery gate must normalize like isPeerConnected, or an - /// established session is misread as insecure and every DM needlessly - /// retains + couriers until an ack. - @Test - func canDeliverSecurelyNormalizesFullNoiseKeyPeerIDs() async throws { - let ble = makeService() - let remote = NoiseEncryptionService(keychain: MockKeychain()) - let remoteKey = remote.getStaticPublicKeyData() - let shortID = PeerID(publicKey: remoteKey) - let fullKeyID = PeerID(hexData: remoteKey) - #expect(fullKeyID.toShort() == shortID) - #expect(!ble.canDeliverSecurely(to: shortID)) - - // Full XX handshake; the local side keys the session by the short - // wire ID, exactly as packets present it in production. - let m1 = try ble._test_noiseInitiateHandshake(with: shortID) - let m2 = try #require(try remote.processHandshakeMessage(from: ble.myPeerID, message: m1)) - let m3 = try #require(try ble._test_noiseProcessHandshakeMessage(from: shortID, message: m2)) - _ = try remote.processHandshakeMessage(from: ble.myPeerID, message: m3) - - #expect(ble.canDeliverSecurely(to: shortID)) - #expect(ble.canDeliverSecurely(to: fullKeyID)) - } - - @Test - func ingressRejectsSelfLoopbackBeforeSpoofChecks() async throws { - let ble = makeService() - let packet = makePublicPacket( - content: "Loopback", - sender: ble.myPeerID, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000) - ) - - #expect(!ble._test_acceptsIngress(packet: packet, boundPeerID: PeerID(str: "1122334455667788"))) - } - - @Test - func ingressAllowsSelfAuthoredRSRWithTTLZeroFromBoundPeer() async throws { - let ble = makeService() - var packet = makePublicPacket( - content: "Recovered by sync", - sender: ble.myPeerID, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000) - ) - packet.isRSR = true - packet.ttl = 0 - - #expect(ble._test_acceptsIngress(packet: packet, boundPeerID: PeerID(str: "1122334455667788"))) - } - - @Test - func ingressRecordSuppressesSecondLinkDuplicate() async throws { - let ble = makeService() - let packet = makePublicPacket( - content: "Duplicate link copy", - sender: PeerID(str: "1122334455667788"), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000) - ) - - #expect(ble._test_recordIngressIfNew(packet: packet, linkID: "central-a")) - #expect(!ble._test_recordIngressIfNew(packet: packet, linkID: "central-b")) - } - - @Test - func panicReset_rotatesPeerIDDerivedFromNewNoiseFingerprint() async throws { - let ble = makeService() - let originalPeerID = ble.myPeerID - let originalFingerprint = ble.noiseIdentityFingerprint() - #expect(originalPeerID == PeerID(str: originalFingerprint.prefix(16))) - - ble.resetIdentityForPanic(currentNickname: "anon") - - // The Noise identity is regenerated and the peer ID swaps with it - // (atomically, behind a messageQueue barrier). - let newFingerprint = ble.noiseIdentityFingerprint() - #expect(newFingerprint != originalFingerprint) - #expect(ble.myPeerID != originalPeerID) - #expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16))) - } - - @Test - func panicSuspension_dropsLateOutboundWorkUntilCommit() async { - let ble = makeService() - let outbound = OutboundPacketTap() - ble._test_onOutboundPacket = outbound.record - let packet = makePublicPacket( - content: "late callback", - sender: ble.myPeerID, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000) - ) - - ble.suspendForPanicReset() - ble.sendPacket(packet) - #expect(outbound.count(ofType: .message) == 0) - - ble.completePanicReset(restartServices: false) - ble.sendPacket(packet) - #expect(outbound.count(ofType: .message) == 1) - } - - @Test @MainActor - func panicSuspension_invalidatesQueuedMainActorIngress() async { - let ble = makeService() - let delegate = TransportEventCaptureDelegate() - ble.eventDelegate = delegate - let message = BitchatMessage( - id: "pre-panic-ingress", - sender: "Peer", - content: "must not survive panic", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: PeerID(str: "1122334455667788") - ) - - // The test already owns MainActor, so this task cannot run until the - // synchronous panic boundary below has invalidated its generation. - ble._test_emitTransportEvent(.messageReceived(message)) - ble.suspendForPanicReset() - await Task.yield() - #expect(delegate.messageIDs.isEmpty) - - ble.completePanicReset(restartServices: false) - ble._test_emitTransportEvent(.messageReceived(message)) - await Task.yield() - #expect(delegate.messageIDs == [message.id]) - } - - @Test @MainActor - func panicSuspension_rejectsPausedBLEReceiveBeforeMessageQueueHandoff() async { - let ble = makeService() - let gate = ReceivePacketHandoffGate() - ble._test_beforeReceivePacketHandoff = gate.pause - ble._test_onReceivePacketHandoff = gate.recordHandoff - defer { - gate.release() - ble._test_beforeReceivePacketHandoff = nil - ble._test_onReceivePacketHandoff = nil - } - - let sender = PeerID(str: "1122334455667788") - let packet = makePublicPacket( - content: "must not cross panic", - sender: sender, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000) - ) - ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender) - #expect(await TestHelpers.waitUntil( - { gate.hasPaused }, - timeout: TestConstants.longTimeout - )) - - // Panic closes the lifecycle before waiting for the paused bleQueue - // callback. Releasing it afterward lets the callback enqueue its - // messageQueue handoff, where the captured generation must be rejected - // before packet processing starts. - let panicIngressObserver = PanicIngressObserver(service: ble) - let didObservePanicClosure = await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - let didObserveClosure = panicIngressObserver.waitUntilClosed( - timeout: TestConstants.settleTimeout - ) - gate.release() - continuation.resume(returning: didObserveClosure) - } - ble.suspendForPanicReset() - } - - #expect(didObservePanicClosure) - #expect(gate.handoffCount == 0) - - // A packet captured under the reopened lifecycle still crosses the - // same handoff, proving the test did not merely disable the hook. - ble.completePanicReset(restartServices: false) - ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender) - #expect(await TestHelpers.waitUntil( - { gate.handoffCount == 1 }, - timeout: TestConstants.longTimeout - )) - } - - @Test @MainActor - func panicSuspension_finalizesStaleTransportEventsAsRejected() async { - let ble = makeService() - let delegate = TransportEventCaptureDelegate() - ble.eventDelegate = delegate - let message = BitchatMessage( - id: "pre-panic-finalization", - sender: "Peer", - content: "must be rejected", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: PeerID(str: "1122334455667788") - ) - var completions = 0 - var outcomes: [TransportEventDeliveryOutcome] = [] - - ble._test_emitTransportEvent( - .messageReceived(message), - completion: { completions += 1 }, - finalization: { outcomes.append($0) } - ) - ble.suspendForPanicReset() - ble._test_emitTransportEvent( - .messageReceived(message), - completion: { completions += 1 }, - finalization: { outcomes.append($0) } - ) - for _ in 0..<4 { - await Task.yield() - } - - #expect(delegate.messageIDs.isEmpty) - #expect(completions == 0) - #expect(outcomes == [.rejected, .rejected]) - } - - @Test - func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws { - let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB") - - #expect(BLEService._test_shouldRediscoverBitChatService( - invalidatedServiceUUIDs: [BLEService.serviceUUID], - cachedServiceUUIDs: [otherService] - )) - } - - @Test - func modifiedServices_rediscoverWhenCachedServicesNoLongerIncludeBitChat() async throws { - let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB") - - #expect(BLEService._test_shouldRediscoverBitChatService( - invalidatedServiceUUIDs: [otherService], - cachedServiceUUIDs: [otherService] - )) - } - - @Test - func modifiedServices_ignoreUnrelatedInvalidationWhenBitChatIsStillCached() async throws { - let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB") - - #expect(!BLEService._test_shouldRediscoverBitChatService( - invalidatedServiceUUIDs: [otherService], - cachedServiceUUIDs: [BLEService.serviceUUID, otherService] - )) - } - - /// Pings are unsigned, so their claimed sender is attacker-controlled. - /// The pong budget must be keyed on the ingress link (the directly - /// connected peer that delivered the packet): rotating forged sender IDs - /// over one link exhausts one budget instead of resetting it, so a single - /// malicious link cannot turn /ping into an amplification primitive. - @Test - func meshPingResponseBudget_isPerIngressLinkNotClaimedSender() async throws { - let ble = makeService() - let outbound = OutboundPacketTap() - ble._test_onOutboundPacket = outbound.record - - let link = PeerID(str: "1122334455667788") - let budget = TransportConfig.meshPingInboundMaxPerLink - let myRecipientData = try #require(Data(hexString: ble.myPeerID.id)) - - for i in 0..<(budget * 2) { - // A fresh forged sender for every ping, all arriving on one link. - let forgedSender = PeerID(str: String(format: "%016x", 0xA0_0000 + i)) - var nonce = Data(repeating: 0, count: MeshPingPayload.nonceLength) - nonce[0] = UInt8(i) - let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7)) - let packet = BitchatPacket( - type: MessageType.ping.rawValue, - senderID: Data(hexString: forgedSender.id) ?? Data(), - recipientID: myRecipientData, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload.encode(), - signature: nil, - ttl: 7 - ) - ble._test_handlePacket(packet, fromPeerID: link, preseedPeer: false) - } - - let reachedBudget = await TestHelpers.waitUntil( - { outbound.count(ofType: .pong) >= budget }, - timeout: TestConstants.longTimeout - ) - #expect(reachedBudget) - // Give any over-budget pong a chance to surface, then confirm the - // rotated sender IDs never bought a sixth response. - let exceededBudget = await TestHelpers.waitUntil( - { outbound.count(ofType: .pong) > budget }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!exceededBudget) - #expect(outbound.count(ofType: .pong) == budget) - } } -/// Thread-safe capture of packets leaving the service under test. -private final class OutboundPacketTap { - private let lock = NSLock() - private var packets: [BitchatPacket] = [] - - func record(_ packet: BitchatPacket) { - lock.lock(); packets.append(packet); lock.unlock() - } - - func count(ofType type: MessageType) -> Int { - lock.lock(); defer { lock.unlock() } - return packets.filter { $0.type == type.rawValue }.count - } - - func snapshot() -> [BitchatPacket] { - lock.lock(); defer { lock.unlock() } - return packets - } -} - -/// Blocks the convergence-recovery callback on its global-queue thread so a -/// test can prove the quarantine-restore handler wins the messageQueue race. -private final class HandshakeRecoveryEnqueueGate: @unchecked Sendable { - private let condition = NSCondition() - private var released = false - - func pause() { - condition.lock() - while !released { - condition.wait() - } - condition.unlock() - } - - func release() { - condition.lock() - released = true - condition.broadcast() - condition.unlock() - } -} - -/// Thread-safe counter for `_test_onPrivateMediaSessionReconciled` firings. -private final class SessionReconcileCounter: @unchecked Sendable { - private let lock = NSLock() - private var reconciles: [PeerID] = [] - - func record(_ peerID: PeerID) { - lock.lock() - reconciles.append(peerID) - lock.unlock() - } - - func count(for peerID: PeerID) -> Int { - lock.lock(); defer { lock.unlock() } - return reconciles.filter { $0 == peerID }.count - } -} - -private final class ReceivePacketHandoffGate: @unchecked Sendable { - private let condition = NSCondition() - private var paused = false - private var released = false - private var recordedHandoffCount = 0 - - var hasPaused: Bool { - condition.lock() - defer { condition.unlock() } - return paused - } - - var handoffCount: Int { - condition.lock() - defer { condition.unlock() } - return recordedHandoffCount - } - - func pause() { - condition.lock() - paused = true - condition.broadcast() - while !released { - condition.wait() - } - condition.unlock() - } - - func release() { - condition.lock() - released = true - condition.broadcast() - condition.unlock() - } - - func recordHandoff() { - condition.lock() - recordedHandoffCount += 1 - condition.unlock() - } -} - -/// Lets a dedicated dispatch worker observe the lock-protected panic gate -/// without treating the full BLE service as generally Sendable. -private final class PanicIngressObserver: @unchecked Sendable { - private let service: BLEService - - init(service: BLEService) { - self.service = service - } - - func waitUntilClosed(timeout: TimeInterval) -> Bool { - let deadline = DispatchTime.now().uptimeNanoseconds - + UInt64(timeout * 1_000_000_000) - while service._test_isPanicIngressOpen, - DispatchTime.now().uptimeNanoseconds < deadline { - Thread.sleep(forTimeInterval: 0.001) - } - return !service._test_isPanicIngressOpen - } -} - -private func makeService( - noiseResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, - engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler() -) -> BLEService { +private func makeService() -> BLEService { let keychain = MockKeychain() let identityManager = MockIdentityManager(keychain) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) @@ -1536,9 +102,7 @@ private func makeService( keychain: keychain, idBridge: idBridge, identityManager: identityManager, - initializeBluetoothManagers: false, - noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout, - engineScheduler: engineScheduler + initializeBluetoothManagers: false ) } @@ -1554,18 +118,6 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64 ) } -private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket { - BitchatPacket( - type: MessageType.leave.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data(marker.utf8), - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) -} - private final class PublicCaptureDelegate: BitchatDelegate { private let lock = NSLock() private(set) var publicMessages: [BitchatMessage] = [] @@ -1599,15 +151,4 @@ private final class PublicCaptureDelegate: BitchatDelegate { defer { lock.unlock() } return publicMessages } - -} - -@MainActor -private final class TransportEventCaptureDelegate: TransportEventDelegate { - private(set) var messageIDs: [String] = [] - - func didReceiveTransportEvent(_ event: TransportEvent) { - guard case .messageReceived(let message) = event else { return } - messageIDs.append(message.id) - } } diff --git a/bitchatTests/BLEServiceTests.swift b/bitchatTests/BLEServiceTests.swift index d704f009..2a32e838 100644 --- a/bitchatTests/BLEServiceTests.swift +++ b/bitchatTests/BLEServiceTests.swift @@ -8,7 +8,7 @@ import Testing import CoreBluetooth -@testable import BitFoundation // to avoid unnecessary public's +import BitFoundation @testable import bitchat struct BLEServiceTests { diff --git a/bitchatTests/BitchatPeerTests.swift b/bitchatTests/BitchatPeerTests.swift index 101f30f8..eba4e4c7 100644 --- a/bitchatTests/BitchatPeerTests.swift +++ b/bitchatTests/BitchatPeerTests.swift @@ -36,30 +36,6 @@ struct BitchatPeerTests { #expect(peer.statusIcon == "🌙") } - @Test("Mutual favorite without a stored Nostr key is offline, not nostr-available") - func mutualFavoriteWithoutNostrKeyIsOffline() { - let peerID = PeerID(str: "0123456789abcdef") - let noiseKey = Data((0..<32).map(UInt8.init)) - var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false) - peer.favoriteStatus = FavoriteRelationship( - peerNoisePublicKey: noiseKey, - peerNostrPublicKey: nil, - peerNickname: "A", - isFavorite: true, - theyFavoritedUs: true, - favoritedAt: Date(timeIntervalSince1970: 1), - lastUpdated: Date(timeIntervalSince1970: 2) - ) - - // Nothing to seal a Nostr envelope to: claiming availability here - // would relight the DM header's "end-to-end encrypted" caption with - // neither a Noise session nor a usable recipient key. - #expect(peer.connectionState == .offline) - - peer.nostrPublicKey = "abcdef" - #expect(peer.connectionState == .nostrAvailable) - } - @Test("Mutual offline peers show Nostr icon") func mutualFavoriteOfflinePeerShowsNostrIcon() { let peerID = PeerID(str: "0011223344556677") diff --git a/bitchatTests/CashuTokenDecoderTests.swift b/bitchatTests/CashuTokenDecoderTests.swift deleted file mode 100644 index 890a7eeb..00000000 --- a/bitchatTests/CashuTokenDecoderTests.swift +++ /dev/null @@ -1,350 +0,0 @@ -// -// CashuTokenDecoderTests.swift -// bitchatTests -// -// Tests for the Cashu token summary decoder: V3 JSON decode, minimal V4 -// CBOR traversal, URI normalization, detection ranges, and adversarial -// (truncated / garbage / huge) input. The decoder renders attacker-controlled -// message content, so "never crash" matters as much as "decode correctly". -// This is free and unencumbered software released into the public domain. -// - -import Foundation -import Testing -@testable import bitchat - -struct CashuTokenDecoderTests { - - // MARK: - Token Builders - - private func base64URL(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - private func makeV3Token( - entries: [(mint: String, amounts: [Int])], - unit: String? = "sat", - memo: String? = nil - ) -> String { - var json: [String: Any] = [ - "token": entries.map { entry in - [ - "mint": entry.mint, - "proofs": entry.amounts.map { - ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] as [String: Any] - } - ] as [String: Any] - } - ] - if let unit { json["unit"] = unit } - if let memo { json["memo"] = memo } - let data = try! JSONSerialization.data(withJSONObject: json) - return "cashuA" + base64URL(data) - } - - /// Tiny deterministic CBOR encoder (definite lengths only) for building - /// V4 test tokens without depending on the decoder under test. - private enum CBOREncode { - static func head(_ major: UInt8, _ value: UInt64) -> [UInt8] { - switch value { - case 0...23: - return [(major << 5) | UInt8(value)] - case 24...0xFF: - return [(major << 5) | 24, UInt8(value)] - case 0x100...0xFFFF: - return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)] - default: - return [(major << 5) | 26, - UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF), - UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)] - } - } - static func uint(_ v: UInt64) -> [UInt8] { head(0, v) } - static func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b } - static func text(_ s: String) -> [UInt8] { - let utf8 = Array(s.utf8) - return head(3, UInt64(utf8.count)) + utf8 - } - static func array(_ items: [[UInt8]]) -> [UInt8] { - head(4, UInt64(items.count)) + items.flatMap { $0 } - } - static func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { - head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } - } - } - - private func makeV4Token( - mint: String = "https://mint.example.com", - unit: String = "sat", - memo: String? = nil, - amounts: [UInt64] = [1, 4] - ) -> String { - var pairs: [(String, [UInt8])] = [ - ("m", CBOREncode.text(mint)), - ("u", CBOREncode.text(unit)) - ] - if let memo { pairs.append(("d", CBOREncode.text(memo))) } - let proofs = amounts.map { amount in - CBOREncode.map([ - ("a", CBOREncode.uint(amount)), - ("s", CBOREncode.text("secret")), - ("c", CBOREncode.bytes([0x02, 0xAB, 0xCD])) - ]) - } - pairs.append(("t", CBOREncode.array([ - CBOREncode.map([ - ("i", CBOREncode.bytes([0x00, 0xAD, 0x26, 0x8C])), - ("p", CBOREncode.array(proofs)) - ]) - ]))) - return "cashuB" + base64URL(Data(CBOREncode.map(pairs))) - } - - // MARK: - V3 Decode - - @Test func v3DecodeValidToken() { - let token = makeV3Token( - entries: [("https://mint.example.com", [2, 8])], - unit: "sat", - memo: "thanks!" - ) - let info = CashuTokenDecoder.decode(token) - #expect(info != nil) - #expect(info?.version == "A") - #expect(info?.amount == 10) - #expect(info?.unit == "sat") - #expect(info?.mintHost == "mint.example.com") - #expect(info?.memo == "thanks!") - #expect(info?.displayAmount == "10 sat") - } - - @Test func v3AmountSumsAcrossEntriesAndProofs() { - let token = makeV3Token(entries: [ - ("https://a.mint.example", [1, 2, 4]), - ("https://b.mint.example", [8, 16]) - ]) - let info = CashuTokenDecoder.decode(token) - #expect(info?.amount == 31) - // First mint wins for the display host - #expect(info?.mintHost == "a.mint.example") - } - - @Test func v3MissingUnitDefaultsToSatForDisplay() { - let token = makeV3Token(entries: [("https://mint.example.com", [5])], unit: nil) - let info = CashuTokenDecoder.decode(token) - #expect(info?.unit == nil) - #expect(info?.displayAmount == "5 sat") - } - - @Test func v3RejectsNonsenseAmounts() { - // Negative and absurd amounts must not poison the sum - let json: [String: Any] = [ - "token": [[ - "mint": "https://mint.example.com", - "proofs": [ - ["amount": -5, "id": "x", "secret": "s", "C": "c"], - ["amount": 3, "id": "x", "secret": "s", "C": "c"] - ] - ] as [String: Any]] - ] - let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json)) - #expect(CashuTokenDecoder.decode(token)?.amount == 3) - } - - @Test func v3MemoIsSanitizedForDisplay() { - let token = makeV3Token( - entries: [("https://mint.example.com", [1])], - memo: "line1\nline2\u{0007}" + String(repeating: "x", count: 300) - ) - let memo = CashuTokenDecoder.decode(token)?.memo - #expect(memo != nil) - #expect(memo?.contains("\n") == false) - #expect(memo?.contains("\u{0007}") == false) - #expect((memo?.count ?? 0) <= 80) - } - - // MARK: - V4 (CBOR) Decode - - @Test func v4DecodeValidToken() { - let token = makeV4Token(memo: "Thank you", amounts: [1, 4, 16]) - let info = CashuTokenDecoder.decode(token) - #expect(info?.version == "B") - #expect(info?.amount == 21) - #expect(info?.unit == "sat") - #expect(info?.mintHost == "mint.example.com") - #expect(info?.memo == "Thank you") - } - - @Test func v4UnparseableCBORDegradesToGenericToken() { - // Valid base64 payload, but not CBOR we can walk: still a token, - // rendered as a generic chip with no amount. - let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02])) - let info = CashuTokenDecoder.decode(token) - #expect(info?.version == "B") - #expect(info?.amount == nil) - #expect(info?.mintHost == nil) - } - - // MARK: - Strict Mode (used by the /pay SEND path) - - @Test func strictAcceptsValidV3WithPositiveAmount() { - let token = makeV3Token(entries: [("https://mint.example.com", [2, 8])]) - let info = CashuTokenDecoder.decode(token, strict: true) - #expect(info?.version == "A") - #expect(info?.amount == 10) - } - - @Test func strictAcceptsValidDefiniteLengthV4() { - let token = makeV4Token(amounts: [1, 4, 16]) - let info = CashuTokenDecoder.decode(token, strict: true) - #expect(info?.version == "B") - #expect(info?.amount == 21) - } - - @Test func strictRejectsUnwalkableV4() { - // Valid base64, but not CBOR we can walk: permissive mode returns a - // generic chip, strict mode refuses it. - let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02])) - #expect(CashuTokenDecoder.decode(token)?.version == "B") - #expect(CashuTokenDecoder.decode(token, strict: true) == nil) - } - - @Test func strictRejectsTruncatedV4() { - let token = makeV4Token(amounts: [1, 4, 16]) - // Lop off the tail of the base64 payload — CBOR can no longer be walked. - let truncated = String(token.prefix(token.count - 12)) - #expect(CashuTokenDecoder.decode(truncated, strict: true) == nil) - } - - @Test func strictRejectsAmountlessToken() { - // A well-formed V3 token that carries no positive proof amount. - let json: [String: Any] = [ - "token": [[ - "mint": "https://mint.example.com", - "proofs": [["amount": 0, "id": "x", "secret": "s", "C": "c"] as [String: Any]] - ] as [String: Any]] - ] - let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json)) - #expect(CashuTokenDecoder.decode(token)?.amount == nil) - #expect(CashuTokenDecoder.decode(token, strict: true) == nil) - } - - // MARK: - URI Form and Normalization - - @Test func uriFormsDecode() { - let token = makeV3Token(entries: [("https://mint.example.com", [7])]) - for wrapped in ["cashu:\(token)", "cashu://\(token)", "CASHU:\(token)"] { - #expect(CashuTokenDecoder.bareToken(from: wrapped) == token, "failed for \(wrapped)") - #expect(CashuTokenDecoder.decode(wrapped)?.amount == 7) - } - } - - @Test func percentEncodedURIDecodes() { - let token = makeV3Token(entries: [("https://mint.example.com", [7])]) - let encoded = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - #expect(CashuTokenDecoder.decode("cashu:\(encoded)")?.amount == 7) - } - - @Test func bareTokenRejectsNonTokens() { - #expect(CashuTokenDecoder.bareToken(from: "hello world") == nil) - #expect(CashuTokenDecoder.bareToken(from: "cashuC" + String(repeating: "a", count: 50)) == nil) - #expect(CashuTokenDecoder.bareToken(from: "cashuA{not-base64!}") == nil) - #expect(CashuTokenDecoder.bareToken(from: "cashuA") == nil) // too short - } - - // MARK: - Adversarial Input (never crash, fail closed) - - @Test func truncatedTokensNeverCrash() { - let v3 = makeV3Token(entries: [("https://mint.example.com", [1, 2, 4, 8])], memo: "memo") - let v4 = makeV4Token(memo: "memo", amounts: [1, 2, 4, 8]) - for token in [v3, v4] { - for length in stride(from: 0, to: token.count, by: 3) { - _ = CashuTokenDecoder.decode(String(token.prefix(length))) - } - } - // Truncating the payload must not produce a phantom V3 summary - #expect(CashuTokenDecoder.decode(String(v3.prefix(v3.count - 10))) == nil) - } - - @Test func garbagePayloadsNeverCrash() { - var rng = SystemRandomNumberGenerator() - for _ in 0..<200 { - let length = Int.random(in: 0..<600, using: &rng) - let junk = Data((0.. -// - -import Testing -@testable import bitchat - -struct ChannelShareTests { - @Test func payloadIncludesGeohashDeepLinkAndStoreURL() { - let text = ChannelShare.payload(forGeohash: "u4pru") - #expect(text.contains("#u4pru")) - #expect(text.contains("bitchat://geohash/u4pru")) - #expect(text.contains(ChannelShare.appStoreURL)) - #expect(!text.lowercased().contains("i'm in")) - } - - @Test func precisionWarningStartsAtNeighborhood() { - #expect(!ChannelShare.shouldWarn(forGeohash: "u4pru")) // city = 5 - #expect(ChannelShare.shouldWarn(forGeohash: "u4pruy")) // neighborhood = 6 - #expect(ChannelShare.shouldWarn(forGeohash: "u4pruyzd")) - } -} diff --git a/bitchatTests/ChatBluetoothAlertPolicyTests.swift b/bitchatTests/ChatBluetoothAlertPolicyTests.swift deleted file mode 100644 index 8d368988..00000000 --- a/bitchatTests/ChatBluetoothAlertPolicyTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -import CoreBluetooth -import Testing -@testable import bitchat - -struct ChatBluetoothAlertPolicyTests { - @Test - func poweredOffShowsAlertMessage() { - let update = ChatBluetoothAlertPolicy.update(for: .poweredOff) - - #expect(update.isPresented) - #expect(update.message?.isEmpty == false) - } - - @Test - func unauthorizedShowsAlertMessage() { - let update = ChatBluetoothAlertPolicy.update(for: .unauthorized) - - #expect(update.isPresented) - #expect(update.message?.isEmpty == false) - } - - @Test - func poweredOnHidesAndClearsAlertMessage() { - let update = ChatBluetoothAlertPolicy.update(for: .poweredOn) - - #expect(!update.isPresented) - #expect(update.message == "") - } - - @Test - func transientStatesHideWithoutChangingAlertMessage() { - let unknown = ChatBluetoothAlertPolicy.update(for: .unknown) - let resetting = ChatBluetoothAlertPolicy.update(for: .resetting) - - #expect(!unknown.isPresented) - #expect(unknown.message == nil) - #expect(!resetting.isPresented) - #expect(resetting.message == nil) - } -} diff --git a/bitchatTests/ChatComposerCoordinatorContextTests.swift b/bitchatTests/ChatComposerCoordinatorContextTests.swift deleted file mode 100644 index d38ddc8a..00000000 --- a/bitchatTests/ChatComposerCoordinatorContextTests.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// ChatComposerCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatComposerCoordinator` against a mock `ChatComposerContext` — -// proving the coordinator works without a `ChatViewModel`, following the -// `ChatDeliveryCoordinatorContextTests` exemplar. -// -// Scope note: mention parsing uses the shared, precompiled -// `ChatViewModel.Patterns.mention` regex (a static, stateless singleton); -// everything else flows through the mock context. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatComposerContext` proving that -/// `ChatComposerCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockChatComposerContext: ChatComposerContext { - // Autocomplete UI state - var autocompleteSuggestions: [String] = [] - var autocompleteRange: NSRange? - var showAutocomplete = false - var selectedAutocompleteIndex = -1 - var queryResult: (suggestions: [String], range: NSRange?) = ([], nil) - private(set) var queriedPeerCandidates: [[String]] = [] - private(set) var appliedSuggestions: [(suggestion: String, text: String, range: NSRange)] = [] - - func autocompleteQuery( - for text: String, - peers: [String], - cursorPosition: Int - ) -> (suggestions: [String], range: NSRange?) { - queriedPeerCandidates.append(peers.sorted()) - return queryResult - } - - func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String { - appliedSuggestions.append((suggestion, text, range)) - guard let textRange = Range(range, in: text) else { return text } - return text.replacingCharacters(in: textRange, with: suggestion) - } - - // Identity & channel state - var nickname = "me" - var myPeerID = PeerID(str: "0011223344556677") - var activeChannel: ChannelID = .mesh - var meshNickname = "me" - var meshNicknamesByPeerID: [PeerID: String] = [:] - var blockedMeshNicknames: Set = [] - var blockedNostrPubkeys: Set = [] - - func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID } - - func isMeshNicknameBlocked(_ nickname: String) -> Bool { - blockedMeshNicknames.contains(nickname) - } - - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { - blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased()) - } - - // Geohash identity - var geoNicknames: [String: String] = [:] - static let dummyIdentity = NostrIdentity( - privateKey: Data(repeating: 0x11, count: 32), - publicKey: Data(repeating: 0x22, count: 32), - npub: "npub1mock", - createdAt: Date(timeIntervalSince1970: 0) - ) - - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { - Self.dummyIdentity - } -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatComposerCoordinator` against `MockChatComposerContext` with -/// no `ChatViewModel`. -struct ChatComposerCoordinatorContextTests { - - @Test @MainActor - func updateAutocomplete_onMesh_excludesOwnNicknameAndPublishesSuggestions() { - let context = MockChatComposerContext() - let coordinator = ChatComposerCoordinator(context: context) - context.meshNicknamesByPeerID = [ - PeerID(str: "1111111111111111"): "alice", - PeerID(str: "2222222222222222"): "bob", - PeerID(str: "3333333333333333"): "me" - ] - - // Matching query: suggestions and range are published, index resets. - context.queryResult = (["@alice"], NSRange(location: 0, length: 3)) - coordinator.updateAutocomplete(for: "@al", cursorPosition: 3) - #expect(context.queriedPeerCandidates == [["alice", "bob"]]) - #expect(context.autocompleteSuggestions == ["@alice"]) - #expect(context.autocompleteRange == NSRange(location: 0, length: 3)) - #expect(context.showAutocomplete) - #expect(context.selectedAutocompleteIndex == 0) - - // No match: all autocomplete state is cleared. - context.queryResult = ([], nil) - context.selectedAutocompleteIndex = 3 - coordinator.updateAutocomplete(for: "plain text", cursorPosition: 5) - #expect(context.autocompleteSuggestions.isEmpty) - #expect(context.autocompleteRange == nil) - #expect(!context.showAutocomplete) - #expect(context.selectedAutocompleteIndex == 0) - } - - @Test @MainActor - func updateAutocomplete_onLocationChannel_buildsGeoTokensWithoutOwnToken() { - let context = MockChatComposerContext() - let coordinator = ChatComposerCoordinator(context: context) - context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq")) - context.geoNicknames = [ - "aaaabbbbccccdddd": "carol", - // Own token (nickname#last-4-of-pubkey) must be removed; the dummy - // identity's public key hex ends in "2222". - "ffffeeeeddddcccc2222": "me" - ] - - coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3) - #expect(context.queriedPeerCandidates == [["carol#dddd"]]) - } - - @Test @MainActor - func updateAutocomplete_excludesBlockedMeshAndGeohashPeers() { - let context = MockChatComposerContext() - let coordinator = ChatComposerCoordinator(context: context) - context.meshNicknamesByPeerID = [ - PeerID(str: "1111111111111111"): "alice", - PeerID(str: "2222222222222222"): "eve", - PeerID(str: "3333333333333333"): "me" - ] - context.blockedMeshNicknames = ["eve"] - context.queryResult = (["@alice"], NSRange(location: 0, length: 3)) - - coordinator.updateAutocomplete(for: "@a", cursorPosition: 2) - #expect(context.queriedPeerCandidates == [["alice"]]) - - let geoContext = MockChatComposerContext() - let geoCoordinator = ChatComposerCoordinator(context: geoContext) - geoContext.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq")) - geoContext.geoNicknames = [ - "aaaabbbbccccdddd": "carol", - "bbbbccccddddeeee": "blocked" - ] - geoContext.blockedNostrPubkeys = ["bbbbccccddddeeee"] - - geoCoordinator.updateAutocomplete(for: "@", cursorPosition: 1) - #expect(geoContext.queriedPeerCandidates == [["carol#dddd"]]) - } - - @Test @MainActor - func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() { - let context = MockChatComposerContext() - let coordinator = ChatComposerCoordinator(context: context) - - // Without an active range the text is untouched. - var text = "hello @al" - #expect(coordinator.completeNickname("@alice", in: &text) == text.count) - #expect(context.appliedSuggestions.isEmpty) - - // With a range the suggestion is applied and state cleared. - context.autocompleteRange = NSRange(location: 6, length: 3) - context.autocompleteSuggestions = ["@alice"] - context.showAutocomplete = true - let cursor = coordinator.completeNickname("@alice", in: &text) - #expect(text == "hello @alice") - #expect(cursor == 6 + "@alice".count + 1) - #expect(!context.showAutocomplete) - #expect(context.autocompleteSuggestions.isEmpty) - #expect(context.autocompleteRange == nil) - #expect(context.selectedAutocompleteIndex == 0) - } - - @Test @MainActor - func parseMentions_acceptsKnownPeersOwnNicknameAndHashSuffix() { - let context = MockChatComposerContext() - let coordinator = ChatComposerCoordinator(context: context) - context.meshNicknamesByPeerID = [PeerID(str: "1111111111111111"): "alice"] - - let mentions = coordinator.parseMentions( - from: "hi @alice and @me and @me#0011 but not @stranger" - ) - #expect(Set(mentions) == ["alice", "me", "me#0011"]) - } -} diff --git a/bitchatTests/ChatFavoriteTogglePolicyTests.swift b/bitchatTests/ChatFavoriteTogglePolicyTests.swift deleted file mode 100644 index 39e3c420..00000000 --- a/bitchatTests/ChatFavoriteTogglePolicyTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Testing -@testable import bitchat - -struct ChatFavoriteTogglePolicyTests { - @Test - func addingWithoutExistingStatusUsesFallbackNicknameAndBridgeKey() { - let plan = ChatFavoriteTogglePolicy.plan( - currentStatus: nil, - fallbackNickname: "alice", - bridgedNostrKey: "npub-alice" - ) - - #expect(plan == ChatFavoriteTogglePlan( - persistenceAction: .add(nickname: "alice", nostrKey: "npub-alice"), - notification: .none - )) - } - - @Test - func addingMutualFavoriteSendsPositiveNotification() { - let plan = ChatFavoriteTogglePolicy.plan( - currentStatus: ChatFavoriteStatusSnapshot( - peerNickname: "alice", - peerNostrPublicKey: "npub-current", - isFavorite: false, - theyFavoritedUs: true - ), - fallbackNickname: "fallback", - bridgedNostrKey: "npub-bridge" - ) - - #expect(plan == ChatFavoriteTogglePlan( - persistenceAction: .add(nickname: "alice", nostrKey: "npub-current"), - notification: .send(isFavorite: true) - )) - } - - @Test - func addingWithoutAnyNicknameUsesUnknown() { - let plan = ChatFavoriteTogglePolicy.plan( - currentStatus: nil, - fallbackNickname: nil, - bridgedNostrKey: nil - ) - - #expect(plan == ChatFavoriteTogglePlan( - persistenceAction: .add(nickname: "Unknown", nostrKey: nil), - notification: .none - )) - } - - @Test - func removingFavoriteSendsNegativeNotification() { - let plan = ChatFavoriteTogglePolicy.plan( - currentStatus: ChatFavoriteStatusSnapshot( - peerNickname: "alice", - peerNostrPublicKey: "npub-current", - isFavorite: true, - theyFavoritedUs: false - ), - fallbackNickname: nil, - bridgedNostrKey: nil - ) - - #expect(plan == ChatFavoriteTogglePlan( - persistenceAction: .remove, - notification: .send(isFavorite: false) - )) - } -} diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift deleted file mode 100644 index d969cd15..00000000 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ /dev/null @@ -1,408 +0,0 @@ -// -// ChatLifecycleCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatLifecycleCoordinator` against a mock `ChatLifecycleContext` -// — proving the coordinator works without a `ChatViewModel`, following the -// `ChatDeliveryCoordinatorContextTests` / -// `ChatPrivateConversationCoordinatorContextTests` exemplars. -// -// Scope note: the GeoDM read pass, the favorites-backed mesh/Nostr -// read-receipt branch (favorites are injected through the context), message -// merging, DM screenshot notices, and lifecycle persistence flows are -// covered here. Screenshots are never announced to public channels. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatLifecycleContext` proving that -/// `ChatLifecycleCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockChatLifecycleContext: ChatLifecycleContext { - // Chat & receipt state - var messages: [BitchatMessage] = [] - var privateChats: [PeerID: [BitchatMessage]] = [:] - - func privateMessages(for peerID: PeerID) -> [BitchatMessage] { - privateChats[peerID] ?? [] - } - var unreadPrivateMessages: Set = [] - var selectedPrivateChatPeer: PeerID? - var sentReadReceipts: Set = [] - var nickname = "me" - var myPeerID = PeerID(str: "0011223344556677") - var activeChannel: ChannelID = .mesh - var nostrKeyMapping: [PeerID: String] = [:] - private(set) var ownerLevelReadPasses: [PeerID] = [] - private(set) var managerReadMarks: [PeerID] = [] - - // Conversation store intents - @discardableResult - func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { - var chat = privateChats[peerID] ?? [] - guard !chat.contains(where: { $0.id == message.id }) else { return false } - let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count - chat.insert(message, at: index) - privateChats[peerID] = chat - return true - } - - func markPrivateChatRead(_ peerID: PeerID) { - unreadPrivateMessages.remove(peerID) - } - - @discardableResult - func markReadReceiptSent(_ messageID: String) -> Bool { - sentReadReceipts.insert(messageID).inserted - } - - func markPrivateMessagesAsRead(from peerID: PeerID) { - ownerLevelReadPasses.append(peerID) - } - - func markChatAsRead(from peerID: PeerID) { - managerReadMarks.append(peerID) - } - - // Scheduled work runs synchronously so tests never poll wall-clock queues. - private(set) var scheduledDelays: [TimeInterval] = [] - func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) { - scheduledDelays.append(delay) - work() - } - - // Peers & sessions - var nicknamesByPeerID: [PeerID: String] = [:] - var peersByID: [PeerID: BitchatPeer] = [:] - var noiseSessionStates: [PeerID: LazyHandshakeState] = [:] - private(set) var stopMeshServicesCount = 0 - private(set) var refreshBluetoothStateCount = 0 - - func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] } - func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] } - func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState { - noiseSessionStates[peerID] ?? .none - } - func stopMeshServices() { stopMeshServicesCount += 1 } - func refreshBluetoothState() { refreshBluetoothStateCount += 1 } - - // Routing & receipts - private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = [] - private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = [] - private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] - - func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { - routedPrivateMessages.append((content, peerID, recipientNickname)) - } - - var routeReadReceiptResult = true - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { - routedReadReceipts.append((receipt.originalMessageID, peerID)) - return routeReadReceiptResult - } - - func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - geoReadReceipts.append((messageID, recipientHex)) - } - - // Nostr & geohash - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity } - - // Favorites - var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] - - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { - favoriteRelationshipsByNoiseKey[noiseKey] - } - - // Identity persistence - private(set) var forceSaveIdentityCount = 0 - private(set) var verifyIdentityKeyExistsCount = 0 - - func forceSaveIdentity() { forceSaveIdentityCount += 1 } - - @discardableResult - func verifyIdentityKeyExists() -> Bool { - verifyIdentityKeyExistsCount += 1 - return true - } - - static let dummyIdentity = NostrIdentity( - privateKey: Data(repeating: 0x11, count: 32), - publicKey: Data(repeating: 0x22, count: 32), - npub: "npub1mock", - createdAt: Date(timeIntervalSince1970: 0) - ) -} - -// MARK: - Helpers - -private func makeFavoriteRelationship( - noiseKey: Data, - nostrPublicKey: String? = nil, - nickname: String = "alice", - isFavorite: Bool = false, - theyFavoritedUs: Bool = false -) -> FavoritesPersistenceService.FavoriteRelationship { - FavoritesPersistenceService.FavoriteRelationship( - peerNoisePublicKey: noiseKey, - peerNostrPublicKey: nostrPublicKey, - peerNickname: nickname, - isFavorite: isFavorite, - theyFavoritedUs: theyFavoritedUs, - favoritedAt: Date(timeIntervalSince1970: 0), - lastUpdated: Date(timeIntervalSince1970: 0) - ) -} - -@MainActor -private func makePrivateMessage( - id: String, - sender: String = "alice", - timestamp: Date = Date(), - senderPeerID: PeerID? = nil, - isRelay: Bool = false, - deliveryStatus: DeliveryStatus? = nil -) -> BitchatMessage { - BitchatMessage( - id: id, - sender: sender, - content: "hello", - timestamp: timestamp, - isRelay: isRelay, - isPrivate: true, - recipientNickname: "me", - senderPeerID: senderPeerID, - deliveryStatus: deliveryStatus - ) -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatLifecycleCoordinator` against `MockChatLifecycleContext` -/// with no `ChatViewModel`. -struct ChatLifecycleCoordinatorContextTests { - - @Test @MainActor - func getPrivateChatMessages_mergesEphemeralAndStableKeepingBestStatus() async { - let context = MockChatLifecycleContext() - let coordinator = ChatLifecycleCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - let noiseKey = Data(repeating: 0xAB, count: 32) - let stablePeerID = PeerID(hexData: noiseKey) - context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") - - let t1 = Date(timeIntervalSince1970: 1) - let t2 = Date(timeIntervalSince1970: 2) - // Same message under both keys: the read copy must win over sent. - context.privateChats[peerID] = [ - makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent), - makePrivateMessage(id: "m2", timestamp: t2) - ] - context.privateChats[stablePeerID] = [ - makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)) - ] - - let merged = coordinator.getPrivateChatMessages(for: peerID) - #expect(merged.map(\.id) == ["m1", "m2"]) - if case .read? = merged.first?.deliveryStatus { - } else { - Issue.record("expected the .read copy of m1 to win the merge") - } - - // getMessages(for: nil) falls back to the public timeline. - context.messages = [makePrivateMessage(id: "pub")] - #expect(coordinator.getMessages(for: nil).map(\.id) == ["pub"]) - } - - @Test @MainActor - func markPrivateMessagesAsRead_geoDM_sendsReadReceiptsOnce() async { - let context = MockChatLifecycleContext() - let coordinator = ChatLifecycleCoordinator(context: context) - let convKey = PeerID(nostr_: "feedface00112233") - let recipientHex = "feedface00112233" - context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) - context.nostrKeyMapping[convKey] = recipientHex - context.sentReadReceipts = ["already-acked"] - context.privateChats[convKey] = [ - makePrivateMessage(id: "m1", senderPeerID: convKey), - makePrivateMessage(id: "already-acked", senderPeerID: convKey), - makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true), - makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID) - ] - - coordinator.markPrivateMessagesAsRead(from: convKey) - - #expect(context.managerReadMarks == [convKey]) - // Only the peer's own un-acked, non-relay message gets a READ. - #expect(context.geoReadReceipts.map(\.messageID) == ["m1"]) - #expect(context.geoReadReceipts.first?.recipientHex == recipientHex) - #expect(context.sentReadReceipts.contains("m1")) - - // Second pass: nothing new to send. - coordinator.markPrivateMessagesAsRead(from: convKey) - #expect(context.geoReadReceipts.count == 1) - } - - @Test @MainActor - func handleScreenshotCaptured_privateChat_echoesOnlyWhenPeerWasNotified() async { - let context = MockChatLifecycleContext() - let coordinator = ChatLifecycleCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.nicknamesByPeerID[peerID] = "alice" - - // No established session: nothing goes out, so nothing is echoed — - // a local "you took a screenshot" would imply the peer was told. - coordinator.handleScreenshotCaptured() - #expect(context.routedPrivateMessages.isEmpty) - #expect(context.privateChats.isEmpty) - - // Established session: the peer is notified and the echo appears. - context.noiseSessionStates[peerID] = .established - coordinator.handleScreenshotCaptured() - #expect(context.routedPrivateMessages.count == 1) - #expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *") - #expect(context.routedPrivateMessages.first?.recipientNickname == "alice") - #expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"]) - #expect(context.privateChats[peerID]?.first?.sender == "system") - } - - @Test @MainActor - func handleScreenshotCaptured_publicChannel_staysSilent() async { - let context = MockChatLifecycleContext() - let coordinator = ChatLifecycleCoordinator(context: context) - - // No DM selected: screenshots must never announce presence to a - // public channel (mesh broadcast or Nostr relays). - coordinator.handleScreenshotCaptured() - - #expect(context.routedPrivateMessages.isEmpty) - #expect(context.privateChats.isEmpty) - } - - @Test @MainActor - func lifecycleEvents_persistIdentityAndScheduleReadPasses() async { - let context = MockChatLifecycleContext() - let coordinator = ChatLifecycleCoordinator(context: context) - - coordinator.applicationWillTerminate() - #expect(context.stopMeshServicesCount == 1) - #expect(context.forceSaveIdentityCount == 1) - #expect(context.verifyIdentityKeyExistsCount == 1) - - // Becoming active with no open chat only refreshes Bluetooth state. - coordinator.handleDidBecomeActive() - #expect(context.refreshBluetoothStateCount == 1) - #expect(context.managerReadMarks.isEmpty) - - // With an open chat the read pass runs immediately (manager-level) and - // a delayed owner-level pass is scheduled. - let peerID = PeerID(nostr_: "feedface00112233") - context.selectedPrivateChatPeer = peerID - coordinator.handleDidBecomeActive() - #expect(context.refreshBluetoothStateCount == 2) - #expect(context.managerReadMarks == [peerID]) - - // The mock executes scheduled work synchronously, so the delayed - // owner-level pass has already run - no wall-clock polling. - #expect(context.scheduledDelays == [TransportConfig.uiAnimationMediumSeconds]) - #expect(context.ownerLevelReadPasses == [peerID]) - } - @Test @MainActor - func markPrivateMessagesAsRead_routesReceiptsForFavoritesAndNonFavorites() { - let context = MockChatLifecycleContext() - let coordinator = ChatLifecycleCoordinator(context: context) - let noiseKey = Data(repeating: 0xAB, count: 32) - let peerID = PeerID(hexData: noiseKey) - context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( - noiseKey: noiseKey, - nostrPublicKey: "npub1alice" - ) - context.privateChats[peerID] = [ - makePrivateMessage(id: "in-1", senderPeerID: peerID), - makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true) - ] - - coordinator.markPrivateMessagesAsRead(from: peerID) - - // Favorite with a Nostr key: READ receipts routed for non-relay - // inbound messages and recorded as sent. - #expect(context.managerReadMarks == [peerID]) - #expect(context.routedReadReceipts.map(\.messageID) == ["in-1"]) - #expect(context.routedReadReceipts.map(\.peerID) == [peerID]) - #expect(context.sentReadReceipts.contains("in-1")) - - // No favorite relationship: receipts still route — the router picks - // whatever transport can reach the peer (mesh included). Gating on a - // stored Nostr key silently starved mesh-connected non-favorites. - let otherKey = Data(repeating: 0xCD, count: 32) - let otherPeer = PeerID(hexData: otherKey) - context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)] - coordinator.markPrivateMessagesAsRead(from: otherPeer) - #expect(context.routedReadReceipts.map(\.messageID) == ["in-1", "in-2"]) - #expect(context.sentReadReceipts.contains("in-2")) - } - -} - -// MARK: - Screenshot Routing - -/// Pins `AppRuntime`'s screenshot decision table: the geohash warn path, -/// the App Info exemption, and the deliberate mesh silence. Combined with -/// `handleScreenshotCaptured_publicChannel_staysSilent` above, this proves -/// location + screenshot → local privacy alert, and nothing sent anywhere. -struct ScreenshotCaptureRoutingTests { - - @Test("Location channels warn locally; the sheet warns everywhere") - func locationScreenshotsWarnLocally() { - // Geohash timeline, no DM open: warn the person, tell no one. - #expect(AppRuntime.resolveScreenshotResponse( - isLocationChannelsSheetPresented: false, - isAppInfoPresented: false, - hasPrivateChatOpen: false, - isLocationChannelActive: true - ) == .warnLocally) - - // The channel sheet reveals location regardless of active channel. - #expect(AppRuntime.resolveScreenshotResponse( - isLocationChannelsSheetPresented: true, - isAppInfoPresented: false, - hasPrivateChatOpen: true, - isLocationChannelActive: false - ) == .warnLocally) - } - - @Test("App Info is exempt; mesh and DMs forward to the chat layer") - func nonLocationScreenshotsForwardOrIgnore() { - #expect(AppRuntime.resolveScreenshotResponse( - isLocationChannelsSheetPresented: false, - isAppInfoPresented: true, - hasPrivateChatOpen: false, - isLocationChannelActive: true - ) == .ignore) - - // Mesh timeline: forwarded, where the coordinator stays silent for - // public channels — no local alert either (nothing is sent and no - // place is revealed; see ScreenshotCaptureResponse docs). - #expect(AppRuntime.resolveScreenshotResponse( - isLocationChannelsSheetPresented: false, - isAppInfoPresented: false, - hasPrivateChatOpen: false, - isLocationChannelActive: false - ) == .forwardToChat) - - // An open DM keeps its peer notice even from a location channel. - #expect(AppRuntime.resolveScreenshotResponse( - isLocationChannelsSheetPresented: false, - isAppInfoPresented: false, - hasPrivateChatOpen: true, - isLocationChannelActive: true - ) == .forwardToChat) - } -} diff --git a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift deleted file mode 100644 index 2e5ca317..00000000 --- a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift +++ /dev/null @@ -1,661 +0,0 @@ -// -// ChatLiveVoiceCoordinatorTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -@MainActor -private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { - var nickname = "me" - var myPeerID = PeerID(str: "0102030405060708") - var selectedPrivateChatPeer: PeerID? - var isViewingPublicMeshTimeline = false - var blockedPeers: Set = [] - - private(set) var handledPrivateMessages: [BitchatMessage] = [] - private(set) var appendedPublicMessages: [BitchatMessage] = [] - private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = [] - private(set) var upsertedPublicMessages: [BitchatMessage] = [] - private(set) var removedMessageIDs: [String] = [] - private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = [] - private(set) var talkerUpdates: [String?] = [] - private(set) var privateMutationLog: [String] = [] - private var readReceiptMessageIDs: Set = [] - - func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } - func resolveNickname(for peerID: PeerID) -> String { "alice" } - func handlePrivateMessage(_ message: BitchatMessage) { handledPrivateMessages.append(message) } - func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) } - func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { - upsertedMessages.append((message, peerID)) - privateMutationLog.append("upsert:\(message.id)") - } - func upsertPublicMeshMessage(_ message: BitchatMessage) { - upsertedPublicMessages.append(message) - } - @discardableResult - func removePrivateMessage(withID messageID: String) -> BitchatMessage? { - removedMessageIDs.append(messageID) - privateMutationLog.append("remove:\(messageID)") - return nil - } - func hasSentReadReceipt(_ messageID: String) -> Bool { - readReceiptMessageIDs.contains(messageID) - } - func markReadReceiptSent(_ messageID: String) -> Bool { - readReceiptMessageIDs.insert(messageID).inserted - } - func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { - sentReadReceipts.append((receipt, peerID)) - } - func removeMessage(withID messageID: String, cleanupFile: Bool) { - removedMessageIDs.append(messageID) - } - func setActivePublicVoiceTalker(_ nickname: String?) { - if talkerUpdates.last ?? nil != nickname { - talkerUpdates.append(nickname) - } - } - func notifyUIChanged() {} -} - -@MainActor -struct ChatLiveVoiceCoordinatorTests { - private let peer = PeerID(str: "aaaabbbbcccc0001") - - private func makeBurstID(_ fill: UInt8) -> Data { - Data(repeating: fill, count: VoiceBurstPacket.burstIDSize) - } - - private func send(_ packet: VoiceBurstPacket, to coordinator: ChatLiveVoiceCoordinator, from peerID: PeerID) { - coordinator.handleVoiceFramePayload(from: peerID, payload: packet.encode(), timestamp: Date()) - } - - private func captureSuffix(burstID: Data, peerID: PeerID, scope: VoiceBurstScope) -> String { - "\(burstID.hexEncodedString())_\(peerID.id)_\(scope == .directMessage ? "dm" : "mesh").aac" - } - - /// Name of a capture still streaming in. - private func liveCaptureName(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> String { - "voice_live_" + captureSuffix(burstID: burstID, peerID: peerID, scope: scope) - } - - /// Name a finished capture is promoted to when it becomes the bubble's - /// replayable fallback. - private func fallbackName(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> String { - "voice_" + captureSuffix(burstID: burstID, peerID: peerID, scope: scope) - } - - private func incomingFileURL(named name: String) -> URL? { - guard let base = try? FileManager.default.url( - for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false - ) else { return nil } - return base - .appendingPathComponent("files/voicenotes/incoming", isDirectory: true) - .appendingPathComponent(name) - } - - private func incomingFileURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> URL? { - incomingFileURL(named: liveCaptureName(burstID: burstID, peerID: peerID, scope: scope)) - } - - private func fallbackFileURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> URL? { - incomingFileURL(named: fallbackName(burstID: burstID, peerID: peerID, scope: scope)) - } - - /// Fresh store rooted in its own temp directory so quota/sweep tests - /// never touch the shared application-support media directories. - private func makeTempStore() throws -> (store: BLEIncomingFileStore, incoming: URL, cleanup: () -> Void) { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("ptt-store-\(UUID().uuidString)", isDirectory: true) - let store = BLEIncomingFileStore(baseDirectory: base) - let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") - return (store, incoming, { try? FileManager.default.removeItem(at: base) }) - } - - private func setModificationDate(_ date: Date, at url: URL) throws { - try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) - } - - @Test func burstCreatesBubbleAndPersistsFramesInOrder() throws { - let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) - let burstID = makeBurstID(0xA1) - defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } } - - let frame1 = Data(repeating: 0x01, count: 60) - let frame2 = Data(repeating: 0x02, count: 60) - - send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) - #expect(context.handledPrivateMessages.count == 1) - let bubble = try #require(context.handledPrivateMessages.first) - #expect(bubble.isPrivate) - #expect(bubble.senderPeerID == peer) - #expect(bubble.content == "[voice] voice_live_\(burstID.hexEncodedString())_\(peer.id)_dm.aac") - #expect(coordinator.isLiveVoiceMessage(bubble)) - - // Deliver out of order: seq 2 buffers behind the seq-1 hole, then - // seq 1 releases both in order. - send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .frames([frame2]))), to: coordinator, from: peer) - send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([frame1]))), to: coordinator, from: peer) - send(try #require(VoiceBurstPacket(burstID: burstID, seq: 3, kind: .end(totalDataPackets: 2, durationMs: 128))), to: coordinator, from: peer) - - // The finished capture is promoted off its voice_live_ name. - let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer)) - let written = try Data(contentsOf: url) - var expected = ADTSFramer.frame(frame1) - expected.append(ADTSFramer.frame(frame2)) - #expect(written == expected) - let liveURL = try #require(incomingFileURL(burstID: burstID, peerID: peer)) - #expect(!FileManager.default.fileExists(atPath: liveURL.path)) - - // Burst ended: no longer live, bubble republished pointing at the - // promoted file. - #expect(!coordinator.isLiveVoiceMessage(bubble)) - let republished = try #require(context.upsertedMessages.last { $0.message.id == bubble.id }) - #expect(republished.message.content == "[voice] \(fallbackName(burstID: burstID, peerID: peer))") - #expect(context.removedMessageIDs.isEmpty) - } - - @Test func absorbsFinalizedNoteIntoLiveBubble() throws { - let context = MockChatLiveVoiceContext() - context.selectedPrivateChatPeer = peer - let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) - let burstID = makeBurstID(0xB2) - let hex = burstID.hexEncodedString() - let fileName = "voice_\(hex).m4a" - let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID( - senderPeerID: peer, - recipientPeerID: context.myPeerID, - fileName: fileName - )) - - send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer) - send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer) - let bubble = try #require(context.handledPrivateMessages.first) - // The user read the live bubble, then left before the finalized file - // arrived. Stable-ID adoption must preserve that read state. - #expect(context.markReadReceiptSent(bubble.id)) - context.selectedPrivateChatPeer = nil - - let note = BitchatMessage( - id: stableMessageID, - sender: "alice", - content: "[voice] \(fileName)", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "me", - senderPeerID: peer - ) - #expect(coordinator.absorbFinalizedVoiceNote(note)) - - // The finalized note adopts the sender-correlatable ID, removes the - // receiver-local live ID, and emits a fresh READ now that the sender - // has created its finalized media row. - let replacement = try #require(context.upsertedMessages.last) - #expect(replacement.message.id == stableMessageID) - #expect(replacement.message.content == note.content) - #expect(replacement.peerID == peer) - #expect(context.removedMessageIDs.contains(bubble.id)) - #expect(Array(context.privateMutationLog.suffix(2)) == [ - "upsert:\(stableMessageID)", - "remove:\(bubble.id)" - ]) - #expect(context.sentReadReceipts.count == 1) - #expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID) - #expect(context.sentReadReceipts.first?.peerID == peer) - // The promoted partial capture is deleted in favor of the note. - let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer)) - #expect(!FileManager.default.fileExists(atPath: url.path)) - - // Absorption is one-shot. - #expect(!coordinator.absorbFinalizedVoiceNote(note)) - } - - @Test func absorbIgnoresUnrelatedVoiceNotes() throws { - let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) - - // A classic voice note (date-stamped name) and a live-capture name - // must both pass through untouched. - let classic = BitchatMessage( - sender: "alice", content: "[voice] voice_20260708_1201.m4a", timestamp: Date(), - isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer - ) - #expect(!coordinator.absorbFinalizedVoiceNote(classic)) - let liveCapture = BitchatMessage( - sender: "alice", content: "[voice] voice_live_aabbccdd00112233.aac", timestamp: Date(), - isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer - ) - #expect(!coordinator.absorbFinalizedVoiceNote(liveCapture)) - // Unknown burst ID. - let unknown = BitchatMessage( - sender: "alice", content: "[voice] voice_ffffffffffffffff.m4a", timestamp: Date(), - isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer - ) - #expect(!coordinator.absorbFinalizedVoiceNote(unknown)) - } - - @Test func canceledBurstRemovesBubbleAndFile() throws { - let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) - let burstID = makeBurstID(0xC3) - - send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 9, count: 40)]))), to: coordinator, from: peer) - let bubble = try #require(context.handledPrivateMessages.first) - send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .canceled)), to: coordinator, from: peer) - - #expect(context.removedMessageIDs == [bubble.id]) - let url = try #require(incomingFileURL(burstID: burstID, peerID: peer)) - #expect(!FileManager.default.fileExists(atPath: url.path)) - #expect(!coordinator.isLiveVoiceMessage(bubble)) - } - - @Test func emptyBurstLeavesNoBubble() throws { - let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) - let burstID = makeBurstID(0xD4) - - send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) - let bubble = try #require(context.handledPrivateMessages.first) - send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .end(totalDataPackets: 0, durationMs: 0))), to: coordinator, from: peer) - - // Nothing audible arrived: the placeholder bubble is withdrawn. - #expect(context.removedMessageIDs == [bubble.id]) - } - - @Test func ignoresBlockedPeersAndUnknownControlPackets() throws { - let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) - - context.blockedPeers = [peer] - send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE5), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) - #expect(context.handledPrivateMessages.isEmpty) - - context.blockedPeers = [] - // END/CANCELED for a burst that never started must not create state. - send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE6), seq: 5, kind: .end(totalDataPackets: 4, durationMs: 256))), to: coordinator, from: peer) - send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE7), seq: 5, kind: .canceled)), to: coordinator, from: peer) - #expect(context.handledPrivateMessages.isEmpty) - } - - @Test func concurrentAssemblyCapDropsExtraBursts() throws { - let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) - - var cleanup: [Data] = [] - defer { - for burstID in cleanup { - incomingFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } - } - } - for i in 0.. URL { - let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png") - #if os(iOS) - let renderer = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)) - let image = renderer.image { context in - UIColor.systemTeal.setFill() - context.fill(CGRect(x: 0, y: 0, width: 64, height: 64)) - } - guard let data = image.pngData() else { - throw ChatMediaPreparationTestError.imageEncodingFailed - } - #else - let image = NSImage(size: NSSize(width: 64, height: 64)) - image.lockFocus() - NSColor.systemTeal.setFill() - NSRect(x: 0, y: 0, width: 64, height: 64).fill() - image.unlockFocus() - guard let tiff = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiff), - let data = bitmap.representation(using: .png, properties: [:]) else { - throw ChatMediaPreparationTestError.imageEncodingFailed - } - #endif - try data.write(to: url, options: .atomic) - return url -} - -private enum ChatMediaPreparationTestError: Error { - case imageEncodingFailed -} diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift deleted file mode 100644 index 1969602c..00000000 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ /dev/null @@ -1,1802 +0,0 @@ -// -// ChatMediaTransferCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatMediaTransferCoordinator` against a mock -// `ChatMediaTransferContext` — proving the coordinator works without a -// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / -// `ChatPrivateConversationCoordinatorContextTests` exemplars. -// -// Real file/codec work remains covered by `ChatMediaPreparationTests`. These -// tests inject paused media preparers to exercise cancellation ownership -// across the detached-preparation/MainActor boundary deterministically. -// - -import Testing -import Foundation -import BitFoundation -#if os(iOS) -import UIKit -#else -import AppKit -#endif -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatMediaTransferContext` proving that -/// `ChatMediaTransferCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockChatMediaTransferContext: ChatMediaTransferContext { - // Composition state - var canSendMediaInCurrentContext = true - var selectedPrivateChatPeer: PeerID? - var nickname = "me" - var myPeerID = PeerID(str: "0011223344556677") - var activeChannel: ChannelID = .mesh - var nicknamesByPeerID: [PeerID: String] = [:] - - func nicknameForPeer(_ peerID: PeerID) -> String { - nicknamesByPeerID[peerID] ?? "user" - } - - func currentPublicSender() -> (name: String, peerID: PeerID) { - (nickname, myPeerID) - } - - // Message state - var privateChats: [PeerID: [BitchatMessage]] = [:] - - @discardableResult - func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { - var chat = privateChats[peerID] ?? [] - guard !chat.contains(where: { $0.id == message.id }) else { return false } - chat.append(message) - privateChats[peerID] = chat - return true - } - - private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = [] - private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = [] - private(set) var untombstonedMediaRemovals: [String] = [] - private(set) var outgoingMediaRemovals: [String] = [] - private(set) var systemMessages: [String] = [] - private(set) var notifyUIChangedCount = 0 - - @discardableResult - func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool { - appendedPublicMessages.append((message, conversationID)) - return true - } - - func removeMessage(withID messageID: String, cleanupFile: Bool) { - removedMessages.append((messageID, cleanupFile)) - } - - func removeUntombstonedMediaMessage(withID messageID: String) { - untombstonedMediaRemovals.append(messageID) - removedMessages.append((messageID, false)) - } - - func removeOutgoingMediaMessage(withID messageID: String) { - outgoingMediaRemovals.append(messageID) - removedMessages.append((messageID, false)) - } - - func addSystemMessage(_ content: String) { systemMessages.append(content) } - private(set) var mediaDeletionRefusals: [String] = [] - func notifyMediaDeletionRefused(messageID: String) { - mediaDeletionRefusals.append(messageID) - } - func notifyUIChanged() { notifyUIChangedCount += 1 } - - // Delivery status & dedup - private(set) var deliveryStatusUpdates: [(messageID: String, status: DeliveryStatus)] = [] - private(set) var recordedContentKeys: [String] = [] - - func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { - deliveryStatusUpdates.append((messageID, status)) - } - - func normalizedContentKey(_ content: String) -> String { content.lowercased() } - - func recordContentKey(_ key: String, timestamp: Date) { - recordedContentKeys.append(key) - } - - // Mesh file transfer - private(set) var privateFileSends: [( - packet: BitchatFilePacket, - peerID: PeerID, - transferId: String - )] = [] - private(set) var privateFileLegacyAllowances: [Bool] = [] - private(set) var privateFileReceiptRetryTransferIDs: [String] = [] - private(set) var broadcastFileSends: [String] = [] - private(set) var cancelledTransfers: [String] = [] - private(set) var privateMediaPolicyResolutionRequests: [PeerID] = [] - private(set) var persistedDeletionBatches: [[String]] = [] - var requiredTombstoneIDs: Set = [] - var deletedMediaPersistenceResult = true - var deferDeletedMediaPersistence = false - private var pendingDeletionCompletions: [ - @MainActor (Bool) -> Void - ] = [] - var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted - var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy? - var resolvesPrivateMediaPolicyImmediately = true - var supportsAuthenticatedPrivateMediaReceipts = false - var authenticatedPrivateMediaReceiptGeneration = UUID( - uuidString: "00000000-0000-0000-0000-000000000001" - )! - private var pendingPrivateMediaPolicyResolutions: [ - @MainActor (PrivateMediaSendPolicy) -> Void - ] = [] - private(set) var legacyConsentRequests: [( - id: UUID, - peerID: PeerID, - transferId: String, - messageID: String - )] = [] - private(set) var invalidatedLegacyConsents: [(transferId: String, messageID: String)] = [] - private var pendingLegacyConsentIDs: [UUID] = [] - private var legacyConsentCompletions: [UUID: @MainActor (Bool) -> Void] = [:] - - func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { - privateMediaPolicy - } - - func authenticatedPrivateMediaReceiptSessionGeneration( - to peerID: PeerID - ) -> UUID? { - supportsAuthenticatedPrivateMediaReceipts - ? authenticatedPrivateMediaReceiptGeneration - : nil - } - - func resolvePrivateMediaSendPolicy( - to peerID: PeerID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) { - privateMediaPolicyResolutionRequests.append(peerID) - if resolvesPrivateMediaPolicyImmediately { - completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy) - } else { - pendingPrivateMediaPolicyResolutions.append(completion) - } - } - - var pendingPrivateMediaPolicyResolutionCount: Int { - pendingPrivateMediaPolicyResolutions.count - } - - func resolveNextPrivateMediaPolicy( - _ policy: PrivateMediaSendPolicy? = nil - ) { - guard !pendingPrivateMediaPolicyResolutions.isEmpty else { return } - let completion = pendingPrivateMediaPolicyResolutions.removeFirst() - completion( - policy - ?? resolvedPrivateMediaPolicy - ?? privateMediaPolicy - ) - } - - func requestLegacyPrivateMediaConsent( - for peerID: PeerID, - transferId: String, - messageID: String, - completion: @escaping @MainActor (Bool) -> Void - ) { - let id = UUID() - legacyConsentRequests.append((id, peerID, transferId, messageID)) - pendingLegacyConsentIDs.append(id) - legacyConsentCompletions[id] = completion - } - - func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) { - invalidatedLegacyConsents.append((transferId, messageID)) - let matchingIDs = Set(legacyConsentRequests.compactMap { request in - request.transferId == transferId && request.messageID == messageID - ? request.id - : nil - }) - pendingLegacyConsentIDs.removeAll { matchingIDs.contains($0) } - } - - func resolveNextLegacyConsent(_ approved: Bool) { - guard !pendingLegacyConsentIDs.isEmpty else { return } - let id = pendingLegacyConsentIDs.removeFirst() - legacyConsentCompletions[id]?(approved) - } - - func invokeLegacyConsentEvenIfInvalidated(id: UUID, approved: Bool) { - legacyConsentCompletions[id]?(approved) - } - - func sendFilePrivate( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool - ) { - privateFileSends.append((packet, peerID, transferId)) - privateFileLegacyAllowances.append(allowLegacyFallback) - } - - func sendFilePrivateReceiptRetry( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String - ) { - privateFileSends.append((packet, peerID, transferId)) - privateFileLegacyAllowances.append(false) - privateFileReceiptRetryTransferIDs.append(transferId) - } - - func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { - broadcastFileSends.append(transferId) - } - - func cancelTransfer(_ transferId: String) { - cancelledTransfers.append(transferId) - } - - func persistDeletedPrivateMedia( - messageIDs: [String], - completion: @escaping @MainActor (Bool) -> Void - ) { - persistedDeletionBatches.append(messageIDs) - if deferDeletedMediaPersistence { - pendingDeletionCompletions.append(completion) - } else { - completion(deletedMediaPersistenceResult) - } - } - - func requiresPrivateMediaTombstone(messageID: String) -> Bool { - requiredTombstoneIDs.contains(messageID) - } - - func resolveNextDeletionPersistence(_ result: Bool? = nil) { - guard !pendingDeletionCompletions.isEmpty else { return } - let completion = pendingDeletionCompletions.removeFirst() - completion(result ?? deletedMediaPersistenceResult) - } -} - -private final class PausedVoiceNotePreparer: @unchecked Sendable { - private let condition = NSCondition() - private var started = false - private var released = false - private var finished = false - - func prepare(_ url: URL) throws -> BitchatFilePacket { - condition.lock() - started = true - condition.broadcast() - while !released { - condition.wait() - } - finished = true - condition.broadcast() - condition.unlock() - let content = Data("voice".utf8) - return BitchatFilePacket( - fileName: url.lastPathComponent, - fileSize: UInt64(content.count), - mimeType: "audio/mp4", - content: content - ) - } - - var hasStarted: Bool { - condition.lock() - defer { condition.unlock() } - return started - } - - var hasFinished: Bool { - condition.lock() - defer { condition.unlock() } - return finished - } - - func release() { - condition.lock() - released = true - condition.broadcast() - condition.unlock() - } -} - -private final class StaticVoiceNotePreparer: @unchecked Sendable { - private let packet: BitchatFilePacket - - init(fileName: String, content: Data = Data("voice".utf8)) { - packet = BitchatFilePacket( - fileName: fileName, - fileSize: UInt64(content.count), - mimeType: "audio/mp4", - content: content - ) - } - - func prepare(_ _: URL) throws -> BitchatFilePacket { - packet - } -} - -private final class DeterministicMediaTransferIDFactory: - @unchecked Sendable { - private let lock = NSLock() - private var nextOrdinal = 0 - - func make(messageID: String) -> String { - lock.lock() - defer { - nextOrdinal += 1 - lock.unlock() - } - return "\(messageID)-attempt-\(nextOrdinal)" - } -} - -private final class MutableMediaRetryClock: @unchecked Sendable { - private let lock = NSLock() - private var value: Date - - init(_ value: Date) { - self.value = value - } - - func now() -> Date { - lock.lock() - defer { lock.unlock() } - return value - } - - func advance(by interval: TimeInterval) { - lock.lock() - value = value.addingTimeInterval(interval) - lock.unlock() - } -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatMediaTransferCoordinator` against -/// `MockChatMediaTransferContext` with no `ChatViewModel`. -struct ChatMediaTransferCoordinatorContextTests { - - @Test @MainActor - func enqueueMediaMessage_privateChatAppendsAndRecordsDedupKey() async { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - context.nicknamesByPeerID[peerID] = "alice" - - let message = coordinator.enqueueMediaMessage(content: "[voice] note.m4a", targetPeer: peerID) - - #expect(context.privateChats[peerID]?.map(\.id) == [message.id]) - #expect(message.isPrivate) - #expect(message.recipientNickname == "alice") - #expect(message.senderPeerID == context.myPeerID) - #expect(message.deliveryStatus == .sending) - #expect(context.recordedContentKeys == ["[voice] note.m4a"]) - #expect(context.notifyUIChangedCount == 1) - #expect(context.appendedPublicMessages.isEmpty) - } - - @Test @MainActor - func enqueueMediaMessage_publicAppendsToActiveConversation() async { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - - let message = coordinator.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: nil) - - #expect(context.appendedPublicMessages.map(\.message.id) == [message.id]) - #expect(context.appendedPublicMessages.first?.conversationID == .mesh) - #expect(!message.isPrivate) - #expect(message.sender == "me") - #expect(context.privateChats.isEmpty) - #expect(context.notifyUIChangedCount == 1) - } - - @Test @MainActor - func transferEvents_driveDeliveryStatusAndMappingCleanup() async { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - coordinator.registerTransfer(transferId: "t1", messageID: "m1") - - coordinator.handleTransferEvent(.started(id: "t1", totalFragments: 10)) - coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 4, totalFragments: 10)) - coordinator.handleTransferEvent(.completed(id: "t1", totalFragments: 10)) - // After completion the mapping is gone: further events are ignored. - coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 9, totalFragments: 10)) - - #expect(context.deliveryStatusUpdates.count == 3) - #expect(context.deliveryStatusUpdates[0].status == .partiallyDelivered(reached: 0, total: 10)) - #expect(context.deliveryStatusUpdates[1].status == .partiallyDelivered(reached: 4, total: 10)) - #expect(context.deliveryStatusUpdates[2].status == .sent) - #expect(coordinator.messageIDToTransferId.isEmpty) - - // A cancelled transfer removes the message (with file cleanup). - coordinator.registerTransfer(transferId: "t2", messageID: "m2") - coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5)) - #expect(context.removedMessages.count == 1) - #expect(context.removedMessages.first?.messageID == "m2") - #expect(context.removedMessages.first?.cleanupFile == false) - #expect(context.outgoingMediaRemovals == ["m2"]) - - // A pre-start rejection keeps the placeholder visible and failed, - // including queued post-handshake encryption failures. - coordinator.registerTransfer(transferId: "t3", messageID: "m3") - coordinator.handleTransferEvent(.rejected(id: "t3", reason: "encryption failed")) - #expect(context.deliveryStatusUpdates.last?.messageID == "m3") - #expect(context.deliveryStatusUpdates.last?.status == .failed(reason: "encryption failed")) - #expect(coordinator.messageIDToTransferId["m3"] == nil) - } - - @Test @MainActor - func cancelMediaSend_cancelsOnlyActiveTransferAndRemovesMessage() async { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - // Two messages share a transfer queue; only the active head cancels - // the underlying transfer. - coordinator.registerTransfer(transferId: "t1", messageID: "m1") - coordinator.registerTransfer(transferId: "t1", messageID: "m2") - - coordinator.cancelMediaSend(messageID: "m2") - #expect(context.cancelledTransfers.isEmpty) - #expect(context.removedMessages.map(\.messageID) == ["m2"]) - - coordinator.cancelMediaSend(messageID: "m1") - #expect(context.cancelledTransfers == ["t1"]) - #expect(context.removedMessages.map(\.messageID) == ["m2", "m1"]) - #expect(coordinator.transferIdToMessageIDs.isEmpty) - #expect(coordinator.messageIDToTransferId.isEmpty) - } - - @Test @MainActor - func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - coordinator.registerTransfer(transferId: "t1", messageID: "m1") - coordinator.registerTransfer(transferId: "t1", messageID: "m2") - coordinator.registerTransfer(transferId: "t2", messageID: "m3") - - coordinator.resetForPanic() - - #expect(Set(context.cancelledTransfers) == Set(["t1", "t2"])) - #expect(coordinator.transferIdToMessageIDs.isEmpty) - #expect(coordinator.messageIDToTransferId.isEmpty) - } - - @Test @MainActor - func resetForPanic_waitsForActiveImageWriterBeforeReturning() async throws { - let context = MockChatMediaTransferContext() - let sourceURL = try makeCoordinatorTestImageURL() - let outputURL = FileManager.default.temporaryDirectory - .appendingPathComponent("panic-prepared-\(UUID().uuidString).jpg") - let preparer = PausedImagePreparer(outputURL: outputURL) - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareImagePacket: { sourceURL in - try preparer.prepare(sourceURL) - } - ) - defer { - preparer.release() - try? FileManager.default.removeItem(at: sourceURL) - try? FileManager.default.removeItem(at: outputURL) - } - - coordinator.sendImage(from: sourceURL) - #expect(await TestHelpers.waitUntil( - { preparer.hasStarted }, - timeout: TestConstants.longTimeout - )) - - DispatchQueue.global(qos: .userInitiated).asyncAfter( - deadline: .now() + .milliseconds(100) - ) { - preparer.release() - } - coordinator.resetForPanic() - - // The synchronous reset boundary cannot return while a pre-panic - // writer can still create output. The real panic path deletes media - // immediately after this method returns. - #expect(preparer.hasFinished) - - try? FileManager.default.removeItem(at: outputURL) - #expect(await TestHelpers.waitUntil( - { !FileManager.default.fileExists(atPath: outputURL.path) }, - timeout: TestConstants.longTimeout - )) - await Task.yield() - #expect(context.privateFileSends.isEmpty) - #expect(context.broadcastFileSends.isEmpty) - #expect(context.systemMessages.isEmpty) - } - - @Test @MainActor - func imagePreparation_doesNotRetainCoordinatorOrDeallocatedContext() async throws { - let sourceURL = try makeCoordinatorTestImageURL() - let outputURL = FileManager.default.temporaryDirectory - .appendingPathComponent("released-context-\(UUID().uuidString).jpg") - let preparer = PausedImagePreparer(outputURL: outputURL) - var context: MockChatMediaTransferContext? = MockChatMediaTransferContext() - var coordinator: ChatMediaTransferCoordinator? = ChatMediaTransferCoordinator( - context: context!, - prepareImagePacket: { sourceURL in - try preparer.prepare(sourceURL) - } - ) - weak var weakContext: MockChatMediaTransferContext? - weak var weakCoordinator: ChatMediaTransferCoordinator? - weakContext = context - weakCoordinator = coordinator - defer { - preparer.release() - try? FileManager.default.removeItem(at: sourceURL) - try? FileManager.default.removeItem(at: outputURL) - } - - coordinator?.sendImage(from: sourceURL) - #expect(await TestHelpers.waitUntil( - { preparer.hasStarted }, - timeout: TestConstants.longTimeout - )) - - coordinator = nil - context = nil - #expect(weakCoordinator == nil) - #expect(weakContext == nil) - - preparer.release() - #expect(await TestHelpers.waitUntil( - { preparer.hasFinished }, - timeout: TestConstants.longTimeout - )) - #expect(await TestHelpers.waitUntil( - { !FileManager.default.fileExists(atPath: outputURL.path) }, - timeout: TestConstants.longTimeout - )) - } - - @Test @MainActor - func deleteMediaMessage_cancelsApprovedTransferBeforeRemovingMapping() { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - coordinator.registerTransfer(transferId: "approved-delete", messageID: "message-delete") - - coordinator.deleteMediaMessage(messageID: "message-delete") - - #expect(context.cancelledTransfers == ["approved-delete"]) - #expect(coordinator.messageIDToTransferId["message-delete"] == nil) - #expect(context.removedMessages.map(\.messageID) == ["message-delete"]) - #expect(context.removedMessages.first?.cleanupFile == false) - #expect( - context.untombstonedMediaRemovals == ["message-delete"] - ) - } - - @Test @MainActor - func deleteIncomingStableMediaWaitsForDurableTombstone() { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let messageID = "media-00112233445566778899aabbccddeeff" - context.requiredTombstoneIDs = [messageID] - context.deferDeletedMediaPersistence = true - coordinator.registerTransfer( - transferId: "incoming-delete", - messageID: messageID - ) - - coordinator.deleteMediaMessage(messageID: messageID) - - #expect(context.persistedDeletionBatches == [[messageID]]) - #expect(context.removedMessages.isEmpty) - #expect(context.cancelledTransfers == ["incoming-delete"]) - #expect(coordinator.messageIDToTransferId[messageID] == nil) - - context.resolveNextDeletionPersistence(true) - - #expect(context.cancelledTransfers == ["incoming-delete"]) - #expect(context.removedMessages.map(\.messageID) == [messageID]) - #expect(context.removedMessages.first?.cleanupFile == false) - #expect(context.untombstonedMediaRemovals.isEmpty) - #expect(coordinator.messageIDToTransferId[messageID] == nil) - } - - @Test @MainActor - func deleteIncomingStableMediaCompletionAfterPanicIsIgnored() { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let messageID = "media-11223344556677889900aabbccddeeff" - context.requiredTombstoneIDs = [messageID] - context.deferDeletedMediaPersistence = true - - coordinator.deleteMediaMessage(messageID: messageID) - #expect(context.persistedDeletionBatches == [[messageID]]) - - coordinator.resetForPanic() - context.resolveNextDeletionPersistence(true) - - #expect(context.removedMessages.isEmpty) - #expect(context.untombstonedMediaRemovals.isEmpty) - } - - @Test @MainActor - func deleteIncomingStableMediaPreservesStateWhenTombstoneFails() { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let messageID = "media-ffeeddccbbaa99887766554433221100" - context.requiredTombstoneIDs = [messageID] - context.deletedMediaPersistenceResult = false - coordinator.registerTransfer( - transferId: "failed-delete", - messageID: messageID - ) - - coordinator.deleteMediaMessage(messageID: messageID) - - #expect(context.persistedDeletionBatches == [[messageID]]) - #expect(context.removedMessages.isEmpty) - #expect(context.cancelledTransfers == ["failed-delete"]) - #expect(coordinator.messageIDToTransferId[messageID] == nil) - // The refusal must be visible in the affected chat, not just logged. - #expect(context.mediaDeletionRefusals == [messageID]) - } - - @Test @MainActor - func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit() - async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.supportsAuthenticatedPrivateMediaReceipts = true - context.deferDeletedMediaPersistence = true - let fileName = "voice_deadbeefdeadbeef.m4a" - let preparer = StaticVoiceNotePreparer(fileName: fileName) - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - try preparer.prepare(url) - } - ) - let url = try makeCoordinatorVoiceURL(fileName: fileName) - defer { - try? FileManager.default.removeItem( - at: url.deletingLastPathComponent() - ) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 1 }, - timeout: TestConstants.longTimeout - )) - let messageID = try #require( - context.privateChats[peerID]?.first?.id - ) - let transferID = try #require( - context.privateFileSends.first?.transferId - ) - context.requiredTombstoneIDs = [messageID] - #expect(coordinator.retainedReconnectRetryCount == 1) - - coordinator.deleteMediaMessage(messageID: messageID) - - #expect(context.persistedDeletionBatches == [[messageID]]) - #expect(coordinator.retainedReconnectRetryCount == 0) - #expect(coordinator.retainedReconnectRetryBytes == 0) - #expect(context.cancelledTransfers == [transferID]) - #expect(context.removedMessages.isEmpty) - - coordinator.peerDidReconnect(peerID) - #expect(context.privateFileSends.count == 1) - - context.resolveNextDeletionPersistence(true) - #expect(context.removedMessages.map(\.messageID) == [messageID]) - } - - @Test @MainActor - func legacyCleanupNeverRecursivelyDeletesDirectoryTarget() throws { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let incoming = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - .appendingPathComponent( - "files/images/incoming", - isDirectory: true - ) - let directoryName = "cleanup-dir-\(UUID().uuidString)" - let directory = incoming.appendingPathComponent( - directoryName, - isDirectory: true - ) - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - let child = directory.appendingPathComponent("child.jpg") - try Data([0x01]).write(to: child) - defer { try? FileManager.default.removeItem(at: directory) } - let message = BitchatMessage( - id: UUID().uuidString, - sender: "Peer", - content: - "\(MimeType.Category.image.messagePrefix)\(directoryName)", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Me" - ) - - coordinator.cleanupIncomingLocalFile(forMessage: message) - - #expect(FileManager.default.fileExists(atPath: directory.path)) - #expect(FileManager.default.fileExists(atPath: child.path)) - } - - @Test @MainActor - func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - context.canSendMediaInCurrentContext = false - - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("voice-note-test-\(UUID().uuidString).m4a") - try Data([0x01, 0x02]).write(to: url) - - coordinator.sendVoiceNote(at: url) - - #expect(!FileManager.default.fileExists(atPath: url.path)) - #expect(context.systemMessages == ["Voice notes are only available in mesh chats."]) - #expect(context.privateChats.isEmpty) - #expect(context.appendedPublicMessages.isEmpty) - #expect(coordinator.transferIdToMessageIDs.isEmpty) - } - - @Test @MainActor - func privateVoiceNoteUsesWireDerivableMessageID() async throws { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("voice_receipt_\(UUID().uuidString).m4a") - try Data("voice".utf8).write(to: url) - defer { try? FileManager.default.removeItem(at: url) } - - coordinator.sendVoiceNote(at: url) - - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 1 }, - timeout: TestConstants.longTimeout - )) - let message = try #require(context.privateChats[peerID]?.first) - let sentPacket = try #require(context.privateFileSends.first?.packet) - #expect(message.id == PrivateMediaMessageIdentity.stableID( - for: sentPacket, - senderPeerID: context.myPeerID, - recipientPeerID: peerID - )) - } - - @Test @MainActor - func privateImageUsesWireDerivableMessageID() async throws { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let peerID = PeerID(str: "99aabbccddeeff00") - context.selectedPrivateChatPeer = peerID - let sourceURL = try makeCoordinatorTestImageURL() - defer { try? FileManager.default.removeItem(at: sourceURL) } - - coordinator.sendImage(from: sourceURL) - - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 1 }, - timeout: TestConstants.longTimeout - )) - let message = try #require(context.privateChats[peerID]?.first) - let sentPacket = try #require(context.privateFileSends.first?.packet) - #expect(message.id == PrivateMediaMessageIdentity.stableID( - for: sentPacket, - senderPeerID: context.myPeerID, - recipientPeerID: peerID - )) - coordinator.cleanupLocalFile(forMessage: message) - } - - @Test @MainActor - func panicDuringImagePreparationDeletesStaleOutputWithoutSideEffects() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "99aabbccddeeff00") - context.selectedPrivateChatPeer = peerID - let sourceURL = try makeCoordinatorTestImageURL() - let outputURL = FileManager.default.temporaryDirectory - .appendingPathComponent( - "panic-stale-image-\(UUID().uuidString).jpg" - ) - let preparer = PausedImagePreparer(outputURL: outputURL) - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareImagePacket: { url in try preparer.prepare(url) } - ) - defer { - preparer.release() - try? FileManager.default.removeItem(at: sourceURL) - try? FileManager.default.removeItem(at: outputURL) - } - - coordinator.sendImage(from: sourceURL) - #expect(await TestHelpers.waitUntil( - { preparer.hasStarted }, - timeout: TestConstants.longTimeout - )) - - DispatchQueue.global(qos: .userInitiated).asyncAfter( - deadline: .now() + .milliseconds(100) - ) { - preparer.release() - } - coordinator.resetForPanic() - - #expect(await TestHelpers.waitUntil( - { preparer.hasFinished }, - timeout: TestConstants.longTimeout - )) - #expect(await TestHelpers.waitUntil( - { !FileManager.default.fileExists(atPath: outputURL.path) }, - timeout: TestConstants.longTimeout - )) - #expect(context.privateChats[peerID]?.isEmpty != false) - #expect(context.appendedPublicMessages.isEmpty) - #expect(context.privateFileSends.isEmpty) - #expect(context.broadcastFileSends.isEmpty) - #expect(context.systemMessages.isEmpty) - #expect(context.deliveryStatusUpdates.isEmpty) - #expect(coordinator.transferIdToMessageIDs.isEmpty) - #expect(coordinator.messageIDToTransferId.isEmpty) - } - - @Test @MainActor - func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "5566778899aabbcc") - context.selectedPrivateChatPeer = peerID - let preparer = PausedVoiceNotePreparer() - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in try preparer.prepare(url) } - ) - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("paused-private-\(UUID().uuidString).m4a") - try Data("voice".utf8).write(to: url) - defer { - preparer.release() - try? FileManager.default.removeItem(at: url) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout)) - let messageID = try #require(context.privateChats[peerID]?.first?.id) - let transferId = try #require(coordinator.messageIDToTransferId[messageID]) - - coordinator.cancelMediaSend(messageID: messageID) - preparer.release() - #expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout)) - for _ in 0..<10 { await Task.yield() } - - #expect(context.cancelledTransfers == [transferId]) - #expect(context.privateFileSends.isEmpty) - #expect(context.broadcastFileSends.isEmpty) - #expect(coordinator.messageIDToTransferId[messageID] == nil) - #expect(coordinator.transferIdToMessageIDs[transferId] == nil) - #expect(context.removedMessages.map(\.messageID) == [messageID]) - } - - @Test @MainActor - func deletePublicVoiceNoteDuringDetachedPreparationCannotBroadcastOrRestoreMapping() async throws { - let context = MockChatMediaTransferContext() - let preparer = PausedVoiceNotePreparer() - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in try preparer.prepare(url) } - ) - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("paused-public-\(UUID().uuidString).m4a") - try Data("voice".utf8).write(to: url) - defer { - preparer.release() - try? FileManager.default.removeItem(at: url) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout)) - let messageID = try #require(context.appendedPublicMessages.first?.message.id) - let transferId = try #require(coordinator.messageIDToTransferId[messageID]) - - coordinator.deleteMediaMessage(messageID: messageID) - preparer.release() - #expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout)) - for _ in 0..<10 { await Task.yield() } - - #expect(context.cancelledTransfers == [transferId]) - #expect(context.broadcastFileSends.isEmpty) - #expect(context.privateFileSends.isEmpty) - #expect(coordinator.messageIDToTransferId[messageID] == nil) - #expect(coordinator.transferIdToMessageIDs[transferId] == nil) - #expect(context.removedMessages.map(\.messageID) == [messageID]) - } - - @Test @MainActor - func voicePreparationFailureMarksPlaceholderFailedAndClearsEarlyMapping() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "66778899aabbccdd") - context.selectedPrivateChatPeer = peerID - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { _ in - throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 999_999) - } - ) - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("failing-private-\(UUID().uuidString).m4a") - try Data("voice".utf8).write(to: url) - defer { try? FileManager.default.removeItem(at: url) } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil( - { - context.deliveryStatusUpdates.contains { update in - if case .failed = update.status { return true } - return false - } - }, - timeout: TestConstants.longTimeout - )) - let messageID = try #require(context.privateChats[peerID]?.first?.id) - - #expect(coordinator.messageIDToTransferId[messageID] == nil) - #expect(coordinator.transferIdToMessageIDs.isEmpty) - #expect(context.privateFileSends.isEmpty) - #expect(context.broadcastFileSends.isEmpty) - } - - @Test @MainActor - func legacyPrivateVoiceNoteWaitsForPerSendConsent() async throws { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.privateMediaPolicy = .legacyRequiresConsent - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("legacy-consent-\(UUID().uuidString).m4a") - try (Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A voice".utf8)).write(to: url) - defer { try? FileManager.default.removeItem(at: url) } - - coordinator.sendVoiceNote(at: url) - - let prompted = await TestHelpers.waitUntil( - { context.legacyConsentRequests.count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(prompted) - #expect(context.legacyConsentRequests.map { $0.peerID } == [peerID]) - #expect(context.privateFileSends.isEmpty) - - context.resolveNextLegacyConsent(true) - - #expect(context.privateFileSends.count == 1) - #expect(context.privateFileLegacyAllowances == [true]) - } - - @Test @MainActor - func capabilityProofTimeoutTransitionsToConsentWithoutAutomaticRawSend() async throws { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let peerID = PeerID(str: "1020304050607080") - context.selectedPrivateChatPeer = peerID - context.privateMediaPolicy = .awaitingCapabilityProof - context.resolvedPrivateMediaPolicy = .legacyRequiresConsent - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("proof-timeout-consent-\(UUID().uuidString).m4a") - try Data("voice".utf8).write(to: url) - defer { try? FileManager.default.removeItem(at: url) } - - coordinator.sendVoiceNote(at: url) - - let prompted = await TestHelpers.waitUntil( - { context.legacyConsentRequests.count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(prompted) - #expect(context.privateFileSends.isEmpty) - context.resolveNextLegacyConsent(false) - #expect(context.privateFileSends.isEmpty) - } - - @Test @MainActor - func legacyConsentApprovalAfterCancelCannotSend() async throws { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let peerID = PeerID(str: "2233445566778899") - context.selectedPrivateChatPeer = peerID - context.privateMediaPolicy = .legacyRequiresConsent - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("legacy-cancel-\(UUID().uuidString).m4a") - try Data("voice".utf8).write(to: url) - defer { try? FileManager.default.removeItem(at: url) } - - coordinator.sendVoiceNote(at: url) - let prompted = await TestHelpers.waitUntil( - { context.legacyConsentRequests.count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(prompted) - let request = try #require(context.legacyConsentRequests.first) - - coordinator.cancelMediaSend(messageID: request.messageID) - #expect(context.invalidatedLegacyConsents.contains { - $0.transferId == request.transferId && $0.messageID == request.messageID - }) - - // Model a stale framework callback that escaped active invalidation. - // The coordinator's transfer/message binding check is the final gate. - context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true) - #expect(context.privateFileSends.isEmpty) - #expect(coordinator.messageIDToTransferId[request.messageID] == nil) - } - - @Test @MainActor - func legacyConsentApprovalAfterDeleteCannotSend() async throws { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let peerID = PeerID(str: "33445566778899aa") - context.selectedPrivateChatPeer = peerID - context.privateMediaPolicy = .legacyRequiresConsent - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("legacy-delete-\(UUID().uuidString).m4a") - try Data("voice".utf8).write(to: url) - defer { try? FileManager.default.removeItem(at: url) } - - coordinator.sendVoiceNote(at: url) - let prompted = await TestHelpers.waitUntil( - { context.legacyConsentRequests.count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(prompted) - let request = try #require(context.legacyConsentRequests.first) - - coordinator.deleteMediaMessage(messageID: request.messageID) - context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true) - - #expect(context.invalidatedLegacyConsents.contains { - $0.transferId == request.transferId && $0.messageID == request.messageID - }) - #expect(context.privateFileSends.isEmpty) - #expect(coordinator.messageIDToTransferId[request.messageID] == nil) - } - - @Test @MainActor - func pinnedPrivateMediaDowngradeNeverPromptsOrSends() async throws { - let context = MockChatMediaTransferContext() - let coordinator = ChatMediaTransferCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.privateMediaPolicy = .blockedDowngrade - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("blocked-downgrade-\(UUID().uuidString).m4a") - try Data("voice".utf8).write(to: url) - defer { try? FileManager.default.removeItem(at: url) } - - coordinator.sendVoiceNote(at: url) - - let failed = await TestHelpers.waitUntil( - { context.deliveryStatusUpdates.contains { update in - if case .failed = update.status { return true } - return false - } }, - timeout: TestConstants.longTimeout - ) - #expect(failed) - #expect(context.legacyConsentRequests.isEmpty) - #expect(context.privateFileSends.isEmpty) - } - - @Test @MainActor - func receiptCapableEncryptedMediaRetriesExactPacketAfterReconnect() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.supportsAuthenticatedPrivateMediaReceipts = true - let fileName = "voice_0011223344556677.m4a" - let preparer = StaticVoiceNotePreparer(fileName: fileName) - let transferIDs = DeterministicMediaTransferIDFactory() - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - try preparer.prepare(url) - }, - transferIDFactory: transferIDs.make - ) - let url = try makeCoordinatorVoiceURL(fileName: fileName) - defer { - try? FileManager.default.removeItem( - at: url.deletingLastPathComponent() - ) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 1 }, - timeout: TestConstants.longTimeout - )) - let messageID = try #require( - context.privateChats[peerID]?.first?.id - ) - let initial = try #require( - context.privateFileSends.first - ) - #expect(PrivateMediaMessageIdentity.isStableID(messageID)) - #expect(coordinator.retainedReconnectRetryCount == 1) - - coordinator.handleTransferEvent(.completed( - id: initial.transferId, - totalFragments: 1 - )) - #expect(coordinator.retainedReconnectRetryCount == 1) - #expect(context.deliveryStatusUpdates.last?.status == .sent) - - coordinator.peerDidReconnect(peerID) - #expect(context.privateFileSends.count == 2) - let retry = try #require(context.privateFileSends.last) - #expect(retry.packet.encode() == initial.packet.encode()) - #expect(retry.peerID == peerID) - #expect(retry.transferId != initial.transferId) - #expect(context.privateFileReceiptRetryTransferIDs == [ - retry.transferId - ]) - #expect(context.privateFileLegacyAllowances == [false, false]) - - coordinator.confirmPrivateMediaDelivery(messageID: messageID) - #expect(coordinator.retainedReconnectRetryCount == 0) - #expect(context.cancelledTransfers == [retry.transferId]) - #expect(context.removedMessages.isEmpty) - #expect(!context.deliveryStatusUpdates.contains { - if case .failed = $0.status { return true } - return false - }) - - // Receipt confirmation removes retry ownership before transport - // cancellation, so its late callback cannot delete the delivered row - // or re-arm another reconnect retry. - coordinator.handleTransferEvent(.cancelled( - id: retry.transferId, - sentFragments: 1, - totalFragments: 2 - )) - coordinator.peerDidReconnect(peerID) - #expect(context.privateFileSends.count == 2) - #expect(context.removedMessages.isEmpty) - #expect(coordinator.messageIDToTransferId[messageID] == nil) - } - - @Test @MainActor - func disconnectClearsDroppedPolicyResolutionSoRetriesRecover() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.supportsAuthenticatedPrivateMediaReceipts = true - let fileName = "voice_3333444455556666.m4a" - let preparer = StaticVoiceNotePreparer(fileName: fileName) - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - try preparer.prepare(url) - } - ) - let url = try makeCoordinatorVoiceURL(fileName: fileName) - defer { - try? FileManager.default.removeItem( - at: url.deletingLastPathComponent() - ) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 1 }, - timeout: TestConstants.longTimeout - )) - let initial = try #require(context.privateFileSends.first) - coordinator.handleTransferEvent(.completed( - id: initial.transferId, - totalFragments: 1 - )) - #expect(coordinator.retainedReconnectRetryCount == 1) - - // The transport accepts this resolution but its completion is never - // invoked (BLEService queue teardown drops the closure), so the - // pending entry parks every subsequent reconnect resolution. - context.resolvesPrivateMediaPolicyImmediately = false - coordinator.peerDidReconnect(peerID) - #expect(context.privateMediaPolicyResolutionRequests.count == 1) - coordinator.peerDidReconnect(peerID) - #expect(context.privateMediaPolicyResolutionRequests.count == 1) - - // Disconnection invalidates the dropped resolution; the next - // reconnect must start fresh and complete the retry. - coordinator.peerDidDisconnect(peerID) - context.resolvesPrivateMediaPolicyImmediately = true - coordinator.peerDidReconnect(peerID) - #expect(context.privateMediaPolicyResolutionRequests.count == 2) - #expect(context.privateFileSends.count == 2) - let retry = try #require(context.privateFileSends.last) - #expect(retry.packet.encode() == initial.packet.encode()) - #expect(retry.peerID == peerID) - #expect(context.privateFileReceiptRetryTransferIDs == [ - retry.transferId - ]) - } - - @Test @MainActor - func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.privateMediaPolicy = .encrypted - context.supportsAuthenticatedPrivateMediaReceipts = false - let fileName = "voice_1111222233334444.m4a" - let preparer = StaticVoiceNotePreparer(fileName: fileName) - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - try preparer.prepare(url) - } - ) - let url = try makeCoordinatorVoiceURL(fileName: fileName) - defer { - try? FileManager.default.removeItem( - at: url.deletingLastPathComponent() - ) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 1 }, - timeout: TestConstants.longTimeout - )) - let initial = try #require(context.privateFileSends.first) - coordinator.handleTransferEvent(.completed( - id: initial.transferId, - totalFragments: 1 - )) - coordinator.peerDidReconnect(peerID) - coordinator.peerDidAuthenticate(peerID) - - #expect(coordinator.retainedReconnectRetryCount == 0) - #expect(context.privateFileSends.count == 1) - #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) - #expect(context.privateMediaPolicyResolutionRequests.isEmpty) - } - - @Test @MainActor - func consentedRawLegacyMediaNeverEntersAutomaticRetry() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.privateMediaPolicy = .legacyRequiresConsent - // Even a contradictory stale bit-9 observation must not retain an - // invocation that actually selected the explicit raw path. - context.supportsAuthenticatedPrivateMediaReceipts = true - let fileName = "voice_2222333344445555.m4a" - let preparer = StaticVoiceNotePreparer(fileName: fileName) - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - try preparer.prepare(url) - } - ) - let url = try makeCoordinatorVoiceURL(fileName: fileName) - defer { - try? FileManager.default.removeItem( - at: url.deletingLastPathComponent() - ) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil( - { context.legacyConsentRequests.count == 1 }, - timeout: TestConstants.longTimeout - )) - context.resolveNextLegacyConsent(true) - let initial = try #require(context.privateFileSends.first) - #expect(context.privateFileLegacyAllowances == [true]) - #expect(coordinator.retainedReconnectRetryCount == 0) - - coordinator.handleTransferEvent(.completed( - id: initial.transferId, - totalFragments: 1 - )) - context.privateMediaPolicy = .encrypted - coordinator.peerDidReconnect(peerID) - coordinator.peerDidAuthenticate(peerID) - - #expect(context.privateFileSends.count == 1) - #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) - #expect(coordinator.retainedReconnectRetryCount == 0) - } - - @Test @MainActor - func authenticatedGenerationSupersedesStaleReconnectResolution() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.supportsAuthenticatedPrivateMediaReceipts = true - context.resolvesPrivateMediaPolicyImmediately = false - let oldGeneration = context - .authenticatedPrivateMediaReceiptGeneration - let newGeneration = UUID( - uuidString: "00000000-0000-0000-0000-000000000002" - )! - let fileName = "voice_3333444455556666.m4a" - let preparer = StaticVoiceNotePreparer(fileName: fileName) - let transferIDs = DeterministicMediaTransferIDFactory() - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - try preparer.prepare(url) - }, - transferIDFactory: transferIDs.make - ) - let url = try makeCoordinatorVoiceURL(fileName: fileName) - defer { - try? FileManager.default.removeItem( - at: url.deletingLastPathComponent() - ) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 1 }, - timeout: TestConstants.longTimeout - )) - let initial = try #require(context.privateFileSends.first) - coordinator.peerDidReconnect(peerID) - #expect(context.pendingPrivateMediaPolicyResolutionCount == 1) - - context.authenticatedPrivateMediaReceiptGeneration = newGeneration - coordinator.peerDidAuthenticate(peerID) - #expect(context.pendingPrivateMediaPolicyResolutionCount == 2) - - // The old-generation completion lost ownership and is inert. - context.resolveNextPrivateMediaPolicy(.encrypted) - #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) - #expect(context.cancelledTransfers.isEmpty) - - context.resolveNextPrivateMediaPolicy(.encrypted) - #expect(context.cancelledTransfers == [initial.transferId]) - #expect(context.privateFileReceiptRetryTransferIDs.count == 1) - #expect( - context.authenticatedPrivateMediaReceiptGeneration - == newGeneration - ) - #expect(oldGeneration != newGeneration) - } - - @Test @MainActor - func panicClearsRetainedRetryPendingResolutionAndExpiry() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.supportsAuthenticatedPrivateMediaReceipts = true - context.resolvesPrivateMediaPolicyImmediately = false - let fileName = "voice_3333444455556666.m4a" - let preparer = StaticVoiceNotePreparer(fileName: fileName) - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - try preparer.prepare(url) - }, - reconnectRetryLimits: PrivateMediaReconnectRetryLimits( - maxRetainedPackets: 1, - maxRetainedBytes: 1_024, - maxRetriesPerMessage: 1, - retentionSeconds: 0.1, - maxRetriesPerReconnect: 1 - ) - ) - let url = try makeCoordinatorVoiceURL(fileName: fileName) - defer { - try? FileManager.default.removeItem( - at: url.deletingLastPathComponent() - ) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 1 }, - timeout: TestConstants.longTimeout - )) - let initial = try #require(context.privateFileSends.first) - coordinator.handleTransferEvent(.completed( - id: initial.transferId, - totalFragments: 1 - )) - #expect(coordinator.retainedReconnectRetryCount == 1) - - coordinator.peerDidReconnect(peerID) - #expect(context.pendingPrivateMediaPolicyResolutionCount == 1) - let failedBeforePanic = context.deliveryStatusUpdates.filter { - if case .failed = $0.status { return true } - return false - }.count - - coordinator.resetForPanic() - context.resolveNextPrivateMediaPolicy(.encrypted) - try await Task.sleep(nanoseconds: 250_000_000) - coordinator._test_expireReconnectRetries() - - #expect(coordinator.retainedReconnectRetryCount == 0) - #expect(coordinator.retainedReconnectRetryBytes == 0) - #expect(coordinator.transferIdToMessageIDs.isEmpty) - #expect(coordinator.messageIDToTransferId.isEmpty) - #expect(context.privateFileSends.count == 1) - #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) - #expect(context.deliveryStatusUpdates.filter { - if case .failed = $0.status { return true } - return false - }.count == failedBeforePanic) - } - - @Test @MainActor - func retryCountAndRetentionTimeEndInVisibleFailure() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.supportsAuthenticatedPrivateMediaReceipts = true - let clock = MutableMediaRetryClock( - Date(timeIntervalSince1970: 4_000) - ) - let limits = PrivateMediaReconnectRetryLimits( - maxRetainedPackets: 2, - maxRetainedBytes: 1_024, - maxRetriesPerMessage: 1, - retentionSeconds: 10, - maxRetriesPerReconnect: 1 - ) - let fileName = "voice_4444555566667777.m4a" - let preparer = StaticVoiceNotePreparer(fileName: fileName) - let transferIDs = DeterministicMediaTransferIDFactory() - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - try preparer.prepare(url) - }, - reconnectRetryLimits: limits, - now: clock.now, - transferIDFactory: transferIDs.make - ) - let url = try makeCoordinatorVoiceURL(fileName: fileName) - defer { - try? FileManager.default.removeItem( - at: url.deletingLastPathComponent() - ) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 1 }, - timeout: TestConstants.longTimeout - )) - let initial = try #require(context.privateFileSends.first) - coordinator.handleTransferEvent(.completed( - id: initial.transferId, - totalFragments: 1 - )) - coordinator.peerDidReconnect(peerID) - let retryID = try #require( - context.privateFileReceiptRetryTransferIDs.first - ) - coordinator.handleTransferEvent(.cancelled( - id: retryID, - sentFragments: 0, - totalFragments: 1 - )) - - #expect(coordinator.retainedReconnectRetryCount == 0) - #expect(context.removedMessages.isEmpty) - #expect(context.deliveryStatusUpdates.contains { - $0.messageID.hasPrefix("media-") - && $0.status == .failed(reason: String( - localized: "content.delivery.reason.not_delivered", - defaultValue: "Not delivered", - comment: "Failure reason shown when a private media transfer could not finish" - )) - }) - - // A separate retained row that locally completed but never received a - // remote receipt expires to a distinct visible failure. - let ttlFileName = "voice_5555666677778888.m4a" - let ttlPreparer = StaticVoiceNotePreparer( - fileName: ttlFileName - ) - let ttlCoordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - try ttlPreparer.prepare(url) - }, - reconnectRetryLimits: limits, - now: clock.now, - transferIDFactory: transferIDs.make - ) - let ttlURL = try makeCoordinatorVoiceURL( - fileName: ttlFileName - ) - defer { - try? FileManager.default.removeItem( - at: ttlURL.deletingLastPathComponent() - ) - } - let sendsBeforeTTL = context.privateFileSends.count - ttlCoordinator.sendVoiceNote(at: ttlURL) - #expect(await TestHelpers.waitUntil( - { - context.privateFileSends.count - == sendsBeforeTTL + 1 - }, - timeout: TestConstants.longTimeout - )) - let ttlInitial = try #require(context.privateFileSends.last) - ttlCoordinator.handleTransferEvent(.completed( - id: ttlInitial.transferId, - totalFragments: 1 - )) - clock.advance(by: 10) - ttlCoordinator._test_expireReconnectRetries() - - #expect(ttlCoordinator.retainedReconnectRetryCount == 0) - #expect(context.deliveryStatusUpdates.contains { - $0.status == .failed( - reason: String( - localized: - "content.delivery.reason.private_media_delivery_unconfirmed", - defaultValue: "Delivery could not be confirmed" - ) - ) - }) - } - - @Test @MainActor - func retentionAndPerReconnectWorkAreBounded() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.supportsAuthenticatedPrivateMediaReceipts = true - let limits = PrivateMediaReconnectRetryLimits( - maxRetainedPackets: 2, - maxRetainedBytes: 10, - maxRetriesPerMessage: 2, - retentionSeconds: 120, - maxRetriesPerReconnect: 1 - ) - let transferIDs = DeterministicMediaTransferIDFactory() - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - let content = Data("voice".utf8) - return BitchatFilePacket( - fileName: url.lastPathComponent, - fileSize: UInt64(content.count), - mimeType: "audio/mp4", - content: content - ) - }, - reconnectRetryLimits: limits, - transferIDFactory: transferIDs.make - ) - let fileNames = [ - "voice_6666777788889999.m4a", - "voice_777788889999aaaa.m4a", - "voice_88889999aaaabbbb.m4a" - ] - var roots: [URL] = [] - defer { - for root in roots { - try? FileManager.default.removeItem(at: root) - } - } - - for fileName in fileNames { - let url = try makeCoordinatorVoiceURL( - fileName: fileName, - bytes: Data("voice".utf8) - ) - roots.append(url.deletingLastPathComponent()) - // The production preparer preserves this stable filename. - coordinator.sendVoiceNote(at: url) - } - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 3 }, - timeout: TestConstants.longTimeout - )) - #expect(coordinator.retainedReconnectRetryCount == 2) - #expect(coordinator.retainedReconnectRetryBytes <= 10) - - for send in context.privateFileSends { - coordinator.handleTransferEvent(.completed( - id: send.transferId, - totalFragments: 1 - )) - } - coordinator.peerDidReconnect(peerID) - #expect(context.privateFileReceiptRetryTransferIDs.count == 1) - } - - @Test @MainActor - func userCancellationReleasesRetainedBytesAndIgnoresLateEvent() async throws { - let context = MockChatMediaTransferContext() - let peerID = PeerID(str: "1122334455667788") - context.selectedPrivateChatPeer = peerID - context.supportsAuthenticatedPrivateMediaReceipts = true - let fileName = "voice_9999aaaabbbbcccc.m4a" - let preparer = StaticVoiceNotePreparer(fileName: fileName) - let coordinator = ChatMediaTransferCoordinator( - context: context, - prepareVoiceNotePacket: { url in - try preparer.prepare(url) - } - ) - let url = try makeCoordinatorVoiceURL(fileName: fileName) - defer { - try? FileManager.default.removeItem( - at: url.deletingLastPathComponent() - ) - } - - coordinator.sendVoiceNote(at: url) - #expect(await TestHelpers.waitUntil( - { context.privateFileSends.count == 1 }, - timeout: TestConstants.longTimeout - )) - let messageID = try #require( - context.privateChats[peerID]?.first?.id - ) - let transferID = try #require( - context.privateFileSends.first?.transferId - ) - - coordinator.cancelMediaSend(messageID: messageID) - coordinator.handleTransferEvent(.cancelled( - id: transferID, - sentFragments: 0, - totalFragments: 1 - )) - - #expect(coordinator.retainedReconnectRetryCount == 0) - #expect(coordinator.retainedReconnectRetryBytes == 0) - #expect(context.cancelledTransfers == [transferID]) - #expect(context.removedMessages.map(\.messageID) == [ - messageID - ]) - #expect(!context.deliveryStatusUpdates.contains { - if case .failed = $0.status { return true } - return false - }) - } -} - -private func makeCoordinatorVoiceURL( - fileName: String, - bytes: Data = Data("voice".utf8) -) throws -> URL { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent( - "media-retry-\(UUID().uuidString)", - isDirectory: true - ) - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - let url = directory.appendingPathComponent(fileName) - try bytes.write(to: url) - return url -} - -private final class PausedImagePreparer: @unchecked Sendable { - private let condition = NSCondition() - private let outputURL: URL - private var started = false - private var released = false - private var finished = false - - init(outputURL: URL) { - self.outputURL = outputURL - } - - var hasStarted: Bool { - condition.lock() - defer { condition.unlock() } - return started - } - - var hasFinished: Bool { - condition.lock() - defer { condition.unlock() } - return finished - } - - func prepare(_ _: URL) throws -> ChatPreparedImage { - condition.lock() - started = true - condition.broadcast() - while !released { - condition.wait() - } - condition.unlock() - - let data = Data("prepared image".utf8) - try data.write(to: outputURL, options: .atomic) - let packet = BitchatFilePacket( - fileName: outputURL.lastPathComponent, - fileSize: UInt64(data.count), - mimeType: "image/jpeg", - content: data - ) - - condition.lock() - finished = true - condition.broadcast() - condition.unlock() - return ChatPreparedImage(outputURL: outputURL, packet: packet) - } - - func release() { - condition.lock() - released = true - condition.broadcast() - condition.unlock() - } -} - -private func makeCoordinatorTestImageURL() throws -> URL { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("coordinator-image-\(UUID().uuidString).png") - #if os(iOS) - let image = UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16)) - .image { context in - UIColor.systemBlue.setFill() - context.fill(CGRect(x: 0, y: 0, width: 16, height: 16)) - } - guard let data = image.pngData() else { - throw CoordinatorImageTestError.encodingFailed - } - #else - let image = NSImage(size: NSSize(width: 16, height: 16)) - image.lockFocus() - NSColor.systemBlue.setFill() - NSRect(x: 0, y: 0, width: 16, height: 16).fill() - image.unlockFocus() - guard let tiff = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiff), - let data = bitmap.representation(using: .png, properties: [:]) else { - throw CoordinatorImageTestError.encodingFailed - } - #endif - try data.write(to: url, options: .atomic) - return url -} - -private enum CoordinatorImageTestError: Error { - case encodingFailed -} diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift deleted file mode 100644 index 6200d4bf..00000000 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ /dev/null @@ -1,684 +0,0 @@ -// -// ChatNostrCoordinatorContextTests.swift -// bitchatTests -// -// Exercises the `ChatNostrCoordinator` facade and its components -// (`NostrInboundPipeline`, `GeohashSubscriptionManager`, `GeoPresenceTracker`) -// against a mock `ChatNostrContext` — proving the stack works without a -// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / -// `ChatPrivateConversationCoordinatorContextTests` exemplars. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatNostrContext` (and, via protocol -/// inheritance, the component contexts) proving that the Nostr stack is -/// testable without a `ChatViewModel`. -@MainActor -private final class MockChatNostrContext: ChatNostrContext { - // Channel & subscription state - var activeChannel: ChannelID = .mesh - var currentGeohash: String? - var geoSubscriptionID: String? - var geoDmSubscriptionID: String? - var geoSamplingSubs: [String: String] = [:] - var lastGeoNotificationAt: [String: Date] = [:] - var nostrRelayManager: NostrRelayManager? { nil } - - func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id } - func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id } - func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash } - func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) } - - func clearGeoSamplingSubs() -> [String] { - defer { geoSamplingSubs.removeAll() } - return Array(geoSamplingSubs.keys) - } - - // Public timeline & pipeline - var messages: [BitchatMessage] = [] - private(set) var pipelineFlushCount = 0 - private(set) var refreshedChannels: [ChannelID?] = [] - private(set) var publicSystemMessages: [String] = [] - var pendingGeohashSystemMessages: [String] = [] - private(set) var appendedGeohashMessages: [(message: BitchatMessage, geohash: String)] = [] - - func flushPublicMessagePipeline() { pipelineFlushCount += 1 } - func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) } - func addPublicSystemMessage(_ content: String) { publicSystemMessages.append(content) } - - func drainPendingGeohashSystemMessages() -> [String] { - defer { pendingGeohashSystemMessages.removeAll() } - return pendingGeohashSystemMessages - } - - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { - guard !appendedGeohashMessages.contains(where: { $0.message.id == message.id && $0.geohash == geohash }) else { - return false - } - appendedGeohashMessages.append((message, geohash)) - return true - } - - // Inbound public messages - private(set) var handledPublicMessages: [BitchatMessage] = [] - private(set) var mentionCheckedMessageIDs: [String] = [] - private(set) var hapticMessageIDs: [String] = [] - - func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessages.append(message) } - func checkForMentions(_ message: BitchatMessage) { mentionCheckedMessageIDs.append(message.id) } - func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) } - func parseMentions(from content: String) -> [String] { [] } - - // Inbound private (geohash DM) payloads - var selectedPrivateChatPeer: PeerID? - var nostrKeyMapping: [PeerID: String] = [:] - func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } - private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = [] - private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = [] - private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = [] - private(set) var startedPrivateChats: [PeerID] = [] - - func handlePrivateMessage( - _ payload: NoisePayload, - senderPubkey: String, - convKey: PeerID, - id: NostrIdentity, - messageTimestamp: Date - ) { - handledPrivateMessages.append((payload, senderPubkey, convKey, messageTimestamp)) - } - - func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { - handledDelivered.append((senderPubkey, convKey)) - } - - func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { - handledReadReceipts.append((senderPubkey, convKey)) - } - - func startPrivateChat(with peerID: PeerID) { startedPrivateChats.append(peerID) } - - // Nostr identity & blocking - var geohashIdentities: [String: NostrIdentity] = [:] - var nostrIdentity: NostrIdentity? - var blockedNostrPubkeys: Set = [] - var displayNamesByPubkey: [String: String] = [:] - - private struct NoIdentity: Error {} - - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { - guard let identity = geohashIdentities[geohash] else { throw NoIdentity() } - return identity - } - - func currentNostrIdentity() -> NostrIdentity? { nostrIdentity } - - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { - blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased()) - } - - func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { - displayNamesByPubkey[pubkeyHex] ?? "anon" - } - - // Event dedup - private(set) var recordedNostrEventIDs: [String] = [] - private var processedNostrEventIDs: Set = [] - private(set) var clearProcessedNostrEventsCount = 0 - - func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) } - - func recordProcessedNostrEvent(_ eventID: String) { - processedNostrEventIDs.insert(eventID) - recordedNostrEventIDs.append(eventID) - } - - func clearProcessedNostrEvents() { - processedNostrEventIDs.removeAll() - clearProcessedNostrEventsCount += 1 - } - - // Geo participants & presence - var geoNicknames: [String: String] = [:] - private(set) var teleportedKeys: Set = [] - var teleportedGeoCount: Int { teleportedKeys.count } - private(set) var refreshTimerStartCount = 0 - private(set) var refreshTimerStopCount = 0 - private(set) var activeParticipantGeohashes: [String?] = [] - private(set) var recordedParticipants: [String] = [] - private(set) var recordedSampledParticipants: [(pubkeyHex: String, geohash: String)] = [] - private(set) var clearTeleportedGeoCount = 0 - private(set) var clearGeoNicknamesCount = 0 - var visiblePeople: [GeoPerson] = [] - - func startGeoParticipantRefreshTimer() { refreshTimerStartCount += 1 } - func stopGeoParticipantRefreshTimer() { refreshTimerStopCount += 1 } - func setActiveParticipantGeohash(_ geohash: String?) { activeParticipantGeohashes.append(geohash) } - func recordGeoParticipant(pubkeyHex: String) { recordedParticipants.append(pubkeyHex) } - - func recordGeoParticipant(pubkeyHex: String, geohash: String) { - recordedSampledParticipants.append((pubkeyHex, geohash)) - } - - func geoParticipantCount(for geohash: String) -> Int { - recordedSampledParticipants.filter { $0.geohash == geohash }.count - } - - func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname } - func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) } - func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) } - - func clearTeleportedGeo() { - teleportedKeys.removeAll() - clearTeleportedGeoCount += 1 - } - - func clearGeoNicknames() { - geoNicknames.removeAll() - clearGeoNicknamesCount += 1 - } - - func visibleGeohashPeople() -> [GeoPerson] { visiblePeople } - - // Location channels - var isTeleported = false - var regionalGeohashes: Set = [] - - func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { - !regionalGeohashes.isEmpty && !regionalGeohashes.contains(geohash) - } - - // Routing & acknowledgements - private(set) var routedFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] - private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] - private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] - - func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { - routedFavoriteNotifications.append((peerID, isFavorite)) - } - - func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - geoDeliveryAcks.append((messageID, recipientHex)) - } - - func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - geoReadReceipts.append((messageID, recipientHex)) - } - - // Favorites & notifications - var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] - private(set) var geohashActivityNotifications: [(geohash: String, bodyPreview: String)] = [] - - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { - favoriteRelationshipsByNoiseKey[noiseKey] - } - - func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { - Array(favoriteRelationshipsByNoiseKey.values) - } - - func notifyGeohashActivity(geohash: String, bodyPreview: String) { - geohashActivityNotifications.append((geohash, bodyPreview)) - } -} - -// MARK: - Helpers - -/// Let the inner `Task { @MainActor in ... }` hops the coordinator schedules -/// run to completion. -@MainActor -private func drainMainQueue() async { - for _ in 0..<5 { - await Task.yield() - } -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no -/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence, -/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch -/// teardown, embedded ack flows, and — now that favorites and notifications -/// are injected through the context — the favorite-notification ingest and -/// the sampled-geohash notification cooldown. Flows that hit live singletons -/// (`NostrRelayManager.shared` subscriptions, `TorManager`) remain covered by -/// the full view-model tests. -struct ChatNostrCoordinatorContextTests { - - @Test @MainActor - func handleNostrEvent_ingestsPublicMessageOnceAndDeduplicates() async throws { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - - let sender = try NostrIdentity.generate() - let event = try NostrProtocol.createEphemeralGeohashEvent( - content: "hello geohash", - geohash: "u4pruyd", - senderIdentity: sender, - nickname: "alice" - ) - context.displayNamesByPubkey[event.pubkey] = "alice#1234" - - coordinator.inbound.handleNostrEvent(event) - await drainMainQueue() - - // Dedup recorded exactly once, presence and key mapping updated. - #expect(context.recordedNostrEventIDs == [event.id]) - #expect(context.geoNicknames[event.pubkey.lowercased()] == "alice") - #expect(context.recordedParticipants == [event.pubkey]) - #expect(context.nostrKeyMapping[PeerID(nostr: event.pubkey)] == event.pubkey) - #expect(context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] == event.pubkey) - - // The message reached the public ingest path with the resolved name. - #expect(context.handledPublicMessages.map(\.id) == [event.id]) - #expect(context.handledPublicMessages.first?.sender == "alice#1234") - #expect(context.handledPublicMessages.first?.content == "hello geohash") - #expect(context.mentionCheckedMessageIDs == [event.id]) - #expect(context.hapticMessageIDs == [event.id]) - - // A replay of the same event is dropped before any processing. - coordinator.inbound.handleNostrEvent(event) - await drainMainQueue() - #expect(context.recordedNostrEventIDs == [event.id]) - #expect(context.handledPublicMessages.count == 1) - #expect(context.recordedParticipants.count == 1) - } - - @Test @MainActor - func handleNostrEvent_marksTeleportedPeerWithoutIngestingEmptyContent() async throws { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - - let sender = try NostrIdentity.generate() - let event = try NostrProtocol.createEphemeralGeohashEvent( - content: "", - geohash: "u4pruyd", - senderIdentity: sender, - teleported: true - ) - - coordinator.inbound.handleNostrEvent(event) - await drainMainQueue() - - // Teleport detection fires even though the empty message is dropped. - #expect(context.teleportedKeys == [event.pubkey.lowercased()]) - #expect(context.recordedParticipants == [event.pubkey]) - #expect(context.handledPublicMessages.isEmpty) - } - - @Test @MainActor - func handleNostrEvent_skipsBlockedSender() async throws { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - - let sender = try NostrIdentity.generate() - let event = try NostrProtocol.createEphemeralGeohashEvent( - content: "spam", - geohash: "u4pruyd", - senderIdentity: sender - ) - context.blockedNostrPubkeys.insert(event.pubkey.lowercased()) - - coordinator.inbound.handleNostrEvent(event) - await drainMainQueue() - - // The event is still recorded for dedup but nothing else happens. - #expect(context.recordedNostrEventIDs == [event.id]) - #expect(context.recordedParticipants.isEmpty) - #expect(context.handledPublicMessages.isEmpty) - } - - @Test @MainActor - func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - - let recipient = try NostrIdentity.generate() - let sender = try NostrIdentity.generate() - let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient( - content: "psst", - messageID: "gm-1", - senderPeerID: PeerID(str: "aabbccddeeff0011") - )) - let giftWrap = try NostrProtocol.createPrivateMessage( - content: embedded, - recipientPubkey: recipient.publicKeyHex, - senderIdentity: sender - ) - - coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) - - // The NIP-17 unwrap runs off the main actor; wait for the hop back. - let convKey = PeerID(nostr_: sender.publicKeyHex) - let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout) - #expect(routed) - #expect(context.recordedNostrEventIDs == [giftWrap.id]) - #expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex) - #expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex) - #expect(context.handledPrivateMessages.first?.convKey == convKey) - - // The embedded Noise payload survives the round trip intact. - let payload = try #require(context.handledPrivateMessages.first?.payload) - #expect(payload.type == .privateMessage) - let pm = try #require(PrivateMessagePacket.decode(from: payload.data)) - #expect(pm.messageID == "gm-1") - #expect(pm.content == "psst") - - // The same gift wrap is dropped on replay. - coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) - await drainMainQueue() - #expect(context.recordedNostrEventIDs == [giftWrap.id]) - #expect(context.handledPrivateMessages.count == 1) - } - - @Test @MainActor - func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - - let recipient = try NostrIdentity.generate() - let sender = try NostrIdentity.generate() - let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient( - content: "pre-wipe secret", - messageID: "gm-wipe-1", - senderPeerID: PeerID(str: "aabbccddeeff0011") - )) - let giftWrap = try NostrProtocol.createPrivateMessage( - content: embedded, - recipientPubkey: recipient.publicKeyHex, - senderIdentity: sender - ) - - // Spawn the detached decrypt (it strongly captures the pre-wipe - // identity), then panic-wipe in the SAME main-actor turn — guaranteed - // to land before the task's first main-actor hop. - coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) - coordinator.inbound.invalidateInFlightDecrypts() - - // Give the detached task ample time to have delivered if the wipe - // guard were broken. - try? await Task.sleep(nanoseconds: 200_000_000) - await drainMainQueue() - - #expect(context.handledPrivateMessages.isEmpty) - #expect(context.recordedNostrEventIDs.isEmpty) - - // The pipeline itself stays usable: a gift wrap spawned AFTER the - // wipe (new generation) still decrypts and delivers. - coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) - let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout) - #expect(delivered) - } - - // NOTE: Inbound Schnorr signature verification (and the forged-copy - // dedup-poisoning invariant) is enforced once, off the main actor, at the - // relay boundary — see NostrRelayManagerTests - // `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and - // `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`. - // The inbound pipeline only ever sees verified events. - - @Test @MainActor - func processNostrMessage_duplicateDeliveryProcessesOnce() async throws { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - - let recipient = try NostrIdentity.generate() - let sender = try NostrIdentity.generate() - context.nostrIdentity = recipient - let giftWrap = try NostrProtocol.createPrivateMessage( - content: "verify:noop", - recipientPubkey: recipient.publicKeyHex, - senderIdentity: sender - ) - - // Fan-in of the same (already verified) gift wrap from several relays - // records and processes exactly once. - await coordinator.inbound.processNostrMessage(giftWrap) - #expect(context.recordedNostrEventIDs == [giftWrap.id]) - - await coordinator.inbound.processNostrMessage(giftWrap) - #expect(context.recordedNostrEventIDs == [giftWrap.id]) - } - - @Test @MainActor - func switchLocationChannel_toMesh_tearsDownGeohashState() async { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - context.activeChannel = .mesh - context.currentGeohash = "u4pruyd" - context.geoNicknames = ["abcd": "alice"] - - coordinator.subscriptions.switchLocationChannel(to: .mesh) - - #expect(context.pipelineFlushCount == 1) - #expect(context.activeChannel == .mesh) - #expect(context.clearProcessedNostrEventsCount == 1) - #expect(context.refreshedChannels == [.mesh]) - #expect(context.refreshTimerStopCount == 1) - #expect(context.clearTeleportedGeoCount == 1) - // Cleared once in the mesh branch, once in the shared teardown. - #expect(context.activeParticipantGeohashes == [nil, nil]) - #expect(context.currentGeohash == nil) - #expect(context.geoSubscriptionID == nil) - #expect(context.geoDmSubscriptionID == nil) - #expect(context.clearGeoNicknamesCount == 1) - #expect(context.geoNicknames.isEmpty) - // Mesh never starts a geohash subscription or refresh timer. - #expect(context.refreshTimerStartCount == 0) - } - - @Test @MainActor - func sendDeliveryAckViaNostrEmbedded_sendsReadReceiptOnlyWhenViewingUnread() async throws { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - context.nostrIdentity = try NostrIdentity.generate() - let senderPubkey = "feedface00112233" - let convKey = PeerID(nostr_: senderPubkey) - - let message = BitchatMessage( - id: "mid-1", - sender: "alice#1234", - content: "hi", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "me", - senderPeerID: convKey - ) - - // Not viewing the chat: delivery ack only. - coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: false, senderPubkey: senderPubkey, key: nil) - #expect(context.geoDeliveryAcks.map(\.messageID) == ["mid-1"]) - #expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey) - #expect(context.geoReadReceipts.isEmpty) - - // Viewing the chat: delivery ack plus read receipt. - context.selectedPrivateChatPeer = convKey - coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: false, senderPubkey: senderPubkey, key: Data([0x01])) - #expect(context.geoDeliveryAcks.count == 2) - #expect(context.geoReadReceipts.map(\.messageID) == ["mid-1"]) - - // Already read: no further read receipt. - coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: true, senderPubkey: senderPubkey, key: nil) - #expect(context.geoDeliveryAcks.count == 3) - #expect(context.geoReadReceipts.count == 1) - } - - @Test @MainActor - func geohashDMKeyMappingHelpers_resolveAndStartChats() async { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" - let convKey = PeerID(nostr_: hex) - context.displayNamesByPubkey[hex] = "bob#eeff" - - coordinator.startGeohashDM(withPubkeyHex: hex) - #expect(context.nostrKeyMapping[convKey] == hex) - #expect(context.startedPrivateChats == [convKey]) - - #expect(coordinator.fullNostrHex(forSenderPeerID: convKey) == hex) - #expect(coordinator.geohashDisplayName(for: convKey) == "bob#eeff") - - // Unmapped conversation keys fall back to the bare peer ID. - let unknown = PeerID(nostr_: "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100") - #expect(coordinator.geohashDisplayName(for: unknown) == unknown.bare) - - // Display-name lookup prefers visible people, then nicknames. - context.visiblePeople = [GeoPerson(id: "aa11", displayName: "carol#aa11", lastSeen: Date())] - context.geoNicknames = ["bb22": "dave"] - #expect(coordinator.nostrPubkeyForDisplayName("carol#aa11") == "aa11") - #expect(coordinator.nostrPubkeyForDisplayName("dave") == "bb22") - #expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil) - } -} - -// MARK: - GeoPresenceTracker Tests - -/// Focused tests for seams the coordinator split made independently -/// testable: the sampling-event LRU dedup and the per-geohash notification -/// cooldown. The cooldown tests stop short of the live notification center by -/// pre-seeding the timeline append as a duplicate. -struct GeoPresenceTrackerTests { - - @Test @MainActor - func samplingEventDedup_evictsOldestBeyondLRUCap() { - let context = MockChatNostrContext() - let tracker = GeoPresenceTracker(context: context) - let cap = TransportConfig.geoSamplingEventLRUCap - - // Empty IDs are never deduplicated. - #expect(tracker.shouldProcessGeoSamplingEvent("")) - #expect(tracker.shouldProcessGeoSamplingEvent("")) - - // First sight passes; a replay is rejected. - #expect(tracker.shouldProcessGeoSamplingEvent("ev-0")) - #expect(!tracker.shouldProcessGeoSamplingEvent("ev-0")) - - // Fill one past the cap: the oldest entry is evicted and accepted - // again, while a still-resident entry stays deduplicated. - for i in 1...cap { - #expect(tracker.shouldProcessGeoSamplingEvent("ev-\(i)")) - } - #expect(tracker.shouldProcessGeoSamplingEvent("ev-0")) - #expect(!tracker.shouldProcessGeoSamplingEvent("ev-\(cap)")) - - // Clearing resets the dedup entirely. - tracker.clearGeoSamplingEventDedup() - #expect(tracker.shouldProcessGeoSamplingEvent("ev-\(cap)")) - } - - @Test @MainActor - func notificationCooldown_skipsWithinWindow() async throws { - let context = MockChatNostrContext() - let tracker = GeoPresenceTracker(context: context) - let sender = try NostrIdentity.generate() - let event = try NostrProtocol.createEphemeralGeohashEvent( - content: "sampled activity", - geohash: "9q8yy", - senderIdentity: sender, - nickname: "alice" - ) - - // Within the cooldown window nothing is appended or re-stamped. - let recent = Date() - context.lastGeoNotificationAt["9q8yy"] = recent - tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event) - await drainMainQueue() - #expect(context.appendedGeohashMessages.isEmpty) - #expect(context.lastGeoNotificationAt["9q8yy"] == recent) - } - - @Test @MainActor - func notificationCooldown_stampsGeohashOnceWindowElapses() async throws { - let context = MockChatNostrContext() - let tracker = GeoPresenceTracker(context: context) - let sender = try NostrIdentity.generate() - let event = try NostrProtocol.createEphemeralGeohashEvent( - content: "sampled activity", - geohash: "9q8yy", - senderIdentity: sender, - nickname: "alice" - ) - - // Pre-seed the same event ID so the timeline append reports a - // duplicate and the flow never reaches the live notification center. - let placeholder = BitchatMessage( - id: event.id, - sender: "seed", - content: "seed", - timestamp: Date(), - isRelay: false - ) - #expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy")) - - // Cooldown elapsed: the geohash is re-stamped and the append is - // attempted (and rejected as a duplicate, so no notification either). - let stale = Date().addingTimeInterval(-TransportConfig.uiGeoNotifyCooldownSeconds - 1) - context.lastGeoNotificationAt["9q8yy"] = stale - tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event) - await drainMainQueue() - - let stamped = try #require(context.lastGeoNotificationAt["9q8yy"]) - #expect(stamped > stale) - #expect(context.appendedGeohashMessages.count == 1) - } - - @Test @MainActor - func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - let sender = try NostrIdentity.generate() - context.geoNicknames[sender.publicKeyHex.lowercased()] = "alice" - - let first = try NostrProtocol.createEphemeralGeohashEvent( - content: "hello geohash", - geohash: "u4pruyd", - senderIdentity: sender, - nickname: "alice" - ) - coordinator.presence.cooldownPerGeohash("u4pruyd", content: "hello geohash", event: first) - await drainMainQueue() - - // Sampled message recorded in the store and notification posted. - #expect(context.appendedGeohashMessages.map(\.message.id) == [first.id]) - #expect(context.appendedGeohashMessages.first?.message.sender == "alice#" + String(first.pubkey.suffix(4))) - #expect(context.geohashActivityNotifications.count == 1) - #expect(context.geohashActivityNotifications.first?.geohash == "u4pruyd") - #expect(context.geohashActivityNotifications.first?.bodyPreview == "hello geohash") - #expect(context.lastGeoNotificationAt["u4pruyd"] != nil) - - // A second sampled event inside the cooldown window is fully suppressed. - let second = try NostrProtocol.createEphemeralGeohashEvent( - content: "again", - geohash: "u4pruyd", - senderIdentity: sender, - nickname: "alice" - ) - coordinator.presence.cooldownPerGeohash("u4pruyd", content: "again", event: second) - await drainMainQueue() - #expect(context.geohashActivityNotifications.count == 1) - #expect(context.appendedGeohashMessages.count == 1) - - // Long previews are truncated to the snippet cap with an ellipsis. - let longContent = String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen + 20) - let third = try NostrProtocol.createEphemeralGeohashEvent( - content: longContent, - geohash: "9q8yyk", - senderIdentity: sender, - nickname: "alice" - ) - coordinator.presence.cooldownPerGeohash("9q8yyk", content: longContent, event: third) - await drainMainQueue() - #expect( - context.geohashActivityNotifications.last?.bodyPreview - == String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen) + "…" - ) - } - -} diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift deleted file mode 100644 index 34963f0a..00000000 --- a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift +++ /dev/null @@ -1,257 +0,0 @@ -// -// ChatOutgoingCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatOutgoingCoordinator` against a mock `ChatOutgoingContext` — -// proving the coordinator works without a `ChatViewModel`, following the -// `ChatDeliveryCoordinatorContextTests` exemplar. -// -// Scope note: the geohash path builds and signs a real Nostr event via -// `NostrProtocol.createEphemeralGeohashEvent` (pure crypto, no shared state); -// everything else flows through the mock context. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatOutgoingContext` proving that -/// `ChatOutgoingCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockChatOutgoingContext: ChatOutgoingContext { - // Identity & channel state - var nickname = "me" - var myPeerID = PeerID(str: "0011223344556677") - var activeChannel: ChannelID = .mesh - var selectedPrivateChatPeer: PeerID? - var isTeleported = false - - // Commands & private messages - var selectedPeerAfterUpdate: PeerID?? - private(set) var handledCommands: [String] = [] - private(set) var updatePrivateChatPeerIfNeededCount = 0 - private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = [] - - func handleCommand(_ command: String) { handledCommands.append(command) } - - func updatePrivateChatPeerIfNeeded() { - updatePrivateChatPeerIfNeededCount += 1 - if let selectedPeerAfterUpdate { - selectedPrivateChatPeer = selectedPeerAfterUpdate - } - } - - func sendPrivateMessage(_ content: String, to peerID: PeerID) { - sentPrivateMessages.append((content, peerID)) - } - - // Public timeline (local echo) - private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = [] - private(set) var systemMessages: [String] = [] - - func parseMentions(from content: String) -> [String] { - content.contains("@bob") ? ["bob"] : [] - } - - @discardableResult - func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool { - appendedPublicMessages.append((message, conversationID)) - return true - } - - func addSystemMessage(_ content: String) { systemMessages.append(content) } - - // Content dedup - private(set) var recordedContentKeys: [(key: String, timestamp: Date)] = [] - - func normalizedContentKey(_ content: String) -> String { "key:\(content)" } - func recordContentKey(_ key: String, timestamp: Date) { - recordedContentKeys.append((key, timestamp)) - } - - // Outbound routing - private(set) var recordedActivityKeys: [String] = [] - private(set) var sentMeshMessages: [(content: String, mentions: [String], messageID: String, timestamp: Date)] = [] - private(set) var sentGeohashContexts: [ChatViewModel.GeoOutgoingContext] = [] - - func recordPublicActivity(forChannelKey key: String) { recordedActivityKeys.append(key) } - - func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { - sentMeshMessages.append((content, mentions, messageID, timestamp)) - } - - func sendGeohash(context: ChatViewModel.GeoOutgoingContext) { - sentGeohashContexts.append(context) - } - - private(set) var bridgedMessages: [(content: String, senderPeerID: PeerID, timestamp: Date)] = [] - func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) { - bridgedMessages.append((content, senderPeerID, timestamp)) - } - - // Geohash identity - struct IdentityUnavailable: Error {} - var deriveNostrIdentityError: Error? - static let dummyIdentity = NostrIdentity( - privateKey: Data(repeating: 0x11, count: 32), - publicKey: Data(repeating: 0x22, count: 32), - npub: "npub1mock", - createdAt: Date(timeIntervalSince1970: 0) - ) - - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { - if let deriveNostrIdentityError { throw deriveNostrIdentityError } - return Self.dummyIdentity - } -} - -// MARK: - Helpers - -/// Lets the coordinator's internal `Task { @MainActor … }` hops run. -@MainActor -private func drainMainActorTasks() async { - for _ in 0..<10 { await Task.yield() } -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatOutgoingCoordinator` against `MockChatOutgoingContext` with -/// no `ChatViewModel`. -struct ChatOutgoingCoordinatorContextTests { - - @Test @MainActor - func sendMessage_routesSlashCommandsAndDropsEmptyContent() async { - let context = MockChatOutgoingContext() - let coordinator = ChatOutgoingCoordinator(context: context) - - coordinator.sendMessage(" ") - coordinator.sendMessage("/who all") - await drainMainActorTasks() - - #expect(context.handledCommands == ["/who all"]) - #expect(context.appendedPublicMessages.isEmpty) - #expect(context.sentMeshMessages.isEmpty) - } - - @Test @MainActor - func sendMessage_inPrivateChat_reResolvesPeerBeforeSending() async { - let context = MockChatOutgoingContext() - let coordinator = ChatOutgoingCoordinator(context: context) - let shortPeer = PeerID(str: "1111111111111111") - let stablePeer = PeerID(str: String(repeating: "ab", count: 32)) - - // The selected peer is refreshed (short → stable) before sending. - context.selectedPrivateChatPeer = shortPeer - context.selectedPeerAfterUpdate = stablePeer - coordinator.sendMessage("hi there") - #expect(context.updatePrivateChatPeerIfNeededCount == 1) - #expect(context.sentPrivateMessages.map(\.peerID) == [stablePeer]) - #expect(context.sentPrivateMessages.map(\.content) == ["hi there"]) - - // If the refresh clears the selection, nothing is sent. - context.selectedPeerAfterUpdate = PeerID??.some(nil) - coordinator.sendMessage("dropped") - await drainMainActorTasks() - #expect(context.sentPrivateMessages.count == 1) - #expect(context.appendedPublicMessages.isEmpty) - } - - @Test @MainActor - func sendMessage_onMesh_appendsLocalEchoRecordsActivityAndSends() async { - let context = MockChatOutgoingContext() - let coordinator = ChatOutgoingCoordinator(context: context) - - coordinator.sendMessage(" hello @bob ") - await drainMainActorTasks() - - // Local echo uses the trimmed content, own nickname/peer ID, mentions. - #expect(context.appendedPublicMessages.count == 1) - let echo = context.appendedPublicMessages[0] - #expect(echo.message.content == "hello @bob") - #expect(echo.message.sender == "me") - #expect(echo.message.senderPeerID == context.myPeerID) - #expect(echo.message.mentions == ["bob"]) - #expect(echo.conversationID == .mesh) - #expect(context.recordedContentKeys.map(\.key) == ["key:hello @bob"]) - - // The mesh send carries the original (untrimmed) content and reuses - // the echo's message ID and timestamp; activity is stamped for "mesh". - #expect(context.recordedActivityKeys == ["mesh"]) - #expect(context.sentMeshMessages.count == 1) - let sent = context.sentMeshMessages[0] - #expect(sent.content == " hello @bob ") - #expect(sent.mentions == ["bob"]) - #expect(sent.messageID == echo.message.id) - #expect(sent.timestamp == echo.message.timestamp) - } - - @Test @MainActor - func sendMessage_onLocationChannel_sendsGeohashEventOrFailsWithSystemMessage() async { - let context = MockChatOutgoingContext() - let coordinator = ChatOutgoingCoordinator(context: context) - let channel = GeohashChannel(level: .city, geohash: "u4pruydq") - context.activeChannel = .location(channel) - context.isTeleported = true - - coordinator.sendMessage("hello geo") - // Geohash sends mine a NIP-13 nonce tag off-main before echoing and - // sending; await the send task, then drain the main queue. - await coordinator.geohashMiningTask?.value - await drainMainActorTasks() - - // Local echo carries the geohash sender suffix (#last-4-of-pubkey) and - // the signed event's ID; the send context targets the same channel. - #expect(context.appendedPublicMessages.count == 1) - let echo = context.appendedPublicMessages[0].message - #expect(context.appendedPublicMessages[0].conversationID == .geohash("u4pruydq")) - #expect(echo.sender == "me#2222") - #expect(context.recordedActivityKeys == ["geo:u4pruydq"]) - #expect(context.sentGeohashContexts.count == 1) - let geoContext = context.sentGeohashContexts[0] - #expect(geoContext.channel == channel) - #expect(geoContext.teleported) - #expect(geoContext.event.id == echo.id) - - // Identity derivation failure: system message, no echo, no send. - context.deriveNostrIdentityError = MockChatOutgoingContext.IdentityUnavailable() - coordinator.sendMessage("doomed") - await drainMainActorTasks() - #expect(context.systemMessages.count == 1) - #expect(context.appendedPublicMessages.count == 1) - #expect(context.sentGeohashContexts.count == 1) - } - - @Test @MainActor - func sendMessage_onLocationChannel_serializesRapidSendsInSendOrder() async { - let context = MockChatOutgoingContext() - let coordinator = ChatOutgoingCoordinator(context: context) - let channel = GeohashChannel(level: .city, geohash: "u4pruydq") - context.activeChannel = .location(channel) - - // Two back-to-back sends. The first carries much larger content, so - // its NIP-13 mining hashes a bigger event per attempt and runs longer - // than the second's. Without serialization the second (faster) task - // could finish first and reorder both the local timeline and the - // relayed events. The coordinator chains the mining tasks — each send - // awaits the previous send's task before it echoes and relays — so the - // visible order must always match the send order. - let first = "first " + String(repeating: "x", count: 4000) - let second = "second" - coordinator.sendMessage(first) - coordinator.sendMessage(second) - - // The stored task is the second send, which awaits the first. - await coordinator.geohashMiningTask?.value - await drainMainActorTasks() - - // Local echoes land in send order… - #expect(context.appendedPublicMessages.map(\.message.content) == [first, second]) - // …and so do the relayed events (IDs match the echoes 1:1, in order). - #expect(context.sentGeohashContexts.count == 2) - #expect(context.sentGeohashContexts.map(\.event.id) - == context.appendedPublicMessages.map(\.message.id)) - } -} diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift deleted file mode 100644 index 0bfcd306..00000000 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ /dev/null @@ -1,485 +0,0 @@ -// -// ChatPeerIdentityCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatPeerIdentityCoordinator` against a mock -// `ChatPeerIdentityContext` — proving the coordinator works without a -// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / -// `ChatPrivateConversationCoordinatorContextTests` exemplars. -// -// Scope note: favorites are injected through the context -// (`favoriteRelationship(forNoiseKey:)` / `addFavorite` / `removeFavorite`), -// so the favorite toggle and lookup flows are covered here alongside the -// session, migration, encryption-status, and nickname resolution flows. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatPeerIdentityContext` proving that -/// `ChatPeerIdentityCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { - // Conversation state - var privateChats: [PeerID: [BitchatMessage]] = [:] - var unreadPrivateMessages: Set = [] - var selectedPrivateChatPeer: PeerID? - var selectedPrivateChatFingerprint: String? - var myPeerID = PeerID(str: "0011223344556677") - var activeChannel: ChannelID = .mesh - private(set) var notifyUIChangedCount = 0 - private(set) var systemMessages: [String] = [] - - func notifyUIChanged() { notifyUIChangedCount += 1 } - func addSystemMessage(_ content: String) { systemMessages.append(content) } - - // Conversation store intents (mirror `ConversationStore` migrate - // semantics: dedup by ID, timestamp order, unread carried, old chat - // removed) while recording calls for assertions. - private(set) var migratedChats: [(from: PeerID, to: PeerID)] = [] - - func markPrivateChatRead(_ peerID: PeerID) { - unreadPrivateMessages.remove(peerID) - } - - func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) { - migratedChats.append((oldPeerID, newPeerID)) - guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return } - var destination = privateChats[newPeerID] ?? [] - for message in source where !destination.contains(where: { $0.id == message.id }) { - let index = destination.firstIndex(where: { $0.timestamp > message.timestamp }) ?? destination.count - destination.insert(message, at: index) - } - privateChats[newPeerID] = destination - privateChats.removeValue(forKey: oldPeerID) - if unreadPrivateMessages.remove(oldPeerID) != nil { - unreadPrivateMessages.insert(newPeerID) - } - } - - // Private chat session lifecycle - private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = [] - private(set) var syncedReadReceiptPeers: [PeerID] = [] - private(set) var begunChatSessions: [PeerID] = [] - private(set) var markedReadPeers: [PeerID] = [] - - @discardableResult - func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool { - consolidatedPeers.append((peerID, peerNickname)) - return false - } - - func syncReadReceiptsForSentMessages(for peerID: PeerID) { - syncedReadReceiptPeers.append(peerID) - } - - func beginPrivateChatSession(with peerID: PeerID) { - begunChatSessions.append(peerID) - } - - func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) } - - // Unified peer service - var connectedPeers: Set = [] - var peersByID: [PeerID: BitchatPeer] = [:] - var blockedPeers: Set = [] - var fingerprintsByPeerID: [PeerID: String] = [:] - var peerIDsByNickname: [String: PeerID] = [:] - var ephemeralPeerIDsByNoiseKey: [Data: PeerID] = [:] - private(set) var toggledFavoritePeers: [PeerID] = [] - - func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] } - func unifiedIsBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } - func unifiedToggleFavorite(_ peerID: PeerID) { toggledFavoritePeers.append(peerID) } - func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } - func unifiedPeerID(forNickname nickname: String) -> PeerID? { peerIDsByNickname[nickname] } - func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { ephemeralPeerIDsByNoiseKey[noiseKey] } - - // Mesh & Noise sessions - var nicknamesByPeerID: [PeerID: String] = [:] - var noiseSessionStates: [PeerID: LazyHandshakeState] = [:] - var establishedNoiseSessions: Set = [] - var activeNoiseSessions: Set = [] - var myNoiseFingerprint = "my-fingerprint" - private(set) var triggeredHandshakes: [PeerID] = [] - - func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] } - func meshPeerNicknames() -> [PeerID: String] { nicknamesByPeerID } - func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState { - noiseSessionStates[peerID] ?? .none - } - func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) } - func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { - establishedNoiseSessions.contains(peerID) - } - func hasNoiseSession(with peerID: PeerID) -> Bool { activeNoiseSessions.contains(peerID) } - func noiseIdentityFingerprint() -> String { myNoiseFingerprint } - - // Identity store (fingerprints & encryption status) - var verifiedFingerprintSet: Set = [] - var socialIdentitiesByFingerprint: [String: SocialIdentity] = [:] - private(set) var storedFingerprints: [(fingerprint: String, peerID: PeerID)] = [] - private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:] - private(set) var cachedEncryptionStatuses: [PeerID: EncryptionStatus] = [:] - private(set) var invalidatedEncryptionCachePeers: [PeerID?] = [] - - func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) { - storedFingerprints.append((fingerprint, peerID)) - fingerprintsByPeerID[peerID] = fingerprint - } - - func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? { - let fingerprint = fingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback - if let fingerprint { - fingerprintsByPeerID[newPeerID] = fingerprint - } - return fingerprint - } - - func isVerifiedFingerprint(_ fingerprint: String) -> Bool { - verifiedFingerprintSet.contains(fingerprint) - } - - func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { - encryptionStatuses[peerID] = status - } - - func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? { - cachedEncryptionStatuses[peerID] - } - - func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) { - cachedEncryptionStatuses[peerID] = status - } - - func invalidateStoredEncryptionCache(for peerID: PeerID?) { - invalidatedEncryptionCachePeers.append(peerID) - if let peerID { - cachedEncryptionStatuses.removeValue(forKey: peerID) - } else { - cachedEncryptionStatuses.removeAll() - } - } - - func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? { - socialIdentitiesByFingerprint[fingerprint] - } - - // Geohash & Nostr - var geoNicknames: [String: String] = [:] - var geohashPeople: [GeoPerson] = [] - private(set) var registeredNostrKeyMappings: [(pubkey: String, peerID: PeerID)] = [] - private(set) var nostrFavoriteNotifications: [(noisePublicKey: Data, isFavorite: Bool)] = [] - var bridgedNostrKeysByNoiseKey: [Data: String] = [:] - - func visibleGeohashPeople() -> [GeoPerson] { geohashPeople } - - func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { - registeredNostrKeyMappings.append((pubkey, peerID)) - } - - func bridgedNostrPublicKey(for noiseKey: Data) -> String? { - bridgedNostrKeysByNoiseKey[noiseKey] - } - - func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { - nostrFavoriteNotifications.append((noisePublicKey, isFavorite)) - } - - // Favorites - var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] - var favoriteRelationshipsByPeerID: [PeerID: FavoritesPersistenceService.FavoriteRelationship] = [:] - private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = [] - private(set) var removedFavorites: [Data] = [] - - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { - favoriteRelationshipsByNoiseKey[noiseKey] - } - - func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? { - favoriteRelationshipsByPeerID[peerID] - } - - func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) { - addedFavorites.append((noiseKey, nostrPublicKey, nickname)) - } - - func removeFavorite(noiseKey: Data) { - removedFavorites.append(noiseKey) - } -} - -// MARK: - Helpers - -private func makeFavoriteRelationship( - noiseKey: Data, - nostrPublicKey: String? = nil, - nickname: String = "alice", - isFavorite: Bool = false, - theyFavoritedUs: Bool = false -) -> FavoritesPersistenceService.FavoriteRelationship { - FavoritesPersistenceService.FavoriteRelationship( - peerNoisePublicKey: noiseKey, - peerNostrPublicKey: nostrPublicKey, - peerNickname: nickname, - isFavorite: isFavorite, - theyFavoritedUs: theyFavoritedUs, - favoritedAt: Date(timeIntervalSince1970: 0), - lastUpdated: Date(timeIntervalSince1970: 0) - ) -} - -@MainActor -private func makePrivateMessage( - id: String, - sender: String = "alice", - timestamp: Date = Date(), - senderPeerID: PeerID? = nil -) -> BitchatMessage { - BitchatMessage( - id: id, - sender: sender, - content: "hello", - timestamp: timestamp, - isRelay: false, - isPrivate: true, - recipientNickname: "me", - senderPeerID: senderPeerID - ) -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatPeerIdentityCoordinator` against -/// `MockChatPeerIdentityContext` with no `ChatViewModel`. -struct ChatPeerIdentityCoordinatorContextTests { - - @Test @MainActor - func startPrivateChat_runsFullSessionSetupSequence() async { - let context = MockChatPeerIdentityContext() - let coordinator = ChatPeerIdentityCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - context.nicknamesByPeerID[peerID] = "alice" - context.fingerprintsByPeerID[peerID] = "fp-alice" - - // Chatting with ourselves is a no-op. - coordinator.startPrivateChat(with: context.myPeerID) - #expect(context.begunChatSessions.isEmpty) - - coordinator.startPrivateChat(with: peerID) - - #expect(context.consolidatedPeers.map(\.peerID) == [peerID]) - #expect(context.consolidatedPeers.first?.peerNickname == "alice") - // No Noise session yet -> handshake triggered. - #expect(context.triggeredHandshakes == [peerID]) - #expect(context.syncedReadReceiptPeers == [peerID]) - #expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"]) - #expect(context.selectedPrivateChatFingerprint == "fp-alice") - #expect(context.begunChatSessions == [peerID]) - #expect(context.markedReadPeers == [peerID]) - - // Established session: no second handshake. - context.noiseSessionStates[peerID] = .established - coordinator.startPrivateChat(with: peerID) - #expect(context.triggeredHandshakes == [peerID]) - } - - @Test @MainActor - func startPrivateChat_blockedPeerOnlyGetsSystemMessage() async { - let context = MockChatPeerIdentityContext() - let coordinator = ChatPeerIdentityCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - context.blockedPeers = [peerID] - - coordinator.startPrivateChat(with: peerID) - - #expect(context.systemMessages.count == 1) - #expect(context.begunChatSessions.isEmpty) - #expect(context.consolidatedPeers.isEmpty) - #expect(context.markedReadPeers.isEmpty) - } - - @Test @MainActor - func updatePrivateChatPeerIfNeeded_migratesChatStateByFingerprint() async { - let context = MockChatPeerIdentityContext() - let coordinator = ChatPeerIdentityCoordinator(context: context) - let oldPeerID = PeerID(str: "1111111111111111") - let newPeerID = PeerID(str: "2222222222222222") - - context.selectedPrivateChatFingerprint = "fp" - context.selectedPrivateChatPeer = oldPeerID - context.connectedPeers = [newPeerID] - context.fingerprintsByPeerID[newPeerID] = "fp" - let earlier = makePrivateMessage(id: "m1", timestamp: Date(timeIntervalSince1970: 1)) - let later = makePrivateMessage(id: "m2", timestamp: Date(timeIntervalSince1970: 2)) - context.privateChats[oldPeerID] = [later] - context.privateChats[newPeerID] = [earlier, later] // duplicate id "m2" - context.unreadPrivateMessages = [oldPeerID] - - coordinator.updatePrivateChatPeerIfNeeded() - - // Old chat is merged into the new peer's chat, deduplicated by id and - // sorted by timestamp; old keys are dropped. - #expect(context.privateChats[oldPeerID] == nil) - #expect(context.privateChats[newPeerID]?.map(\.id) == ["m1", "m2"]) - #expect(context.selectedPrivateChatPeer == newPeerID) - // Unread moved to the new peer, then cleared for the now-open chat. - #expect(context.unreadPrivateMessages.isEmpty) - } - - @Test @MainActor - func getEncryptionStatus_computesVerifiedStatusAndCachesIt() async { - let context = MockChatPeerIdentityContext() - let coordinator = ChatPeerIdentityCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - - // Unknown peer with no fingerprint or session: no handshake yet. - #expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake) - #expect(context.cachedEncryptionStatuses[peerID] == .noHandshake) - - // Cache hit short-circuits recomputation. - context.noiseSessionStates[peerID] = .established - #expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake) - - // After invalidation, an established session with a verified - // fingerprint resolves (and re-caches) as verified. - coordinator.invalidateEncryptionCache(for: peerID) - context.fingerprintsByPeerID[peerID] = "fp" - context.verifiedFingerprintSet = ["fp"] - #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified) - #expect(context.cachedEncryptionStatuses[peerID] == .noiseVerified) - - // updateEncryptionStatus publishes to the store and invalidates the cache. - context.establishedNoiseSessions = [peerID] - coordinator.updateEncryptionStatus(for: peerID) - #expect(context.encryptionStatuses[peerID] == .noiseVerified) - #expect(context.cachedEncryptionStatuses[peerID] == nil) - } - - @Test @MainActor - func getEncryptionStatus_reflectsLiveSessionNotHistory() async { - let context = MockChatPeerIdentityContext() - let coordinator = ChatPeerIdentityCoordinator(context: context) - let peerID = PeerID(str: "8877665544332211") - - // A remembered (even verified) fingerprint must not conjure a lock on - // its own: the DM header and "end-to-end encrypted" caption follow - // this status, and claiming secured with no live session is the false - // security signal this mapping used to produce. - context.fingerprintsByPeerID[peerID] = "fp" - context.verifiedFingerprintSet = ["fp"] - - #expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake) - - coordinator.invalidateEncryptionCache(for: peerID) - context.noiseSessionStates[peerID] = .handshaking - #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseHandshaking) - - // A FAILED handshake is a red lock.slash, not yesterday's seal. - struct HandshakeError: Error {} - coordinator.invalidateEncryptionCache(for: peerID) - context.noiseSessionStates[peerID] = .failed(HandshakeError()) - #expect(coordinator.getEncryptionStatus(for: peerID) == .none) - - // Only a live established session upgrades to the verified seal. - coordinator.invalidateEncryptionCache(for: peerID) - context.noiseSessionStates[peerID] = .established - #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified) - } - - @Test @MainActor - func resolveNickname_walksMeshIdentityAndAnonFallbacks() async { - let context = MockChatPeerIdentityContext() - let coordinator = ChatPeerIdentityCoordinator(context: context) - let meshPeer = PeerID(str: "aabbccddeeff0011") - let identityPeer = PeerID(str: "1234567890abcdef") - let unknownPeer = PeerID(str: "feedfacefeedface") - - context.nicknamesByPeerID[meshPeer] = "alice" - #expect(coordinator.resolveNickname(for: meshPeer) == "alice") - - context.fingerprintsByPeerID[identityPeer] = "fp" - context.socialIdentitiesByFingerprint["fp"] = SocialIdentity( - fingerprint: "fp", - localPetname: "bob!", - claimedNickname: "bob", - trustLevel: .casual, - isFavorite: false, - isBlocked: false, - notes: nil - ) - #expect(coordinator.resolveNickname(for: identityPeer) == "bob!") - - // Local alias outranks a live mesh announce for the same peer. - context.nicknamesByPeerID[identityPeer] = "bob" - #expect(coordinator.resolveNickname(for: identityPeer) == "bob!") - - #expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed") - #expect(coordinator.getMyFingerprint() == "my-fingerprint") - } - - @Test @MainActor - func getPeerIDForNickname_inGeohashChannel_registersNostrMapping() async { - let context = MockChatPeerIdentityContext() - let coordinator = ChatPeerIdentityCoordinator(context: context) - let pubkey = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".lowercased() - context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) - context.geohashPeople = [GeoPerson(id: pubkey, displayName: "alice#6789", lastSeen: Date())] - context.geoNicknames[pubkey] = "alice" - - // Suffixed display-name match. - let bySuffix = coordinator.getPeerIDForNickname("alice#6789") - #expect(bySuffix == PeerID(nostr_: pubkey)) - // Base-nickname match via geoNicknames. - let byBase = coordinator.getPeerIDForNickname("ALICE") - #expect(byBase == PeerID(nostr_: pubkey)) - #expect(context.registeredNostrKeyMappings.count == 2) - #expect(context.registeredNostrKeyMappings.allSatisfy { $0.pubkey == pubkey }) - - // Mesh channel falls through to the unified peer service. - context.activeChannel = .mesh - let meshPeer = PeerID(str: "1122334455667788") - context.peerIDsByNickname["carol"] = meshPeer - #expect(coordinator.getPeerIDForNickname("carol") == meshPeer) - } - @Test @MainActor - func toggleFavorite_forNoiseKeyPeer_usesInjectedFavoritesStore() async { - let context = MockChatPeerIdentityContext() - let coordinator = ChatPeerIdentityCoordinator(context: context) - let noiseKey = Data(repeating: 0xAB, count: 32) - let peerID = PeerID(hexData: noiseKey) - - // No prior relationship: adds a favorite, no Nostr notification yet. - coordinator.toggleFavorite(peerID: peerID) - #expect(context.addedFavorites.count == 1) - #expect(context.addedFavorites.first?.noiseKey == noiseKey) - #expect(context.addedFavorites.first?.nickname == "Unknown") - #expect(context.nostrFavoriteNotifications.isEmpty) - #expect(coordinator.isFavorite(peerID: peerID) == false) - - // They already favorite us: adding sends the mutual notification. - context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( - noiseKey: noiseKey, - theyFavoritedUs: true - ) - coordinator.toggleFavorite(peerID: peerID) - #expect(context.addedFavorites.count == 2) - #expect(context.addedFavorites.last?.nickname == "alice") - #expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true]) - - // Existing favorite: toggling removes it and notifies the unfavorite. - context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( - noiseKey: noiseKey, - isFavorite: true - ) - #expect(coordinator.isFavorite(peerID: peerID) == true) - coordinator.toggleFavorite(peerID: peerID) - #expect(context.removedFavorites == [noiseKey]) - #expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true, false]) - } - -} diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift deleted file mode 100644 index d7882f0e..00000000 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ /dev/null @@ -1,282 +0,0 @@ -// -// ChatPeerListCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatPeerListCoordinator` against a mock `ChatPeerListContext` — -// proving the coordinator works without a `ChatViewModel`, following the -// `ChatDeliveryCoordinatorContextTests` / -// `ChatTransportEventCoordinatorContextTests` exemplars. -// -// Scope note: the network-availability notification now posts through the -// injected `ChatPeerListContext` (`notifyNetworkAvailable(peerCount:)`), so -// its gating is covered here; the wall-clock timer-driven reset flows are -// covered by integration-level tests. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatPeerListContext` proving that -/// `ChatPeerListCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockChatPeerListContext: ChatPeerListContext { - // Connection & chat state - var isConnected = false - var privateChats: [PeerID: [BitchatMessage]] = [:] - - func privateMessages(for peerID: PeerID) -> [BitchatMessage] { - privateChats[peerID] ?? [] - } - var unreadPrivateMessages: Set = [] - var hasTrackedPrivateChatSelection = false - private(set) var updatePrivateChatPeerIfNeededCount = 0 - private(set) var cleanupOldReadReceiptsCount = 0 - - func markPrivateChatRead(_ peerID: PeerID) { - unreadPrivateMessages.remove(peerID) - } - - func updatePrivateChatPeerIfNeeded() { - updatePrivateChatPeerIfNeededCount += 1 - } - - func cleanupOldReadReceipts() { - cleanupOldReadReceiptsCount += 1 - } - - // Peers & sessions - var unifiedPeers: [BitchatPeer] = [] - var connectedMeshPeers: Set = [] - var reachableMeshPeers: Set = [] - var activeMeshPeerCountValue = 0 - private(set) var registeredEphemeralSessions: [PeerID] = [] - private(set) var updateEncryptionStatusForPeersCount = 0 - - func isPeerConnected(_ peerID: PeerID) -> Bool { connectedMeshPeers.contains(peerID) } - func isPeerReachable(_ peerID: PeerID) -> Bool { reachableMeshPeers.contains(peerID) } - func activeMeshPeerCount() -> Int { activeMeshPeerCountValue } - func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) } - func updateEncryptionStatusForPeers() { updateEncryptionStatusForPeersCount += 1 } - - // Notifications - private(set) var networkAvailableNotifications: [Int] = [] - - func notifyNetworkAvailable(peerCount: Int) { - networkAvailableNotifications.append(peerCount) - } - - // Sightings - private(set) var recordedSightings: [[PeerID]] = [] - - func recordMeshSightings(peerIDs: [PeerID]) { - recordedSightings.append(peerIDs) - } -} - -// MARK: - Helpers - -/// Lets the coordinator's internal `Task { @MainActor … }` hops run. -@MainActor -private func drainMainActorTasks() async { - for _ in 0..<10 { await Task.yield() } -} - -private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMessage { - BitchatMessage( - id: id, - sender: "alice", - content: "hello", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "me", - senderPeerID: senderPeerID - ) -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatPeerListCoordinator` against `MockChatPeerListContext` with -/// no `ChatViewModel`. -struct ChatPeerListCoordinatorContextTests { - - @Test @MainActor - func synchronousPeerListUpdate_appliesBeforeReturning() { - let context = MockChatPeerListContext() - let coordinator = ChatPeerListCoordinator(context: context) - let peerID = PeerID(str: "0011223344556677") - - coordinator.didUpdatePeerListSynchronously([peerID]) - - #expect(context.isConnected) - #expect(context.registeredEphemeralSessions == [peerID]) - #expect(context.updateEncryptionStatusForPeersCount == 1) - #expect(context.cleanupOldReadReceiptsCount == 1) - } - - @Test @MainActor - func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async { - let context = MockChatPeerListContext() - let coordinator = ChatPeerListCoordinator(context: context) - let peerA = PeerID(str: "0011223344556677") - let peerB = PeerID(str: "8899aabbccddeeff") - context.isConnected = true - - // Empty list: disconnected, read-receipt hygiene still runs, no sessions. - coordinator.didUpdatePeerList([]) - await drainMainActorTasks() - #expect(!context.isConnected) - #expect(context.cleanupOldReadReceiptsCount == 1) - #expect(context.registeredEphemeralSessions.isEmpty) - #expect(context.updateEncryptionStatusForPeersCount == 1) - - // Non-empty list: connected, every peer gets an ephemeral session. - coordinator.didUpdatePeerList([peerA, peerB]) - await drainMainActorTasks() - #expect(context.isConnected) - #expect(context.registeredEphemeralSessions == [peerA, peerB]) - #expect(context.updateEncryptionStatusForPeersCount == 2) - #expect(context.cleanupOldReadReceiptsCount == 2) - } - - @Test @MainActor - func didUpdatePeerList_refreshesPrivateChatPeerOnlyWhenSelectionIsTracked() async { - let context = MockChatPeerListContext() - let coordinator = ChatPeerListCoordinator(context: context) - let peerID = PeerID(str: "0011223344556677") - - coordinator.didUpdatePeerList([peerID]) - await drainMainActorTasks() - #expect(context.updatePrivateChatPeerIfNeededCount == 0) - - context.hasTrackedPrivateChatSelection = true - coordinator.didUpdatePeerList([peerID]) - await drainMainActorTasks() - #expect(context.updatePrivateChatPeerIfNeededCount == 1) - } - - @Test @MainActor - func didUpdatePeerList_removesStaleUnreadPeerIDsButKeepsBackedConversations() async { - let context = MockChatPeerListContext() - let coordinator = ChatPeerListCoordinator(context: context) - - let currentPeer = PeerID(str: "0011223344556677") - let staleShortPeer = PeerID(str: "8899aabbccddeeff") - let geoDMWithMessages = PeerID(str: "nostr_" + String(repeating: "ab", count: 8)) - let geoDMWithoutMessages = PeerID(str: "nostr_" + String(repeating: "cd", count: 8)) - let noiseKeyWithMessages = PeerID(str: String(repeating: "ef", count: 32)) - - context.unifiedPeers = [ - BitchatPeer( - peerID: currentPeer, - noisePublicKey: Data(repeating: 0x01, count: 32), - nickname: "alice" - ) - ] - context.unreadPrivateMessages = [ - currentPeer, - staleShortPeer, - geoDMWithMessages, - geoDMWithoutMessages, - noiseKeyWithMessages - ] - context.privateChats = [ - geoDMWithMessages: [makeMessage(id: "geo-1")], - noiseKeyWithMessages: [makeMessage(id: "noise-1")] - ] - - coordinator.didUpdatePeerList([currentPeer]) - await drainMainActorTasks() - - // Stale IDs without a backing conversation are dropped; geo-DM and - // Noise-key IDs with stored messages survive, as does the live peer. - #expect(context.unreadPrivateMessages == [currentPeer, geoDMWithMessages, noiseKeyWithMessages]) - #expect(context.cleanupOldReadReceiptsCount == 1) - } - - @Test @MainActor - func didUpdatePeerList_notifiesNetworkAvailableOncePerCooldownForNewMeshPeers() async { - let context = MockChatPeerListContext() - let coordinator = ChatPeerListCoordinator(context: context) - let peerA = PeerID(str: "0011223344556677") - let peerB = PeerID(str: "8899aabbccddeeff") - context.connectedMeshPeers = [peerA, peerB] - - // First sighting of a mesh-active peer notifies with the mesh peer count. - coordinator.didUpdatePeerList([peerA]) - await drainMainActorTasks() - #expect(context.networkAvailableNotifications == [1]) - - // The same peer again is not new — no repeat notification. - coordinator.didUpdatePeerList([peerA]) - await drainMainActorTasks() - #expect(context.networkAvailableNotifications == [1]) - - // A genuinely new peer inside the cooldown window stays silent too. - coordinator.didUpdatePeerList([peerA, peerB]) - await drainMainActorTasks() - #expect(context.networkAvailableNotifications == [1]) - } - - @Test @MainActor - func didUpdatePeerList_peerJoiningExistingMeshDoesNotNotify() async { - // Cooldown zero so this proves the empty-transition gate alone — a - // new peer joining while already meshed must stay silent even with - // the cooldown long expired (the sitting-idle re-notify bug). - let context = MockChatPeerListContext() - let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0) - let peerA = PeerID(str: "0011223344556677") - let peerB = PeerID(str: "8899aabbccddeeff") - context.connectedMeshPeers = [peerA, peerB] - - coordinator.didUpdatePeerList([peerA]) - await drainMainActorTasks() - #expect(context.networkAvailableNotifications == [1]) - - // peerB arrives while peerA is still connected: no notification. - coordinator.didUpdatePeerList([peerA, peerB]) - await drainMainActorTasks() - #expect(context.networkAvailableNotifications == [1]) - - // Repeat events while idle keep staying silent. - coordinator.didUpdatePeerList([peerA, peerB]) - await drainMainActorTasks() - #expect(context.networkAvailableNotifications == [1]) - } - - @Test @MainActor - func didUpdatePeerList_briefMeshFlapDoesNotRenotify() async { - let context = MockChatPeerListContext() - let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0) - let peerA = PeerID(str: "0011223344556677") - context.connectedMeshPeers = [peerA] - - coordinator.didUpdatePeerList([peerA]) - await drainMainActorTasks() - #expect(context.networkAvailableNotifications == [1]) - - // Link flap: empty list, then the peer returns before the 30s empty - // confirmation fires — silent. - coordinator.didUpdatePeerList([]) - await drainMainActorTasks() - coordinator.didUpdatePeerList([peerA]) - await drainMainActorTasks() - #expect(context.networkAvailableNotifications == [1]) - } - - @Test @MainActor - func didUpdatePeerList_meshInactivePeersNeverNotify() async { - let context = MockChatPeerListContext() - let coordinator = ChatPeerListCoordinator(context: context) - let peerA = PeerID(str: "0011223344556677") - - // Peer present but neither connected nor reachable: no notification. - coordinator.didUpdatePeerList([peerA]) - await drainMainActorTasks() - #expect(context.networkAvailableNotifications.isEmpty) - } -} diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift deleted file mode 100644 index 54cb341e..00000000 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ /dev/null @@ -1,953 +0,0 @@ -// -// ChatPrivateConversationCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatPrivateConversationCoordinator` against a mock -// `ChatPrivateConversationContext` — proving the coordinator works without a -// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` exemplar. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatPrivateConversationContext` proving that -/// `ChatPrivateConversationCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockChatPrivateConversationContext: ChatPrivateConversationContext { - // Conversation state - var privateChats: [PeerID: [BitchatMessage]] = [:] - var sentReadReceipts: Set = [] - var sentGeoDeliveryAcks: Set = [] - var unreadPrivateMessages: Set = [] - var selectedPrivateChatPeer: PeerID? - var nickname = "me" - var activeChannel: ChannelID = .mesh - var nostrKeyMapping: [PeerID: String] = [:] - private(set) var notifyUIChangedCount = 0 - - @discardableResult - func markReadReceiptSent(_ messageID: String) -> Bool { - sentReadReceipts.insert(messageID).inserted - } - - @discardableResult - func markGeoDeliveryAckSent(_ messageID: String) -> Bool { - sentGeoDeliveryAcks.insert(messageID).inserted - } - - func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) { - guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return } - selectedPrivateChatPeer = newPeerID - } - - func notifyUIChanged() { - notifyUIChangedCount += 1 - } - - // Conversation store intents (mirror `ConversationStore` semantics: - // ordered insert, dedup by ID, no-downgrade status, unread carry on - // migrate) while recording calls for assertions. - private(set) var upsertedMessages: [(messageID: String, peerID: PeerID)] = [] - private(set) var migratedChats: [(from: PeerID, to: PeerID)] = [] - - @discardableResult - func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { - var chat = privateChats[peerID] ?? [] - guard !chat.contains(where: { $0.id == message.id }) else { - privateChats[peerID] = chat - return false - } - let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count - chat.insert(message, at: index) - privateChats[peerID] = chat - return true - } - - func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { - upsertedMessages.append((message.id, peerID)) - if var chat = privateChats[peerID], - let index = chat.firstIndex(where: { $0.id == message.id }) { - chat[index] = message - privateChats[peerID] = chat - } else { - appendPrivateMessage(message, to: peerID) - } - } - - @discardableResult - func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool { - guard let chat = privateChats[peerID], - let index = chat.firstIndex(where: { $0.id == messageID }) else { - return false - } - if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) { - return false - } - chat[index].deliveryStatus = status - privateChats[peerID] = chat - return true - } - - func markPrivateChatUnread(_ peerID: PeerID) { - unreadPrivateMessages.insert(peerID) - } - - func markPrivateChatRead(_ peerID: PeerID) { - unreadPrivateMessages.remove(peerID) - } - - func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) { - migratedChats.append((oldPeerID, newPeerID)) - guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return } - for message in source { - appendPrivateMessage(message, to: newPeerID) - } - if privateChats[newPeerID] == nil { - privateChats[newPeerID] = [] - } - let wasUnread = unreadPrivateMessages.contains(oldPeerID) - privateChats.removeValue(forKey: oldPeerID) - unreadPrivateMessages.remove(oldPeerID) - if wasUnread { - unreadPrivateMessages.insert(newPeerID) - } - } - - func privateChatsContainMessage(withID messageID: String) -> Bool { - privateChats.values.contains { chat in - chat.contains { $0.id == messageID } - } - } - - func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool { - privateChats[peerID]?.contains { $0.id == messageID } == true - } - - // Peers & identity - var myPeerID = PeerID(str: "0011223344556677") - var nicknamesByPeerID: [PeerID: String] = [:] - var connectedPeers: Set = [] - var reachablePeers: Set = [] - var blockedPeers: Set = [] - var noiseKeysByPeerID: [PeerID: Data] = [:] - var ephemeralPeerIDsByNoiseKey: [Data: PeerID] = [:] - var peerIDsByNickname: [String: PeerID] = [:] - var fingerprintsByPeerID: [PeerID: String] = [:] - private(set) var clearedFingerprints: [PeerID] = [] - - func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] } - func isPeerConnected(_ peerID: PeerID) -> Bool { connectedPeers.contains(peerID) } - func isPeerReachable(_ peerID: PeerID) -> Bool { reachablePeers.contains(peerID) } - func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } - func noisePublicKey(for peerID: PeerID) -> Data? { noiseKeysByPeerID[peerID] } - func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { ephemeralPeerIDsByNoiseKey[noiseKey] } - func getPeerIDForNickname(_ nickname: String) -> PeerID? { peerIDsByNickname[nickname] } - func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } - func storedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } - - func clearStoredFingerprint(for peerID: PeerID) { - fingerprintsByPeerID.removeValue(forKey: peerID) - clearedFingerprints.append(peerID) - } - - // Nostr identity - var blockedNostrPubkeys: Set = [] - var displayNamesByPubkey: [String: String] = [:] - - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { - blockedNostrPubkeys.contains(pubkeyHexLowercased) - } - - func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { - displayNamesByPubkey[pubkeyHex] ?? "anon" - } - - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity } - func currentNostrIdentity() -> NostrIdentity? { Self.dummyIdentity } - - // Routing & acknowledgements - private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, messageID: String)] = [] - private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = [] - private(set) var meshReadReceipts: [(messageID: String, peerID: PeerID)] = [] - private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = [] - private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] - private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] - var queuedMessageIDsByPeerID: [PeerID: Set] = [:] - private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = [] - private(set) var deliveredMessageIDs: [String] = [] - - func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { - routedPrivateMessages.append((content, peerID, messageID)) - } - - var routeReadReceiptResult = true - func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool { - routedReadReceipts.append((receipt.originalMessageID, peerID)) - return routeReadReceiptResult - } - - @discardableResult - func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool { - deliveryAckAttempts.append((messageID, peerIDs)) - var cleared = false - for peerID in Set(peerIDs) { - guard var queued = queuedMessageIDsByPeerID[peerID], - queued.remove(messageID) != nil else { continue } - queuedMessageIDsByPeerID[peerID] = queued.isEmpty ? nil : queued - cleared = true - } - if cleared { - deliveredMessageIDs.append(messageID) - } - return cleared - } - - func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { - meshReadReceipts.append((receipt.originalMessageID, peerID)) - } - - func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { - geoPrivateMessages.append((content, recipientHex, messageID)) - } - - func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - geoDeliveryAcks.append((messageID, recipientHex)) - } - - func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - geoReadReceipts.append((messageID, recipientHex)) - } - - // Favorites & notifications - var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] - private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = [] - private(set) var privateMessageNotifications: [(senderName: String, message: String, peerID: PeerID)] = [] - - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { - favoriteRelationshipsByNoiseKey[noiseKey] - } - - func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? { - favoriteRelationshipsByNoiseKey.first(where: { PeerID(publicKey: $0.key) == peerID })?.value - } - - func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { - peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey)) - } - - func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) { - privateMessageNotifications.append((senderName, message, peerID)) - } - - // System messages - private(set) var meshOnlySystemMessages: [String] = [] - - func addMeshOnlySystemMessage(_ content: String) { - meshOnlySystemMessages.append(content) - } - - private(set) var privateSystemMessages: [(content: String, peerID: PeerID)] = [] - - func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { - privateSystemMessages.append((content, peerID)) - } - - static let dummyIdentity = NostrIdentity( - privateKey: Data(repeating: 0x11, count: 32), - publicKey: Data(repeating: 0x22, count: 32), - npub: "npub1mock", - createdAt: Date(timeIntervalSince1970: 0) - ) -} - -// MARK: - Helpers - -@MainActor -private func makeIncomingMessage( - id: String, - sender: String = "alice", - content: String = "hello", - timestamp: Date = Date(), - senderPeerID: PeerID? = nil, - recipientNickname: String? = "me" -) -> BitchatMessage { - BitchatMessage( - id: id, - sender: sender, - content: content, - timestamp: timestamp, - isRelay: false, - isPrivate: true, - recipientNickname: recipientNickname, - senderPeerID: senderPeerID, - deliveryStatus: .delivered(to: "me", at: timestamp) - ) -} - -private func isDelivered(_ status: DeliveryStatus?, to expected: String) -> Bool { - if case .delivered(let to, _) = status { return to == expected } - return false -} - -private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool { - if case .read(let by, _) = status { return by == expected } - return false -} - -private func makeFavoriteRelationship( - noiseKey: Data, - nostrPublicKey: String? = nil, - nickname: String = "alice", - isFavorite: Bool = false, - theyFavoritedUs: Bool = false -) -> FavoritesPersistenceService.FavoriteRelationship { - FavoritesPersistenceService.FavoriteRelationship( - peerNoisePublicKey: noiseKey, - peerNostrPublicKey: nostrPublicKey, - peerNickname: nickname, - isFavorite: isFavorite, - theyFavoritedUs: theyFavoritedUs, - favoritedAt: Date(timeIntervalSince1970: 0), - lastUpdated: Date(timeIntervalSince1970: 0) - ) -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatPrivateConversationCoordinator` against -/// `MockChatPrivateConversationContext` with no `ChatViewModel`. Scoped to the -/// pure-state and ack flows plus — now that notifications and favorites are -/// injected through the context (`notifyPrivateMessage`, -/// `favoriteRelationship(forNoiseKey:)`, `updatePeerFavoritedUs`) — the -/// notification and favorite-transition flows that previously required the -/// live singletons. -struct ChatPrivateConversationCoordinatorContextTests { - - @Test @MainActor - func addMessageToPrivateChats_upsertsByIdAndSanitizes() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let peerID = PeerID(str: "0102030405060708") - - let original = makeIncomingMessage(id: "m1", content: "first") - coordinator.addMessageToPrivateChatsIfNeeded(original, targetPeerID: peerID) - #expect(context.privateChats[peerID]?.map(\.id) == ["m1"]) - - // Same id again must replace in place, not append a duplicate. - let updated = makeIncomingMessage(id: "m1", content: "edited") - coordinator.addMessageToPrivateChatsIfNeeded(updated, targetPeerID: peerID) - #expect(context.privateChats[peerID]?.count == 1) - #expect(context.privateChats[peerID]?.first?.content == "edited") - - // A different id appends. - coordinator.addMessageToPrivateChatsIfNeeded(makeIncomingMessage(id: "m2"), targetPeerID: peerID) - #expect(context.privateChats[peerID]?.map(\.id) == ["m1", "m2"]) - // Every add went through the store's upsert intent. - #expect(context.upsertedMessages.map(\.peerID) == [peerID, peerID, peerID]) - #expect(context.upsertedMessages.map(\.messageID) == ["m1", "m1", "m2"]) - - #expect(coordinator.isDuplicateMessage("m1", targetPeerID: peerID)) - #expect(!coordinator.isDuplicateMessage("m3", targetPeerID: peerID)) - } - - @Test @MainActor - func geoDeliveredAndReadAcks_updateStatusAndNotify() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let convKey = PeerID(str: "nostr_abcdef12") - let senderPubkey = "feedface00112233" - context.displayNamesByPubkey[senderPubkey] = "alice#1234" - context.privateChats[convKey] = [ - makeIncomingMessage(id: "mine-1", sender: "me"), - makeIncomingMessage(id: "mine-2", sender: "me") - ] - - coordinator.handleDelivered( - NoisePayload(type: .delivered, data: Data("mine-1".utf8)), - senderPubkey: senderPubkey, - convKey: convKey - ) - #expect(isDelivered(context.privateChats[convKey]?.first?.deliveryStatus, to: "alice#1234")) - #expect(context.notifyUIChangedCount == 1) - - coordinator.handleReadReceipt( - NoisePayload(type: .readReceipt, data: Data("mine-2".utf8)), - senderPubkey: senderPubkey, - convKey: convKey - ) - #expect(isRead(context.privateChats[convKey]?.last?.deliveryStatus, by: "alice#1234")) - #expect(context.notifyUIChangedCount == 2) - - // Unknown message id: no state change, no UI notification. - coordinator.handleDelivered( - NoisePayload(type: .delivered, data: Data("missing".utf8)), - senderPubkey: senderPubkey, - convKey: convKey - ) - #expect(context.notifyUIChangedCount == 2) - #expect(context.deliveryAckAttempts.isEmpty) - #expect(context.deliveredMessageIDs.isEmpty) - } - - @Test @MainActor - func accountDMAcks_findShortIDMessageAndHandConversationToStable() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xD5, count: 32) - let stablePeerID = PeerID(hexData: noiseKey) - let shortPeerID = stablePeerID.toShort() - let senderPubkey = "feedface00112233" - context.displayNamesByPubkey[senderPubkey] = "alice" - context.selectedPrivateChatPeer = shortPeerID - context.privateChats[shortPeerID] = [ - makeIncomingMessage(id: "mine-short-1", sender: "me"), - makeIncomingMessage(id: "mine-short-2", sender: "me") - ] - context.queuedMessageIDsByPeerID[shortPeerID] = ["mine-short-1", "mine-short-2"] - - coordinator.handleDelivered( - NoisePayload(type: .delivered, data: Data("mine-short-1".utf8)), - senderPubkey: senderPubkey, - convKey: stablePeerID - ) - coordinator.handleReadReceipt( - NoisePayload(type: .readReceipt, data: Data("mine-short-2".utf8)), - senderPubkey: senderPubkey, - convKey: stablePeerID - ) - - #expect(context.privateChats[shortPeerID] == nil) - #expect(isDelivered(context.privateChats[stablePeerID]?.first?.deliveryStatus, to: "alice")) - #expect(isRead(context.privateChats[stablePeerID]?.last?.deliveryStatus, by: "alice")) - #expect(context.selectedPrivateChatPeer == stablePeerID) - #expect(context.deliveredMessageIDs == ["mine-short-1", "mine-short-2"]) - #expect(context.notifyUIChangedCount == 2) - } - - @Test @MainActor - func accountDMAck_doesNotTouchAnUnrelatedConversationWithTheSameMessageID() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let stablePeerID = PeerID(hexData: Data(repeating: 0xC1, count: 32)) - let unrelatedPeerID = PeerID(str: "1111222233334444") - context.privateChats[unrelatedPeerID] = [ - makeIncomingMessage(id: "collision", sender: "me") - ] - context.queuedMessageIDsByPeerID[unrelatedPeerID] = ["collision"] - - coordinator.handleDelivered( - NoisePayload(type: .delivered, data: Data("collision".utf8)), - senderPubkey: "feedface00112233", - convKey: stablePeerID - ) - - #expect(isDelivered(context.privateChats[unrelatedPeerID]?.first?.deliveryStatus, to: "me")) - #expect(context.deliveredMessageIDs.isEmpty) - #expect(context.queuedMessageIDsByPeerID[unrelatedPeerID] == ["collision"]) - #expect(context.notifyUIChangedCount == 0) - } - - @Test @MainActor - func geoPrivateMessage_sendsDeliveryAckOnceAndDeduplicates() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let convKey = PeerID(str: "nostr_abcdef12") - let senderPubkey = "feedface00112233" - context.displayNamesByPubkey[senderPubkey] = "bob#5678" - let payloadData = PrivateMessagePacket(messageID: "geo-1", content: "hi there").encode()! - let payload = NoisePayload(type: .privateMessage, data: payloadData) - // Old timestamp: not "recent", so no unread marking (and no notification). - let oldTimestamp = Date().addingTimeInterval(-120) - - coordinator.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: convKey, - id: MockChatPrivateConversationContext.dummyIdentity, - messageTimestamp: oldTimestamp - ) - - #expect(context.geoDeliveryAcks.map(\.messageID) == ["geo-1"]) - #expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey) - #expect(context.sentGeoDeliveryAcks == ["geo-1"]) - #expect(context.privateChats[convKey]?.map(\.id) == ["geo-1"]) - #expect(context.privateChats[convKey]?.first?.sender == "bob#5678") - #expect(context.unreadPrivateMessages.isEmpty) - #expect(context.notifyUIChangedCount == 1) - - // Redelivery: ack is deduplicated and the message is not appended twice. - coordinator.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: convKey, - id: MockChatPrivateConversationContext.dummyIdentity, - messageTimestamp: oldTimestamp - ) - #expect(context.geoDeliveryAcks.count == 1) - #expect(context.privateChats[convKey]?.count == 1) - } - - @Test @MainActor - func accountDM_handsOpenShortIDConversationToStableWhenOffline() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xA7, count: 32) - let stablePeerID = PeerID(hexData: noiseKey) - let shortPeerID = stablePeerID.toShort() - let senderPubkey = "feedface00112233" - let now = Date() - context.displayNamesByPubkey[senderPubkey] = "bob" - context.selectedPrivateChatPeer = shortPeerID - context.privateChats[shortPeerID] = [ - makeIncomingMessage( - id: "short-history", - sender: "me", - timestamp: now.addingTimeInterval(-30), - senderPeerID: context.myPeerID - ), - makeIncomingMessage( - id: "short-inbound", - sender: "bob", - timestamp: now.addingTimeInterval(-25), - senderPeerID: shortPeerID - ) - ] - context.privateChats[stablePeerID] = [ - makeIncomingMessage( - id: "stable-history", - sender: "bob", - timestamp: now.addingTimeInterval(-20), - senderPeerID: stablePeerID - ) - ] - let payloadData = PrivateMessagePacket(messageID: "account-live-1", content: "live reply").encode()! - - coordinator.handlePrivateMessage( - NoisePayload(type: .privateMessage, data: payloadData), - senderPubkey: senderPubkey, - convKey: stablePeerID, - id: MockChatPrivateConversationContext.dummyIdentity, - messageTimestamp: now - ) - - #expect(context.privateChats[shortPeerID] == nil) - #expect(context.privateChats[stablePeerID]?.map(\.id) == [ - "short-history", - "short-inbound", - "stable-history", - "account-live-1" - ]) - #expect(context.privateChats[stablePeerID]?[1].senderPeerID == stablePeerID) - #expect(context.privateChats[stablePeerID]?.last?.senderPeerID == stablePeerID) - #expect(context.migratedChats.contains(where: { $0.from == shortPeerID && $0.to == stablePeerID })) - #expect(context.selectedPrivateChatPeer == stablePeerID) - #expect(context.geoReadReceipts.map(\.messageID) == ["account-live-1"]) - #expect(context.unreadPrivateMessages.isEmpty) - #expect(context.notifyUIChangedCount == 1) - } - - @Test @MainActor - func accountDM_aliasMergePreservesTheCanonicalDestinationCopy() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xA8, count: 32) - let stablePeerID = PeerID(hexData: noiseKey) - let shortPeerID = stablePeerID.toShort() - let senderPubkey = "feedface00112233" - let olderSource = makeIncomingMessage( - id: "duplicate-history", - sender: "bob", - content: "older source copy", - senderPeerID: shortPeerID - ) - olderSource.deliveryStatus = .delivered(to: "me", at: Date(timeIntervalSince1970: 10)) - let newerDestination = makeIncomingMessage( - id: "duplicate-history", - sender: "bob", - content: "newer destination copy", - senderPeerID: shortPeerID - ) - newerDestination.deliveryStatus = .read(by: "me", at: Date(timeIntervalSince1970: 20)) - context.privateChats[shortPeerID] = [olderSource] - context.privateChats[stablePeerID] = [newerDestination] - context.displayNamesByPubkey[senderPubkey] = "bob" - let payloadData = PrivateMessagePacket(messageID: "after-merge", content: "new reply").encode()! - - coordinator.handlePrivateMessage( - NoisePayload(type: .privateMessage, data: payloadData), - senderPubkey: senderPubkey, - convKey: stablePeerID, - id: MockChatPrivateConversationContext.dummyIdentity, - messageTimestamp: Date() - ) - - let merged = context.privateChats[stablePeerID]?.first - #expect(context.privateChats[shortPeerID] == nil) - #expect(merged?.content == "newer destination copy") - #expect(isRead(merged?.deliveryStatus, by: "me")) - #expect(merged?.senderPeerID == stablePeerID) - } - - @Test @MainActor - func accountDM_keepsTheConnectedShortIDConversationCanonical() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xB8, count: 32) - let stablePeerID = PeerID(hexData: noiseKey) - let shortPeerID = stablePeerID.toShort() - let senderPubkey = "feedface00112233" - context.connectedPeers = [shortPeerID] - context.selectedPrivateChatPeer = stablePeerID - context.displayNamesByPubkey[senderPubkey] = "bob" - context.privateChats[stablePeerID] = [ - makeIncomingMessage(id: "stable-history", senderPeerID: stablePeerID) - ] - let payloadData = PrivateMessagePacket(messageID: "connected-live-1", content: "still nearby").encode()! - - coordinator.handlePrivateMessage( - NoisePayload(type: .privateMessage, data: payloadData), - senderPubkey: senderPubkey, - convKey: stablePeerID, - id: MockChatPrivateConversationContext.dummyIdentity, - messageTimestamp: Date() - ) - - #expect(context.privateChats[stablePeerID] == nil) - #expect(context.privateChats[shortPeerID]?.map(\.id) == ["stable-history", "connected-live-1"]) - #expect(context.privateChats[shortPeerID]?.first?.senderPeerID == shortPeerID) - #expect(context.privateChats[shortPeerID]?.last?.senderPeerID == shortPeerID) - #expect(context.selectedPrivateChatPeer == shortPeerID) - #expect(context.geoReadReceipts.map(\.messageID) == ["connected-live-1"]) - } - - @Test @MainActor - func accountDM_duplicateDowngradeAckStillClearsTheRetainedOutbox() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let stablePeerID = PeerID(hexData: Data(repeating: 0xE2, count: 32)) - let message = makeIncomingMessage(id: "already-read", sender: "me") - message.deliveryStatus = .read(by: "bob", at: Date()) - context.privateChats[stablePeerID] = [message] - context.queuedMessageIDsByPeerID[stablePeerID] = ["already-read"] - - coordinator.handleDelivered( - NoisePayload(type: .delivered, data: Data("already-read".utf8)), - senderPubkey: "feedface00112233", - convKey: stablePeerID - ) - - #expect(isRead(context.privateChats[stablePeerID]?.first?.deliveryStatus, by: "bob")) - #expect(context.deliveredMessageIDs == ["already-read"]) - #expect(context.notifyUIChangedCount == 0) - } - - @Test @MainActor - func accountDMAck_clearsRetainedMessageAfterConversationWasRemoved() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let stablePeerID = PeerID(hexData: Data(repeating: 0xE3, count: 32)) - let shortPeerID = stablePeerID.toShort() - context.queuedMessageIDsByPeerID[shortPeerID] = ["cleared-bubble"] - - coordinator.handleDelivered( - NoisePayload(type: .delivered, data: Data("cleared-bubble".utf8)), - senderPubkey: "feedface00112233", - convKey: stablePeerID - ) - - #expect(context.privateChats.isEmpty) - #expect(context.queuedMessageIDsByPeerID[shortPeerID] == nil) - #expect(context.deliveredMessageIDs == ["cleared-bubble"]) - #expect(context.notifyUIChangedCount == 0) - } - - @Test @MainActor - func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xAB, count: 32) - let stablePeerID = PeerID(hexData: noiseKey) - let ephemeralPeerID = PeerID(str: "0102030405060708") - context.ephemeralPeerIDsByNoiseKey[noiseKey] = ephemeralPeerID - context.unreadPrivateMessages = [stablePeerID, ephemeralPeerID] - let message = makeIncomingMessage(id: "read-1", senderPeerID: stablePeerID) - - coordinator.handleViewingThisChat( - message, - targetPeerID: stablePeerID, - key: noiseKey, - senderPubkey: "feedface00112233" - ) - - #expect(context.unreadPrivateMessages.isEmpty) - #expect(context.routedReadReceipts.map(\.messageID) == ["read-1"]) - #expect(context.routedReadReceipts.first?.peerID == stablePeerID) - #expect(context.sentReadReceipts == ["read-1"]) - - // Already-acked message must not produce a second receipt. - coordinator.handleViewingThisChat( - message, - targetPeerID: stablePeerID, - key: noiseKey, - senderPubkey: "feedface00112233" - ) - #expect(context.routedReadReceipts.count == 1) - - // Without a Noise key, the receipt goes out via the geohash transport. - context.sentReadReceipts = [] - coordinator.handleViewingThisChat( - message, - targetPeerID: stablePeerID, - key: nil, - senderPubkey: "feedface00112233" - ) - #expect(context.geoReadReceipts.map(\.messageID) == ["read-1"]) - #expect(context.sentReadReceipts == ["read-1"]) - } - - @Test @MainActor - func markAsUnread_tracksTargetAndEphemeralWithoutNotificationWhenStale() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xCD, count: 32) - let stablePeerID = PeerID(hexData: noiseKey) - let ephemeralPeerID = PeerID(str: "1112131415161718") - context.ephemeralPeerIDsByNoiseKey[noiseKey] = ephemeralPeerID - - // isRecentMessage false keeps the flow off the NotificationService singleton. - coordinator.markAsUnreadIfNeeded( - shouldMarkAsUnread: true, - targetPeerID: stablePeerID, - key: noiseKey, - isRecentMessage: false, - senderNickname: "alice", - messageContent: "hello" - ) - #expect(context.unreadPrivateMessages == [stablePeerID, ephemeralPeerID]) - - context.unreadPrivateMessages = [] - coordinator.markAsUnreadIfNeeded( - shouldMarkAsUnread: false, - targetPeerID: stablePeerID, - key: noiseKey, - isRecentMessage: false, - senderNickname: "alice", - messageContent: "hello" - ) - #expect(context.unreadPrivateMessages.isEmpty) - } - - @Test @MainActor - func migratePrivateChats_movesMessagesOnFingerprintMatchAndClearsOldPeer() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let oldPeerID = PeerID(str: "aaaaaaaaaaaaaaaa") - let newPeerID = PeerID(str: "bbbbbbbbbbbbbbbb") - context.fingerprintsByPeerID[oldPeerID] = "fp-1" - context.fingerprintsByPeerID[newPeerID] = "fp-1" - let older = makeIncomingMessage(id: "old-1", timestamp: Date().addingTimeInterval(-60)) - let newer = makeIncomingMessage(id: "old-2", timestamp: Date().addingTimeInterval(-30)) - context.privateChats[oldPeerID] = [newer, older] - context.unreadPrivateMessages = [oldPeerID] - context.selectedPrivateChatPeer = oldPeerID - - coordinator.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "alice") - - #expect(context.privateChats[oldPeerID] == nil) - #expect(context.privateChats[newPeerID]?.map(\.id) == ["old-1", "old-2"]) - #expect(context.unreadPrivateMessages.isEmpty) - #expect(context.clearedFingerprints == [oldPeerID]) - #expect(context.selectedPrivateChatPeer == newPeerID) - // The wholesale move went through the store's migrate intent. - #expect(context.migratedChats.map(\.from) == [oldPeerID]) - #expect(context.migratedChats.map(\.to) == [newPeerID]) - #expect(context.notifyUIChangedCount == 1) - } - - @Test @MainActor - func handlePrivateMessage_postsNotificationOnlyWhenNotViewingChat() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let peerID = PeerID(str: "0102030405060708") - - // Not viewing: marked unread and a local notification is posted. - coordinator.handlePrivateMessage( - makeIncomingMessage(id: "pm-1", content: "hi there", senderPeerID: peerID) - ) - #expect(context.unreadPrivateMessages == [peerID]) - #expect(context.privateMessageNotifications.count == 1) - #expect(context.privateMessageNotifications.first?.senderName == "alice") - #expect(context.privateMessageNotifications.first?.message == "hi there") - #expect(context.privateMessageNotifications.first?.peerID == peerID) - #expect(context.meshReadReceipts.isEmpty) - - // Viewing the chat: a READ ack is sent instead and no notification fires. - context.selectedPrivateChatPeer = peerID - coordinator.handlePrivateMessage(makeIncomingMessage(id: "pm-2", senderPeerID: peerID)) - #expect(context.meshReadReceipts.map(\.messageID) == ["pm-2"]) - #expect(context.sentReadReceipts.contains("pm-2")) - #expect(context.privateMessageNotifications.count == 1) - } - - @Test @MainActor - func handleFavoriteNotification_persistsAndAnnouncesTransitionsOnly() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xAB, count: 32) - let peerID = PeerID(hexData: noiseKey) - - // First [FAVORITED] flips theyFavoritedUs: store write + announcement. - coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") - #expect(context.peerFavoritedUsUpdates.count == 1) - #expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey) - #expect(context.peerFavoritedUsUpdates.first?.favorited == true) - #expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice") - #expect(context.meshOnlySystemMessages == ["alice favorited you"]) - - // Same state again: store write still happens, but no repeat announcement. - context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( - noiseKey: noiseKey, - theyFavoritedUs: true - ) - coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") - #expect(context.peerFavoritedUsUpdates.count == 2) - #expect(context.meshOnlySystemMessages == ["alice favorited you"]) - - // [UNFAVORITED] transition announces again. - coordinator.handleFavoriteNotification("[UNFAVORITED]", from: peerID, senderNickname: "alice") - #expect(context.peerFavoritedUsUpdates.last?.favorited == false) - #expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"]) - } - - /// A Nostr DM whose sender resolved to a known noise key must be labeled - /// with the favorite's nickname, not the geohash-scoped anon fallback. - @Test @MainActor - func nostrPrivateMessage_noiseKeyedConversationUsesFavoriteNickname() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xDA, count: 32) - let convKey = PeerID(hexData: noiseKey) - let senderPubkey = "0badc0de00112233" - // No displayNamesByPubkey entry: the geo fallback would be "anon". - context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( - noiseKey: noiseKey, - nostrPublicKey: "npub1bob", - nickname: "bob", - isFavorite: true, - theyFavoritedUs: true - ) - - let payloadData = PrivateMessagePacket(messageID: "nostr-dm-1", content: "hello from afar").encode()! - let payload = NoisePayload(type: .privateMessage, data: payloadData) - - coordinator.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: convKey, - id: MockChatPrivateConversationContext.dummyIdentity, - messageTimestamp: Date() - ) - - #expect(context.privateChats[convKey]?.first?.sender == "bob") - } - - /// Over Nostr, [FAVORITED] markers arrive as embedded PMs on the convKey - /// path; they must update the relationship, not render as chat text. - @Test @MainActor - func nostrPrivateMessage_favoritedMarkerUpdatesRelationshipInsteadOfAppending() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xEE, count: 32) - // The inbound pipeline resolves known favorites to their noise-key ID. - let convKey = PeerID(hexData: noiseKey) - let senderPubkey = "feedface99887766" - context.displayNamesByPubkey[senderPubkey] = "alice#1234" - - let payloadData = PrivateMessagePacket(messageID: "fav-1", content: "[FAVORITED]:npub1alice").encode()! - let payload = NoisePayload(type: .privateMessage, data: payloadData) - - coordinator.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: convKey, - id: MockChatPrivateConversationContext.dummyIdentity, - messageTimestamp: Date() - ) - - #expect(context.peerFavoritedUsUpdates.count == 1) - #expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey) - #expect(context.peerFavoritedUsUpdates.first?.favorited == true) - #expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice") - #expect(context.privateChats[convKey, default: []].isEmpty) - #expect(context.meshOnlySystemMessages == ["alice#1234 favorited you"]) - } - - @Test @MainActor - func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xCD, count: 32) - let peerID = PeerID(hexData: noiseKey) - context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( - noiseKey: noiseKey, - nostrPublicKey: "npub1bob", - nickname: "bob", - isFavorite: true, - theyFavoritedUs: true - ) - - coordinator.sendPrivateMessage("hello bob", to: peerID) - - // Offline but mutual favorite with a Nostr key: routed, marked sent, - // and the nickname falls back to the favorite relationship. - #expect(context.routedPrivateMessages.map(\.content) == ["hello bob"]) - #expect(context.privateChats[peerID]?.first?.deliveryStatus == .sent) - #expect(context.privateChats[peerID]?.first?.recipientNickname == "bob") - } - - /// Same as above, but the conversation is keyed by the SHORT mesh ID — - /// the DM window was opened while the peer was on mesh, then they went - /// out of range. The favorite must resolve via the derived short ID and - /// route over Nostr instead of failing "peer not reachable". - @Test @MainActor - func sendPrivateMessage_routesViaNostrWhenMeshKeyedPeerGoesOffline() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xCE, count: 32) - let shortID = PeerID(publicKey: noiseKey) - context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( - noiseKey: noiseKey, - nostrPublicKey: "npub1bob", - nickname: "bob", - isFavorite: true, - theyFavoritedUs: true - ) - - coordinator.sendPrivateMessage("hello again", to: shortID) - - #expect(context.routedPrivateMessages.map(\.content) == ["hello again"]) - #expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent) - #expect(context.privateChats[shortID]?.first?.recipientNickname == "bob") - } - - /// Field-found: pre-judging reachability here marked the message failed - /// without ever routing it, so the router's retained outbox, courier - /// deposits, and bridge drops never got a chance. A fully unreachable - /// non-favorite must still be routed and stay "sending" (the router's - /// callbacks later move it to carried/delivered or expire it as failed). - @Test @MainActor - func sendPrivateMessage_routesAndStaysSendingWhenOfflineWithoutMutualFavorite() async { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let peerID = PeerID(hexData: Data(repeating: 0xCD, count: 32)) - - coordinator.sendPrivateMessage("hello?", to: peerID) - - #expect(context.routedPrivateMessages.map(\.content) == ["hello?"]) - #expect(context.privateChats[peerID]?.first?.deliveryStatus == .sending) - } -} diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift deleted file mode 100644 index 8a9aec64..00000000 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ /dev/null @@ -1,561 +0,0 @@ -// -// ChatPublicConversationCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatPublicConversationCoordinator` against a mock -// `ChatPublicConversationContext` — proving the coordinator works without a -// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / -// `ChatPrivateConversationCoordinatorContextTests` exemplars. -// -// Scope note: haptics (UIApplication) and the geohash branch of -// `sendPublicRaw` (NostrRelayManager.shared, GeoRelayDirectory.shared) are -// intentionally not exercised here. `checkForMentions` posts through the -// injected context (`notifyMention(from:message:)`) and is covered, as are -// the mesh branch of `sendPublicRaw` and all timeline/store/blocking flows. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatPublicConversationContext` proving that -/// `ChatPublicConversationCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockChatPublicConversationContext: ChatPublicConversationContext { - // Channel state - var activeChannel: ChannelID = .mesh - var currentGeohash: String? - var nickname = "me" - var myPeerID = PeerID(str: "0011223344556677") - private(set) var isBatchingPublic = false - private(set) var notifyUIChangedCount = 0 - - func setPublicBatching(_ isBatching: Bool) { - isBatchingPublic = isBatching - } - - func notifyUIChanged() { - notifyUIChangedCount += 1 - } - - // Public conversation store (single-writer intents) - var conversations: [ConversationID: [BitchatMessage]] = [:] - private(set) var queuedGeohashSystemMessages: [String] = [] - - func publicMessages(in conversationID: ConversationID) -> [BitchatMessage] { - conversations[conversationID] ?? [] - } - - @discardableResult - func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool { - guard conversations[conversationID]?.contains(where: { $0.id == message.id }) != true else { - return false - } - conversations[conversationID, default: []].append(message) - return true - } - - func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool { - conversations[conversationID]?.contains(where: { $0.id == messageID }) == true - } - - @discardableResult - func removePublicMessage(withID messageID: String) -> BitchatMessage? { - for (conversationID, timeline) in conversations { - guard let index = timeline.firstIndex(where: { $0.id == messageID }) else { continue } - var updated = timeline - let removed = updated.remove(at: index) - conversations[conversationID] = updated - return removed - } - return nil - } - - func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) { - conversations[.geohash(geohash.lowercased())]?.removeAll(where: predicate) - } - - private(set) var clearedConversations: [ConversationID] = [] - private(set) var archivePurgeCount = 0 - - func clearPublicConversation(_ conversationID: ConversationID) { - clearedConversations.append(conversationID) - conversations[conversationID] = [] - } - - func purgeArchivedPublicMessages() { - archivePurgeCount += 1 - } - - func queueGeohashSystemMessage(_ content: String) { - queuedGeohashSystemMessages.append(content) - } - - // Private chats - var privateChats: [PeerID: [BitchatMessage]] = [:] - var unreadPrivateMessages: Set = [] - private(set) var removedPrivateChats: [PeerID] = [] - private(set) var cleanedUpFileMessageIDs: [String] = [] - - func removePrivateChat(_ peerID: PeerID) { - removedPrivateChats.append(peerID) - privateChats.removeValue(forKey: peerID) - unreadPrivateMessages.remove(peerID) - } - - @discardableResult - func removePrivateMessage(withID messageID: String) -> BitchatMessage? { - var removed: BitchatMessage? - for (peerID, chat) in privateChats { - guard let message = chat.first(where: { $0.id == messageID }) else { continue } - removed = removed ?? message - let remaining = chat.filter { $0.id != messageID } - if remaining.isEmpty { - privateChats.removeValue(forKey: peerID) - } else { - privateChats[peerID] = remaining - } - } - return removed - } - - func cleanupLocalFile(forMessage message: BitchatMessage) { - cleanedUpFileMessageIDs.append(message.id) - } - - // Geohash participants & presence - var geoNicknames: [String: String] = [:] - var isTeleported = false - var nostrKeyMapping: [PeerID: String] = [:] - var geoPeople: [GeoPerson] = [] - var geoParticipantCounts: [String: Int] = [:] - private(set) var removedGeoParticipants: [String] = [] - - func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) { - for (key, value) in nostrKeyMapping where value.lowercased() == hex { - nostrKeyMapping.removeValue(forKey: key) - } - } - - func visibleGeoPeople() -> [GeoPerson] { - geoPeople - } - - func geoParticipantCount(for geohash: String) -> Int { - geoParticipantCounts[geohash] ?? 0 - } - - func removeGeoParticipant(pubkeyHex: String) { - removedGeoParticipants.append(pubkeyHex) - } - - // Nostr identity & blocking - var blockedNostrPubkeys: Set = [] - - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { - Self.dummyIdentity - } - - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { - blockedNostrPubkeys.contains(pubkeyHexLowercased) - } - - func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { - if isBlocked { - blockedNostrPubkeys.insert(pubkeyHexLowercased) - } else { - blockedNostrPubkeys.remove(pubkeyHexLowercased) - } - } - - // Mesh transport - var meshNicknames: [PeerID: String] = [:] - private(set) var sentMeshMessages: [(content: String, messageID: String)] = [] - - func meshPeerNicknames() -> [PeerID: String] { - meshNicknames - } - - func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { - sentMeshMessages.append((content, messageID)) - } - - // Inbound public message processing - var blockedMessageIDs: Set = [] - var rateLimitAllowed = true - private(set) var rateLimitChecks: [(senderKey: String, contentKey: String, powBits: Int)] = [] - private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = [] - var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) } - var stablePeerIDs: [PeerID: PeerID] = [:] - - func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { - message - } - - func isMessageBlocked(_ message: BitchatMessage) -> Bool { - blockedMessageIDs.contains(message.id) - } - - func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool { - rateLimitChecks.append((senderKey, contentKey, powBits)) - return rateLimitAllowed - } - - func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) { - enqueuedMessages.append((message.id, conversationID)) - } - - func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { - stablePeerIDs[shortPeerID] - } - - // Content dedup & formatting - var contentTimestamps: [String: Date] = [:] - private(set) var recordedContentKeys: [(key: String, timestamp: Date)] = [] - private(set) var prewarmedMessageIDs: [String] = [] - - func normalizedContentKey(_ content: String) -> String { - content.lowercased() - } - - func contentTimestamp(forKey key: String) -> Date? { - contentTimestamps[key] - } - - func recordContentKey(_ key: String, timestamp: Date) { - recordedContentKeys.append((key, timestamp)) - } - - func prewarmMessageFormatting(_ message: BitchatMessage) { - prewarmedMessageIDs.append(message.id) - } - - // Notifications - private(set) var mentionNotifications: [(sender: String, message: String)] = [] - - func notifyMention(from sender: String, message: String) { - mentionNotifications.append((sender, message)) - } - - static let dummyIdentity = NostrIdentity( - privateKey: Data(repeating: 0x11, count: 32), - publicKey: Data(repeating: 0x22, count: 32), - npub: "npub1mock", - createdAt: Date(timeIntervalSince1970: 0) - ) -} - -// MARK: - Helpers - -@MainActor -private func makePublicMessage( - id: String = UUID().uuidString, - sender: String = "alice", - content: String = "hello world", - senderPeerID: PeerID? = PeerID(str: "aabbccddeeff0011") -) -> BitchatMessage { - BitchatMessage( - id: id, - sender: sender, - content: content, - timestamp: Date(), - isRelay: false, - isPrivate: false, - senderPeerID: senderPeerID - ) -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatPublicConversationCoordinator` against -/// `MockChatPublicConversationContext` — no `ChatViewModel` involved. -struct ChatPublicConversationCoordinatorContextTests { - - @Test @MainActor - func handlePublicMessage_meshMessage_enqueuesForBatchedStoreCommit() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - let message = makePublicMessage(id: "mesh-msg-1", content: "Hello Mesh") - - coordinator.handlePublicMessage(message) - - // Visible-channel arrival: buffered for the batched pipeline flush - // (which commits to the store), not appended directly. - #expect(context.rateLimitChecks.count == 1) - #expect(context.rateLimitChecks.first?.senderKey == "mesh:aabbccddeeff0011") - #expect(context.rateLimitChecks.first?.contentKey == "hello mesh") - #expect(context.enqueuedMessages.map(\.messageID) == ["mesh-msg-1"]) - #expect(context.enqueuedMessages.first?.conversationID == .mesh) - #expect(context.publicMessages(in: .mesh).isEmpty) - - // Already committed to the store: not re-enqueued. - context.appendPublicMessage(message, to: .mesh) - coordinator.handlePublicMessage(message) - #expect(context.enqueuedMessageIDs == ["mesh-msg-1"]) - } - - @Test @MainActor - func handlePublicMessage_blockedOrRateLimited_dropsMessage() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - - // Blocked sender: dropped before rate limiting and storage. - context.blockedMessageIDs = ["blocked-msg"] - coordinator.handlePublicMessage(makePublicMessage(id: "blocked-msg")) - #expect(context.rateLimitChecks.isEmpty) - #expect(context.publicMessages(in: .mesh).isEmpty) - #expect(context.enqueuedMessageIDs.isEmpty) - - // Rate limited: consulted, then dropped before storage. - context.rateLimitAllowed = false - coordinator.handlePublicMessage(makePublicMessage(id: "limited-msg")) - #expect(context.rateLimitChecks.count == 1) - #expect(context.publicMessages(in: .mesh).isEmpty) - #expect(context.enqueuedMessageIDs.isEmpty) - } - - @Test @MainActor - func handlePublicMessage_geoMessage_respectsActiveChannel() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - let geohash = "u4pruy" - context.currentGeohash = geohash - let senderHex = String(repeating: "ab", count: 32) - let geoMessage = makePublicMessage( - id: "geo-msg-1", - content: "geo hello", - senderPeerID: PeerID(nostr: senderHex) - ) - - // On mesh channel: a background-channel arrival lands in the geohash - // conversation immediately, with no pipeline batching. - context.activeChannel = .mesh - coordinator.handlePublicMessage(geoMessage) - #expect(context.publicMessages(in: .geohash(geohash)).map(\.id) == ["geo-msg-1"]) - #expect(context.publicMessages(in: .mesh).isEmpty) - #expect(context.enqueuedMessageIDs.isEmpty) - - // On the matching location channel: enqueued for the batched flush. - context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash)) - let second = makePublicMessage( - id: "geo-msg-2", - content: "geo again", - senderPeerID: PeerID(nostr: senderHex) - ) - coordinator.handlePublicMessage(second) - #expect(context.enqueuedMessages.map(\.messageID) == ["geo-msg-2"]) - #expect(context.enqueuedMessages.first?.conversationID == .geohash(geohash)) - } - - /// `/clear` on the mesh timeline used to only record a watermark, leaving - /// the archive on disk for up to its freshness window — so someone who - /// cleared before a police stop had deleted nothing. It must now erase the - /// archive behind the timeline. - @Test @MainActor - func clearCurrentPublicTimeline_onMesh_erasesTheArchive() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - context.activeChannel = .mesh - - coordinator.clearCurrentPublicTimeline() - - #expect(context.clearedConversations == [.mesh]) - #expect(context.archivePurgeCount == 1) - } - - /// Geohash timelines are Nostr-backed and carry no mesh gossip archive, so - /// clearing one must not reach for it. - @Test @MainActor - func clearCurrentPublicTimeline_onGeohash_leavesTheArchiveAlone() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) - - coordinator.clearCurrentPublicTimeline() - - #expect(context.archivePurgeCount == 0) - } - - @Test @MainActor - func blockGeohashUser_purgesMessagesMappingsAndPrivateChats() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - let geohash = "u4pruy" - let hex = String(repeating: "cd", count: 32) - let senderPeerID = PeerID(nostr: hex) - let convKey = PeerID(nostr_: hex) - let geoMessage = makePublicMessage(id: "geo-bad-1", sender: "rude", senderPeerID: senderPeerID) - - context.currentGeohash = geohash - context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash)) - context.conversations[.geohash(geohash)] = [geoMessage] - context.nostrKeyMapping = [senderPeerID: hex, convKey: hex] - context.privateChats[convKey] = [geoMessage] - context.unreadPrivateMessages = [convKey] - - coordinator.blockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd") - - #expect(context.blockedNostrPubkeys.contains(hex)) - #expect(context.removedGeoParticipants == [hex]) - #expect(context.privateChats[convKey] == nil) - #expect(context.unreadPrivateMessages.isEmpty) - #expect(context.nostrKeyMapping.isEmpty) - // The blocked user's message is purged from the geohash conversation - // (the visible timeline is the same conversation now); a system - // notice was appended to the active conversation. - #expect(!context.publicMessages(in: .geohash(geohash)).contains(where: { $0.id == "geo-bad-1" })) - #expect(context.publicMessages(in: .geohash(geohash)).last?.sender == "system") - - coordinator.unblockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd") - #expect(!context.blockedNostrPubkeys.contains(hex)) - } - - @Test @MainActor - func removeMessage_removesEverywhereAndCleansUpFile() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - let peerID = PeerID(str: "0102030405060708") - let message = makePublicMessage(id: "doomed-msg") - context.conversations[.mesh] = [message] - context.privateChats[peerID] = [message] - - coordinator.removeMessage(withID: "doomed-msg", cleanupFile: true) - - #expect(context.publicMessages(in: .mesh).isEmpty) - #expect(context.privateChats[peerID] == nil) - #expect(context.cleanedUpFileMessageIDs == ["doomed-msg"]) - #expect(context.notifyUIChangedCount == 1) - } - - @Test @MainActor - func addPublicSystemMessage_appendsToActiveConversationAndRecordsContentKey() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - - coordinator.addPublicSystemMessage("Tor Ready") - - #expect(context.publicMessages(in: .mesh).count == 1) - #expect(context.publicMessages(in: .mesh).first?.sender == "system") - #expect(context.recordedContentKeys.map(\.key) == ["tor ready"]) - - // On mesh, geohash-only system messages are queued for the next geo visit. - coordinator.addGeohashOnlySystemMessage("geo notice") - #expect(context.queuedGeohashSystemMessages == ["geo notice"]) - } - - @Test @MainActor - func sendPublicRaw_onMeshChannel_sendsViaMeshTransport() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - - coordinator.sendPublicRaw("raw mesh payload") - - #expect(context.sentMeshMessages.count == 1) - #expect(context.sentMeshMessages.first?.content == "raw mesh payload") - } - - @Test @MainActor - func pipelineDelegate_readsAndWritesContextState() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - let pipeline = PublicMessagePipeline() - let message = makePublicMessage(id: "pipeline-msg") - context.contentTimestamps["key-1"] = Date(timeIntervalSince1970: 42) - - #expect(coordinator.pipeline(pipeline, normalizeContent: "HeLLo") == "hello") - #expect(coordinator.pipeline(pipeline, contentTimestampForKey: "key-1") == Date(timeIntervalSince1970: 42)) - - // Commit lands in the store via the append intent; a duplicate ID - // reports `false` (the store's dedup contract). - #expect(coordinator.pipeline(pipeline, commit: message, to: .mesh)) - #expect(context.publicMessages(in: .mesh).map(\.id) == ["pipeline-msg"]) - #expect(!coordinator.pipeline(pipeline, commit: message, to: .mesh)) - - coordinator.pipeline(pipeline, recordContentKey: "key-2", timestamp: Date(timeIntervalSince1970: 7)) - #expect(context.recordedContentKeys.map(\.key) == ["key-2"]) - - coordinator.pipelinePrewarmMessage(pipeline, message: message) - #expect(context.prewarmedMessageIDs == ["pipeline-msg"]) - - coordinator.pipelineSetBatchingState(pipeline, isBatching: true) - #expect(context.isBatchingPublic) - } - - @Test @MainActor - func currentPublicSenderAndDisplayName_deriveGeoSuffixedIdentity() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - let identityHex = MockChatPublicConversationContext.dummyIdentity.publicKeyHex - let suffix = String(identityHex.suffix(4)) - - // Mesh: plain nickname and mesh peer ID. - let meshSender = coordinator.currentPublicSender() - #expect(meshSender.name == "me") - #expect(meshSender.peerID == context.myPeerID) - - // Location channel: suffixed nickname and nostr peer ID. - context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) - let geoSender = coordinator.currentPublicSender() - #expect(geoSender.name == "me#\(suffix)") - #expect(geoSender.peerID == PeerID(nostr: identityHex)) - - // Display names: own geo identity, known nickname, and anon fallback. - context.currentGeohash = "u4pruy" - #expect(coordinator.displayNameForNostrPubkey(identityHex) == "me#\(suffix)") - let otherHex = String(repeating: "ef", count: 32) - context.geoNicknames[otherHex] = "bob" - #expect(coordinator.displayNameForNostrPubkey(otherHex) == "bob#" + otherHex.suffix(4)) - let unknownHex = String(repeating: "12", count: 32) - #expect(coordinator.displayNameForNostrPubkey(unknownHex) == "anon#" + unknownHex.suffix(4)) - } - @Test @MainActor - func checkForMentions_postsMentionNotificationOnlyForOthersMentioningMe() async { - let context = MockChatPublicConversationContext() - let coordinator = ChatPublicConversationCoordinator(context: context) - - // A mention of my nickname from someone else notifies. - coordinator.checkForMentions( - BitchatMessage( - id: "mention-1", - sender: "alice", - content: "hey @me", - timestamp: Date(), - isRelay: false, - mentions: ["me"] - ) - ) - #expect(context.mentionNotifications.count == 1) - #expect(context.mentionNotifications.first?.sender == "alice") - #expect(context.mentionNotifications.first?.message == "hey @me") - - // Mentioning someone else does not notify. - coordinator.checkForMentions( - BitchatMessage( - id: "mention-2", - sender: "alice", - content: "hey @bob", - timestamp: Date(), - isRelay: false, - mentions: ["bob"] - ) - ) - #expect(context.mentionNotifications.count == 1) - - // My own message mentioning myself does not notify. - coordinator.checkForMentions( - BitchatMessage( - id: "mention-3", - sender: "me", - content: "talking about @me", - timestamp: Date(), - isRelay: false, - mentions: ["me"] - ) - ) - #expect(context.mentionNotifications.count == 1) - } - -} diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift deleted file mode 100644 index aa02b255..00000000 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ /dev/null @@ -1,483 +0,0 @@ -// -// ChatTransportEventCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatTransportEventCoordinator` against a mock -// `ChatTransportEventContext` — proving the coordinator works without a -// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / -// `ChatPrivateConversationCoordinatorContextTests` exemplars. -// -// Scope note: the coordinator hops every event onto the main actor via an -// internal `Task`; tests drain those tasks with `Task.yield()`. All flows are -// mockable — no singletons are involved at this layer. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatTransportEventContext` proving that -/// `ChatTransportEventCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockChatTransportEventContext: ChatTransportEventContext { - // Connection & chat state - var isConnected = false - var nickname = "me" - var myPeerID = PeerID(str: "0011223344556677") - var privateChats: [PeerID: [BitchatMessage]] = [:] - - func privateMessages(for peerID: PeerID) -> [BitchatMessage] { - privateChats[peerID] ?? [] - } - var unreadPrivateMessages: Set = [] - var selectedPrivateChatPeer: PeerID? - private(set) var unmarkedReadReceiptBatches: [[String]] = [] - private(set) var notifyUIChangedCount = 0 - - // Conversation store intents (mirror `ConversationStore` semantics) - @discardableResult - func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { - var chat = privateChats[peerID] ?? [] - guard !chat.contains(where: { $0.id == message.id }) else { return false } - let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count - chat.insert(message, at: index) - privateChats[peerID] = chat - return true - } - - func removePrivateChat(_ peerID: PeerID) { - privateChats.removeValue(forKey: peerID) - unreadPrivateMessages.remove(peerID) - } - - func markPrivateChatUnread(_ peerID: PeerID) { - unreadPrivateMessages.insert(peerID) - } - - func markPrivateChatRead(_ peerID: PeerID) { - unreadPrivateMessages.remove(peerID) - } - - func unmarkReadReceiptsSent(_ ids: [String]) { - unmarkedReadReceiptBatches.append(ids) - } - - func notifyUIChanged() { - notifyUIChangedCount += 1 - } - - // Inbound message handling - var blockedMessageIDs: Set = [] - private(set) var handledPrivateMessages: [BitchatMessage] = [] - private(set) var handledPublicMessages: [BitchatMessage] = [] - private(set) var mentionCheckedMessageIDs: [String] = [] - private(set) var hapticMessageIDs: [String] = [] - - func isMessageBlocked(_ message: BitchatMessage) -> Bool { - blockedMessageIDs.contains(message.id) - } - - func handlePrivateMessage(_ message: BitchatMessage) { - handledPrivateMessages.append(message) - } - - func handlePublicMessage(_ message: BitchatMessage) { - handledPublicMessages.append(message) - } - - func checkForMentions(_ message: BitchatMessage) { - mentionCheckedMessageIDs.append(message.id) - } - - func sendHapticFeedback(for message: BitchatMessage) { - hapticMessageIDs.append(message.id) - } - - func parseMentions(from content: String) -> [String] { - content.contains("@me") ? ["me"] : [] - } - - // Peer identity & sessions - var blockedPeers: Set = [] - var peersByID: [PeerID: BitchatPeer] = [:] - var noiseSessionKeysByPeerID: [PeerID: Data] = [:] - private(set) var stablePeerIDCache: [PeerID: PeerID] = [:] - private(set) var registeredEphemeralSessions: [PeerID] = [] - private(set) var removedEphemeralSessions: [PeerID] = [] - - func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } - func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] } - func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" } - func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) } - func removeEphemeralSession(peerID: PeerID) { removedEphemeralSessions.append(peerID) } - func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] } - func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) { - stablePeerIDCache[shortPeerID] = stablePeerID - } - func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] } - - // Routing & acknowledgements - private(set) var flushedOutboxPeerIDs: [PeerID] = [] - private(set) var courierRetryPeerIDs: [PeerID] = [] - private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] - - func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) } - func retryCourierDeposits(via peerID: PeerID) { courierRetryPeerIDs.append(peerID) } - func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { - meshDeliveryAcks.append((messageID, peerID)) - } - - // Delivery status - var applyMessageDeliveryStatusResult = true - var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:] - private(set) var appliedDeliveryStatuses: [ - (messageID: String, status: DeliveryStatus, peerIDAliases: Set) - ] = [] - - @discardableResult - func applyAcknowledgedMessageDeliveryStatus( - _ messageID: String, - status: DeliveryStatus, - from peerIDAliases: Set - ) -> Bool { - appliedDeliveryStatuses.append((messageID, status, peerIDAliases)) - return applyMessageDeliveryStatusResult - } - - func deliveryStatus(for messageID: String) -> DeliveryStatus? { - deliveryStatusesByMessageID[messageID] - } - - // Verification payloads - private(set) var verifyChallengePayloads: [(peerID: PeerID, payload: Data)] = [] - private(set) var verifyResponsePayloads: [(peerID: PeerID, payload: Data)] = [] - - func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) { - verifyChallengePayloads.append((peerID, payload)) - } - - func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { - verifyResponsePayloads.append((peerID, payload)) - } - - // Group payloads - private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = [] - private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = [] - - func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { - groupInvitePayloads.append((peerID, payload)) - } - - func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { - groupKeyUpdatePayloads.append((peerID, payload)) - } - - private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = [] - - func handleVouchPayload(from peerID: PeerID, payload: Data) { - vouchPayloads.append((peerID, payload)) - } - - // Live voice payloads - private(set) var voiceFramePayloads: [(peerID: PeerID, payload: Data, timestamp: Date)] = [] - - func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) { - voiceFramePayloads.append((peerID, payload, timestamp)) - } -} - -// MARK: - Helpers - -/// Lets the coordinator's internal `Task { @MainActor … }` hops run. -@MainActor -private func drainMainActorTasks() async { - for _ in 0..<10 { await Task.yield() } -} - -private func makeMessage( - id: String, - sender: String = "alice", - content: String = "hello", - isPrivate: Bool = false, - senderPeerID: PeerID? = nil -) -> BitchatMessage { - BitchatMessage( - id: id, - sender: sender, - content: content, - timestamp: Date(), - isRelay: false, - isPrivate: isPrivate, - recipientNickname: isPrivate ? "me" : nil, - senderPeerID: senderPeerID - ) -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatTransportEventCoordinator` against -/// `MockChatTransportEventContext` with no `ChatViewModel`. -struct ChatTransportEventCoordinatorContextTests { - - @Test @MainActor - func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async { - let context = MockChatTransportEventContext() - let coordinator = ChatTransportEventCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - - // Blocked messages are dropped before any handling. - context.blockedMessageIDs = ["blocked", "blocked-private"] - coordinator.didReceiveMessage(makeMessage(id: "blocked")) - coordinator.didReceiveMessage(makeMessage( - id: "blocked-private", - isPrivate: true, - senderPeerID: peerID - )) - // Empty public content is dropped too. - coordinator.didReceiveMessage(makeMessage(id: "empty", content: " ")) - await drainMainActorTasks() - #expect(context.handledPublicMessages.isEmpty) - #expect(context.handledPrivateMessages.isEmpty) - #expect(context.mentionCheckedMessageIDs.isEmpty) - #expect(context.meshDeliveryAcks.isEmpty) - - // Private goes to the private handler, public to the public handler; - // both get mention checks and haptics. Stable-media ACK authorization - // belongs to BLEFileTransferHandler after its durable commit and this - // synchronous acceptance result, not to the generic UI coordinator. - let stableMediaID = "media-\(String(repeating: "a", count: 32))" - coordinator.didReceiveMessage(makeMessage( - id: stableMediaID, - isPrivate: true, - senderPeerID: peerID - )) - coordinator.didReceiveMessage(makeMessage( - id: "legacy-media", - isPrivate: true, - senderPeerID: peerID - )) - coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true)) - coordinator.didReceiveMessage(makeMessage(id: "pub")) - await drainMainActorTasks() - #expect(context.handledPrivateMessages.map(\.id) == [ - stableMediaID, - "legacy-media", - "pm-missing-sender" - ]) - #expect(context.handledPublicMessages.map(\.id) == ["pub"]) - #expect(context.mentionCheckedMessageIDs == [ - stableMediaID, - "legacy-media", - "pm-missing-sender", - "pub" - ]) - #expect(context.hapticMessageIDs == [ - stableMediaID, - "legacy-media", - "pm-missing-sender", - "pub" - ]) - #expect(context.meshDeliveryAcks.isEmpty) - } - - @Test @MainActor - func synchronousMessageDeliveryReportsAcceptanceForAckGating() { - let context = MockChatTransportEventContext() - let coordinator = ChatTransportEventCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - let blocked = makeMessage( - id: "blocked-private-media", - isPrivate: true, - senderPeerID: peerID - ) - context.blockedMessageIDs = [blocked.id] - - #expect(coordinator.didReceiveMessageSynchronously(blocked) == false) - #expect(context.handledPrivateMessages.isEmpty) - - let accepted = makeMessage( - id: "accepted-private-media", - isPrivate: true, - senderPeerID: peerID - ) - #expect(coordinator.didReceiveMessageSynchronously(accepted) == true) - #expect(context.handledPrivateMessages.map(\.id) == [accepted.id]) - } - - @Test @MainActor - func didReceivePublicMessage_trimsContentAndParsesMentions() async { - let context = MockChatTransportEventContext() - let coordinator = ChatTransportEventCoordinator(context: context) - let peerID = PeerID(str: "aabbccdd00112233") - - coordinator.didReceivePublicMessage( - from: peerID, - nickname: "alice", - content: " hi @me ", - timestamp: Date(), - messageID: "m1" - ) - await drainMainActorTasks() - - #expect(context.handledPublicMessages.count == 1) - let message = context.handledPublicMessages[0] - #expect(message.content == "hi @me") - #expect(message.mentions == ["me"]) - #expect(message.senderPeerID == peerID) - #expect(context.hapticMessageIDs == ["m1"]) - } - - @Test @MainActor - func didConnectAndDisconnect_manageSessionsStableIDsAndReadReceipts() async { - let context = MockChatTransportEventContext() - let coordinator = ChatTransportEventCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - let noiseKey = Data(repeating: 0xAB, count: 32) - context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") - - coordinator.didConnectToPeer(peerID) - await drainMainActorTasks() - #expect(context.isConnected) - #expect(context.registeredEphemeralSessions == [peerID]) - #expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey)) - #expect(context.flushedOutboxPeerIDs == [peerID]) - #expect(context.notifyUIChangedCount == 1) - - // Their messages' read receipts are un-marked on disconnect so READ - // acks can be re-sent after reconnect; our own messages are not. - context.privateChats[peerID] = [ - makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID), - makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), - makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID) - ] - coordinator.didDisconnectFromPeer(peerID) - await drainMainActorTasks() - #expect(context.removedEphemeralSessions == [peerID]) - #expect(context.unmarkedReadReceiptBatches == [["theirs-1", "theirs-2"]]) - #expect(context.notifyUIChangedCount == 2) - } - - @Test @MainActor - func synchronousConnectAndDisconnect_applyBeforeReturning() { - let context = MockChatTransportEventContext() - let coordinator = ChatTransportEventCoordinator(context: context) - let peerID = PeerID(str: "2233445566778899") - let incoming = makeMessage( - id: "incoming-receipt", - isPrivate: true, - senderPeerID: peerID - ) - context.privateChats[peerID] = [incoming] - - coordinator.didConnectToPeerSynchronously(peerID) - - #expect(context.isConnected) - #expect(context.registeredEphemeralSessions == [peerID]) - #expect(context.flushedOutboxPeerIDs == [peerID]) - #expect(context.courierRetryPeerIDs == [peerID]) - - coordinator.didDisconnectFromPeerSynchronously(peerID) - - #expect(context.removedEphemeralSessions == [peerID]) - #expect(context.unmarkedReadReceiptBatches == [[incoming.id]]) - #expect(context.notifyUIChangedCount == 2) - } - - @Test @MainActor - func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async { - let context = MockChatTransportEventContext() - let coordinator = ChatTransportEventCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - let noiseKey = Data(repeating: 0xCD, count: 32) - let stablePeerID = PeerID(hexData: noiseKey) - - // No cached stable ID: it must be derived from the Noise session key. - context.noiseSessionKeysByPeerID[peerID] = noiseKey - context.selectedPrivateChatPeer = peerID - context.unreadPrivateMessages = [peerID] - context.privateChats[peerID] = [ - makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID), - makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID) - ] - - coordinator.didDisconnectFromPeer(peerID) - await drainMainActorTasks() - - #expect(context.privateChats[peerID] == nil) - #expect(context.privateChats[stablePeerID]?.map(\.id) == ["m1", "mine"]) - // Sender IDs migrate to the stable peer ID, except our own. - #expect(context.privateChats[stablePeerID]?.first?.senderPeerID == stablePeerID) - #expect(context.privateChats[stablePeerID]?.last?.senderPeerID == context.myPeerID) - #expect(context.selectedPrivateChatPeer == stablePeerID) - #expect(context.unreadPrivateMessages == [stablePeerID]) - #expect(context.stablePeerIDCache[peerID] == stablePeerID) - } - - @Test @MainActor - func noisePayloads_driveDeliveryStatusAcksAndVerification() async { - let context = MockChatTransportEventContext() - let coordinator = ChatTransportEventCoordinator(context: context) - let peerID = PeerID(str: "99aabbccddeeff00") - let noiseKey = Data(repeating: 0x44, count: 32) - context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") - let stablePeerID = PeerID(hexData: noiseKey) - let staleStablePeerID = PeerID(hexData: Data(repeating: 0x55, count: 32)) - context.cacheStablePeerID(staleStablePeerID, for: peerID) - context.noiseSessionKeysByPeerID[peerID] = noiseKey - - // Inbound private message: decoded, handled, and delivery-acked. - let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there") - coordinator.didReceiveNoisePayload( - from: peerID, - type: .privateMessage, - payload: packet.encode() ?? Data(), - timestamp: Date() - ) - await drainMainActorTasks() - #expect(context.handledPrivateMessages.map(\.id) == ["pm-1"]) - #expect(context.handledPrivateMessages.first?.sender == "alice") - #expect(context.meshDeliveryAcks.count == 1) - #expect(context.meshDeliveryAcks.first?.messageID == "pm-1") - - // Delivered / read acks resolve the display name from the unified peer. - coordinator.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data("m-1".utf8), timestamp: Date()) - coordinator.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data("m-2".utf8), timestamp: Date()) - await drainMainActorTasks() - #expect(context.appliedDeliveryStatuses.count == 2) - #expect(context.appliedDeliveryStatuses[0].messageID == "m-1") - #expect(context.appliedDeliveryStatuses[0].peerIDAliases == [peerID, stablePeerID]) - #expect(!context.appliedDeliveryStatuses[0].peerIDAliases.contains(staleStablePeerID)) - if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status { - #expect(to == "alice") - } else { - Issue.record("expected .delivered status") - } - if case .read(let by, _) = context.appliedDeliveryStatuses[1].status { - #expect(by == "alice") - } else { - Issue.record("expected .read status") - } - - // Verification payloads are forwarded untouched. - coordinator.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data([0x01]), timestamp: Date()) - coordinator.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data([0x02]), timestamp: Date()) - await drainMainActorTasks() - #expect(context.verifyChallengePayloads.count == 1) - #expect(context.verifyResponsePayloads.count == 1) - - // Blocked peers' private messages are dropped (no handling, no ack). - context.blockedPeers = [peerID] - coordinator.didReceiveNoisePayload( - from: peerID, - type: .privateMessage, - payload: packet.encode() ?? Data(), - timestamp: Date() - ) - await drainMainActorTasks() - #expect(context.handledPrivateMessages.count == 1) - #expect(context.meshDeliveryAcks.count == 1) - } -} diff --git a/bitchatTests/ChatUnreadStateResolverTests.swift b/bitchatTests/ChatUnreadStateResolverTests.swift deleted file mode 100644 index 37b80b57..00000000 --- a/bitchatTests/ChatUnreadStateResolverTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct ChatUnreadStateResolverTests { - @Test - func directUnreadPeerReturnsTrue() { - let peerID = PeerID(str: "peer-a") - let context = ChatUnreadPeerContext( - peerID: peerID, - noiseKeyPeerID: nil, - nostrPeerID: nil, - nickname: nil - ) - - #expect(ChatUnreadStateResolver.hasUnreadMessages( - for: context, - unreadPrivateMessages: [peerID], - privateChats: [:] - )) - } - - @Test - func stableNoiseKeyUnreadReturnsTrue() { - let peerID = PeerID(str: "ephemeral") - let stableID = PeerID(str: "stable") - let context = ChatUnreadPeerContext( - peerID: peerID, - noiseKeyPeerID: stableID, - nostrPeerID: nil, - nickname: nil - ) - - #expect(ChatUnreadStateResolver.hasUnreadMessages( - for: context, - unreadPrivateMessages: [stableID], - privateChats: [:] - )) - } - - @Test - func nostrConversationUnreadReturnsTrue() { - let peerID = PeerID(str: "ephemeral") - let nostrID = PeerID(nostr_: "abcdef") - let context = ChatUnreadPeerContext( - peerID: peerID, - noiseKeyPeerID: nil, - nostrPeerID: nostrID, - nickname: nil - ) - - #expect(ChatUnreadStateResolver.hasUnreadMessages( - for: context, - unreadPrivateMessages: [nostrID], - privateChats: [:] - )) - } - - @Test - func temporaryGeoDMWithMatchingNicknameReturnsTrue() { - let peerID = PeerID(str: "mesh-peer") - let geoDM = PeerID(nostr_: "0123456789abcdef") - let context = ChatUnreadPeerContext( - peerID: peerID, - noiseKeyPeerID: nil, - nostrPeerID: nil, - nickname: "Alice" - ) - let message = BitchatMessage( - sender: "alice", - content: "hi", - timestamp: Date(timeIntervalSince1970: 1), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: "me", - senderPeerID: geoDM - ) - - #expect(ChatUnreadStateResolver.hasUnreadMessages( - for: context, - unreadPrivateMessages: [geoDM], - privateChats: [geoDM: [message]] - )) - } - - @Test - func unmatchedUnreadStateReturnsFalse() { - let peerID = PeerID(str: "mesh-peer") - let geoDM = PeerID(nostr_: "0123456789abcdef") - let context = ChatUnreadPeerContext( - peerID: peerID, - noiseKeyPeerID: nil, - nostrPeerID: nil, - nickname: "Alice" - ) - let message = BitchatMessage( - sender: "bob", - content: "hi", - timestamp: Date(timeIntervalSince1970: 1), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: "me", - senderPeerID: geoDM - ) - - #expect(!ChatUnreadStateResolver.hasUnreadMessages( - for: context, - unreadPrivateMessages: [geoDM], - privateChats: [geoDM: [message]] - )) - } -} diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift deleted file mode 100644 index 83cc7296..00000000 --- a/bitchatTests/ChatVerificationCoordinatorContextTests.swift +++ /dev/null @@ -1,340 +0,0 @@ -// -// ChatVerificationCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatVerificationCoordinator` against a mock -// `ChatVerificationContext` — proving the coordinator works without a -// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / -// `ChatPrivateConversationCoordinatorContextTests` exemplars. -// -// Scope note: `handleVerifyResponsePayload` requires a real Ed25519 -// signature; it remains covered by the full view-model/integration tests. -// Challenge handling, QR kickoff, fingerprint verification, verified-set -// loading, and the mutual-verification notification (posted through the -// injected context) are covered here (`VerificationService.shared` is only -// used for pure payload build/parse). -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `ChatVerificationContext` proving that -/// `ChatVerificationCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockChatVerificationContext: ChatVerificationContext { - // Fingerprints & verification state - var fingerprintsByPeerID: [PeerID: String] = [:] - var verifiedFingerprints: Set = [] - var persistedFingerprints: Set = [] - private(set) var identityVerifiedCalls: [(fingerprint: String, verified: Bool)] = [] - private(set) var storedVerifiedCalls: [(fingerprint: String, verified: Bool)] = [] - private(set) var saveIdentityStateCount = 0 - - func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } - func persistedVerifiedFingerprints() -> Set { persistedFingerprints } - - func setIdentityVerified(fingerprint: String, verified: Bool) { - identityVerifiedCalls.append((fingerprint, verified)) - } - - func setStoredVerified(_ fingerprint: String, verified: Bool) { - storedVerifiedCalls.append((fingerprint, verified)) - } - - func isVerifiedFingerprint(_ fingerprint: String) -> Bool { - verifiedFingerprints.contains(fingerprint) - } - - func saveIdentityState() { saveIdentityStateCount += 1 } - - private(set) var vouchToConnectedVerifiedPeersCount = 0 - func vouchToConnectedVerifiedPeers() { vouchToConnectedVerifiedPeersCount += 1 } - - // Encryption status - private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:] - private(set) var updatedEncryptionStatusPeers: [PeerID] = [] - private(set) var invalidatedEncryptionCachePeers: [PeerID?] = [] - private(set) var notifyUIChangedCount = 0 - - func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { - encryptionStatuses[peerID] = status - } - - func updateEncryptionStatus(for peerID: PeerID) { - updatedEncryptionStatusPeers.append(peerID) - } - - func invalidateEncryptionCache(for peerID: PeerID?) { - invalidatedEncryptionCachePeers.append(peerID) - } - - func notifyUIChanged() { notifyUIChangedCount += 1 } - - // Peers - var unifiedPeers: [BitchatPeer] = [] - var unifiedFavorites: [BitchatPeer] = [] - private(set) var stablePeerIDCache: [PeerID: PeerID] = [:] - - func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { - unifiedPeers.first { $0.peerID == peerID } - } - - func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } - func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" } - func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] } - - func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) { - stablePeerIDCache[shortPeerID] = stablePeerID - } - - // Noise sessions & verification transport - var myNoiseStaticKey = Data(repeating: 0x42, count: 32) - var establishedNoiseSessions: Set = [] - var noiseSessionKeysByPeerID: [PeerID: Data] = [:] - private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)? - private(set) var triggeredHandshakes: [PeerID] = [] - private(set) var privateMediaAuthenticatedPeers: [PeerID] = [] - private(set) var securePrivateMessageRetryAliases: [[PeerID]] = [] - private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] - private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] - - func installNoiseSessionCallbacks( - onPeerAuthenticated: @escaping (PeerID, String) -> Void, - onHandshakeRequired: @escaping (PeerID) -> Void - ) { - installedCallbacks = (onPeerAuthenticated, onHandshakeRequired) - } - - func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] } - func noiseStaticPublicKeyData() -> Data { myNoiseStaticKey } - func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { - establishedNoiseSessions.contains(peerID) - } - func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) } - func privateMediaPeerDidAuthenticate(_ peerID: PeerID) { - privateMediaAuthenticatedPeers.append(peerID) - } - - func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) { - securePrivateMessageRetryAliases.append(peerIDAliases) - } - - func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { - sentChallenges.append((peerID, noiseKeyHex, nonceA)) - } - - func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { - sentResponses.append((peerID, noiseKeyHex, nonceA)) - } - - // Notifications - private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = [] - - func postLocalNotification(title: String, body: String, identifier: String) { - postedLocalNotifications.append((title, body, identifier)) - } -} - -// MARK: - Helpers - -/// Builds the raw verify-challenge TLV as it arrives at the coordinator -/// (i.e. with the `NoisePayload` type byte already stripped). -private func makeVerifyChallengeTLV(noiseKeyHex: String, nonceA: Data) -> Data { - var tlv = Data() - tlv.append(0x01) - tlv.append(UInt8(noiseKeyHex.count)) - tlv.append(Data(noiseKeyHex.utf8)) - tlv.append(0x02) - tlv.append(UInt8(nonceA.count)) - tlv.append(nonceA) - return tlv -} - -private func makeVerificationQR(noiseKeyHex: String) -> VerificationService.VerificationQR { - VerificationService.VerificationQR( - v: 1, - noiseKeyHex: noiseKeyHex, - signKeyHex: "00" + String(repeating: "ab", count: 31), - npub: nil, - nickname: "alice", - ts: 0, - nonceB64: "", - sigHex: "" - ) -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatVerificationCoordinator` against -/// `MockChatVerificationContext` with no `ChatViewModel`. -struct ChatVerificationCoordinatorContextTests { - - @Test @MainActor - func verifyAndUnverifyFingerprint_updateBothStoresAndStatus() async { - let context = MockChatVerificationContext() - let coordinator = ChatVerificationCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - - // Unknown fingerprint: nothing happens. - coordinator.verifyFingerprint(for: peerID) - #expect(context.identityVerifiedCalls.isEmpty) - - context.fingerprintsByPeerID[peerID] = "fp" - coordinator.verifyFingerprint(for: peerID) - coordinator.unverifyFingerprint(for: peerID) - - #expect(context.identityVerifiedCalls.map(\.fingerprint) == ["fp", "fp"]) - #expect(context.identityVerifiedCalls.map(\.verified) == [true, false]) - #expect(context.storedVerifiedCalls.map(\.verified) == [true, false]) - #expect(context.saveIdentityStateCount == 2) - #expect(context.updatedEncryptionStatusPeers == [peerID, peerID]) - } - - @Test @MainActor - func beginQRVerification_sendsChallengeOrTriggersHandshake() async { - let context = MockChatVerificationContext() - let coordinator = ChatVerificationCoordinator(context: context) - let noiseKey = Data(repeating: 0xCD, count: 32) - let peerID = PeerID(str: "1122334455667788") - let qr = makeVerificationQR(noiseKeyHex: noiseKey.hexEncodedString()) - - // No matching peer -> not started. - #expect(!coordinator.beginQRVerification(with: qr)) - - // Matching peer without an established session -> handshake first. - context.unifiedPeers = [BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")] - #expect(coordinator.beginQRVerification(with: qr)) - #expect(context.triggeredHandshakes == [peerID]) - #expect(context.sentChallenges.isEmpty) - - // Already pending -> short-circuits without re-triggering. - #expect(coordinator.beginQRVerification(with: qr)) - #expect(context.triggeredHandshakes == [peerID]) - - // Fresh coordinator with an established session -> immediate challenge. - let context2 = MockChatVerificationContext() - context2.unifiedPeers = [BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")] - context2.establishedNoiseSessions = [peerID] - let coordinator2 = ChatVerificationCoordinator(context: context2) - #expect(coordinator2.beginQRVerification(with: qr)) - #expect(context2.sentChallenges.count == 1) - #expect(context2.sentChallenges.first?.noiseKeyHex == qr.noiseKeyHex) - #expect(context2.triggeredHandshakes.isEmpty) - } - - @Test @MainActor - func handleVerifyChallengePayload_respondsOncePerNonceForOurKeyOnly() async { - let context = MockChatVerificationContext() - let coordinator = ChatVerificationCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - let myHex = context.myNoiseStaticKey.hexEncodedString() - let nonce = Data(repeating: 0x07, count: 16) - let payload = makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: nonce) - - coordinator.handleVerifyChallengePayload(from: peerID, payload: payload) - #expect(context.sentResponses.count == 1) - #expect(context.sentResponses.first?.noiseKeyHex.lowercased() == myHex) - #expect(context.sentResponses.first?.nonceA == nonce) - - // Same nonce again: deduplicated, no second response. - coordinator.handleVerifyChallengePayload(from: peerID, payload: payload) - #expect(context.sentResponses.count == 1) - - // A challenge for someone else's key is ignored. - let otherHex = Data(repeating: 0x99, count: 32).hexEncodedString() - let otherPayload = makeVerifyChallengeTLV( - noiseKeyHex: otherHex, - nonceA: Data(repeating: 0x08, count: 16) - ) - coordinator.handleVerifyChallengePayload(from: peerID, payload: otherPayload) - #expect(context.sentResponses.count == 1) - } - - @Test @MainActor - func loadVerifiedFingerprints_syncsPersistedSetAndRefreshesUI() async { - let context = MockChatVerificationContext() - let coordinator = ChatVerificationCoordinator(context: context) - context.persistedFingerprints = ["fp1", "fp2"] - - coordinator.loadVerifiedFingerprints() - - #expect(context.verifiedFingerprints == ["fp1", "fp2"]) - #expect(context.invalidatedEncryptionCachePeers == [nil]) - #expect(context.notifyUIChangedCount == 1) - } - - @Test @MainActor - func installedNoiseCallbacks_publishStatusAndStableIDs() async { - let context = MockChatVerificationContext() - let coordinator = ChatVerificationCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - let noiseKey = Data(repeating: 0x33, count: 32) - context.noiseSessionKeysByPeerID[peerID] = noiseKey - context.cacheStablePeerID( - PeerID(hexData: Data(repeating: 0x44, count: 32)), - for: peerID - ) - context.verifiedFingerprints = ["fp-verified"] - - coordinator.setupNoiseCallbacks() - let callbacks = context.installedCallbacks - - // Authenticated with a verified fingerprint -> verified status and a - // cached stable peer ID derived from the session key. - callbacks?.onPeerAuthenticated(peerID, "fp-verified") - await waitForMainQueue() - #expect(context.encryptionStatuses[peerID] == .noiseVerified) - let stablePeerID = PeerID(hexData: noiseKey) - #expect(context.stablePeerIDCache[peerID] == stablePeerID) - #expect(context.invalidatedEncryptionCachePeers.contains(peerID)) - #expect(context.privateMediaAuthenticatedPeers == [peerID]) - #expect(context.securePrivateMessageRetryAliases == [[peerID, stablePeerID]]) - - // Handshake required -> handshaking status. - callbacks?.onHandshakeRequired(peerID) - await waitForMainQueue() - #expect(context.encryptionStatuses[peerID] == .noiseHandshaking) - } - - @Test @MainActor - func handleVerifyChallengePayload_postsMutualVerificationToastOncePerMinute() async { - let context = MockChatVerificationContext() - let coordinator = ChatVerificationCoordinator(context: context) - let peerID = PeerID(str: "1122334455667788") - let myHex = context.myNoiseStaticKey.hexEncodedString() - context.fingerprintsByPeerID[peerID] = "fp-mutual" - context.verifiedFingerprints = ["fp-mutual"] - - coordinator.handleVerifyChallengePayload( - from: peerID, - payload: makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: Data(repeating: 0x07, count: 16)) - ) - - // Already-verified peer challenging us: mutual-verification toast. - #expect(context.postedLocalNotifications.count == 1) - #expect(context.postedLocalNotifications.first?.title == "Mutual verification") - #expect(context.postedLocalNotifications.first?.body.hasSuffix("verified each other") == true) - #expect(context.postedLocalNotifications.first?.identifier.hasPrefix("verify-mutual-") == true) - - // A fresh nonce inside the per-fingerprint toast cooldown stays silent. - coordinator.handleVerifyChallengePayload( - from: peerID, - payload: makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: Data(repeating: 0x08, count: 16)) - ) - #expect(context.postedLocalNotifications.count == 1) - #expect(context.sentResponses.count == 2) - } -} - -/// The installed callbacks hop through `DispatchQueue.main.async`; tests must -/// let that queue drain before asserting. -@MainActor -private func waitForMainQueue() async { - await withCheckedContinuation { continuation in - DispatchQueue.main.async { continuation.resume() } - } -} diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 409b897f..057a97c3 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -13,11 +13,8 @@ import BitFoundation // MARK: - Test Helpers @MainActor -private func makeTestableViewModel( - keychain injectedKeychain: MockKeychain? = nil, - outboxStore: MessageOutboxStore? = nil -) -> (viewModel: ChatViewModel, transport: MockTransport) { - let keychain = injectedKeychain ?? MockKeychain() +private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { + let keychain = MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) @@ -27,8 +24,7 @@ private func makeTestableViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, - transport: transport, - outboxStore: outboxStore + transport: transport ) return (viewModel, transport) @@ -58,7 +54,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .read(by: "Peer", at: Date()) ) - viewModel.seedPrivateChat([message], for: peerID) + viewModel.privateChats[peerID] = [message] // Action: try to downgrade to .delivered viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) @@ -71,117 +67,6 @@ struct ChatViewModelDeliveryStatusTests { }()) } - @Test @MainActor - func deliveryStatus_noDowngrade_carriedToSent() async { - // Regression: the optimistic `.sent` stamp the send path writes after - // routing must not clobber the `.carried` the router already set when - // it handed a copy to a courier/bridge (store-and-forward), or the - // offline-favorite flow shows "sent" instead of 📦 carried. - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0102030405060708") - let messageID = "test-msg-carried" - - let message = BitchatMessage( - id: messageID, - sender: viewModel.nickname, - content: "Test message", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: transport.myPeerID, - deliveryStatus: .carried - ) - viewModel.seedPrivateChat([message], for: peerID) - - viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent) - - let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus - #expect({ - if case .carried = currentStatus { return true } - return false - }()) - } - - @Test @MainActor - func deliveryStatus_noDowngrade_carriedToSending() async { - // Regression: a pre-handshake resend stamps `.sending`; it must not - // wipe the 📦 carried indicator (nor a delivered/read ack). - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0102030405060708") - let messageID = "test-msg-carried-sending" - - let message = BitchatMessage( - id: messageID, - sender: viewModel.nickname, - content: "Test message", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: transport.myPeerID, - deliveryStatus: .carried - ) - viewModel.seedPrivateChat([message], for: peerID) - - viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sending) - - let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus - #expect({ - if case .carried = currentStatus { return true } - return false - }()) - - #expect(Conversation.shouldSkipStatusUpdate(current: .delivered(to: "Peer", at: Date()), new: .sending)) - #expect(Conversation.shouldSkipStatusUpdate(current: .read(by: "Peer", at: Date()), new: .sending)) - #expect(Conversation.shouldSkipStatusUpdate( - current: .delivered(to: "Peer", at: Date()), - new: .failed(reason: "late transport failure") - )) - #expect(Conversation.shouldSkipStatusUpdate( - current: .read(by: "Peer", at: Date()), - new: .failed(reason: "late transfer failure") - )) - // A late async `.sending` (pre-handshake resend) must not visibly - // downgrade a truthful "Sent" either... - #expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending)) - // ...but a retry after a real failure stays visible. - #expect(!Conversation.shouldSkipStatusUpdate(current: .failed(reason: "no route"), new: .sending)) - // .notSentYet is the pre-transport initial state: leaving it is always - // allowed, returning to it never is. - #expect(!Conversation.shouldSkipStatusUpdate(current: .notSentYet, new: .sending)) - #expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .notSentYet)) - } - - @Test @MainActor - func deliveryStatus_upgrade_carriedToDelivered() async { - // A delivery ack must still promote a carried message. - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0102030405060708") - let messageID = "test-msg-carried-delivered" - - let message = BitchatMessage( - id: messageID, - sender: viewModel.nickname, - content: "Test message", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: transport.myPeerID, - deliveryStatus: .carried - ) - viewModel.seedPrivateChat([message], for: peerID) - - viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) - - let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus - #expect({ - if case .delivered = currentStatus { return true } - return false - }()) - } - @Test @MainActor func deliveryStatus_upgrade_sentToDelivered() async { let (viewModel, transport) = makeTestableViewModel() @@ -200,7 +85,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .sent ) - viewModel.seedPrivateChat([message], for: peerID) + viewModel.privateChats[peerID] = [message] // Action: upgrade to .delivered viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) @@ -213,30 +98,6 @@ struct ChatViewModelDeliveryStatusTests { }()) } - @Test @MainActor - func deliveryStatus_identicalUpdateIsNoop() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0102030405060708") - let messageID = "test-msg-identical" - let message = BitchatMessage( - id: messageID, - sender: viewModel.nickname, - content: "Test message", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: transport.myPeerID, - deliveryStatus: .sent - ) - viewModel.seedPrivateChat([message], for: peerID) - - let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: .sent) - - #expect(!didUpdate) - #expect(isSent(viewModel.privateChats[peerID]?.first?.deliveryStatus)) - } - @Test @MainActor func deliveryStatus_upgrade_deliveredToRead() async { let (viewModel, transport) = makeTestableViewModel() @@ -255,7 +116,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60)) ) - viewModel.seedPrivateChat([message], for: peerID) + viewModel.privateChats[peerID] = [message] // Action: upgrade to .read viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date())) @@ -288,7 +149,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .sent ) - viewModel.seedPrivateChat([message], for: peerID) + viewModel.privateChats[peerID] = [message] // Action: receive read receipt let receipt = ReadReceipt( @@ -306,221 +167,6 @@ struct ChatViewModelDeliveryStatusTests { }()) } - @Test @MainActor - func authenticatedNoiseAckCannotClearAnotherPeersRetryState() async { - let (viewModel, transport) = makeTestableViewModel() - let intendedPeer = PeerID(str: "0102030405060708") - let otherPeer = PeerID(str: "1112131415161718") - let messageID = "noise-peer-bound-ack" - - viewModel.seedPrivateChat( - [ - BitchatMessage( - id: messageID, - sender: viewModel.nickname, - content: "Keep retrying", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Intended", - senderPeerID: viewModel.myPeerID, - deliveryStatus: .sent - ) - ], - for: intendedPeer - ) - transport.reachablePeers.insert(intendedPeer) - viewModel.messageRouter.sendPrivate( - "Keep retrying", - to: intendedPeer, - recipientNickname: "Intended", - messageID: messageID - ) - #expect(transport.sentPrivateMessages.count == 1) - - // This models a decrypted Noise receipt: the transport-authenticated - // peer is authoritative, not the attacker-controlled message ID. - viewModel.didReceiveNoisePayload( - from: otherPeer, - type: .delivered, - payload: Data(messageID.utf8), - timestamp: Date() - ) - for _ in 0..<10 { await Task.yield() } - - #expect(isSent(viewModel.conversations.deliveryStatus(forMessageID: messageID))) - viewModel.messageRouter.flushOutbox(for: intendedPeer) - #expect(transport.sentPrivateMessages.count == 2) - - viewModel.didReceiveNoisePayload( - from: intendedPeer, - type: .delivered, - payload: Data(messageID.utf8), - timestamp: Date() - ) - for _ in 0..<10 { await Task.yield() } - - #expect(isDelivered(viewModel.conversations.deliveryStatus(forMessageID: messageID))) - viewModel.messageRouter.flushOutbox(for: intendedPeer) - #expect(transport.sentPrivateMessages.count == 2) - } - - @Test @MainActor - func authenticatedNoiseAckClearsOnlyIntendedPeersPrivateMediaRetry() async throws { - let (viewModel, transport) = makeTestableViewModel() - let intendedPeer = PeerID(str: "0102030405060708") - let otherPeer = PeerID(str: "1112131415161718") - let fileName = "voice_0011223344556677.m4a" - let content = Data("voice".utf8) - - transport.privateMediaPolicies[intendedPeer] = .encrypted - transport.privateMediaReceiptSessionGenerations[intendedPeer] = UUID() - viewModel.selectedPrivateChatPeer = intendedPeer - let coordinator = ChatMediaTransferCoordinator( - context: viewModel, - prepareVoiceNotePacket: { _ in - BitchatFilePacket( - fileName: fileName, - fileSize: UInt64(content.count), - mimeType: "audio/mp4", - content: content - ) - }, - transferIDFactory: { "\($0)-receipt-ack" } - ) - viewModel.mediaTransferCoordinator = coordinator - - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent( - "scoped-media-ack-\(UUID().uuidString)", - isDirectory: true - ) - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - let sourceURL = directory.appendingPathComponent(fileName) - try content.write(to: sourceURL) - defer { try? FileManager.default.removeItem(at: directory) } - - coordinator.sendVoiceNote(at: sourceURL) - #expect(await TestHelpers.waitUntil( - { - transport.sentPrivateFiles.count == 1 - && coordinator.retainedReconnectRetryCount == 1 - }, - timeout: TestConstants.longTimeout - )) - let messageID = try #require( - viewModel.privateChats[intendedPeer]?.first?.id - ) - let transferID = try #require( - transport.sentPrivateFiles.first?.transferID - ) - - #expect(!viewModel.deliveryCoordinator - .updateAcknowledgedMessageDeliveryStatus( - messageID, - status: .delivered(to: "Other", at: Date()), - from: [otherPeer] - )) - #expect(coordinator.retainedReconnectRetryCount == 1) - #expect(transport.cancelledTransfers.isEmpty) - - #expect(viewModel.deliveryCoordinator - .updateAcknowledgedMessageDeliveryStatus( - messageID, - status: .delivered(to: "Intended", at: Date()), - from: [intendedPeer] - )) - #expect(coordinator.retainedReconnectRetryCount == 0) - #expect(transport.cancelledTransfers == [transferID]) - } - - @Test @MainActor - func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0102030405060709") - - let message = BitchatMessage( - id: "keep-receipt", - sender: viewModel.nickname, - content: "Keep me", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: transport.myPeerID, - deliveryStatus: .sent - ) - viewModel.seedPrivateChat([message], for: peerID) - viewModel.sentReadReceipts = ["keep-receipt", "drop-receipt"] - viewModel.isStartupPhase = false - - viewModel.cleanupOldReadReceipts() - - #expect(viewModel.sentReadReceipts == ["keep-receipt"]) - } - - // MARK: - Relaunch-Restored Outbox Tests - - @Test @MainActor - func deliveryAckAfterRelaunchClearsRestoredOutboxWithoutConversation() async { - // Force-quit → relaunch: the durable outbox restores the retained DM, - // but the in-memory conversation store starts empty. The delivery ack - // must still clear the router's retained copy — gating it on the - // conversation lookup would leave the entry re-sending on every - // flush/auth event until the attempt cap marks it failed despite - // delivery. - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("relaunch-ack-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "0102030405060708") - let messageID = "relaunch-retained-dm" - - // Pre-quit state: one retained private message on disk. - MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([ - peerID: [MessageOutboxStore.QueuedMessage( - content: "Sent before force-quit", - nickname: "Peer", - messageID: messageID, - timestamp: Date() - )] - ]) - - // Relaunch: fresh view model over the same durable store. - let (viewModel, transport) = makeTestableViewModel( - keychain: keychain, - outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) - ) - #expect(viewModel.privateChats[peerID] == nil) - - // The peer's delivery ack arrives with no conversation in the store. - // No UI transition is possible (and none must crash), but the durable - // retry state has to clear. - let didUpdate = viewModel.deliveryCoordinator - .updateAcknowledgedMessageDeliveryStatus( - messageID, - status: .delivered(to: "Peer", at: Date()), - from: [peerID] - ) - #expect(!didUpdate) - #expect(viewModel.privateChats[peerID] == nil) - - // Neither a flush nor a replacement-handshake auth event may re-send - // the already-delivered message. - transport.connectedPeers.insert(peerID) - transport.securePeers = [peerID] - viewModel.messageRouter.flushOutbox(for: peerID) - viewModel.messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) - #expect(transport.sentPrivateMessages.isEmpty) - - // The clear reached the durable snapshot: the next relaunch restores - // nothing. - #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) - } - // MARK: - Public Timeline Status Tests @Test @MainActor @@ -538,7 +184,7 @@ struct ChatViewModelDeliveryStatusTests { isPrivate: false, deliveryStatus: .sending ) - viewModel.seedPublicMessages([message]) + viewModel.messages.append(message) // Action: update to .sent viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent) @@ -551,196 +197,17 @@ struct ChatViewModelDeliveryStatusTests { }()) } - @Test @MainActor - func deliveryStatus_updatesPublicAndMirroredPrivateMessages() async { - let (viewModel, transport) = makeTestableViewModel() - let messageID = "mirrored-msg-1" - let firstPeerID = PeerID(str: "0102030405060708") - let secondPeerID = PeerID(str: "1112131415161718") - - viewModel.seedPublicMessages([ - BitchatMessage( - id: messageID, - sender: viewModel.nickname, - content: "Public copy", - timestamp: Date(), - isRelay: false, - isPrivate: false, - senderPeerID: transport.myPeerID, - deliveryStatus: .sent - ) - ]) - viewModel.seedPrivateChat([ - BitchatMessage( - id: messageID, - sender: viewModel.nickname, - content: "Private copy A", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer A", - senderPeerID: transport.myPeerID, - deliveryStatus: .sent - ) - ], for: firstPeerID) - viewModel.seedPrivateChat([ - BitchatMessage( - id: messageID, - sender: viewModel.nickname, - content: "Private copy B", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer B", - senderPeerID: transport.myPeerID, - deliveryStatus: .sent - ) - ], for: secondPeerID) - - let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus( - messageID, - status: .delivered(to: "Peer", at: Date()) - ) - - #expect(didUpdate) - #expect(isDelivered(viewModel.messages.first?.deliveryStatus)) - #expect(isDelivered(viewModel.privateChats[firstPeerID]?.first?.deliveryStatus)) - #expect(isDelivered(viewModel.privateChats[secondPeerID]?.first?.deliveryStatus)) - } - - @Test @MainActor - func deliveryStatus_survivesPrivateChatReorder() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0102030405060708") - let messageID = "reordered-msg-1" - let olderMessage = BitchatMessage( - id: "older-msg", - sender: viewModel.nickname, - content: "Older message", - timestamp: Date(timeIntervalSince1970: 1), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: transport.myPeerID, - deliveryStatus: .sent - ) - let targetMessage = BitchatMessage( - id: messageID, - sender: viewModel.nickname, - content: "Target message", - timestamp: Date(timeIntervalSince1970: 2), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: transport.myPeerID, - deliveryStatus: .sent - ) - - viewModel.seedPrivateChat([targetMessage], for: peerID) - #expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID))) - - // A late arrival with an older timestamp is inserted before the - // target by the store, shifting its position; the store's ID-keyed - // indexes must keep the target updatable. - viewModel.seedPrivateChat([olderMessage], for: peerID) - #expect(viewModel.privateChats[peerID]?.map(\.id) == ["older-msg", messageID]) - - let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus( - messageID, - status: .read(by: "Peer", at: Date()) - ) - - #expect(didUpdate) - #expect(isRead(viewModel.privateChats[peerID]?.last?.deliveryStatus)) - } - - // MARK: - MessageRouter Drop Wiring Tests - - /// Drives a real outbox drop (per-peer overflow eviction with no - /// reachable transport) and proves the bootstrapper wiring marks the - /// dropped message `.failed` in the conversation store. - @Test @MainActor - func messageRouterDrop_marksMessageFailedInStore() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0102030405060708") - let droppedID = "router-drop-0" - - let message = BitchatMessage( - id: droppedID, - sender: viewModel.nickname, - content: "Will be dropped", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: transport.myPeerID, - deliveryStatus: .sent - ) - viewModel.seedPrivateChat([message], for: peerID) - - // No transport is reachable, so every send is queued; the 101st - // enqueue for this peer evicts the oldest queued message. - viewModel.messageRouter.sendPrivate("Will be dropped", to: peerID, recipientNickname: "Peer", messageID: droppedID) - for i in 1...100 { - viewModel.messageRouter.sendPrivate("Filler \(i)", to: peerID, recipientNickname: "Peer", messageID: "router-drop-\(i)") - } - - let status = viewModel.conversations.deliveryStatus(forMessageID: droppedID) - #expect({ - if case .failed = status { return true } - return false - }()) - } - - /// The store's no-downgrade rule does not cover `.failed` over confirmed - /// receipts, so the wiring guards it: a drop of an already-delivered - /// message must not downgrade its status. - @Test @MainActor - func messageRouterDrop_doesNotDowngradeDeliveredStatus() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0102030405060708") - let droppedID = "router-drop-delivered" - - let message = BitchatMessage( - id: droppedID, - sender: viewModel.nickname, - content: "Already delivered", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: transport.myPeerID, - deliveryStatus: .delivered(to: "Peer", at: Date()) - ) - viewModel.seedPrivateChat([message], for: peerID) - - // Same eviction-driven drop as above, but the store already recorded - // a delivery confirmation for the message. - viewModel.messageRouter.sendPrivate("Already delivered", to: peerID, recipientNickname: "Peer", messageID: droppedID) - for i in 1...100 { - viewModel.messageRouter.sendPrivate("Filler \(i)", to: peerID, recipientNickname: "Peer", messageID: "router-keep-\(i)") - } - - let status = viewModel.conversations.deliveryStatus(forMessageID: droppedID) - #expect({ - if case .delivered = status { return true } - return false - }()) - } - // MARK: - Status Rank Tests (for deduplication) @Test @MainActor func statusRank_orderingIsCorrect() async { // This tests the implicit ordering used in refreshVisibleMessages - // notSentYet < failed < sending < sent < carried < partiallyDelivered < delivered < read + // failed < sending < sent < partiallyDelivered < delivered < read let statuses: [DeliveryStatus] = [ - .notSentYet, .failed(reason: "test"), .sending, .sent, - .carried, .partiallyDelivered(reached: 1, total: 3), .delivered(to: "B", at: Date()), .read(by: "C", at: Date()) @@ -750,480 +217,13 @@ struct ChatViewModelDeliveryStatusTests { // This is more of a documentation test to ensure the ranking logic is understood for (index, status) in statuses.enumerated() { switch status { - case .notSentYet: #expect(index == 0) - case .failed: #expect(index == 1) - case .sending: #expect(index == 2) - case .sent: #expect(index == 3) - case .carried: #expect(index == 4) - case .partiallyDelivered: #expect(index == 5) - case .delivered: #expect(index == 6) - case .read: #expect(index == 7) + case .failed: #expect(index == 0) + case .sending: #expect(index == 1) + case .sent: #expect(index == 2) + case .partiallyDelivered: #expect(index == 3) + case .delivered: #expect(index == 4) + case .read: #expect(index == 5) } } } } - -// MARK: - Mock Delivery Context - -/// Lightweight stand-in for `ChatDeliveryContext` proving that -/// `ChatDeliveryCoordinator` is testable without constructing a -/// `ChatViewModel`: the delivery surface forwards to a real -/// `ConversationStore` (the coordinator is a thin mapper over store -/// intents), and assertions read store state. -@MainActor -private final class MockChatDeliveryContext: ChatDeliveryContext { - let store = ConversationStore() - var sentReadReceipts: Set = [] - var isStartupPhase = false - private(set) var notifyUIChangedCount = 0 - private(set) var markedDeliveredMessageIDs: [String] = [] - private(set) var peerBoundDeliveredMessages: [(messageID: String, peerIDs: Set)] = [] - private(set) var confirmedMediaMessageIDs: [String] = [] - - @discardableResult - func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { - store.setDeliveryStatus(status, forMessageID: messageID) - } - - @discardableResult - func setDeliveryStatus( - _ status: DeliveryStatus, - forMessageID messageID: String, - inDirectPeerAliases peerIDs: Set - ) -> Bool { - store.setDeliveryStatus( - status, - forMessageID: messageID, - inDirectPeerAliases: peerIDs - ) - } - - func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { - store.deliveryStatus(forMessageID: messageID) - } - - func privateMessageIDs() -> Set { - store.directMessageIDs() - } - - func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int { - let oldCount = sentReadReceipts.count - sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) - return oldCount - sentReadReceipts.count - } - - func notifyUIChanged() { - notifyUIChangedCount += 1 - } - - func markMessageDelivered(_ messageID: String) { - markedDeliveredMessageIDs.append(messageID) - } - - func markMessageDelivered(_ messageID: String, from peerIDs: Set) { - peerBoundDeliveredMessages.append((messageID, peerIDs)) - } - - func confirmPrivateMediaDelivery(_ messageID: String) { - confirmedMediaMessageIDs.append(messageID) - } - - func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool { - peerIDs.contains { peerID in - contextMessages(for: peerID).contains { message in - message.id == messageID && message.senderPeerID == localPeerID - } - } - } - - private let localPeerID = PeerID(str: "aabbccddeeff0011") - - private func contextMessages(for peerID: PeerID) -> [BitchatMessage] { - store.conversationsByID[.directPeer(peerID)]?.messages ?? [] - } -} - -@MainActor -private func makePrivateMessage( - id: String, - status: DeliveryStatus, - timestamp: Date = Date() -) -> BitchatMessage { - BitchatMessage( - id: id, - sender: "me", - content: "Test message", - timestamp: timestamp, - isRelay: false, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: PeerID(str: "aabbccddeeff0011"), - deliveryStatus: status - ) -} - -@MainActor -private func makePublicMessage( - id: String, - status: DeliveryStatus, - timestamp: Date = Date() -) -> BitchatMessage { - BitchatMessage( - id: id, - sender: "me", - content: "Public message", - timestamp: timestamp, - isRelay: false, - isPrivate: false, - deliveryStatus: status - ) -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `ChatDeliveryCoordinator` against `MockChatDeliveryContext` — -/// the exemplar for the narrow-dependency coordinator pattern. State is -/// seeded into and asserted against the mock's `ConversationStore`. -struct ChatDeliveryCoordinatorContextTests { - - @Test @MainActor - func updateDeliveryStatus_updatesPrivateChatNotifiesAndMarksDelivered() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - let peerID = PeerID(str: "0102030405060708") - let messageID = "mock-msg-1" - context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(peerID)) - - let didUpdate = coordinator.updateMessageDeliveryStatus( - messageID, - status: .delivered(to: "Peer", at: Date()) - ) - - #expect(didUpdate) - #expect(isDelivered(context.store.conversation(for: .directPeer(peerID)).message(withID: messageID)?.deliveryStatus)) - #expect(context.notifyUIChangedCount == 1) - #expect(context.markedDeliveredMessageIDs == [messageID]) - } - - @Test @MainActor - func readReceipt_marksDeliveredAndUpgradesStatus() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - let peerID = PeerID(str: "0102030405060708") - let messageID = "mock-msg-2" - context.store.append( - makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date())), - to: .directPeer(peerID) - ) - - coordinator.didReceiveReadReceipt( - ReadReceipt(originalMessageID: messageID, readerID: peerID, readerNickname: "Peer") - ) - - #expect(isRead(coordinator.deliveryStatus(for: messageID))) - #expect(context.notifyUIChangedCount == 1) - #expect(context.markedDeliveredMessageIDs.isEmpty) - #expect(context.peerBoundDeliveredMessages.count == 1) - #expect(context.peerBoundDeliveredMessages[0].messageID == messageID) - #expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID]) - } - - @Test @MainActor - func authenticatedReceiptWithCollidingIDUpdatesOnlyAuthenticatedAliases() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - let ephemeralPeerID = PeerID(str: "0102030405060708") - let stablePeerID = PeerID(hexData: Data(repeating: 0x08, count: 32)) - let otherPeerID = PeerID(str: "1112131415161718") - let messageID = "authenticated-receipt-collision" - let mirroredMessage = makePrivateMessage(id: messageID, status: .sent) - - context.store.append(mirroredMessage, to: .directPeer(ephemeralPeerID)) - context.store.append(mirroredMessage, to: .directPeer(stablePeerID)) - context.store.append( - makePrivateMessage(id: messageID, status: .sent), - to: .directPeer(otherPeerID) - ) - context.store.append(makePublicMessage(id: messageID, status: .sent), to: .mesh) - - let aliases: Set = [ephemeralPeerID, stablePeerID] - let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus( - messageID, - status: .delivered(to: "Peer", at: Date()), - from: aliases - ) - - #expect(didUpdate) - #expect(isDelivered( - context.store.conversation(for: .directPeer(ephemeralPeerID)) - .message(withID: messageID)?.deliveryStatus - )) - #expect(isDelivered( - context.store.conversation(for: .directPeer(stablePeerID)) - .message(withID: messageID)?.deliveryStatus - )) - #expect(isSent( - context.store.conversation(for: .directPeer(otherPeerID)) - .message(withID: messageID)?.deliveryStatus - )) - #expect(isSent( - context.store.conversation(for: .mesh) - .message(withID: messageID)?.deliveryStatus - )) - #expect(context.peerBoundDeliveredMessages.count == 1) - #expect(context.peerBoundDeliveredMessages[0].messageID == messageID) - #expect(context.peerBoundDeliveredMessages[0].peerIDs == aliases) - #expect(context.notifyUIChangedCount == 1) - } - - @Test @MainActor - func authenticatedReceiptRemainsScopedAfterPeerAliasMigration() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - let ephemeralPeerID = PeerID(str: "2122232425262728") - let stablePeerID = PeerID(hexData: Data(repeating: 0x28, count: 32)) - let otherPeerID = PeerID(str: "3132333435363738") - let messageID = "authenticated-receipt-after-migration" - - context.store.append( - makePrivateMessage(id: messageID, status: .sent), - to: .directPeer(ephemeralPeerID) - ) - context.store.append( - makePrivateMessage(id: messageID, status: .sent), - to: .directPeer(otherPeerID) - ) - context.store.migrateConversation( - from: .directPeer(ephemeralPeerID), - to: .directPeer(stablePeerID) - ) - - let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus( - messageID, - status: .read(by: "Peer", at: Date()), - from: [ephemeralPeerID, stablePeerID] - ) - - #expect(didUpdate) - #expect(context.store.conversationsByID[.directPeer(ephemeralPeerID)] == nil) - #expect(isRead( - context.store.conversation(for: .directPeer(stablePeerID)) - .message(withID: messageID)?.deliveryStatus - )) - #expect(isSent( - context.store.conversation(for: .directPeer(otherPeerID)) - .message(withID: messageID)?.deliveryStatus - )) - } - - @Test @MainActor - func receiptFromWrongPeerDoesNotUpdateOrTerminalizeOutgoingMessage() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - let intendedPeer = PeerID(str: "0102030405060708") - let otherPeer = PeerID(str: "1112131415161718") - let messageID = "wrong-peer-receipt" - context.store.append( - makePrivateMessage(id: messageID, status: .sent), - to: .directPeer(intendedPeer) - ) - - coordinator.didReceiveReadReceipt( - ReadReceipt( - originalMessageID: messageID, - readerID: otherPeer, - readerNickname: "Other" - ) - ) - - #expect(isSent(coordinator.deliveryStatus(for: messageID))) - // The router-side clear runs, but bound only to the wrong peer's own - // aliases — a scoped no-op that cannot touch the intended peer's - // retained copy. Status, media retry, and UI stay untouched. - #expect(context.peerBoundDeliveredMessages.count == 1) - #expect(context.peerBoundDeliveredMessages[0].messageID == messageID) - #expect(context.peerBoundDeliveredMessages[0].peerIDs == [otherPeer]) - #expect(context.markedDeliveredMessageIDs.isEmpty) - #expect(context.confirmedMediaMessageIDs.isEmpty) - #expect(context.notifyUIChangedCount == 0) - } - - @Test @MainActor - func rejectedStaleReceiptDoesNotDowngradeStatusOrNotify() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - let peerID = PeerID(str: "0102030405060708") - let messageID = "stale-receipt" - context.store.append( - makePrivateMessage( - id: messageID, - status: .read(by: "Peer", at: Date()) - ), - to: .directPeer(peerID) - ) - - let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus( - messageID, - status: .delivered(to: "Peer", at: Date()), - from: [peerID] - ) - - #expect(!didUpdate) - #expect(isRead(coordinator.deliveryStatus(for: messageID))) - // The peer-scoped router clear re-runs (idempotent: the earlier read - // receipt already emptied this peer's retained copy), but the stale - // delivered ack must not downgrade the read status, release media, or - // notify the UI. - #expect(context.peerBoundDeliveredMessages.count == 1) - #expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID]) - #expect(context.markedDeliveredMessageIDs.isEmpty) - #expect(context.confirmedMediaMessageIDs.isEmpty) - #expect(context.notifyUIChangedCount == 0) - } - - @Test @MainActor - func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - context.store.append(makePublicMessage(id: "public-mock-1", status: .sending), to: .mesh) - - // .sent is not a confirmed receipt — must not reach markMessageDelivered. - let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent) - #expect(didUpdate) - #expect(isSent(context.store.conversation(for: .mesh).message(withID: "public-mock-1")?.deliveryStatus)) - #expect(context.markedDeliveredMessageIDs.isEmpty) - #expect(context.notifyUIChangedCount == 1) - - // Unknown message: no state change, no extra UI notification. - let didUpdateUnknown = coordinator.updateMessageDeliveryStatus("missing-msg", status: .sent) - #expect(!didUpdateUnknown) - #expect(context.notifyUIChangedCount == 1) - } - - // The old positional `messageLocationIndex` could go stale when a late - // arrival was inserted mid-array (count grew but indexed locations - // shifted). The store's per-conversation ID index is reindexed inside the - // same mutation, so staleness is structurally impossible — these tests - // pin the equivalent behavior through the new path: after out-of-order - // insertion, updates keyed by ID still land on the right messages. - - @Test @MainActor - func middleInsertedMessage_isStillUpdatableByID() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - context.store.append( - makePublicMessage(id: "public-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)), - to: .mesh - ) - context.store.append( - makePublicMessage(id: "public-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)), - to: .mesh - ) - - #expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent)) - - // Out-of-order arrival: the store inserts by timestamp, shifting the - // tail's position. - context.store.append( - makePublicMessage(id: "public-mid", status: .sending, timestamp: Date(timeIntervalSince1970: 20)), - to: .mesh - ) - let mesh = context.store.conversation(for: .mesh) - #expect(mesh.messages.map(\.id) == ["public-a", "public-mid", "public-b"]) - - // Both the inserted message and the shifted tail stay updatable. - #expect(coordinator.updateMessageDeliveryStatus("public-mid", status: .sent)) - #expect(isSent(mesh.message(withID: "public-mid")?.deliveryStatus)) - #expect(coordinator.updateMessageDeliveryStatus("public-b", status: .sent)) - #expect(isSent(mesh.message(withID: "public-b")?.deliveryStatus)) - } - - @Test @MainActor - func middleInsertedPrivateMessage_isStillUpdatableByID() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - let peerID = PeerID(str: "0102030405060708") - let conversationID = ConversationID.directPeer(peerID) - context.store.append( - makePrivateMessage(id: "pm-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)), - to: conversationID - ) - context.store.append( - makePrivateMessage(id: "pm-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)), - to: conversationID - ) - - #expect(coordinator.updateMessageDeliveryStatus("pm-a", status: .sent)) - - // A late arrival with an older timestamp lands mid-array. - context.store.append( - makePrivateMessage(id: "pm-mid", status: .sending, timestamp: Date(timeIntervalSince1970: 20)), - to: conversationID - ) - let chat = context.store.conversation(for: conversationID) - #expect(chat.messages.map(\.id) == ["pm-a", "pm-mid", "pm-b"]) - - #expect(coordinator.updateMessageDeliveryStatus("pm-mid", status: .sent)) - #expect(isSent(chat.message(withID: "pm-mid")?.deliveryStatus)) - #expect(coordinator.updateMessageDeliveryStatus("pm-b", status: .sent)) - #expect(isSent(chat.message(withID: "pm-b")?.deliveryStatus)) - } - - @Test @MainActor - func mirroredPrivateCopies_bothReceiveDeliveryUpdate() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - let stablePeerID = PeerID(str: String(repeating: "ab", count: 32)) - let ephemeralPeerID = PeerID(str: "0102030405060708") - let messageID = "mirrored-mock-1" - - // Step 2's keying mirrors a private message into both the stable-key - // and ephemeral-peer conversations (distinct copies here to prove - // per-conversation application, not shared-reference aliasing). - context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(stablePeerID)) - context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(ephemeralPeerID)) - - let didUpdate = coordinator.updateMessageDeliveryStatus( - messageID, - status: .delivered(to: "Peer", at: Date()) - ) - - #expect(didUpdate) - #expect(isDelivered(context.store.conversation(for: .directPeer(stablePeerID)).message(withID: messageID)?.deliveryStatus)) - #expect(isDelivered(context.store.conversation(for: .directPeer(ephemeralPeerID)).message(withID: messageID)?.deliveryStatus)) - #expect(context.markedDeliveredMessageIDs == [messageID]) - } - - @Test @MainActor - func cleanupOldReadReceipts_prunesReceiptsAgainstMockContext() async { - let context = MockChatDeliveryContext() - let coordinator = ChatDeliveryCoordinator(context: context) - let peerID = PeerID(str: "0102030405060708") - context.store.append(makePrivateMessage(id: "keep-receipt", status: .sent), to: .directPeer(peerID)) - context.sentReadReceipts = ["keep-receipt", "drop-receipt"] - - // Startup phase: cleanup must be a no-op. - context.isStartupPhase = true - coordinator.cleanupOldReadReceipts() - #expect(context.sentReadReceipts == ["keep-receipt", "drop-receipt"]) - - context.isStartupPhase = false - coordinator.cleanupOldReadReceipts() - #expect(context.sentReadReceipts == ["keep-receipt"]) - } -} - -private func isSent(_ status: DeliveryStatus?) -> Bool { - if case .sent = status { return true } - return false -} - -private func isDelivered(_ status: DeliveryStatus?) -> Bool { - if case .delivered = status { return true } - return false -} - -private func isRead(_ status: DeliveryStatus?) -> Bool { - if case .read = status { return true } - return false -} diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 6f9609ee..13934872 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -71,11 +71,8 @@ struct ChatViewModelPrivateChatExtensionTests { // Check MockTransport implementation... it might need update or verification } - /// An unreachable recipient no longer means instant failure: the message - /// is routed anyway so the router's outbox/courier/bridge machinery can - /// deliver it, and it stays "sending" until a router callback resolves it. @Test @MainActor - func sendPrivateMessage_unreachable_staysSendingForStoreAndForward() async { + func sendPrivateMessage_unreachable_setsFailedStatus() async { let (viewModel, _) = makeTestableViewModel() let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10" let peerID = PeerID(str: validHex) @@ -83,7 +80,11 @@ struct ChatViewModelPrivateChatExtensionTests { viewModel.sendPrivateMessage("Hello", to: peerID) #expect(viewModel.privateChats[peerID]?.count == 1) - #expect(viewModel.privateChats[peerID]?.last?.deliveryStatus == .sending) + let status = viewModel.privateChats[peerID]?.last?.deliveryStatus + #expect({ + if case .failed = status { return true } + return false + }()) } @Test @MainActor @@ -176,7 +177,7 @@ struct ChatViewModelPrivateChatExtensionTests { isPrivate: true, senderPeerID: oldPeerID ) - viewModel.seedPrivateChat([oldMessage], for: oldPeerID) + viewModel.privateChats[oldPeerID] = [oldMessage] viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint // Setup new peer fingerprint @@ -224,7 +225,7 @@ struct ChatViewModelPrivateChatExtensionTests { // "Check geohash (Nostr) blocks using mapping to full pubkey" let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001" - viewModel.registerNostrKeyMapping(hexPubkey, for: blockedPeerID) + viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true) // Force isGeoChat/isGeoDM check to be true by setting prefix? @@ -234,7 +235,7 @@ struct ChatViewModelPrivateChatExtensionTests { // We need a peerID that looks like geo. let geoPeerID = PeerID(nostr_: hexPubkey) - viewModel.registerNostrKeyMapping(hexPubkey, for: geoPeerID) + viewModel.nostrKeyMapping[geoPeerID] = hexPubkey let geoMessage = BitchatMessage( id: "msg-geo-blocked", @@ -277,11 +278,11 @@ struct ChatViewModelNostrExtensionTests { LocationChannelManager.shared.select(channel) defer { LocationChannelManager.shared.select(.mesh) } - _ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }, timeout: TestConstants.settleTimeout) + _ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }) let (viewModel, _) = makeTestableViewModel() - _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }, timeout: TestConstants.settleTimeout) + _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }) let signer = try NostrIdentity.generate() let event = NostrEvent( @@ -296,23 +297,8 @@ struct ChatViewModelNostrExtensionTests { let didAppend = await TestHelpers.waitUntil({ viewModel.publicMessagePipeline.flushIfNeeded() - if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true } - // LocationChannelManager is a process-wide singleton: a suite - // running in parallel (e.g. CommandProcessorTests) can flip the - // selected channel mid-test, which reroutes or drops the event - // permanently — no amount of waiting recovers it. Re-assert the - // channel and redeliver on each poll: every channel switch clears - // the processed-event set and the store dedups by message ID, so - // redelivery is idempotent and interference heals on the next - // poll while a genuine failure still times out. - if LocationChannelManager.shared.selectedChannel != channel { - LocationChannelManager.shared.select(channel) - } - if viewModel.activeChannel == channel { - viewModel.handleNostrEvent(signed) - } - return false - }, timeout: TestConstants.settleTimeout) + return viewModel.messages.contains { $0.content == "Hello Geo" } + }) #expect(didAppend) } @@ -366,11 +352,31 @@ struct ChatViewModelNostrExtensionTests { #expect(!viewModel.messages.contains { $0.content == "Blocked" }) } - // NOTE: Tampered-signature rejection is enforced once, off the main - // actor, at the relay boundary (events only reach the inbound pipeline - // after verification) — see NostrRelayManagerTests - // `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and - // `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`. + @Test @MainActor + func handleNostrEvent_rejectsInvalidSignature() async throws { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + let identity = try NostrIdentity.generate() + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [["g", geohash]], + content: "Valid" + ) + var signed = try event.sign(with: identity.schnorrSigningKey()) + signed.id = "deadbeef" + + viewModel.handleNostrEvent(signed) + + try? await Task.sleep(nanoseconds: 100_000_000) + viewModel.publicMessagePipeline.flushIfNeeded() + + #expect(!viewModel.messages.contains { $0.content == "Tampered" }) + } @Test @MainActor func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws { @@ -438,7 +444,7 @@ struct ChatViewModelNostrExtensionTests { let convKey = PeerID(nostr_: sender.publicKeyHex) let messageID = "geo-ack-delivered" - viewModel.seedPrivateChat([ + viewModel.privateChats[convKey] = [ BitchatMessage( id: messageID, sender: viewModel.nickname, @@ -450,7 +456,7 @@ struct ChatViewModelNostrExtensionTests { senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sent ) - ], for: convKey) + ] let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let giftWrap = try NostrProtocol.createPrivateMessage( @@ -463,7 +469,7 @@ struct ChatViewModelNostrExtensionTests { let didUpdate = await TestHelpers.waitUntil( { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, - timeout: TestConstants.settleTimeout + timeout: 0.5 ) #expect(didUpdate) } @@ -476,7 +482,7 @@ struct ChatViewModelNostrExtensionTests { let convKey = PeerID(nostr_: sender.publicKeyHex) let messageID = "geo-ack-read" - viewModel.seedPrivateChat([ + viewModel.privateChats[convKey] = [ BitchatMessage( id: messageID, sender: viewModel.nickname, @@ -488,7 +494,7 @@ struct ChatViewModelNostrExtensionTests { senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .delivered(to: "Friend", at: Date()) ) - ], for: convKey) + ] let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let giftWrap = try NostrProtocol.createPrivateMessage( @@ -501,7 +507,7 @@ struct ChatViewModelNostrExtensionTests { let didUpdate = await TestHelpers.waitUntil( { isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, - timeout: TestConstants.settleTimeout + timeout: 0.5 ) #expect(didUpdate) } @@ -529,7 +535,7 @@ struct ChatViewModelNostrExtensionTests { let didStore = await TestHelpers.waitUntil( { viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" }, - timeout: TestConstants.settleTimeout + timeout: 0.5 ) #expect(didStore) #expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex) @@ -559,14 +565,9 @@ struct ChatViewModelNostrExtensionTests { viewModel.handleGiftWrap(giftWrap, id: recipient) - // Gift-wrap decryption runs off the main actor; wait for the ack - // (sent even for blocked senders) to know processing finished. - let didAck = await TestHelpers.waitUntil( - { viewModel.sentGeoDeliveryAcks.contains(messageID) }, - timeout: TestConstants.settleTimeout - ) - #expect(didAck) + try? await Task.sleep(nanoseconds: 50_000_000) #expect(viewModel.privateChats[convKey] == nil) + #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) } @Test @MainActor @@ -577,7 +578,7 @@ struct ChatViewModelNostrExtensionTests { let convKey = PeerID(nostr_: sender.publicKeyHex) let messageID = "gift-delivered" - viewModel.seedPrivateChat([ + viewModel.privateChats[convKey] = [ BitchatMessage( id: messageID, sender: viewModel.nickname, @@ -589,7 +590,7 @@ struct ChatViewModelNostrExtensionTests { senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sent ) - ], for: convKey) + ] let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let giftWrap = try NostrProtocol.createPrivateMessage( @@ -602,7 +603,7 @@ struct ChatViewModelNostrExtensionTests { let didUpdate = await TestHelpers.waitUntil( { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, - timeout: TestConstants.settleTimeout + timeout: 0.5 ) #expect(didUpdate) } @@ -639,10 +640,8 @@ struct ChatViewModelNostrExtensionTests { #expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey) } - /// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay - /// out of the conversation transcript. @Test @MainActor - func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws { + func handleFavoriteNotification_updatesFavoriteAssociation() async throws { let (viewModel, _) = makeTestableViewModel() let identity = try NostrIdentity.generate() let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) }) @@ -650,33 +649,19 @@ struct ChatViewModelNostrExtensionTests { FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: noiseKey, peerNostrPublicKey: identity.npub, - peerNickname: "Alice" + peerNickname: "Before" ) - defer { - FavoritesPersistenceService.shared.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) - FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) - } + defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) } - // The inbound pipeline resolves a known sender to their noise-key ID. - let convKey = PeerID(hexData: noiseKey) - let payloadData = try #require( - PrivateMessagePacket(messageID: "fav-e2e-1", content: "[FAVORITED]:\(identity.npub)").encode() - ) - let payload = NoisePayload(type: .privateMessage, data: payloadData) - - viewModel.handlePrivateMessage( - payload, - senderPubkey: identity.publicKeyHex, - convKey: convKey, - id: identity, - messageTimestamp: Date() + viewModel.handleFavoriteNotification( + content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice", + from: identity.publicKeyHex ) let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) - #expect(relationship?.theyFavoritedUs == true) - #expect(relationship?.isMutual == true) + #expect(relationship?.peerNickname == "Alice") #expect(relationship?.peerNostrPublicKey == identity.npub) - #expect(viewModel.privateChats[convKey, default: []].isEmpty) + #expect(relationship?.isFavorite == true) } @Test @MainActor @@ -746,11 +731,9 @@ struct ChatViewModelGeoDMTests { viewModel.sendGeohashDM("hello", to: convKey) - // The failure is surfaced inside the geoDM thread, not on the public - // timeline (matches the sibling in-thread errors from #1415). - #expect(viewModel.messages.isEmpty) - #expect(viewModel.privateChats[convKey]?.count == 1) - #expect(viewModel.privateChats[convKey]?.last?.sender == "system") + #expect(viewModel.privateChats[convKey] == nil) + #expect(viewModel.messages.count == 1) + #expect(viewModel.messages.last?.sender == "system") } @Test @MainActor @@ -766,26 +749,22 @@ struct ChatViewModelGeoDMTests { #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) } - /// The blocked notice belongs in the DM thread the person is typing in, - /// not on the active location-channel timeline. @Test @MainActor - func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessageInThread() async { + func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async { let (viewModel, _) = makeTestableViewModel() let geohash = "u4pruydq" let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003" let convKey = PeerID(nostr_: recipientHex) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) - viewModel.registerNostrKeyMapping(recipientHex, for: convKey) + viewModel.nostrKeyMapping[convKey] = recipientHex viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true) viewModel.sendGeohashDM("hello", to: convKey) - let thread = viewModel.privateChats[convKey] ?? [] - #expect(thread.count == 2) - #expect(isFailed(status: thread.first?.deliveryStatus)) - #expect(thread.last?.sender == "system") - #expect(!viewModel.messages.contains(where: { $0.sender == "system" })) + #expect(viewModel.privateChats[convKey]?.count == 1) + #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) + #expect(viewModel.messages.contains(where: { $0.sender == "system" })) } @Test @MainActor @@ -818,132 +797,6 @@ struct ChatViewModelGeoDMTests { } } -// MARK: - Single-Writer Intent Operation Tests - -/// Contracts for the owner-side intent ops that are the sole mutation paths -/// for `ChatViewModel`'s shared coordinator state (`nostrKeyMapping`, -/// `sentReadReceipts`, `sentGeoDeliveryAcks`, `isBatchingPublic`, the geo -/// subscription IDs, and the selected private chat hand-off). -struct ChatViewModelIntentOperationTests { - - @Test @MainActor - func markGeoDeliveryAckSent_returnsFalseOnSecondCall() async { - let (viewModel, _) = makeTestableViewModel() - - #expect(viewModel.markGeoDeliveryAckSent("geo-ack-1")) - #expect(!viewModel.markGeoDeliveryAckSent("geo-ack-1")) - #expect(viewModel.markGeoDeliveryAckSent("geo-ack-2")) - #expect(viewModel.sentGeoDeliveryAcks == ["geo-ack-1", "geo-ack-2"]) - } - - @Test @MainActor - func markReadReceiptSent_returnsFalseOnSecondCall() async { - let (viewModel, _) = makeTestableViewModel() - - #expect(viewModel.markReadReceiptSent("read-1")) - #expect(!viewModel.markReadReceiptSent("read-1")) - #expect(viewModel.sentReadReceipts.contains("read-1")) - } - - @Test @MainActor - func pruneSentReadReceipts_dropsStaleIDsAndReturnsRemovedCount() async { - let (viewModel, _) = makeTestableViewModel() - viewModel.sentReadReceipts = ["keep-1", "keep-2", "drop-1", "drop-2"] - - let removed = viewModel.pruneSentReadReceipts(keeping: ["keep-1", "keep-2", "unrelated"]) - - #expect(removed == 2) - #expect(viewModel.sentReadReceipts == ["keep-1", "keep-2"]) - // Nothing stale left: a second prune removes nothing. - #expect(viewModel.pruneSentReadReceipts(keeping: ["keep-1", "keep-2"]) == 0) - } - - @Test @MainActor - func registerNostrKeyMapping_isVisibleToNostrCoordinatorLookups() async { - let (viewModel, _) = makeTestableViewModel() - let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" - let convKey = PeerID(nostr_: hex) - - // Registered through the owner intent op (as e.g. the private - // conversation flow does) and resolved through the Nostr coordinator, - // which reads the same backing dictionary via `ChatNostrContext`. - viewModel.registerNostrKeyMapping(hex, for: convKey) - - #expect(viewModel.nostrKeyMapping[convKey] == hex) - #expect(viewModel.nostrCoordinator.fullNostrHex(forSenderPeerID: convKey) == hex) - } - - @Test @MainActor - func removeNostrKeyMappings_dropsEveryMappingForThePubkeyCaseInsensitively() async { - let (viewModel, _) = makeTestableViewModel() - let hex = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" - let otherHex = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100" - viewModel.registerNostrKeyMapping(hex.uppercased(), for: PeerID(nostr: hex)) - viewModel.registerNostrKeyMapping(hex, for: PeerID(nostr_: hex)) - viewModel.registerNostrKeyMapping(otherHex, for: PeerID(nostr_: otherHex)) - - viewModel.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex) - - #expect(viewModel.nostrKeyMapping[PeerID(nostr: hex)] == nil) - #expect(viewModel.nostrKeyMapping[PeerID(nostr_: hex)] == nil) - #expect(viewModel.nostrKeyMapping[PeerID(nostr_: otherHex)] == otherHex) - } - - @Test @MainActor - func setPublicBatching_publishesBatchingState() async { - let (viewModel, _) = makeTestableViewModel() - - #expect(!viewModel.isBatchingPublic) - viewModel.setPublicBatching(true) - #expect(viewModel.isBatchingPublic) - viewModel.setPublicBatching(false) - #expect(!viewModel.isBatchingPublic) - } - - @Test @MainActor - func geoSubscriptionIntentOps_setClearAndDrainSubscriptions() async { - let (viewModel, _) = makeTestableViewModel() - - viewModel.setGeoChatSubscriptionID("geo-u4pruy") - viewModel.setGeoDmSubscriptionID("geo-dm-u4pruy") - #expect(viewModel.geoSubscriptionID == "geo-u4pruy") - #expect(viewModel.geoDmSubscriptionID == "geo-dm-u4pruy") - - viewModel.setGeoChatSubscriptionID(nil) - viewModel.setGeoDmSubscriptionID(nil) - #expect(viewModel.geoSubscriptionID == nil) - #expect(viewModel.geoDmSubscriptionID == nil) - - viewModel.addGeoSamplingSub("geo-sample-aaaa", forGeohash: "aaaa") - viewModel.addGeoSamplingSub("geo-sample-bbbb", forGeohash: "bbbb") - viewModel.removeGeoSamplingSub("geo-sample-aaaa") - #expect(viewModel.geoSamplingSubs == ["geo-sample-bbbb": "bbbb"]) - - let cleared = viewModel.clearGeoSamplingSubs() - #expect(cleared == ["geo-sample-bbbb"]) - #expect(viewModel.geoSamplingSubs.isEmpty) - } - - @Test @MainActor - func handOffSelectedPrivateChat_movesSelectionOnlyWhenSelectedPeerIsMigrated() async { - let (viewModel, _) = makeTestableViewModel() - let oldPeer = PeerID(str: "aaaaaaaaaaaaaaaa") - let unrelatedPeer = PeerID(str: "cccccccccccccccc") - let newPeer = PeerID(str: "bbbbbbbbbbbbbbbb") - - // Selection not among the migrated peers: untouched. - viewModel.selectedPrivateChatPeer = unrelatedPeer - viewModel.handOffSelectedPrivateChat(from: [oldPeer], to: newPeer) - #expect(viewModel.selectedPrivateChatPeer == unrelatedPeer) - - // Selection being migrated away: handed off to the new peer. - viewModel.selectedPrivateChatPeer = oldPeer - viewModel.handOffSelectedPrivateChat(from: [oldPeer], to: newPeer) - #expect(viewModel.selectedPrivateChatPeer == newPeer) - } -} - -@Suite(.serialized) struct ChatViewModelMediaTransferTests { @Test @MainActor @@ -1021,11 +874,7 @@ struct ChatViewModelMediaTransferTests { viewModel.selectedPrivateChatPeer = peerID viewModel.sendVoiceNote(at: url) - // Media sends hop through Task.detached; the global executor is - // shared with every parallel test worker, so a loaded runner can be - // starved for seconds. waitUntil returns as soon as the condition - // holds, so passing runs never pay the settle deadline. - let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout) + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 0.5) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true) @@ -1033,89 +882,6 @@ struct ChatViewModelMediaTransferTests { #expect(viewModel.transferIdToMessageIDs.count == 1) } - @Test @MainActor - func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws { - let (viewModel, _) = makeTestableViewModel() - let firstPeer = PeerID(str: "1111111111111111") - let secondPeer = PeerID(str: "2222222222222222") - var decisions: [Bool] = [] - - viewModel.enqueueLegacyPrivateMediaConsent( - for: firstPeer, - transferId: "transfer-1", - messageID: "message-1" - ) { decisions.append($0) } - viewModel.enqueueLegacyPrivateMediaConsent( - for: secondPeer, - transferId: "transfer-2", - messageID: "message-2" - ) { decisions.append($0) } - - #expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer) - let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) - viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true) - let showedSecond = await TestHelpers.waitUntil( - { viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer }, - timeout: TestConstants.settleTimeout - ) - #expect(showedSecond) - let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) - - // A button action and the dialog binding may both resolve the first - // ID. The stale second callback must not consume the queued request. - viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false) - #expect(decisions == [true]) - #expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID) - - viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false) - - #expect(decisions == [true, false]) - #expect(viewModel.legacyPrivateMediaConsentRequest == nil) - } - - @Test @MainActor - func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws { - let (viewModel, _) = makeTestableViewModel() - let firstPeer = PeerID(str: "3333333333333333") - let secondPeer = PeerID(str: "4444444444444444") - var decisions: [String] = [] - - viewModel.enqueueLegacyPrivateMediaConsent( - for: firstPeer, - transferId: "transfer-cancelled", - messageID: "message-cancelled" - ) { decisions.append("first:\($0)") } - viewModel.enqueueLegacyPrivateMediaConsent( - for: secondPeer, - transferId: "transfer-kept", - messageID: "message-kept" - ) { decisions.append("second:\($0)") } - - let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) - viewModel.invalidateLegacyPrivateMediaConsent( - transferId: "transfer-cancelled", - messageID: "message-cancelled" - ) - let advanced = await TestHelpers.waitUntil( - { viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer }, - timeout: TestConstants.settleTimeout - ) - #expect(advanced) - #expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send") - - viewModel.resolveLegacyPrivateMediaConsent( - requestID: cancelledRequestID, - approved: true - ) - #expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer) - #expect(decisions.isEmpty) - - let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) - viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true) - #expect(decisions == ["second:true"]) - #expect(viewModel.legacyPrivateMediaConsentRequest == nil) - } - @Test @MainActor func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws { let (viewModel, transport) = makeTestableViewModel() @@ -1128,7 +894,7 @@ struct ChatViewModelMediaTransferTests { let didFail = await TestHelpers.waitUntil({ isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus) - }, timeout: TestConstants.settleTimeout) + }, timeout: 0.5) #expect(didFail) #expect(!FileManager.default.fileExists(atPath: url.path)) #expect(transport.sentPrivateFiles.isEmpty) @@ -1144,7 +910,7 @@ struct ChatViewModelMediaTransferTests { viewModel.selectedPrivateChatPeer = peerID viewModel.sendImage(from: sourceURL) - let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout) + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg") @@ -1165,7 +931,7 @@ struct ChatViewModelMediaTransferTests { let didNotify = await TestHelpers.waitUntil({ viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") }) - }, timeout: TestConstants.settleTimeout) + }, timeout: 2.0) #expect(didNotify) #expect(transport.sentPrivateFiles.isEmpty) #expect(viewModel.privateChats[peerID]?.isEmpty != false) @@ -1203,7 +969,7 @@ struct ChatViewModelMediaTransferTests { senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sending ) - viewModel.seedPrivateChat([message], for: peerID) + viewModel.privateChats[peerID] = [message] viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id) viewModel.cancelMediaSend(messageID: message.id) @@ -1232,7 +998,7 @@ struct ChatViewModelMediaTransferTests { senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sent ) - viewModel.seedPrivateChat([message], for: peerID) + viewModel.privateChats[peerID] = [message] viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id) viewModel.deleteMediaMessage(messageID: message.id) @@ -1374,37 +1140,3 @@ private func makeImageData() throws -> Data { return data #endif } - -// MARK: - Tor Extension Tests - -struct ChatViewModelTorExtensionTests { - - /// Turning Tor off mid-bootstrap must not read as "the network is - /// blocking tor": `torEnforced` is a compile-time constant, so the stall - /// handler has to consult the runtime preference before announcing. - @Test @MainActor - func bootstrapStall_withTorPreferenceOff_announcesNothing() async { - let key = NetworkActivationService.torPreferenceKey - let previous = UserDefaults.standard.object(forKey: key) - defer { - if let previous { - UserDefaults.standard.set(previous, forKey: key) - } else { - UserDefaults.standard.removeObject(forKey: key) - } - } - let (viewModel, _) = makeTestableViewModel() - - UserDefaults.standard.set(false, forKey: key) - viewModel.handleTorBootstrapDidStall() - try? await Task.sleep(nanoseconds: 50_000_000) - #expect(viewModel.torStallAnnounced == false) - - // The same stall with the preference on (the persisted default) is - // exactly what must still be announced. - UserDefaults.standard.set(true, forKey: key) - viewModel.handleTorBootstrapDidStall() - try? await Task.sleep(nanoseconds: 50_000_000) - #expect(viewModel.torStallAnnounced == true) - } -} diff --git a/bitchatTests/ChatViewModelRefactoringTests.swift b/bitchatTests/ChatViewModelRefactoringTests.swift index afc8e443..fcb3f13f 100644 --- a/bitchatTests/ChatViewModelRefactoringTests.swift +++ b/bitchatTests/ChatViewModelRefactoringTests.swift @@ -43,14 +43,14 @@ struct ChatViewModelRefactoringTests { transport.simulateConnect(peerID, nickname: "alice") let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil }, - timeout: TestConstants.settleTimeout) + timeout: TestConstants.shortTimeout) #expect(didResolve) // Action: User types /msg command viewModel.sendMessage("/msg @alice Hello Private World") let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 }, - timeout: TestConstants.settleTimeout) + timeout: TestConstants.shortTimeout) #expect(didSend) // Assert: @@ -74,7 +74,7 @@ struct ChatViewModelRefactoringTests { transport.simulateConnect(peerID, nickname: "troll") let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil }, - timeout: TestConstants.settleTimeout) + timeout: TestConstants.shortTimeout) #expect(didResolve) // Action @@ -83,7 +83,7 @@ struct ChatViewModelRefactoringTests { // Assert // Verify identity manager was called to block "fingerprint_123" let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") }, - timeout: TestConstants.settleTimeout) + timeout: TestConstants.shortTimeout) #expect(didBlock) } @@ -114,7 +114,7 @@ struct ChatViewModelRefactoringTests { // Wait for async processing with proper timeout let found = await TestHelpers.waitUntil( { viewModel.privateChats[senderID]?.first?.content == "Secret" }, - timeout: TestConstants.settleTimeout + timeout: TestConstants.defaultTimeout ) // Assert @@ -138,9 +138,9 @@ struct ChatViewModelRefactoringTests { // Wait for async processing with proper timeout let found = await TestHelpers.waitUntil( { - viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" }) + viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" }) }, - timeout: TestConstants.settleTimeout + timeout: TestConstants.defaultTimeout ) // Assert diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 35ab2975..1be3b578 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -15,13 +15,8 @@ import BitFoundation /// Creates a ChatViewModel with mock dependencies for testing @MainActor -private func makeTestableViewModel( - keychain injectedKeychain: MockKeychain? = nil, - panicMediaWipe: (() throws -> Void)? = nil, - panicRecoveryOperations: PanicRecoveryOperations? = nil, - panicNetworkLifecycle: PanicNetworkLifecycle = .noop -) -> (viewModel: ChatViewModel, transport: MockTransport) { - let keychain = injectedKeychain ?? MockKeychain() +private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { + let keychain = MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) @@ -31,10 +26,7 @@ private func makeTestableViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, - transport: transport, - panicMediaWipe: panicMediaWipe, - panicRecoveryOperations: panicRecoveryOperations, - panicNetworkLifecycle: panicNetworkLifecycle + transport: transport ) return (viewModel, transport) @@ -50,7 +42,6 @@ struct ChatViewModelInitializationTests { // The viewModel should set itself as the transport delegate #expect(transport.delegate === viewModel) - #expect(transport.eventDelegate === viewModel) } @Test @MainActor @@ -76,141 +67,6 @@ struct ChatViewModelInitializationTests { // Nickname should be set during init #expect(!transport.myNickname.isEmpty) } - - @Test @MainActor - func initialization_bindsPeerSnapshotsIntoAllPeers() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "00000000000000a1") - let noiseKey = Data(repeating: 0xAB, count: 32) - - transport.updatePeerSnapshots([ - TransportPeerSnapshot( - peerID: peerID, - nickname: "Alice", - isConnected: true, - noisePublicKey: noiseKey, - lastSeen: Date() - ) - ]) - - // The snapshot → allPeers binding hops the transport's unstructured - // Task, UnifiedPeerService, a receive(on: main), and another Task — - // all contending with every parallel worker, so a loaded CI runner - // can exceed defaultTimeout (observed: one 5s miss on a run where - // the whole suite took 10s instead of the usual ~4s). - let updated = await TestHelpers.waitUntil({ - viewModel.allPeers.contains { $0.peerID == peerID && $0.nickname == "Alice" } - }, timeout: TestConstants.longTimeout) - - #expect(updated) - } -} - -// MARK: - Identity Tests - -struct ChatViewModelIdentityTests { - - @Test @MainActor - func updatePrivateChatPeerIfNeeded_migratesSelectionHistoryByFingerprint() async { - let (viewModel, transport) = makeTestableViewModel() - let oldPeerID = PeerID(str: "00000000000000d1") - let newPeerID = PeerID(str: "00000000000000d2") - let sharedFingerprint = "fingerprint-shared" - let sharedNoiseKey = Data(repeating: 0xAB, count: 32) - - transport.peerFingerprints[oldPeerID] = sharedFingerprint - transport.peerFingerprints[newPeerID] = sharedFingerprint - transport.peerNicknames[oldPeerID] = "Alice" - transport.peerNicknames[newPeerID] = "Alice" - transport.updatePeerSnapshots([ - TransportPeerSnapshot( - peerID: oldPeerID, - nickname: "Alice", - isConnected: true, - noisePublicKey: sharedNoiseKey, - lastSeen: Date() - ) - ]) - - // Same multi-hop snapshot pipeline as above: longTimeout for load. - let oldPeerBound = await TestHelpers.waitUntil({ - viewModel.connectedPeers.contains(oldPeerID) - }, timeout: TestConstants.longTimeout) - #expect(oldPeerBound) - - let existingMessage = BitchatMessage( - id: "pm-migrate-1", - sender: "Alice", - content: "Still here", - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: oldPeerID, - mentions: nil - ) - viewModel.seedPrivateChat([existingMessage], for: oldPeerID) - viewModel.startPrivateChat(with: oldPeerID) - - #expect(viewModel.selectedPrivateChatPeer == oldPeerID) - #expect(viewModel.hasTrackedPrivateChatSelection) - #expect(viewModel.selectedPrivateChatFingerprint == viewModel.getFingerprint(for: oldPeerID)) - - transport.updatePeerSnapshots([ - TransportPeerSnapshot( - peerID: newPeerID, - nickname: "Alice", - isConnected: true, - noisePublicKey: sharedNoiseKey, - lastSeen: Date() - ) - ]) - - let newPeerBound = await TestHelpers.waitUntil({ - viewModel.connectedPeers.contains(newPeerID) && !viewModel.connectedPeers.contains(oldPeerID) - }, timeout: TestConstants.longTimeout) - #expect(newPeerBound) - - viewModel.updatePrivateChatPeerIfNeeded() - - #expect(viewModel.selectedPrivateChatPeer == newPeerID) - #expect(viewModel.privateChats[oldPeerID] == nil) - #expect(viewModel.privateChats[newPeerID]?.contains(where: { $0.id == "pm-migrate-1" }) == true) - } - - @Test @MainActor - func resolveNickname_prefersLocalPetnameOverClaimedNickname() async { - let keychain = MockKeychain() - let keychainHelper = MockKeychainHelper() - let idBridge = NostrIdentityBridge(keychain: keychainHelper) - let identityManager = MockIdentityManager(keychain) - let transport = MockTransport() - let viewModel = ChatViewModel( - keychain: keychain, - idBridge: idBridge, - identityManager: identityManager, - transport: transport - ) - let peerID = PeerID(str: "00000000000000d3") - let fingerprint = "fingerprint-petname" - - transport.peerFingerprints[peerID] = fingerprint - identityManager.updateSocialIdentity( - SocialIdentity( - fingerprint: fingerprint, - localPetname: "Buddy", - claimedNickname: "Alice", - trustLevel: .trusted, - isFavorite: false, - isBlocked: false, - notes: nil - ) - ) - - #expect(viewModel.getFingerprint(for: peerID) == fingerprint) - #expect(viewModel.resolveNickname(for: peerID) == "Buddy") - } } // MARK: - Message Sending Tests @@ -277,171 +133,6 @@ struct ChatViewModelCommandTests { #expect(transport.sentPrivateMessages.isEmpty) } } - - @Test @MainActor - func handleCommand_outputRoutesToOpenPrivateChat() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0000000000000002") - transport.simulateConnect(peerID, nickname: "Alice") - viewModel.selectedPrivateChatPeer = peerID - - viewModel.handleCommand("/help") - - #expect(viewModel.privateChats[peerID]?.last?.content == CommandProcessor.helpText) - #expect(!viewModel.messages.contains { $0.content == CommandProcessor.helpText }) - } - - @Test @MainActor - func handleCommand_errorRoutesToOpenPrivateChat() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0000000000000002") - transport.simulateConnect(peerID, nickname: "Alice") - viewModel.selectedPrivateChatPeer = peerID - - viewModel.handleCommand("/bogus") - - let dmContents = viewModel.privateChats[peerID]?.map(\.content) ?? [] - #expect(dmContents.contains { $0.hasPrefix("unknown command: /bogus") }) - #expect(!viewModel.messages.contains { $0.content.hasPrefix("unknown command: /bogus") }) - } - - @Test @MainActor - func handleCommand_outputRoutesToPublicTimelineWithoutOpenDM() async { - let (viewModel, _) = makeTestableViewModel() - - viewModel.handleCommand("/bogus") - - #expect(viewModel.messages.last?.content.hasPrefix("unknown command: /bogus") == true) - } - - @Test @MainActor - func handleCommand_msgSuccessLandsInNewlyOpenedChat() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0000000000000002") - transport.simulateConnect(peerID, nickname: "Alice") - let resolved = await TestHelpers.waitUntil({ - viewModel.getPeerIDForNickname("Alice") == peerID - }, timeout: TestConstants.negativeWaitWindow) - #expect(resolved) - - viewModel.handleCommand("/msg Alice") - - #expect(viewModel.selectedPrivateChatPeer == peerID) - #expect(viewModel.privateChats[peerID]?.last?.content == "started private chat with Alice") - #expect(!viewModel.messages.contains { $0.content == "started private chat with Alice" }) - } -} - -// MARK: - Composer Tests - -struct ChatViewModelComposerTests { - - @Test @MainActor - func updateAutocomplete_suggestsKnownPeersAndExcludesSelf() async { - let (viewModel, transport) = makeTestableViewModel() - let alice = PeerID(str: "00000000000000a1") - let selfAlias = PeerID(str: "00000000000000a2") - - transport.simulateConnect(alice, nickname: "Alice") - transport.simulateConnect(selfAlias, nickname: viewModel.nickname) - - viewModel.updateAutocomplete(for: "hello @Al", cursorPosition: 9) - - #expect(viewModel.showAutocomplete) - #expect(viewModel.autocompleteSuggestions == ["@Alice"]) - } - - @Test @MainActor - func completeNickname_replacesTokenAndClearsAutocompleteState() async { - let (viewModel, _) = makeTestableViewModel() - var text = "hello @Al" - - viewModel.autocompleteSuggestions = ["@Alice"] - viewModel.autocompleteRange = NSRange(location: 6, length: 3) - viewModel.showAutocomplete = true - viewModel.selectedAutocompleteIndex = 0 - - _ = viewModel.completeNickname("@Alice", in: &text) - - #expect(text == "hello @Alice") - #expect(viewModel.autocompleteSuggestions.isEmpty) - #expect(viewModel.autocompleteRange == nil) - #expect(!viewModel.showAutocomplete) - #expect(viewModel.selectedAutocompleteIndex == 0) - } - - @Test @MainActor - func parseMentions_filtersToKnownPeerAndSelfTokens() async { - let (viewModel, transport) = makeTestableViewModel() - let alice = PeerID(str: "00000000000000b1") - - transport.simulateConnect(alice, nickname: "Alice") - - let mentions = Set( - viewModel.parseMentions( - from: "hi @Alice @nobody @\(viewModel.nickname)" - ) - ) - - #expect(mentions.contains("Alice")) - #expect(mentions.contains(viewModel.nickname)) - #expect(!mentions.contains("nobody")) - } -} - -// MARK: - Lifecycle Tests - -struct ChatViewModelServiceLifecycleTests { - - @Test @MainActor - func handleDidBecomeActive_marksVisiblePrivateMessagesAsRead() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0000000000000001") - transport.simulateConnect(peerID, nickname: "Alice") - - let message = BitchatMessage( - id: "read-1", - sender: "Alice", - content: "Hello from Alice", - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: peerID, - mentions: nil - ) - - viewModel.seedPrivateChat([message], for: peerID) - viewModel.markPrivateChatUnread(peerID) - viewModel.selectedPrivateChatPeer = peerID - - viewModel.handleDidBecomeActive() - - let sentReadReceipt = await TestHelpers.waitUntil({ - transport.sentReadReceipts.contains { - $0.peerID == peerID && $0.receipt.originalMessageID == "read-1" - } - }, timeout: TestConstants.negativeWaitWindow) - - #expect(sentReadReceipt) - #expect(!viewModel.unreadPrivateMessages.contains(peerID)) - } - - @Test @MainActor - func handleScreenshotCaptured_privateChatStaysSilentWithoutSession() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0000000000000002") - transport.simulateConnect(peerID, nickname: "Alice") - - viewModel.selectedPrivateChatPeer = peerID - viewModel.handleScreenshotCaptured() - - // No session means no notice went out, so no local echo either — - // an echo here would imply Alice was told when she wasn't. - #expect(transport.sentPrivateMessages.isEmpty) - #expect(viewModel.privateChats[peerID]?.contains { $0.content == "you took a screenshot" } != true) - } } // MARK: - Timeline Cap Tests @@ -468,7 +159,7 @@ struct ChatViewModelReceivingTests { @Test @MainActor func didReceiveMessage_callsDelegate() async { - let (viewModel, transport) = makeTestableViewModel() + let (_, transport) = makeTestableViewModel() let message = BitchatMessage( id: "msg-001", @@ -490,8 +181,7 @@ struct ChatViewModelReceivingTests { // Message may or may not appear due to rate limiting/pipeline batching // The important thing is no crash and delegate was called - #expect(transport.delegate === viewModel) - #expect(transport.eventDelegate === viewModel) + #expect(transport.delegate != nil) } @Test @MainActor @@ -507,250 +197,13 @@ struct ChatViewModelReceivingTests { ) let found = await TestHelpers.waitUntil({ - viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" } - }, timeout: TestConstants.settleTimeout) + viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" } + }, timeout: TestConstants.defaultTimeout) #expect(found) } } -// MARK: - Noise Payload Tests - -struct ChatViewModelNoisePayloadTests { - - @Test @MainActor - func didReceiveNoisePayload_privateMessageStoresMessageAndSendsDeliveryAck() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0000000000000003") - transport.simulateConnect(peerID, nickname: "Alice") - - let payload = PrivateMessagePacket(messageID: "pm-noise-1", content: "Secret hello").encode() - #expect(payload != nil) - guard let payload else { return } - - viewModel.didReceiveNoisePayload( - from: peerID, - type: .privateMessage, - payload: payload, - timestamp: Date() - ) - - let stored = await TestHelpers.waitUntil({ - viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true - }, timeout: TestConstants.settleTimeout) - - let acked = await TestHelpers.waitUntil({ - transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID } - }, timeout: TestConstants.settleTimeout) - - #expect(stored) - #expect(acked) - } - - @Test @MainActor - func didReceiveNoisePayload_deliveredUpdatesExistingPrivateMessageStatus() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "0000000000000004") - transport.simulateConnect(peerID, nickname: "Bob") - - let message = BitchatMessage( - id: "pm-delivered-1", - sender: viewModel.nickname, - content: "Waiting on ack", - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: "Bob", - senderPeerID: viewModel.meshService.myPeerID, - mentions: nil, - deliveryStatus: .sent - ) - viewModel.seedPrivateChat([message], for: peerID) - - viewModel.didReceiveNoisePayload( - from: peerID, - type: .delivered, - payload: Data("pm-delivered-1".utf8), - timestamp: Date() - ) - - let delivered = await TestHelpers.waitUntil({ - guard let status = viewModel.privateChats[peerID]?.first?.deliveryStatus else { return false } - if case .delivered(let name, _) = status { - return name == "Bob" - } - return false - }, timeout: TestConstants.settleTimeout) - - #expect(delivered) - } - - @Test @MainActor - func didReceiveNoisePayload_readReceiptUpdatesBeforePeerNicknameIsKnown() async { - let (viewModel, _) = makeTestableViewModel() - let peerID = PeerID(str: "0000000000000005") - - let message = BitchatMessage( - id: "pm-read-before-name", - sender: viewModel.nickname, - content: "Waiting on read receipt", - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: "Peer", - senderPeerID: viewModel.meshService.myPeerID, - mentions: nil, - deliveryStatus: .sent - ) - viewModel.seedPrivateChat([message], for: peerID) - - viewModel.didReceiveNoisePayload( - from: peerID, - type: .readReceipt, - payload: Data("pm-read-before-name".utf8), - timestamp: Date() - ) - - let privateChatUpdated = await TestHelpers.waitUntil({ - guard let status = viewModel.privateChats[peerID]?.first?.deliveryStatus else { return false } - if case .read = status { - return true - } - return false - }, timeout: TestConstants.settleTimeout) - - let conversationStoreUpdated = await TestHelpers.waitUntil({ - let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? [] - guard let status = messages.first?.deliveryStatus else { return false } - if case .read = status { - return true - } - return false - }, timeout: TestConstants.settleTimeout) - - #expect(privateChatUpdated) - #expect(conversationStoreUpdated) - } -} - -// MARK: - Formatting Tests - -struct ChatViewModelFormattingTests { - - @Test @MainActor - func formatMessageAsText_formatsSenderContentAndTimestamp() async { - let (viewModel, _) = makeTestableViewModel() - let message = BitchatMessage( - id: "fmt-1", - sender: "Alice#a1b2", - content: "hello #mesh", - timestamp: Date(timeIntervalSince1970: 1_700_010_000), - isRelay: false, - senderPeerID: PeerID(str: "00000000000000b1") - ) - - let formatted = viewModel.formatMessageAsText(message, colorScheme: .light) - - #expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]") - } - - @Test @MainActor - func formatMessageAsText_longCashuFallsBackToPlain() async { - let (viewModel, _) = makeTestableViewModel() - let cashu = "cashuA" + String(repeating: "a", count: 40) - let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100) - let message = BitchatMessage( - id: "fmt-long-cashu", - sender: "Alice#a1b2", - content: longContent, - timestamp: Date(timeIntervalSince1970: 1_700_010_123), - isRelay: false, - senderPeerID: PeerID(str: "00000000000000b3") - ) - - let formatted = viewModel.formatMessageAsText(message, colorScheme: .light) - - #expect(String(formatted.characters) == "<@Alice#a1b2> \(longContent) [\(message.formattedTimestamp)]") - } - - @Test @MainActor - func formatMessageHeader_formatsSenderHeader() async { - let (viewModel, _) = makeTestableViewModel() - let message = BitchatMessage( - id: "fmt-2", - sender: "Alice#a1b2", - content: "hello", - timestamp: Date(), - isRelay: false, - senderPeerID: PeerID(str: "00000000000000b2") - ) - - let header = viewModel.formatMessageHeader(message, colorScheme: .dark) - - #expect(String(header.characters) == "<@Alice#a1b2> ") - } -} - -// MARK: - Verification Tests - -struct ChatViewModelVerificationTests { - - @Test @MainActor - func beginQRVerification_unknownNoiseKeyReturnsFalse() async { - let (viewModel, _) = makeTestableViewModel() - let qr = VerificationService.VerificationQR( - v: 1, - noiseKeyHex: String(repeating: "a", count: 64), - signKeyHex: String(repeating: "b", count: 64), - npub: nil, - nickname: "Alice", - ts: 1_700_000_000, - nonceB64: "nonce", - sigHex: "sig" - ) - - #expect(!viewModel.beginQRVerification(with: qr)) - } - - @Test @MainActor - func beginQRVerification_knownPeerTriggersHandshakeWhenSessionNotEstablished() async { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: "00000000000000c1") - let noiseKey = Data(repeating: 0xAB, count: 32) - - transport.updatePeerSnapshots([ - TransportPeerSnapshot( - peerID: peerID, - nickname: "Alice", - isConnected: true, - noisePublicKey: noiseKey, - lastSeen: Date() - ) - ]) - - let bound = await TestHelpers.waitUntil({ - viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID } - }, timeout: TestConstants.settleTimeout) - #expect(bound) - - let qr = VerificationService.VerificationQR( - v: 1, - noiseKeyHex: noiseKey.hexEncodedString(), - signKeyHex: String(repeating: "c", count: 64), - npub: nil, - nickname: "Alice", - ts: 1_700_000_000, - nonceB64: "nonce", - sigHex: "sig" - ) - - #expect(viewModel.beginQRVerification(with: qr)) - #expect(transport.triggeredHandshakes == [peerID]) - } -} - // MARK: - Rate Limiting Tests struct ChatViewModelRateLimitingTests { @@ -785,161 +238,10 @@ struct ChatViewModelRateLimitingTests { } } -// MARK: - Public Conversation Tests - -struct ChatViewModelPublicConversationTests { - - @Test @MainActor - func bridgeAliasReplacementDoesNotContentDedupAwayAuthenticatedRadioRow() { - let (viewModel, _) = makeTestableViewModel() - let content = "same bridge and radio payload" - let timestamp = Date() - let bridgeMessage = BitchatMessage( - id: "bridge-event-id", - sender: "remote#beef", - content: content, - timestamp: timestamp, - isRelay: false, - senderPeerID: PeerID(bridge: String(repeating: "a", count: 64)), - isBridged: true - ) - viewModel.handlePublicMessage(bridgeMessage) - viewModel.publicMessagePipeline.flushIfNeeded() - #expect(viewModel.publicConversationContainsMessage(withID: bridgeMessage.id, in: .mesh)) - - viewModel.removeBridgeInjectedPublicMessage(withID: bridgeMessage.id) - let radioMessage = BitchatMessage( - id: "radio-stable-id", - sender: "remote", - content: content, - timestamp: timestamp, - isRelay: false, - senderPeerID: PeerID(str: "1122334455667788") - ) - viewModel.handlePublicMessage(radioMessage) - viewModel.publicMessagePipeline.flushIfNeeded() - - #expect(!viewModel.publicConversationContainsMessage(withID: bridgeMessage.id, in: .mesh)) - #expect(viewModel.publicConversationContainsMessage(withID: radioMessage.id, in: .mesh)) - } - - @Test @MainActor - func addPublicSystemMessage_persistsAcrossTimelineRefresh() async { - let (viewModel, _) = makeTestableViewModel() - - viewModel.addPublicSystemMessage("system refresh test") - viewModel.refreshVisibleMessages(from: .mesh) - - // The system message lives in the mesh conversation itself, so the - // derived `messages` view still surfaces it after a refresh. - #expect(viewModel.messages.last?.content == "system refresh test") - #expect(viewModel.publicMessages(for: .mesh).last?.content == "system refresh test") - } - - @Test @MainActor - func clearCurrentPublicTimeline_clearsVisibleAndBackedMessages() async { - let (viewModel, _) = makeTestableViewModel() - - viewModel.addPublicSystemMessage("system clear test") - #expect(!viewModel.messages.isEmpty) - - viewModel.clearCurrentPublicTimeline() - viewModel.refreshVisibleMessages(from: .mesh) - - #expect(viewModel.messages.isEmpty) - #expect(viewModel.publicMessages(for: .mesh).isEmpty) - } - - @Test @MainActor - func queuedGeohashSystemMessages_drainOnce() async { - let (viewModel, _) = makeTestableViewModel() - - viewModel.queueGeohashSystemMessage("first") - viewModel.queueGeohashSystemMessage("second") - - #expect(viewModel.drainPendingGeohashSystemMessages() == ["first", "second"]) - #expect(viewModel.drainPendingGeohashSystemMessages().isEmpty) - } -} - // MARK: - Peer Connection Tests struct ChatViewModelPeerTests { - @Test @MainActor - func typedPeerLifecycleEvents_applyBeforeReturning() { - let (viewModel, _) = makeTestableViewModel() - let peerID = PeerID(str: "1122334455667788") - let incoming = BitchatMessage( - id: "typed-peer-incoming", - sender: "Alice", - content: "Hello", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: peerID - ) - viewModel.seedPrivateChat([incoming], for: peerID) - viewModel.sentReadReceipts.insert(incoming.id) - - viewModel.didReceiveTransportEvent(.peerConnected(peerID)) - - #expect(viewModel.isConnected) - - viewModel.didReceiveTransportEvent(.peerDisconnected(peerID)) - - #expect(!viewModel.sentReadReceipts.contains(incoming.id)) - } - - @Test @MainActor - func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() { - let (viewModel, transport) = makeTestableViewModel() - let stalePeer = PeerID(str: "00000000000000a2") - let deliveryPeer = PeerID(str: "0102030405060708") - let messageID = "typed-delivery-status" - let delivered = DeliveryStatus.delivered( - to: "Alice", - at: Date(timeIntervalSince1970: 1_234) - ) - let outgoing = BitchatMessage( - id: messageID, - sender: viewModel.nickname, - content: "On the way", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Alice", - senderPeerID: transport.myPeerID, - deliveryStatus: .sent - ) - viewModel.markPrivateChatUnread(stalePeer) - viewModel.seedPrivateChat([outgoing], for: deliveryPeer) - - viewModel.didReceiveTransportEvent(.peerListUpdated([])) - #expect(!viewModel.unreadPrivateMessages.contains(stalePeer)) - - viewModel.didReceiveTransportEvent( - .messageDeliveryStatusUpdated( - messageID: messageID, - status: delivered - ) - ) - #expect( - viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus - == delivered - ) - - viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff)) - #expect(viewModel.bluetoothState == .poweredOff) - #expect(viewModel.showBluetoothAlert) - - // Snapshot events belong to TransportPeerEventsDelegate and are - // intentionally ignored at this typed sink. - viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([])) - #expect(viewModel.bluetoothState == .poweredOff) - } - @Test @MainActor func didConnectToPeer_notifiesDelegate() async { let (_, transport) = makeTestableViewModel() @@ -973,46 +275,6 @@ struct ChatViewModelPeerTests { #expect(transport.isPeerConnected(peerID)) } - - @Test @MainActor - func didUpdatePeerList_removesStaleUnreadPeerWithoutMessages() async { - let (viewModel, _) = makeTestableViewModel() - let stalePeer = PeerID(str: "00000000000000a2") - viewModel.markPrivateChatUnread(stalePeer) - - viewModel.didUpdatePeerList([]) - - let cleaned = await TestHelpers.waitUntil({ - !viewModel.unreadPrivateMessages.contains(stalePeer) - }, timeout: TestConstants.settleTimeout) - - #expect(cleaned) - } - - @Test @MainActor - func didUpdatePeerList_preservesStableUnreadPeerWhenMessagesExist() async { - let (viewModel, _) = makeTestableViewModel() - let stablePeer = PeerID(str: String(repeating: "a", count: 64)) - let message = BitchatMessage( - id: "stable-noise-1", - sender: "Alice", - content: "Offline hello", - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: stablePeer, - mentions: nil - ) - viewModel.seedPrivateChat([message], for: stablePeer) - viewModel.markPrivateChatUnread(stablePeer) - - viewModel.didUpdatePeerList([]) - try? await Task.sleep(nanoseconds: 100_000_000) - - #expect(viewModel.unreadPrivateMessages.contains(stablePeer)) - } } // MARK: - Deduplication Integration Tests @@ -1066,7 +328,7 @@ struct ChatViewModelPrivateChatTests { // The message routing depends on connection state and other factors // At minimum, it should not crash - #expect(Bool(true)) // If we get here without crash, the test passes + #expect(true) // If we get here without crash, the test passes } } @@ -1083,32 +345,33 @@ struct ChatViewModelPrivateChatSelectionTests { let older = Date().addingTimeInterval(-120) let newer = Date().addingTimeInterval(-30) - viewModel.seedPrivateChat([ - BitchatMessage( - id: "a-1", - sender: "A", - content: "Old", - timestamp: older, - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerA - ) - ], for: peerA) - viewModel.seedPrivateChat([ - BitchatMessage( - id: "b-1", - sender: "B", - content: "New", - timestamp: newer, - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerB - ) - ], for: peerB) - viewModel.markPrivateChatUnread(peerA) - viewModel.markPrivateChatUnread(peerB) + viewModel.privateChats = [ + peerA: [ + BitchatMessage( + id: "a-1", + sender: "A", + content: "Old", + timestamp: older, + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerA + ) + ], + peerB: [ + BitchatMessage( + id: "b-1", + sender: "B", + content: "New", + timestamp: newer, + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerB + ) + ] + ] + viewModel.unreadPrivateMessages = [peerA, peerB] viewModel.openMostRelevantPrivateChat() @@ -1124,30 +387,32 @@ struct ChatViewModelPrivateChatSelectionTests { let older = Date().addingTimeInterval(-200) let newer = Date().addingTimeInterval(-20) - viewModel.seedPrivateChat([ - BitchatMessage( - id: "a-1", - sender: "A", - content: "Old", - timestamp: older, - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerA - ) - ], for: peerA) - viewModel.seedPrivateChat([ - BitchatMessage( - id: "b-1", - sender: "B", - content: "New", - timestamp: newer, - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerB - ) - ], for: peerB) + viewModel.privateChats = [ + peerA: [ + BitchatMessage( + id: "a-1", + sender: "A", + content: "Old", + timestamp: older, + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerA + ) + ], + peerB: [ + BitchatMessage( + id: "b-1", + sender: "B", + content: "New", + timestamp: newer, + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerB + ) + ] + ] viewModel.openMostRelevantPrivateChat() @@ -1196,1047 +461,17 @@ struct ChatViewModelBluetoothTests { } } -// MARK: - Private Media Deletion Tests - -struct ChatViewModelPrivateMediaDeletionTests { - - @Test @MainActor - func deleteMediaMessageTombstonesIncomingButNotOutgoingStableMedia() { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: String(repeating: "8", count: 64)) - let incomingID = "media-\(String(repeating: "e", count: 32))" - let outgoingID = "media-\(String(repeating: "f", count: 32))" - viewModel.seedPrivateChat([ - privateMediaMessage( - id: incomingID, - sender: "Peer", - senderPeerID: peerID, - recipient: viewModel.nickname, - filename: "incoming.jpg" - ), - privateMediaMessage( - id: outgoingID, - sender: viewModel.nickname, - senderPeerID: transport.myPeerID, - recipient: "Peer", - filename: "outgoing.jpg" - ) - ], for: peerID) - - viewModel.deleteMediaMessage(messageID: outgoingID) - #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) - #expect(viewModel.privateChats[peerID]?.map(\.id) == [incomingID]) - - viewModel.deleteMediaMessage(messageID: incomingID) - #expect( - transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] - ) - #expect( - transport.deletedPrivateMediaRelativePaths - == [[incomingID: "images/incoming/incoming.jpg"]] - ) - #expect((viewModel.privateChats[peerID] ?? []).isEmpty) - } - - @Test @MainActor - func stableDeleteProtectsPathSharedWithLegacyBubble() { - let (viewModel, transport) = makeTestableViewModel() - transport.persistDeletedPrivateMediaResult = false - let peerID = PeerID(str: String(repeating: "6", count: 64)) - let stableID = "media-\(String(repeating: "5", count: 32))" - let legacyID = UUID().uuidString - let filename = "shared-migration.jpg" - viewModel.seedPrivateChat([ - privateMediaMessage( - id: stableID, - sender: "Peer", - senderPeerID: peerID, - recipient: viewModel.nickname, - filename: filename - ), - privateMediaMessage( - id: legacyID, - sender: "Old client", - senderPeerID: peerID, - recipient: viewModel.nickname, - filename: filename - ) - ], for: peerID) - - viewModel.deleteMediaMessage(messageID: stableID) - - #expect( - transport.deletedPrivateMediaMessageIDBatches == [[stableID]] - ) - #expect(transport.deletedPrivateMediaRelativePaths == [[:]]) - #expect( - transport.protectedPrivateMediaRelativePaths == [[ - "images/incoming/\(filename)" - ]] - ) - let messages = viewModel.privateChats[peerID] ?? [] - #expect(messages.prefix(2).map(\.id) == [stableID, legacyID]) - // The refusal is surfaced in the affected chat, not just logged. - #expect(messages.last?.sender == "system") - #expect( - messages.last?.content - == String(localized: "content.system.media_delete_refused") - ) - } - - @Test @MainActor - func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: String(repeating: "1", count: 64)) - let incomingID = "media-\(String(repeating: "a", count: 32))" - let outgoingID = "media-\(String(repeating: "b", count: 32))" - viewModel.seedPrivateChat([ - privateMediaMessage( - id: incomingID, - sender: "Peer", - senderPeerID: peerID, - recipient: viewModel.nickname, - filename: "incoming.jpg" - ), - privateMediaMessage( - id: outgoingID, - sender: viewModel.nickname, - senderPeerID: transport.myPeerID, - recipient: "Peer", - filename: "outgoing.jpg" - ), - BitchatMessage( - id: "ordinary-message", - sender: "Peer", - content: "hello", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: peerID - ) - ], for: peerID) - viewModel.registerTransfer( - transferId: "outgoing-clear-transfer", - messageID: outgoingID - ) - - viewModel.clearPrivateChat(peerID) - - #expect( - transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] - ) - #expect( - transport.deletedPrivateMediaRelativePaths - == [[incomingID: "images/incoming/incoming.jpg"]] - ) - #expect( - transport.cancelledTransfers == ["outgoing-clear-transfer"] - ) - #expect(viewModel.messageIDToTransferId[outgoingID] == nil) - #expect(viewModel.privateChats[peerID]?.isEmpty == true) - } - - @Test @MainActor - func clearPrivateChatPreservesCapturedMessagesWhenTombstoneFails() { - let (viewModel, transport) = makeTestableViewModel() - transport.persistDeletedPrivateMediaResult = false - let peerID = PeerID(str: String(repeating: "2", count: 64)) - let incomingID = "media-\(String(repeating: "c", count: 32))" - let outgoingID = "media-\(String(repeating: "7", count: 32))" - viewModel.seedPrivateChat([ - privateMediaMessage( - id: incomingID, - sender: "Peer", - senderPeerID: peerID, - recipient: viewModel.nickname, - filename: "incoming.jpg" - ), - privateMediaMessage( - id: outgoingID, - sender: viewModel.nickname, - senderPeerID: transport.myPeerID, - recipient: "Peer", - filename: "outgoing.jpg" - ), - BitchatMessage( - id: "ordinary-message", - sender: "Peer", - content: "keep me on failure", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: peerID - ) - ], for: peerID) - viewModel.registerTransfer( - transferId: "failed-clear-outgoing", - messageID: outgoingID - ) - - viewModel.clearPrivateChat(peerID) - - #expect( - transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] - ) - let messages = viewModel.privateChats[peerID] ?? [] - #expect( - messages.prefix(3).map(\.id) - == [incomingID, outgoingID, "ordinary-message"] - ) - // The refused /clear is surfaced in the affected chat. - #expect(messages.last?.sender == "system") - #expect( - messages.last?.content - == String(localized: "content.system.media_delete_refused") - ) - #expect(transport.cancelledTransfers == ["failed-clear-outgoing"]) - #expect(viewModel.messageIDToTransferId[outgoingID] == nil) - } - - @Test @MainActor - func clearFailurePreservesSameNameIncomingPayload() throws { - let (viewModel, transport) = makeTestableViewModel() - transport.persistDeletedPrivateMediaResult = false - let peerID = PeerID(str: String(repeating: "7", count: 64)) - let incomingID = "media-\(String(repeating: "8", count: 32))" - let outgoingID = "media-\(String(repeating: "9", count: 32))" - let filename = "clear-collision-\(UUID().uuidString).jpg" - let filesDirectory = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - .appendingPathComponent("files/images", isDirectory: true) - let incomingDirectory = filesDirectory.appendingPathComponent( - "incoming", - isDirectory: true - ) - let outgoingDirectory = filesDirectory.appendingPathComponent( - "outgoing", - isDirectory: true - ) - try FileManager.default.createDirectory( - at: incomingDirectory, - withIntermediateDirectories: true - ) - try FileManager.default.createDirectory( - at: outgoingDirectory, - withIntermediateDirectories: true - ) - let incomingURL = incomingDirectory.appendingPathComponent(filename) - let outgoingURL = outgoingDirectory.appendingPathComponent(filename) - try Data("incoming".utf8).write(to: incomingURL, options: .atomic) - try Data("outgoing".utf8).write(to: outgoingURL, options: .atomic) - defer { - try? FileManager.default.removeItem(at: incomingURL) - try? FileManager.default.removeItem(at: outgoingURL) - } - viewModel.seedPrivateChat([ - privateMediaMessage( - id: incomingID, - sender: "Peer", - senderPeerID: peerID, - recipient: viewModel.nickname, - filename: filename - ), - privateMediaMessage( - id: outgoingID, - sender: viewModel.nickname, - senderPeerID: transport.myPeerID, - recipient: "Peer", - filename: filename - ) - ], for: peerID) - - viewModel.clearPrivateChat(peerID) - - #expect(FileManager.default.fileExists(atPath: incomingURL.path)) - #expect(FileManager.default.fileExists(atPath: outgoingURL.path)) - #expect( - transport.deletedPrivateMediaRelativePaths - == [[incomingID: "images/incoming/\(filename)"]] - ) - let messages = viewModel.privateChats[peerID] ?? [] - #expect(messages.prefix(2).map(\.id) == [incomingID, outgoingID]) - #expect(messages.last?.sender == "system") - #expect( - messages.last?.content - == String(localized: "content.system.media_delete_refused") - ) - } - - @Test @MainActor - func clearPrivateChatPreservesArrivalDuringTombstoneIO() { - let (viewModel, transport) = makeTestableViewModel() - transport.deferDeletedPrivateMediaPersistence = true - let peerID = PeerID(str: String(repeating: "3", count: 64)) - let incomingID = "media-\(String(repeating: "d", count: 32))" - viewModel.seedPrivateChat([ - privateMediaMessage( - id: incomingID, - sender: "Peer", - senderPeerID: peerID, - recipient: viewModel.nickname, - filename: "incoming.jpg" - ), - BitchatMessage( - id: "captured-text", - sender: "Peer", - content: "old", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: peerID - ) - ], for: peerID) - - viewModel.clearPrivateChat(peerID) - let arrival = BitchatMessage( - id: "concurrent-arrival", - sender: "Peer", - content: "new", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: peerID - ) - #expect(viewModel.appendPrivateMessage(arrival, to: peerID)) - - transport.resolveNextDeletedPrivateMediaPersistence(true) - - #expect( - viewModel.privateChats[peerID]?.map(\.id) - == ["concurrent-arrival"] - ) - } - - @Test @MainActor - func clearPrivateChatPreservesActiveLiveVoiceAssembly() throws { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: String(repeating: "7", count: 64)) - viewModel.selectedPrivateChatPeer = peerID - let burstID = Data( - repeating: 0xE1, - count: VoiceBurstPacket.burstIDSize - ) - let start = try #require(VoiceBurstPacket( - burstID: burstID, - seq: 0, - kind: .start(codec: .aacLC16kMono) - )) - let cancel = try #require(VoiceBurstPacket( - burstID: burstID, - seq: 1, - kind: .canceled - )) - let coordinator = viewModel.liveVoiceCoordinator - defer { - coordinator.handleVoiceFramePayload( - from: peerID, - payload: cancel.encode(), - timestamp: Date() - ) - } - coordinator.handleVoiceFramePayload( - from: peerID, - payload: start.encode(), - timestamp: Date() - ) - let liveMessage = try #require( - viewModel.privateChats[peerID]?.first - ) - #expect(coordinator.isLiveVoiceMessage(liveMessage)) - let ordinary = BitchatMessage( - id: "clear-around-live-voice", - sender: "Peer", - content: "old text", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: peerID - ) - #expect(viewModel.appendPrivateMessage(ordinary, to: peerID)) - - viewModel.clearPrivateChat(peerID) - - #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) - #expect( - viewModel.privateChats[peerID]?.map(\.id) - == [liveMessage.id] - ) - #expect(coordinator.isLiveVoiceMessage(liveMessage)) - } - - @Test @MainActor - func overlappingClearsTombstoneTheLastMirroredStableAlias() { - let (viewModel, transport) = makeTestableViewModel() - transport.deferDeletedPrivateMediaPersistence = true - let firstPeerID = PeerID(str: String(repeating: "b", count: 64)) - let secondPeerID = PeerID(str: String(repeating: "c", count: 64)) - let sharedID = "media-\(String(repeating: "1", count: 32))" - let firstUniqueID = "media-\(String(repeating: "2", count: 32))" - let secondUniqueID = "media-\(String(repeating: "3", count: 32))" - viewModel.seedPrivateChat([ - privateMediaMessage( - id: sharedID, - sender: "Peer", - senderPeerID: firstPeerID, - recipient: viewModel.nickname, - filename: "shared.jpg" - ), - privateMediaMessage( - id: firstUniqueID, - sender: "Peer", - senderPeerID: firstPeerID, - recipient: viewModel.nickname, - filename: "first.jpg" - ) - ], for: firstPeerID) - viewModel.seedPrivateChat([ - privateMediaMessage( - id: sharedID, - sender: "Peer", - senderPeerID: secondPeerID, - recipient: viewModel.nickname, - filename: "shared.jpg" - ), - privateMediaMessage( - id: secondUniqueID, - sender: "Peer", - senderPeerID: secondPeerID, - recipient: viewModel.nickname, - filename: "second.jpg" - ) - ], for: secondPeerID) - - viewModel.clearPrivateChat(firstPeerID) - viewModel.clearPrivateChat(secondPeerID) - let queuedArrival = BitchatMessage( - id: "arrival-after-queued-clear", - sender: "Peer", - content: "new", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: secondPeerID - ) - #expect(viewModel.appendPrivateMessage( - queuedArrival, - to: secondPeerID - )) - - #expect( - transport.deletedPrivateMediaMessageIDBatches - == [[firstUniqueID]] - ) - - transport.resolveNextDeletedPrivateMediaPersistence(true) - - #expect( - transport.deletedPrivateMediaMessageIDBatches == [ - [firstUniqueID], - [secondUniqueID, sharedID].sorted() - ] - ) - - transport.resolveNextDeletedPrivateMediaPersistence(true) - - #expect((viewModel.privateChats[firstPeerID] ?? []).isEmpty) - #expect( - viewModel.privateChats[secondPeerID]?.map(\.id) - == ["arrival-after-queued-clear"] - ) - } - - @Test @MainActor - func clearFollowsCapturedRowsAcrossPeerIdentityMigration() { - let (viewModel, transport) = makeTestableViewModel() - transport.deferDeletedPrivateMediaPersistence = true - let sourcePeerID = PeerID(str: String(repeating: "d", count: 64)) - let destinationPeerID = PeerID(str: String(repeating: "e", count: 64)) - let thirdPeerID = PeerID(str: String(repeating: "f", count: 64)) - let stableID = "media-\(String(repeating: "4", count: 32))" - viewModel.selectedPrivateChatPeer = sourcePeerID - viewModel.seedPrivateChat([ - privateMediaMessage( - id: stableID, - sender: "Peer", - senderPeerID: sourcePeerID, - recipient: viewModel.nickname, - filename: "migrated.jpg" - ), - BitchatMessage( - id: "captured-before-migration", - sender: "Peer", - content: "old", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: sourcePeerID - ) - ], for: sourcePeerID) - - viewModel.clearPrivateChat(sourcePeerID) - viewModel.migratePrivateChat( - from: sourcePeerID, - to: destinationPeerID - ) - viewModel.selectedPrivateChatPeer = thirdPeerID - let arrival = BitchatMessage( - id: "arrival-after-migration", - sender: "Peer", - content: "new", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: destinationPeerID - ) - #expect(viewModel.appendPrivateMessage( - arrival, - to: destinationPeerID - )) - - transport.resolveNextDeletedPrivateMediaPersistence(true) - - #expect( - viewModel.privateChats[destinationPeerID]?.map(\.id) - == ["arrival-after-migration"] - ) - } - - @Test @MainActor - func clearFollowsMigrationWhenOldSourceIsRecreated() { - let (viewModel, transport) = makeTestableViewModel() - transport.deferDeletedPrivateMediaPersistence = true - let sourcePeerID = PeerID(str: String(repeating: "1", count: 64)) - let destinationPeerID = PeerID(str: String(repeating: "2", count: 64)) - let stableID = "media-\(String(repeating: "6", count: 32))" - viewModel.seedPrivateChat([ - privateMediaMessage( - id: stableID, - sender: "Peer", - senderPeerID: sourcePeerID, - recipient: viewModel.nickname, - filename: "migrated-recreated.jpg" - ), - BitchatMessage( - id: "captured-before-recreation", - sender: "Peer", - content: "old", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: sourcePeerID - ) - ], for: sourcePeerID) - - viewModel.clearPrivateChat(sourcePeerID) - viewModel.migratePrivateChat( - from: sourcePeerID, - to: destinationPeerID - ) - let recreatedArrival = BitchatMessage( - id: "arrival-recreating-source", - sender: "Peer", - content: "new", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: sourcePeerID - ) - #expect(viewModel.appendPrivateMessage( - recreatedArrival, - to: sourcePeerID - )) - - transport.resolveNextDeletedPrivateMediaPersistence(true) - - #expect( - (viewModel.privateChats[destinationPeerID] ?? []).isEmpty - ) - #expect( - viewModel.privateChats[sourcePeerID]?.map(\.id) - == ["arrival-recreating-source"] - ) - } - - @Test @MainActor - func clearPrivateChatKeepsMediaReferencedByAnotherConversation() { - let (viewModel, transport) = makeTestableViewModel() - let firstPeerID = PeerID(str: String(repeating: "4", count: 64)) - let aliasPeerID = PeerID(str: String(repeating: "5", count: 64)) - let messageID = "media-\(String(repeating: "6", count: 32))" - let message = privateMediaMessage( - id: messageID, - sender: "Peer", - senderPeerID: firstPeerID, - recipient: viewModel.nickname, - filename: "mirrored.jpg" - ) - viewModel.seedPrivateChat([message], for: firstPeerID) - viewModel.seedPrivateChat([message], for: aliasPeerID) - - viewModel.clearPrivateChat(firstPeerID) - - #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) - #expect(viewModel.privateChats[firstPeerID]?.isEmpty == true) - #expect( - viewModel.privateChats[aliasPeerID]?.map(\.id) == [messageID] - ) - } - - @Test @MainActor - func clearPrivateChatKeepsOutgoingMediaReferencedByAnotherConversation() - throws { - let (viewModel, transport) = makeTestableViewModel() - let firstPeerID = PeerID(str: String(repeating: "4", count: 64)) - let aliasPeerID = PeerID(str: String(repeating: "5", count: 64)) - let outgoingDirectory = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - .appendingPathComponent("files/images/outgoing", isDirectory: true) - try FileManager.default.createDirectory( - at: outgoingDirectory, - withIntermediateDirectories: true - ) - let fileURL = outgoingDirectory.appendingPathComponent( - "outgoing-mirrored-\(UUID().uuidString).jpg" - ) - try Data("outgoing-image".utf8).write(to: fileURL, options: .atomic) - defer { try? FileManager.default.removeItem(at: fileURL) } - let message = privateMediaMessage( - id: UUID().uuidString, - sender: viewModel.nickname, - senderPeerID: transport.myPeerID, - recipient: "Peer", - filename: fileURL.lastPathComponent - ) - viewModel.seedPrivateChat([message], for: firstPeerID) - viewModel.seedPrivateChat([message], for: aliasPeerID) - - viewModel.clearPrivateChat(firstPeerID) - - // The mirrored conversation keeps its bubble and the payload file: - // clearing conversation A must not reach across an identity-alias - // handoff into conversation B. - #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) - #expect(viewModel.privateChats[firstPeerID]?.isEmpty == true) - #expect( - viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id] - ) - #expect(FileManager.default.fileExists(atPath: fileURL.path)) - - // Clearing the last conversation that references the outgoing - // payload removes both the bubble and the file. - viewModel.clearPrivateChat(aliasPeerID) - - #expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty) - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - } - - @Test @MainActor - func clearPrivateChatUnlinksLegacyFileOnlyAfterLastReference() - throws { - let (viewModel, transport) = makeTestableViewModel() - let firstPeerID = PeerID(str: String(repeating: "9", count: 64)) - let aliasPeerID = PeerID(str: String(repeating: "a", count: 64)) - let incomingDirectory = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - .appendingPathComponent("files/images/incoming", isDirectory: true) - try FileManager.default.createDirectory( - at: incomingDirectory, - withIntermediateDirectories: true - ) - let fileURL = incomingDirectory.appendingPathComponent( - "legacy-clear-\(UUID().uuidString).jpg" - ) - try Data("legacy-image".utf8).write(to: fileURL, options: .atomic) - defer { try? FileManager.default.removeItem(at: fileURL) } - let message = privateMediaMessage( - id: UUID().uuidString, - sender: "Old client", - senderPeerID: firstPeerID, - recipient: viewModel.nickname, - filename: fileURL.lastPathComponent - ) - viewModel.seedPrivateChat([message], for: firstPeerID) - viewModel.seedPrivateChat([message], for: aliasPeerID) - - viewModel.clearPrivateChat(firstPeerID) - - // A surviving mirror in another conversation keeps the payload. - #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) - #expect(transport.removedLegacyPrivateMediaPaths.isEmpty) - #expect(FileManager.default.fileExists(atPath: fileURL.path)) - #expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id]) - - viewModel.clearPrivateChat(aliasPeerID) - - // Clearing the last reference routes the legacy payload through the - // transport's gated unlink, which deletes it (nothing pending or - // reserved names this basename). - #expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty) - #expect( - transport.removedLegacyPrivateMediaPaths - == ["images/incoming/\(fileURL.lastPathComponent)"] - ) - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - } - - @Test @MainActor - func deleteLegacyIncomingMediaUnlinksUnreferencedPayload() throws { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: String(repeating: "b", count: 64)) - let incomingDirectory = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - .appendingPathComponent("files/images/incoming", isDirectory: true) - try FileManager.default.createDirectory( - at: incomingDirectory, - withIntermediateDirectories: true - ) - let fileURL = incomingDirectory.appendingPathComponent( - "legacy-delete-\(UUID().uuidString).jpg" - ) - try Data("legacy-image".utf8).write(to: fileURL, options: .atomic) - defer { try? FileManager.default.removeItem(at: fileURL) } - let message = privateMediaMessage( - id: UUID().uuidString, - sender: "Old client", - senderPeerID: peerID, - recipient: viewModel.nickname, - filename: fileURL.lastPathComponent - ) - viewModel.seedPrivateChat([message], for: peerID) - - viewModel.deleteMediaMessage(messageID: message.id) - - // Legacy incoming media has no stable receipt, so no journal batch — - // but the explicit delete must still remove the decrypted payload - // through the gated unlink. - #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) - #expect((viewModel.privateChats[peerID] ?? []).isEmpty) - #expect( - transport.removedLegacyPrivateMediaPaths - == ["images/incoming/\(fileURL.lastPathComponent)"] - ) - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - } - - @Test @MainActor - func deleteLegacyIncomingMediaKeepsPayloadReferencedByAnotherBubble() - throws { - let (viewModel, transport) = makeTestableViewModel() - let peerID = PeerID(str: String(repeating: "c", count: 64)) - let incomingDirectory = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - .appendingPathComponent("files/images/incoming", isDirectory: true) - try FileManager.default.createDirectory( - at: incomingDirectory, - withIntermediateDirectories: true - ) - let fileURL = incomingDirectory.appendingPathComponent( - "legacy-shared-\(UUID().uuidString).jpg" - ) - try Data("legacy-image".utf8).write(to: fileURL, options: .atomic) - defer { try? FileManager.default.removeItem(at: fileURL) } - let deleted = privateMediaMessage( - id: UUID().uuidString, - sender: "Old client", - senderPeerID: peerID, - recipient: viewModel.nickname, - filename: fileURL.lastPathComponent - ) - // A different bubble (different ID) references the same basename. - let survivor = privateMediaMessage( - id: UUID().uuidString, - sender: "Old client", - senderPeerID: peerID, - recipient: viewModel.nickname, - filename: fileURL.lastPathComponent - ) - viewModel.seedPrivateChat([deleted, survivor], for: peerID) - - viewModel.deleteMediaMessage(messageID: deleted.id) - - #expect( - viewModel.privateChats[peerID]?.map(\.id) == [survivor.id] - ) - #expect(transport.removedLegacyPrivateMediaPaths.isEmpty) - #expect(FileManager.default.fileExists(atPath: fileURL.path)) - } - - @Test @MainActor - func panicInvalidatesActiveAndQueuedPrivateChatClears() { - let (viewModel, transport) = makeTestableViewModel() - transport.deferDeletedPrivateMediaPersistence = true - let firstPeerID = PeerID(str: String(repeating: "4", count: 64)) - let secondPeerID = PeerID(str: String(repeating: "5", count: 64)) - let firstID = "media-\(String(repeating: "6", count: 32))" - let secondID = "media-\(String(repeating: "7", count: 32))" - viewModel.seedPrivateChat([ - privateMediaMessage( - id: firstID, - sender: "First", - senderPeerID: firstPeerID, - recipient: viewModel.nickname, - filename: "first-pre-panic.jpg" - ) - ], for: firstPeerID) - viewModel.seedPrivateChat([ - privateMediaMessage( - id: secondID, - sender: "Second", - senderPeerID: secondPeerID, - recipient: viewModel.nickname, - filename: "second-pre-panic.jpg" - ) - ], for: secondPeerID) - - viewModel.clearPrivateChat(firstPeerID) - viewModel.clearPrivateChat(secondPeerID) - #expect( - transport.deletedPrivateMediaMessageIDBatches == [[firstID]] - ) - - _ = viewModel.panicClearAllData(restartServices: false) - viewModel.seedPrivateChat([ - privateMediaMessage( - id: firstID, - sender: "First", - senderPeerID: firstPeerID, - recipient: viewModel.nickname, - filename: "first-post-panic.jpg" - ) - ], for: firstPeerID) - viewModel.seedPrivateChat([ - privateMediaMessage( - id: secondID, - sender: "Second", - senderPeerID: secondPeerID, - recipient: viewModel.nickname, - filename: "second-post-panic.jpg" - ) - ], for: secondPeerID) - - transport.resolveNextDeletedPrivateMediaPersistence(true) - - #expect( - transport.deletedPrivateMediaMessageIDBatches == [[firstID]] - ) - #expect(viewModel.privateChats[firstPeerID]?.map(\.id) == [firstID]) - #expect(viewModel.privateChats[secondPeerID]?.map(\.id) == [secondID]) - } - - private func privateMediaMessage( - id: String, - sender: String, - senderPeerID: PeerID, - recipient: String, - filename: String - ) -> BitchatMessage { - BitchatMessage( - id: id, - sender: sender, - content: "\(MimeType.Category.image.messagePrefix)\(filename)", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: recipient, - senderPeerID: senderPeerID - ) - } -} - // MARK: - Panic Clear Tests struct ChatViewModelPanicTests { - @Test @MainActor - func panicClearAllData_finishesMediaWipeBeforeReturning() { - var wipeFinished = false - let (viewModel, _) = makeTestableViewModel(panicMediaWipe: { - wipeFinished = true - }) - - viewModel.panicClearAllData() - - #expect(wipeFinished) - } - - @Test @MainActor - func panicClearAllData_stopsNetworkBeforeWipeAndRestartsAfterCommit() { - var events: [String] = [] - let lifecycle = PanicNetworkLifecycle( - stop: { events.append("stop") }, - restart: { events.append("restart") } - ) - let (viewModel, _) = makeTestableViewModel( - panicMediaWipe: { events.append("wipe") }, - panicNetworkLifecycle: lifecycle - ) - - let completed = viewModel.panicClearAllData() - - #expect(completed) - #expect(events == ["stop", "wipe", "restart"]) - #expect(viewModel.networkActivationAllowed) - } - - @Test @MainActor - func panicKeychainFailureKeepsRecoveryPendingAndServicesStopped() { - let keychain = MockKeychain() - keychain.simulatedDeleteAllResult = false - var events: [String] = [] - let operations = PanicRecoveryOperations( - isPending: { false }, - begin: { - events.append("begin") - return PanicRecoveryIntent( - fileMarkerEstablished: true, - externalMarkerEstablished: false - ) - }, - wipeMedia: { _ in events.append("wipe") }, - complete: { events.append("complete") } - ) - let lifecycle = PanicNetworkLifecycle( - stop: { events.append("stop") }, - restart: { events.append("restart") } - ) - let (viewModel, transport) = makeTestableViewModel( - keychain: keychain, - panicRecoveryOperations: operations, - panicNetworkLifecycle: lifecycle - ) - let startsBeforePanic = transport.startServicesCallCount - - let completed = viewModel.panicClearAllData() - - #expect(!completed) - #expect(events == ["stop", "begin", "wipe"]) - #expect(keychain.deleteAllCallCount == 1) - #expect(transport.startServicesCallCount == startsBeforePanic) - #expect(!viewModel.networkActivationAllowed) - } - - @Test @MainActor - func pendingPanicRecoveryCompletesBeforeTransportBootstrap() { - var events: [String] = [] - let operations = PanicRecoveryOperations( - isPending: { - events.append("read") - return true - }, - begin: { - events.append("begin") - return PanicRecoveryIntent( - fileMarkerEstablished: true, - externalMarkerEstablished: false - ) - }, - wipeMedia: { _ in events.append("wipe") }, - complete: { events.append("complete") } - ) - - let (viewModel, transport) = makeTestableViewModel( - panicRecoveryOperations: operations - ) - - #expect(events == ["read", "begin", "wipe", "complete"]) - #expect(transport.emergencyDisconnectCallCount == 1) - #expect(transport.startServicesCallCount == 1) - #expect(viewModel.networkActivationAllowed) - } - - @Test @MainActor - func failedStartupRecoveryLeavesTransportAndNetworkBlocked() { - enum WipeFailure: Error { case failed } - var completedMarker = false - let operations = PanicRecoveryOperations( - isPending: { true }, - begin: { - PanicRecoveryIntent( - fileMarkerEstablished: true, - externalMarkerEstablished: false - ) - }, - wipeMedia: { _ in throw WipeFailure.failed }, - complete: { completedMarker = true } - ) - - let (viewModel, transport) = makeTestableViewModel( - panicRecoveryOperations: operations - ) - - #expect(!completedMarker) - #expect(transport.emergencyDisconnectCallCount == 1) - #expect(transport.startServicesCallCount == 0) - #expect(!viewModel.networkActivationAllowed) - } - - @Test @MainActor - func failedStartupKeychainRecoveryLeavesIntentAndTransportBlocked() { - let keychain = MockKeychain() - keychain.simulatedDeleteAllResult = false - var events: [String] = [] - let operations = PanicRecoveryOperations( - isPending: { true }, - begin: { - events.append("begin") - return PanicRecoveryIntent( - fileMarkerEstablished: true, - externalMarkerEstablished: true - ) - }, - wipeMedia: { _ in events.append("wipe") }, - complete: { events.append("complete") } - ) - - let (viewModel, transport) = makeTestableViewModel( - keychain: keychain, - panicRecoveryOperations: operations - ) - - #expect(events == ["begin", "wipe"]) - #expect(keychain.deleteAllCallCount == 1) - #expect(transport.emergencyDisconnectCallCount == 1) - #expect(transport.startServicesCallCount == 0) - #expect(!viewModel.networkActivationAllowed) - } - @Test @MainActor func panicClearAllData_delegatesToTransport() async { let (viewModel, transport) = makeTestableViewModel() // Set up some state transport.connectedPeers.insert(PeerID(str: "PEER1")) - viewModel.seedPublicMessages([ + viewModel.messages = [ BitchatMessage( id: "panic-1", sender: "Tester", @@ -2244,8 +479,8 @@ struct ChatViewModelPanicTests { timestamp: Date(), isRelay: false ) - ]) - viewModel.seedPrivateChat([ + ] + viewModel.privateChats[PeerID(str: "PEER1")] = [ BitchatMessage( id: "pm-1", sender: "Peer", @@ -2256,52 +491,18 @@ struct ChatViewModelPanicTests { recipientNickname: "Me", senderPeerID: PeerID(str: "PEER1") ) - ], for: PeerID(str: "PEER1")) - viewModel.markPrivateChatUnread(PeerID(str: "PEER1")) + ] + viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1")) viewModel.panicClearAllData() // After panic, emergency disconnect should be called #expect(transport.emergencyDisconnectCallCount == 1) - // Pre-panic content is gone; the only survivor is the system message - // confirming the wipe (in duress, "did it work?" must not be a guess). - #expect(viewModel.messages.map(\.sender) == ["system"]) + #expect(viewModel.messages.isEmpty) #expect(viewModel.privateChats.isEmpty) #expect(viewModel.unreadPrivateMessages.isEmpty) #expect(viewModel.selectedPrivateChatPeer == nil) } - - @Test @MainActor - func panicClearAllData_resetsLiveGeohashAndNostrState() async throws { - let (viewModel, _) = makeTestableViewModel() - let geohash = "u4pruy" - let channel = GeohashChannel(level: .city, geohash: geohash) - let identity = try NostrIdentity.generate() - let pubkey = String(repeating: "ab", count: 32) - let peerID = PeerID(nostr: pubkey) - - viewModel.activeChannel = .location(channel) - viewModel.setGeoChatSubscriptionID("geo-\(geohash)") - viewModel.setGeoDmSubscriptionID("geo-dm-\(geohash)") - viewModel.addGeoSamplingSub("geo-sample-\(geohash)", forGeohash: geohash) - viewModel.cachedGeohashIdentity = (geohash, identity) - viewModel.registerNostrKeyMapping(pubkey, for: peerID) - viewModel.currentGeohash = geohash - viewModel.geoNicknames = [pubkey: "alice"] - viewModel.teleportedGeo = [pubkey] - - viewModel.panicClearAllData() - - #expect(viewModel.activeChannel == .mesh) - #expect(viewModel.geoSubscriptionID == nil) - #expect(viewModel.geoDmSubscriptionID == nil) - #expect(viewModel.geoSamplingSubs.isEmpty) - #expect(viewModel.cachedGeohashIdentity == nil) - #expect(viewModel.nostrKeyMapping.isEmpty) - #expect(viewModel.currentGeohash == nil) - #expect(viewModel.geoNicknames.isEmpty) - #expect(viewModel.teleportedGeo.isEmpty) - } } // MARK: - Service Lifecycle Tests diff --git a/bitchatTests/ChatViewModelTorTests.swift b/bitchatTests/ChatViewModelTorTests.swift index d5eba112..d4697dd8 100644 --- a/bitchatTests/ChatViewModelTorTests.swift +++ b/bitchatTests/ChatViewModelTorTests.swift @@ -165,33 +165,14 @@ struct ChatViewModelTorTests { viewModel.torStatusAnnounced = true viewModel.torInitialReadyAnnounced = true viewModel.torRestartPending = true - viewModel.torBlocked = true // Action viewModel.handleTorPreferenceChanged(Notification(name: .init("test"))) try? await Task.sleep(nanoseconds: 100_000_000) - // Assert: all flags reset — torBlocked drives the connectivity - // banner; toggling tor off must not leave a stale "tor can't - // connect" line for a network nobody is waiting on. + // Assert: all flags reset #expect(!viewModel.torStatusAnnounced) #expect(!viewModel.torInitialReadyAnnounced) #expect(!viewModel.torRestartPending) - #expect(!viewModel.torBlocked) - } - - @Test @MainActor - func handleTorDidBecomeReady_clearsBlockedBanner() async { - let (viewModel, _) = makeTestableViewModel() - - // Setup: a stalled bootstrap raised the banner, then tor got through. - viewModel.torBlocked = true - - // Action - viewModel.handleTorDidBecomeReady() - try? await Task.sleep(nanoseconds: 100_000_000) - - // Assert - #expect(!viewModel.torBlocked) } } diff --git a/bitchatTests/ChatVouchCoordinatorContextTests.swift b/bitchatTests/ChatVouchCoordinatorContextTests.swift deleted file mode 100644 index 18175576..00000000 --- a/bitchatTests/ChatVouchCoordinatorContextTests.swift +++ /dev/null @@ -1,353 +0,0 @@ -// -// ChatVouchCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `ChatVouchCoordinator` against a mock `ChatVouchContext` — -// proving the exchange policy (verified + capable peers only, batch cap, -// 24h rate limit) and the accept policy (verified senders only, real -// Ed25519 signature verification, expiry) without a `ChatViewModel`. -// Storage-level gates (self-vouch, already-verified vouchee, per-vouchee -// cap) are covered by `SecureIdentityStateManagerVouchTests`. -// - -import CryptoKit -import Foundation -import BitFoundation -import Testing - -@testable import bitchat - -// MARK: - Mock Context - -@MainActor -private final class MockChatVouchContext: ChatVouchContext { - // Identity & trust state - var fingerprintsByPeerID: [PeerID: String] = [:] - var verifiedFingerprints: Set = [] - var signingKeysByFingerprint: [String: Data] = [:] - var recentVerified: [String] = [] - private(set) var recentVerifiedRequests: [(limit: Int, excluding: String)] = [] - private(set) var recordedVouches: [(vouchee: String, voucher: String, timestamp: Date)] = [] - var recordVouchResult = true - var lastBatchSentAt: [String: Date] = [:] - private(set) var markedBatchSent: [(fingerprint: String, date: Date)] = [] - - func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } - func isVerifiedFingerprint(_ fingerprint: String) -> Bool { verifiedFingerprints.contains(fingerprint) } - func signingKey(forFingerprint fingerprint: String) -> Data? { signingKeysByFingerprint[fingerprint] } - - func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { - recentVerifiedRequests.append((limit, fingerprint)) - return Array(recentVerified.filter { $0 != fingerprint }.prefix(limit)) - } - - @discardableResult - func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { - recordedVouches.append((voucheeFingerprint, voucherFingerprint, timestamp)) - return recordVouchResult - } - - func lastVouchBatchSent(to fingerprint: String) -> Date? { lastBatchSentAt[fingerprint] } - - func markVouchBatchSent(to fingerprint: String, at date: Date) { - markedBatchSent.append((fingerprint, date)) - lastBatchSentAt[fingerprint] = date - } - - // Transport - var capabilitiesByPeerID: [PeerID: PeerCapabilities] = [:] - var mySigningKey = Curve25519.Signing.PrivateKey() - private(set) var installedObservers: [(PeerID, String) -> Void] = [] - private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = [] - - var connectedPeerIDList: [PeerID] = [] - - func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] } - - func connectedPeerIDs() -> [PeerID] { connectedPeerIDList } - - func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { - installedObservers.append(handler) - } - - func noiseSignData(_ data: Data) -> Data? { try? mySigningKey.signature(for: data) } - - func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { - sentVouchPayloads.append((payload, peerID)) - } - - // UI refresh - private(set) var trustChangedCount = 0 - - func notifyPeerTrustChanged() { trustChangedCount += 1 } -} - -// MARK: - Tests - -struct ChatVouchCoordinatorContextTests { - private let peerID = PeerID(str: "1122334455667788") - private let peerFingerprint = String(repeating: "0f", count: 32) - - @MainActor - private func makeVerifiedCapablePeer() -> (MockChatVouchContext, ChatVouchCoordinator) { - let context = MockChatVouchContext() - let coordinator = ChatVouchCoordinator(context: context) - context.fingerprintsByPeerID[peerID] = peerFingerprint - context.verifiedFingerprints.insert(peerFingerprint) - context.capabilitiesByPeerID[peerID] = [.vouch] - return (context, coordinator) - } - - // MARK: Exchange policy - - @Test @MainActor - func peerAuthenticated_sendsBatchForVerifiedCapablePeer() throws { - let (context, coordinator) = makeVerifiedCapablePeer() - let vouchees = [String(repeating: "01", count: 32), String(repeating: "02", count: 32)] - context.recentVerified = vouchees - for vouchee in vouchees { - context.signingKeysByFingerprint[vouchee] = Data(repeating: 0x33, count: 32) - } - - coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) - - // Candidates are requested most-recent-first, excluding the target. - #expect(context.recentVerifiedRequests.count == 1) - #expect(context.recentVerifiedRequests.first?.limit == VouchAttestation.maxBatchCount) - #expect(context.recentVerifiedRequests.first?.excluding == peerFingerprint) - - let sent = try #require(context.sentVouchPayloads.first) - #expect(sent.peerID == peerID) - let attestations = VouchAttestation.decodeList(from: sent.payload) - #expect(attestations.map(\.voucheeFingerprintHex) == vouchees) - // Every attestation carries a valid signature under our signing key. - let myPublicKey = context.mySigningKey.publicKey.rawRepresentation - #expect(attestations.allSatisfy { $0.verifySignature(voucherSigningKey: myPublicKey) }) - - // The rate limit is stamped only after an actual send. - #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint]) - } - - @Test @MainActor - func peerAuthenticated_requiresVerificationAndCapability() { - let (context, coordinator) = makeVerifiedCapablePeer() - context.recentVerified = [String(repeating: "01", count: 32)] - context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) - - // Not verified by me: nothing. - context.verifiedFingerprints.remove(peerFingerprint) - coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) - #expect(context.sentVouchPayloads.isEmpty) - - // Verified but advertises a non-empty capability set lacking .vouch: - // nothing. (An *empty*/unknown set is race-tolerant and still sends — - // see `attemptVouch_sendsWhenCapabilitiesUnknown`.) - context.verifiedFingerprints.insert(peerFingerprint) - context.capabilitiesByPeerID[peerID] = [.prekeys] - coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) - #expect(context.sentVouchPayloads.isEmpty) - #expect(context.markedBatchSent.isEmpty) - } - - // MARK: Capability race tolerance & new triggers - - @Test @MainActor - func attemptVouch_sendsWhenCapabilitiesUnknown() { - // Capability set still empty at attempt time (the peer's .vouch bit - // arrives on a later announce): the batch must still go out. - let (context, coordinator) = makeVerifiedCapablePeer() - context.capabilitiesByPeerID[peerID] = [] - context.recentVerified = [String(repeating: "01", count: 32)] - context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) - - coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) - #expect(context.sentVouchPayloads.count == 1) - #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint]) - } - - @Test @MainActor - func vouchToConnectedVerifiedPeers_sendsToConnectedVerifiedCapablePeer() { - let (context, coordinator) = makeVerifiedCapablePeer() - context.connectedPeerIDList = [peerID] - context.recentVerified = [String(repeating: "01", count: 32)] - context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) - - // Session is already up (no peerAuthenticated re-fire); the verify pass - // is what makes the batch go out. - coordinator.vouchToConnectedVerifiedPeers() - let sent = context.sentVouchPayloads - #expect(sent.count == 1) - #expect(sent.first?.peerID == peerID) - #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint]) - } - - @Test @MainActor - func vouchToConnectedVerifiedPeers_skipsUnverifiedConnectedPeers() { - let (context, coordinator) = makeVerifiedCapablePeer() - context.connectedPeerIDList = [peerID] - context.verifiedFingerprints.remove(peerFingerprint) - context.recentVerified = [String(repeating: "01", count: 32)] - context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) - - coordinator.vouchToConnectedVerifiedPeers() - #expect(context.sentVouchPayloads.isEmpty) - } - - @Test @MainActor - func peersUpdated_sendsOnceCapabilityBearingAnnounceArrives() { - let (context, coordinator) = makeVerifiedCapablePeer() - context.recentVerified = [String(repeating: "01", count: 32)] - context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) - - // First announce before the .vouch bit is known: empty set is - // race-tolerant, so it already sends and stamps the throttle. - context.capabilitiesByPeerID[peerID] = [] - coordinator.peersUpdated([peerID]) - #expect(context.sentVouchPayloads.count == 1) - - // A later announce carrying .vouch must not double-send (throttled). - context.capabilitiesByPeerID[peerID] = [.vouch] - coordinator.peersUpdated([peerID]) - #expect(context.sentVouchPayloads.count == 1) - } - - @Test @MainActor - func peerAuthenticated_rateLimitsPerPeerPer24Hours() { - let (context, coordinator) = makeVerifiedCapablePeer() - context.recentVerified = [String(repeating: "01", count: 32)] - context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) - - let now = Date() - context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-60 * 60) - coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now) - #expect(context.sentVouchPayloads.isEmpty) - - // Once the interval has elapsed the batch goes out again. - context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-ChatVouchCoordinator.batchInterval - 1) - coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now) - #expect(context.sentVouchPayloads.count == 1) - } - - @Test @MainActor - func peerAuthenticated_skipsCandidatesWithoutSigningKeysAndEmptyBatches() { - let (context, coordinator) = makeVerifiedCapablePeer() - let withKey = String(repeating: "01", count: 32) - let withoutKey = String(repeating: "02", count: 32) - context.recentVerified = [withoutKey, withKey] - context.signingKeysByFingerprint[withKey] = Data(repeating: 0x33, count: 32) - - coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) - let attestations = VouchAttestation.decodeList(from: context.sentVouchPayloads[0].payload) - #expect(attestations.map(\.voucheeFingerprintHex) == [withKey]) - - // No signable candidates at all: nothing is sent or rate-stamped. - let freshPeer = PeerID(str: "aabbccddeeff0011") - let freshFingerprint = String(repeating: "0e", count: 32) - context.fingerprintsByPeerID[freshPeer] = freshFingerprint - context.verifiedFingerprints.insert(freshFingerprint) - context.capabilitiesByPeerID[freshPeer] = [.vouch] - context.recentVerified = [withoutKey] - coordinator.peerAuthenticated(freshPeer, fingerprint: freshFingerprint) - #expect(context.sentVouchPayloads.count == 1) - #expect(!context.markedBatchSent.contains { $0.fingerprint == freshFingerprint }) - } - - // MARK: Accept policy - - @MainActor - private func makeInboundBatch( - signedBy key: Curve25519.Signing.PrivateKey, - vouchee: String = String(repeating: "07", count: 32), - timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000) - ) throws -> Data { - let voucheeData = try #require(Data(hexString: vouchee)) - let attestation = try #require(VouchAttestation.build( - voucheeFingerprint: voucheeData, - voucheeSigningKey: Data(repeating: 0x44, count: 32), - timestampMs: timestampMs, - sign: { try? key.signature(for: $0) } - )) - return try #require(VouchAttestation.encodeList([attestation])) - } - - @Test @MainActor - func handleVouchPayload_acceptsValidVouchFromVerifiedSender() throws { - let (context, coordinator) = makeVerifiedCapablePeer() - let senderKey = Curve25519.Signing.PrivateKey() - context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation - - let vouchee = String(repeating: "07", count: 32) - let payload = try makeInboundBatch(signedBy: senderKey, vouchee: vouchee) - coordinator.handleVouchPayload(from: peerID, payload: payload) - - #expect(context.recordedVouches.count == 1) - #expect(context.recordedVouches.first?.vouchee == vouchee) - #expect(context.recordedVouches.first?.voucher == peerFingerprint) - #expect(context.trustChangedCount == 1) - } - - @Test @MainActor - func handleVouchPayload_rejectsUnverifiedOrUnknownSender() throws { - let (context, coordinator) = makeVerifiedCapablePeer() - let senderKey = Curve25519.Signing.PrivateKey() - context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation - let payload = try makeInboundBatch(signedBy: senderKey) - - // Sender's fingerprint is not in my verified set. - context.verifiedFingerprints.remove(peerFingerprint) - coordinator.handleVouchPayload(from: peerID, payload: payload) - #expect(context.recordedVouches.isEmpty) - - // Unknown peer entirely. - coordinator.handleVouchPayload(from: PeerID(str: "ffeeddccbbaa9988"), payload: payload) - #expect(context.recordedVouches.isEmpty) - #expect(context.trustChangedCount == 0) - } - - @Test @MainActor - func handleVouchPayload_rejectsForgedSignaturesAndExpiredAttestations() throws { - let (context, coordinator) = makeVerifiedCapablePeer() - let senderKey = Curve25519.Signing.PrivateKey() - context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation - - // Signed by an imposter key: signature check against the sender's - // announce-bound key fails. - let imposter = Curve25519.Signing.PrivateKey() - let forged = try makeInboundBatch(signedBy: imposter) - coordinator.handleVouchPayload(from: peerID, payload: forged) - #expect(context.recordedVouches.isEmpty) - - // Correctly signed but expired. - let staleMs = UInt64(Date().addingTimeInterval(-31 * 24 * 60 * 60).timeIntervalSince1970 * 1000) - let expired = try makeInboundBatch(signedBy: senderKey, timestampMs: staleMs) - coordinator.handleVouchPayload(from: peerID, payload: expired) - #expect(context.recordedVouches.isEmpty) - #expect(context.trustChangedCount == 0) - - // No signing key known for the sender: batch dropped. - context.signingKeysByFingerprint.removeValue(forKey: peerFingerprint) - let valid = try makeInboundBatch(signedBy: senderKey) - coordinator.handleVouchPayload(from: peerID, payload: valid) - #expect(context.recordedVouches.isEmpty) - } - - @Test @MainActor - func handleVouchPayload_skipsUIRefreshWhenNothingStored() throws { - let (context, coordinator) = makeVerifiedCapablePeer() - let senderKey = Curve25519.Signing.PrivateKey() - context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation - context.recordVouchResult = false // e.g. self-vouch dropped by the store - - let payload = try makeInboundBatch(signedBy: senderKey) - coordinator.handleVouchPayload(from: peerID, payload: payload) - #expect(context.recordedVouches.count == 1) - #expect(context.trustChangedCount == 0) - } - - @Test @MainActor - func setupNoiseCallbacks_installsAdditiveObserver() { - let (context, coordinator) = makeVerifiedCapablePeer() - coordinator.setupNoiseCallbacks() - #expect(context.installedObservers.count == 1) - } -} diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 232aa265..12e57d27 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -53,7 +53,7 @@ struct CommandProcessorTests { context.nicknameToPeerID["alice"] = peerID let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) - let result = await withSelectedChannel(.mesh, context: context) { + let result = await withSelectedChannel(.mesh) { processor.process("/msg @alice hello there") } @@ -72,15 +72,14 @@ struct CommandProcessorTests { @MainActor @Test func whoInMeshListsSortedPeerNicknames() async { let identityManager = MockIdentityManager(MockKeychain()) - let context = MockCommandContextProvider() let transport = MockTransport() transport.peerNicknames = [ PeerID(str: "b"): "bob", PeerID(str: "a"): "alice" ] - let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) + let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager) - let result = await withSelectedChannel(.mesh, context: context) { + let result = await withSelectedChannel(.mesh) { processor.process("/who") } @@ -106,7 +105,7 @@ struct CommandProcessorTests { let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) - let result = await withSelectedChannel(channel, context: context) { + let result = await withSelectedChannel(channel) { processor.process("/who") } @@ -131,7 +130,7 @@ struct CommandProcessorTests { ] let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) - let result = await withSelectedChannel(.mesh, context: context) { + let result = await withSelectedChannel(.mesh) { processor.process("/clear") } @@ -151,7 +150,7 @@ struct CommandProcessorTests { let context = MockCommandContextProvider() let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) - let result = await withSelectedChannel(.mesh, context: context) { + let result = await withSelectedChannel(.mesh) { processor.process("/clear") } @@ -175,7 +174,7 @@ struct CommandProcessorTests { transport.peerNicknames[peerID] = "Bob" let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) - let result = await withSelectedChannel(.mesh, context: context) { + let result = await withSelectedChannel(.mesh) { processor.process("/hug @bob") } @@ -199,7 +198,7 @@ struct CommandProcessorTests { context.nicknameToPeerID["bob"] = peerID let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) - let result = await withSelectedChannel(.mesh, context: context) { + let result = await withSelectedChannel(.mesh) { processor.process("/slap @bob") } @@ -228,7 +227,7 @@ struct CommandProcessorTests { identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true) let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) - let result = await withSelectedChannel(.mesh, context: context) { + let result = await withSelectedChannel(.mesh) { processor.process("/block") } @@ -250,20 +249,18 @@ struct CommandProcessorTests { context.nicknameToPeerID["bob"] = peerID let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) - let blockResult = await withSelectedChannel(.mesh, context: context) { + let blockResult = await withSelectedChannel(.mesh) { processor.process("/block @bob") } switch blockResult { case .success(let message): - // "no longer see" — blocking filters at display time; packets still - // arrive and relay, so "receive" overpromised (UX audit fix). - #expect(message == "blocked bob. you will no longer see their messages") + #expect(message == "blocked bob. you will no longer receive messages from them") default: Issue.record("Expected success result") } #expect(identityManager.isBlocked(fingerprint: "fp-bob")) - let unblockResult = await withSelectedChannel(.mesh, context: context) { + let unblockResult = await withSelectedChannel(.mesh) { processor.process("/unblock bob") } switch unblockResult { @@ -282,7 +279,7 @@ struct CommandProcessorTests { context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64) let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) - let blockResult = await withSelectedChannel(.mesh, context: context) { + let blockResult = await withSelectedChannel(.mesh) { processor.process("/block carol") } switch blockResult { @@ -293,7 +290,7 @@ struct CommandProcessorTests { } #expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64))) - let unblockResult = await withSelectedChannel(.mesh, context: context) { + let unblockResult = await withSelectedChannel(.mesh) { processor.process("/unblock @carol") } switch unblockResult { @@ -305,62 +302,17 @@ struct CommandProcessorTests { #expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64))) } - /// /fav must go through toggleFavorite (which persists by the real noise - /// key) — not write the hex peer ID into the favorites store, and not - /// send a second favorite notification. - @MainActor - @Test func favoriteCommandTogglesWithoutDirectStoreWrite() async { - let identityManager = MockIdentityManager(MockKeychain()) - let context = MockCommandContextProvider() - let processor = CommandProcessor( - contextProvider: context, - meshService: MockTransport(), - identityManager: identityManager - ) - let peerID = PeerID(str: "00aa00bb00cc00dd") - context.nicknameToPeerID["alice"] = peerID - - let result = await withSelectedChannel(.mesh, context: context) { - processor.process("/fav alice") - } - - switch result { - case .success(let message): - #expect(message == "added alice to favorites") - default: - Issue.record("Expected success result") - } - #expect(context.toggledFavorites == [peerID]) - #expect(context.favoriteNotifications.isEmpty) - // The 8-byte routing ID must never be stored as a "noise key". - let bogusKey = Data(hexString: peerID.id)! - #expect(FavoritesPersistenceService.shared.getFavoriteStatus(for: bogusKey) == nil) - - // Unfavoriting someone who is not a favorite is a no-op. - let unfavResult = await withSelectedChannel(.mesh, context: context) { - processor.process("/unfav alice") - } - switch unfavResult { - case .success(let message): - #expect(message == "alice is not a favorite") - default: - Issue.record("Expected success result") - } - #expect(context.toggledFavorites == [peerID]) - } - @MainActor @Test func favoriteCommandIsRejectedOutsideMesh() async { let identityManager = MockIdentityManager(MockKeychain()) - let context = MockCommandContextProvider() let processor = CommandProcessor( - contextProvider: context, + contextProvider: MockCommandContextProvider(), meshService: MockTransport(), identityManager: identityManager ) let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy")) - let result = await withSelectedChannel(channel, context: context) { + let result = await withSelectedChannel(channel) { processor.process("/fav alice") } @@ -372,199 +324,22 @@ struct CommandProcessorTests { } } - // MARK: - /pay - @MainActor - @Test func payWithoutArgumentsPrintsUsage() { - let processor = makePayProcessor(context: MockCommandContextProvider()) - switch processor.process("/pay") { - case .success(let message): - #expect(message?.contains("usage: /pay") == true) - default: - Issue.record("Expected success (usage) result") - } - } - - @MainActor - @Test func payRejectsInvalidToken() { - let context = MockCommandContextProvider() - let processor = makePayProcessor(context: context) - for bad in ["/pay nonsense", "/pay cashuAshort", "/pay cashuA!!!!!!!!!!!!!!!!"] { - switch processor.process(bad) { - case .error: - break - default: - Issue.record("Expected error for \(bad)") - } - } - #expect(context.sentPrivateMessages.isEmpty) - #expect(context.sentPublicMessages.isEmpty) - } - - @MainActor - @Test func paySendsBareTokenInPrivateChat() { - let context = MockCommandContextProvider() - let peerID = PeerID(str: "abcd1234abcd1234") - context.selectedPrivateChatPeer = peerID - let processor = makePayProcessor(context: context) - - // cashu: URI form must be normalized to the bare token before sending - switch processor.process("/pay cashu:\(Self.validV3Token)") { - case .success(let message): - #expect(message?.contains("21 sat") == true) - default: - Issue.record("Expected success result") - } - #expect(context.sentPrivateMessages.count == 1) - #expect(context.sentPrivateMessages.first?.content == Self.validV3Token) - #expect(context.sentPrivateMessages.first?.peerID == peerID) - #expect(context.sentPublicMessages.isEmpty) - } - - @MainActor - @Test func payInPublicChannelRequiresExplicitConfirm() { - let context = MockCommandContextProvider() - let processor = makePayProcessor(context: context) - - switch processor.process("/pay \(Self.validV3Token)") { - case .error(let message): - #expect(message.contains("public") == true) - default: - Issue.record("Expected error without confirm") - } - #expect(context.sentPublicMessages.isEmpty) - - switch processor.process("/pay \(Self.validV3Token) public") { - case .success: - break - default: - Issue.record("Expected success with confirm") - } - #expect(context.sentPublicMessages == [Self.validV3Token]) - #expect(context.sentPrivateMessages.isEmpty) - } - - @MainActor - @Test func payRejectsTruncatedOrJunkV4Token() { - let context = MockCommandContextProvider() - context.selectedPrivateChatPeer = PeerID(str: "abcd1234abcd1234") - let processor = makePayProcessor(context: context) - - // Truncated V4 (definite-length CBOR can no longer be walked) and - // pure base64 junk under the cashuB prefix must both be refused. - let truncatedV4 = String(Self.validV4Token.prefix(Self.validV4Token.count - 12)) - let junkV4 = "cashuB" + String(repeating: "Q", count: 40) - for bad in ["/pay \(truncatedV4)", "/pay \(junkV4)"] { - switch processor.process(bad) { - case .error(let message): - #expect(message.contains("invalid cashu token") == true) - default: - Issue.record("Expected error for \(bad)") - } - } - #expect(context.sentPrivateMessages.isEmpty) - #expect(context.sentPublicMessages.isEmpty) - } - - @MainActor - @Test func paySendsValidDefiniteLengthV4Token() { - let context = MockCommandContextProvider() - let peerID = PeerID(str: "abcd1234abcd1234") - context.selectedPrivateChatPeer = peerID - let processor = makePayProcessor(context: context) - - switch processor.process("/pay \(Self.validV4Token)") { - case .success(let message): - #expect(message?.contains("21 sat") == true) - default: - Issue.record("Expected success result for valid V4 token") - } - #expect(context.sentPrivateMessages.count == 1) - #expect(context.sentPrivateMessages.first?.content == Self.validV4Token) - } - - /// 21-sat single-mint V3 token (proofs of 1+4+16). - private static let validV3Token: String = { - let json: [String: Any] = [ - "token": [[ - "mint": "https://mint.example.com", - "proofs": [1, 4, 16].map { ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] } - ]], - "unit": "sat" - ] - let data = try! JSONSerialization.data(withJSONObject: json) - let b64 = data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - return "cashuA" + b64 - }() - - /// 21-sat single-mint definite-length V4 (CBOR) token (proofs of 1+4+16). - private static let validV4Token: String = { - func head(_ major: UInt8, _ value: UInt64) -> [UInt8] { - switch value { - case 0...23: return [(major << 5) | UInt8(value)] - case 24...0xFF: return [(major << 5) | 24, UInt8(value)] - default: return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)] - } - } - func text(_ s: String) -> [UInt8] { head(3, UInt64(s.utf8.count)) + Array(s.utf8) } - func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b } - func uint(_ v: UInt64) -> [UInt8] { head(0, v) } - func array(_ items: [[UInt8]]) -> [UInt8] { head(4, UInt64(items.count)) + items.flatMap { $0 } } - func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } } - - let proofs = [UInt64(1), 4, 16].map { amount in - map([("a", uint(amount)), ("s", text("secret")), ("c", bytes([0x02, 0xAB, 0xCD]))]) - } - let cbor = map([ - ("m", text("https://mint.example.com")), - ("u", text("sat")), - ("t", array([map([("i", bytes([0x00, 0xAD, 0x26, 0x8C])), ("p", array(proofs))])])) - ]) - let b64 = Data(cbor).base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - return "cashuB" + b64 - }() - - @MainActor - private func makePayProcessor(context: MockCommandContextProvider) -> CommandProcessor { - CommandProcessor( - contextProvider: context, - meshService: MockTransport(), - identityManager: MockIdentityManager(MockKeychain()) - ) - } - - @MainActor - private func withSelectedChannel( - _ channel: ChannelID, - context: MockCommandContextProvider? = nil, - perform work: @escaping () throws -> T - ) async rethrows -> T { + private func withSelectedChannel(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T { let originalChannel = LocationChannelManager.shared.selectedChannel - let originalContextChannel = context?.activeChannel - await setSelectedChannel(channel, context: context) + await setSelectedChannel(channel) do { let result = try work() - await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel) + await setSelectedChannel(originalChannel) return result } catch { - await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel) + await setSelectedChannel(originalChannel) throw error } } @MainActor - private func setSelectedChannel( - _ channel: ChannelID, - context: MockCommandContextProvider? = nil, - explicitChannel: ChannelID? = nil - ) async { - context?.activeChannel = explicitChannel ?? channel + private func setSelectedChannel(_ channel: ChannelID) async { LocationChannelManager.shared.select(channel) for _ in 0..<40 { if LocationChannelManager.shared.selectedChannel == channel { @@ -588,7 +363,6 @@ struct CommandProcessorTests { @MainActor private final class MockCommandContextProvider: CommandContextProvider { var nickname: String - var activeChannel: ChannelID = .mesh var selectedPrivateChatPeer: PeerID? var blockedUsers: Set = [] var privateChats: [PeerID: [BitchatMessage]] = [:] @@ -604,8 +378,6 @@ private final class MockCommandContextProvider: CommandContextProvider { private(set) var sentPublicRawMessages: [String] = [] private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = [] private(set) var publicSystemMessages: [String] = [] - private(set) var commandOutputs: [String] = [] - private(set) var commandOutputDestinations: [CommandOutputDestination] = [] private(set) var toggledFavorites: [PeerID] = [] private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] @@ -638,21 +410,10 @@ private final class MockCommandContextProvider: CommandContextProvider { clearCurrentPublicTimelineCallCount += 1 } - private(set) var clearedPrivateChats: [PeerID] = [] - func clearPrivateChat(_ peerID: PeerID) { - clearedPrivateChats.append(peerID) - privateChats[peerID] = [] - } - func sendPublicRaw(_ content: String) { sentPublicRawMessages.append(content) } - private(set) var sentPublicMessages: [String] = [] - func sendPublicMessage(_ content: String) { - sentPublicMessages.append(content) - } - func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { localPrivateSystemMessages.append((content, peerID)) } @@ -661,47 +422,11 @@ private final class MockCommandContextProvider: CommandContextProvider { publicSystemMessages.append(content) } - func currentCommandDestination() -> CommandOutputDestination { - if let peerID = selectedPrivateChatPeer { - return .privateChat(peerID) - } - return .meshTimeline - } - - func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { - commandOutputs.append(content) - commandOutputDestinations.append(destination) - } - func toggleFavorite(peerID: PeerID) { toggledFavorites.append(peerID) } - // Groups: record the parsed subcommand + argument the processor forwarded. - private(set) var groupCommands: [(subcommand: String, argument: String)] = [] - - func groupCreate(named name: String) -> CommandResult { - groupCommands.append(("create", name)) - return .handled - } - - func groupInvite(nickname: String) -> CommandResult { - groupCommands.append(("invite", nickname)) - return .handled - } - - func groupRemove(nickname: String) -> CommandResult { - groupCommands.append(("remove", nickname)) - return .handled - } - - func groupLeave() -> CommandResult { - groupCommands.append(("leave", "")) - return .handled - } - - func groupList() -> CommandResult { - groupCommands.append(("list", "")) - return .handled + func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { + favoriteNotifications.append((peerID, isFavorite)) } } diff --git a/bitchatTests/ConnectivityStatusTests.swift b/bitchatTests/ConnectivityStatusTests.swift deleted file mode 100644 index 93ecc2fb..00000000 --- a/bitchatTests/ConnectivityStatusTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// ConnectivityStatusTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import CoreBluetooth -import Testing -@testable import bitchat - -struct ConnectivityIssueTests { - - @Test("Bluetooth problems outrank a tor stall; healthy state shows nothing") - func resolvePrioritizesBluetoothOverTor() { - #expect(ConnectivityIssue.resolve(bluetoothState: .poweredOff, torBlocked: true) == .bluetoothOff) - #expect(ConnectivityIssue.resolve(bluetoothState: .unauthorized, torBlocked: false) == .bluetoothDenied) - #expect(ConnectivityIssue.resolve(bluetoothState: .unsupported, torBlocked: false) == .bluetoothUnsupported) - #expect(ConnectivityIssue.resolve(bluetoothState: .poweredOn, torBlocked: true) == .torBlocked) - #expect(ConnectivityIssue.resolve(bluetoothState: .poweredOn, torBlocked: false) == nil) - } - - @Test("A starting radio must not flash a false 'bluetooth is off' banner") - func resolveStaysQuietWhileRadioIsStarting() { - #expect(ConnectivityIssue.resolve(bluetoothState: .unknown, torBlocked: false) == nil) - #expect(ConnectivityIssue.resolve(bluetoothState: .resetting, torBlocked: false) == nil) - // A tor stall still surfaces once known, even while the radio starts. - #expect(ConnectivityIssue.resolve(bluetoothState: .unknown, torBlocked: true) == .torBlocked) - } -} diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift deleted file mode 100644 index cc1e6e09..00000000 --- a/bitchatTests/ConversationStoreTests.swift +++ /dev/null @@ -1,1422 +0,0 @@ -// -// ConversationStoreTests.swift -// bitchatTests -// -// Tests for the new single-source-of-truth ConversationStore -// (docs/CONVERSATION-STORE-DESIGN.md): intent API, ordered insertion, -// dedup, caps, delivery-status rules, migration, unread state, change -// emission, and per-conversation publish isolation. -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import Combine -import Foundation -import Testing -@testable import bitchat - -@MainActor -private func makeMessage( - id: String, - timestamp: TimeInterval, - content: String? = nil, - isPrivate: Bool = false, - deliveryStatus: DeliveryStatus? = nil -) -> BitchatMessage { - BitchatMessage( - id: id, - sender: "alice", - content: content ?? "message \(id)", - timestamp: Date(timeIntervalSince1970: timestamp), - isRelay: false, - isPrivate: isPrivate, - recipientNickname: isPrivate ? "bob" : nil, - senderPeerID: PeerID(str: "peer-a"), - deliveryStatus: deliveryStatus - ) -} - -private func makeDirectConversationID(_ suffix: String) -> ConversationID { - .direct(PeerHandle( - id: "noise:\(suffix)", - routingPeerID: PeerID(str: "peer-\(suffix)") - )) -} - -/// Deliberately simple O(n) model used to differentially test the store's -/// optimized logical-index bookkeeping. It models observable behavior only; -/// it has no offset or ID index and therefore cannot reproduce the same bug. -private struct ReferenceConversationTimeline { - struct Message: Equatable { - let id: String - let timestamp: Date - let content: String - var deliveryStatus: DeliveryStatus? - - init(_ message: BitchatMessage) { - id = message.id - timestamp = message.timestamp - content = message.content - deliveryStatus = message.deliveryStatus - } - } - - struct AppendResult { - let inserted: Bool - let trimmedCount: Int - } - - let cap: Int - private(set) var messages: [Message] = [] - - func contains(_ id: String) -> Bool { - messages.contains { $0.id == id } - } - - mutating func append(_ message: BitchatMessage) -> AppendResult { - guard !contains(message.id) else { - return AppendResult(inserted: false, trimmedCount: 0) - } - - let snapshot = Message(message) - var low = 0 - var high = messages.count - while low < high { - let mid = (low + high) / 2 - if messages[mid].timestamp <= snapshot.timestamp { - low = mid + 1 - } else { - high = mid - } - } - messages.insert(snapshot, at: low) - - let overflow = max(0, messages.count - cap) - if overflow > 0 { - messages.removeFirst(overflow) - } - return AppendResult(inserted: true, trimmedCount: overflow) - } - - mutating func upsert(_ message: BitchatMessage) -> Int { - if let index = messages.firstIndex(where: { $0.id == message.id }) { - messages[index] = Message(message) - return 0 - } - return append(message).trimmedCount - } - - mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool { - guard let index = messages.firstIndex(where: { $0.id == id }), - messages[index].deliveryStatus != status else { - return false - } - // The differential stream uses only unique `.delivered` values (or - // an exact repeat), so no-downgrade policy is intentionally outside - // this index-focused reference model. - messages[index].deliveryStatus = status - return true - } - - mutating func remove(at index: Int) -> Message { - messages.remove(at: index) - } - - mutating func removeAll(where predicate: (Message) -> Bool) { - messages.removeAll(where: predicate) - } - - mutating func clear() { - messages.removeAll() - } -} - -private struct ConversationStoreDifferentialRNG { - private var state: UInt64 - - init(seed: UInt64) { - state = seed - } - - mutating func next() -> UInt64 { - state &+= 0x9E37_79B9_7F4A_7C15 - var value = state - value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9 - value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB - return value ^ (value >> 31) - } - - mutating func index(upperBound: Int) -> Int { - precondition(upperBound > 0) - return Int(next() % UInt64(upperBound)) - } -} - -@MainActor -private func expectStore( - _ store: ConversationStore, - matches reference: ReferenceConversationTimeline, - issuedIDs: [String], - checkpoint: String -) { - let conversation = store.conversation(for: .mesh) - let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init) - #expect(actual == reference.messages, "timeline mismatch at \(checkpoint)") - - let lookupSnapshot = reference.messages.compactMap { expected in - conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init) - } - #expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)") - #expect( - Set(conversation.messageIDs) == Set(reference.messages.map(\.id)), - "per-conversation ID set mismatch at \(checkpoint)" - ) - - if !reference.messages.isEmpty { - for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) { - let id = reference.messages[index].id - #expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)") - } - } - - let activeIDs = Set(reference.messages.map(\.id)) - var checkedStaleIDs = 0 - for id in issuedIDs.reversed() where !activeIDs.contains(id) { - #expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)") - #expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)") - checkedStaleIDs += 1 - if checkedStaleIDs == 16 { break } - } - - #expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)") -} - -@Suite("ConversationStore") -struct ConversationStoreTests { - - // MARK: - Append, dedup, ordering - - @Test("append dedups by message ID and reports duplicates") - @MainActor - func appendDedupsByMessageID() { - let store = ConversationStore() - var received: [ConversationChange] = [] - let cancellable = store.changes.sink { received.append($0) } - defer { cancellable.cancel() } - - #expect(store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)) - #expect(!store.append(makeMessage(id: "m1", timestamp: 2, content: "dup"), to: .mesh)) - - let conversation = store.conversation(for: .mesh) - #expect(conversation.messages.count == 1) - #expect(conversation.messages.first?.content == "message m1") - #expect(conversation.containsMessage(withID: "m1")) - #expect(received.count == 1) - guard case .appended(.mesh, let message) = received.first else { - Issue.record("expected a single .appended change, got \(received)") - return - } - #expect(message.id == "m1") - } - - @Test("out-of-order appends are inserted in timestamp order") - @MainActor - func outOfOrderInsertKeepsTimestampOrder() { - let store = ConversationStore() - - store.append(makeMessage(id: "m1", timestamp: 10), to: .mesh) - store.append(makeMessage(id: "m3", timestamp: 30), to: .mesh) - store.append(makeMessage(id: "m2", timestamp: 20), to: .mesh) - store.append(makeMessage(id: "m0", timestamp: 5), to: .mesh) - - let conversation = store.conversation(for: .mesh) - #expect(conversation.messages.map(\.id) == ["m0", "m1", "m2", "m3"]) - - // The ID index must survive middle inserts: lookups by ID still - // resolve to the right message. - #expect(conversation.message(withID: "m2")?.timestamp == Date(timeIntervalSince1970: 20)) - #expect(conversation.message(withID: "m0")?.timestamp == Date(timeIntervalSince1970: 5)) - } - - @Test("equal timestamps preserve arrival order") - @MainActor - func equalTimestampsPreserveArrivalOrder() { - let store = ConversationStore() - - store.append(makeMessage(id: "first", timestamp: 10), to: .mesh) - store.append(makeMessage(id: "second", timestamp: 10), to: .mesh) - // A late message with an equal timestamp lands after existing peers. - store.append(makeMessage(id: "late-tail", timestamp: 20), to: .mesh) - store.append(makeMessage(id: "third", timestamp: 10), to: .mesh) - - let conversation = store.conversation(for: .mesh) - #expect(conversation.messages.map(\.id) == ["first", "second", "third", "late-tail"]) - } - - // MARK: - Caps - - @Test("cap trims oldest messages and keeps the ID index valid") - @MainActor - func capTrimsOldestAndKeepsIndexValid() { - let store = ConversationStore() - let conversation = store.conversation(for: .mesh) - let cap = conversation.cap - #expect(cap == TransportConfig.meshTimelineCap) - - let overflow = 3 - for i in 0..<(cap + overflow) { - store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh) - } - - #expect(conversation.messages.count == cap) - #expect(conversation.messages.first?.id == "m\(overflow)") - #expect(conversation.messages.last?.id == "m\(cap + overflow - 1)") - - // Trimmed messages left the index entirely… - for i in 0.. BitchatMessage { - let number = nextID - nextID += 1 - let id = "diff-\(number)" - issuedIDs.append(id) - let resolvedTimestamp: TimeInterval - if let timestamp { - resolvedTimestamp = timestamp - } else { - resolvedTimestamp = nextTailTimestamp - nextTailTimestamp += 1 - } - let dropMarker = number.isMultiple(of: 11) ? " [drop]" : "" - return makeMessage( - id: id, - timestamp: resolvedTimestamp, - content: "\(tag) \(number)\(dropMarker)" - ) - } - - @discardableResult - func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult { - let expected = reference.append(message) - let actual = store.append(message, to: .mesh) - #expect(actual == expected.inserted, "append result mismatch at \(checkpoint)") - trimmedCount += expected.trimmedCount - return expected - } - - func refill(extra: Int, checkpoint: String) async { - let appendCount = max(0, cap - reference.messages.count) + extra - for index in 0.. 1_200) - #expect(tailAppendCount > 300) - #expect(outOfOrderCount > 150) - #expect(duplicateOrReuseCount > 75) - #expect(headRemovalCount > 50) - #expect(middleRemovalCount > 50) - #expect(upsertCount > 75) - #expect(deliveryUpdateCount > 75) - #expect(filterCount == 2) - #expect(clearCount == 1) - } - - // MARK: - Upsert - - @Test("upsertByID replaces in place and appends when absent") - @MainActor - func upsertReplacesOrAppends() { - let store = ConversationStore() - var received: [ConversationChange] = [] - let cancellable = store.changes.sink { received.append($0) } - defer { cancellable.cancel() } - - store.upsertByID(makeMessage(id: "m1", timestamp: 10), in: .mesh) - store.append(makeMessage(id: "m2", timestamp: 20), to: .mesh) - store.upsertByID(makeMessage(id: "m1", timestamp: 10, content: "edited"), in: .mesh) - - let conversation = store.conversation(for: .mesh) - #expect(conversation.messages.map(\.id) == ["m1", "m2"]) - #expect(conversation.message(withID: "m1")?.content == "edited") - - #expect(received.count == 3) - guard case .appended(.mesh, let first) = received[0], first.id == "m1", - case .appended(.mesh, let second) = received[1], second.id == "m2", - case .updated(.mesh, let updatedID) = received[2], updatedID == "m1" else { - Issue.record("unexpected change sequence: \(received)") - return - } - } - - // MARK: - Delivery status - - @Test("setDeliveryStatus never downgrades read and skips equal statuses") - @MainActor - func deliveryStatusNoDowngrade() { - let store = ConversationStore() - let id = makeDirectConversationID("aa") - store.append(makeMessage(id: "m1", timestamp: 1, isPrivate: true, deliveryStatus: .sending), to: id) - var statusChanges: [DeliveryStatus] = [] - let cancellable = store.changes.sink { change in - if case .statusChanged(_, _, let status) = change { - statusChanges.append(status) - } - } - defer { cancellable.cancel() } - - let conversation = store.conversation(for: id) - let readStatus = DeliveryStatus.read(by: "bob", at: Date(timeIntervalSince1970: 100)) - - #expect(store.setDeliveryStatus(.sent, forMessageID: "m1", in: id)) - // Equal status is a no-op. - #expect(!store.setDeliveryStatus(.sent, forMessageID: "m1", in: id)) - #expect(store.setDeliveryStatus(readStatus, forMessageID: "m1", in: id)) - // Read beats delivered and sent: both downgrades are refused. - #expect(!store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "m1", in: id)) - #expect(!store.setDeliveryStatus(.sent, forMessageID: "m1", in: id)) - #expect(conversation.message(withID: "m1")?.deliveryStatus == readStatus) - - // Unknown message or conversation: refused, nothing emitted. - #expect(!store.setDeliveryStatus(.sent, forMessageID: "nope", in: id)) - #expect(!store.setDeliveryStatus(.sent, forMessageID: "m1", in: .mesh)) - - #expect(statusChanges == [.sent, readStatus]) - } - - // MARK: - Unread state - - @Test("markUnread and markRead keep the set and conversation flag consistent") - @MainActor - func unreadStateConsistency() { - let store = ConversationStore() - let id = makeDirectConversationID("bb") - var unreadChanges: [(ConversationID, Bool)] = [] - let cancellable = store.changes.sink { change in - if case .unreadChanged(let conversationID, let isUnread) = change { - unreadChanges.append((conversationID, isUnread)) - } - } - defer { cancellable.cancel() } - - // markRead on a never-unread conversation is a no-op. - store.markRead(id) - #expect(unreadChanges.isEmpty) - - store.markUnread(id) - #expect(store.unreadConversations == [id]) - #expect(store.conversation(for: id).isUnread) - // Idempotent: marking unread twice emits once. - store.markUnread(id) - - store.markRead(id) - #expect(store.unreadConversations.isEmpty) - #expect(!store.conversation(for: id).isUnread) - - #expect(unreadChanges.count == 2) - #expect(unreadChanges[0].0 == id && unreadChanges[0].1 == true) - #expect(unreadChanges[1].0 == id && unreadChanges[1].1 == false) - } - - // MARK: - Selection - - @Test("select creates the conversation and clears with nil") - @MainActor - func selectTracksConversation() { - let store = ConversationStore() - let id = makeDirectConversationID("cc") - - store.select(id) - #expect(store.selectedConversationID == id) - #expect(store.conversationsByID[id] != nil) - #expect(store.conversationIDs == [id]) - - store.select(nil) - #expect(store.selectedConversationID == nil) - } - - // MARK: - Migration - - @Test("migrateConversation moves messages, dedups, and preserves order") - @MainActor - func migrationMovesAndDedups() { - let store = ConversationStore() - let ephemeral = makeDirectConversationID("old") - let stable = makeDirectConversationID("new") - - store.append(makeMessage(id: "m1", timestamp: 10), to: ephemeral) - store.append(makeMessage(id: "m3", timestamp: 30), to: ephemeral) - store.append(makeMessage(id: "m2", timestamp: 20), to: stable) - store.append(makeMessage(id: "m3", timestamp: 30, content: "already there"), to: stable) - - var received: [ConversationChange] = [] - let cancellable = store.changes.sink { received.append($0) } - defer { cancellable.cancel() } - - store.migrateConversation(from: ephemeral, to: stable) - - let destination = store.conversation(for: stable) - #expect(destination.messages.map(\.id) == ["m1", "m2", "m3"]) - // Existing copy wins the dedup. - #expect(destination.message(withID: "m3")?.content == "already there") - #expect(store.conversationsByID[ephemeral] == nil) - #expect(!store.conversationIDs.contains(ephemeral)) - - #expect(received.count == 1) - guard case .migrated(let from, let to) = received.first, from == ephemeral, to == stable else { - Issue.record("expected a single .migrated change, got \(received)") - return - } - } - - @Test("migrateConversation hands off unread state and selection") - @MainActor - func migrationMovesUnreadAndSelection() { - let store = ConversationStore() - let ephemeral = makeDirectConversationID("old") - let stable = makeDirectConversationID("new") - - store.append(makeMessage(id: "m1", timestamp: 10), to: ephemeral) - store.markUnread(ephemeral) - store.select(ephemeral) - - store.migrateConversation(from: ephemeral, to: stable) - - #expect(store.unreadConversations == [stable]) - #expect(store.conversation(for: stable).isUnread) - #expect(store.selectedConversationID == stable) - - // Migrating from a missing source or onto itself is a no-op. - store.migrateConversation(from: ephemeral, to: stable) - store.migrateConversation(from: stable, to: stable) - #expect(store.conversation(for: stable).messages.map(\.id) == ["m1"]) - } - - // MARK: - Clear / remove - - @Test("clear empties the timeline but keeps the conversation") - @MainActor - func clearKeepsConversation() { - let store = ConversationStore() - let id = makeDirectConversationID("dd") - store.append(makeMessage(id: "m1", timestamp: 1), to: id) - store.markUnread(id) - var received: [ConversationChange] = [] - let cancellable = store.changes.sink { received.append($0) } - defer { cancellable.cancel() } - - store.clear(id) - - let conversation = store.conversation(for: id) - #expect(conversation.messages.isEmpty) - #expect(!conversation.containsMessage(withID: "m1")) - #expect(store.conversationIDs.contains(id)) - #expect(store.unreadConversations.contains(id)) - // The ID index was cleared too: the same message can return. - #expect(store.append(makeMessage(id: "m1", timestamp: 2), to: id)) - - guard case .cleared(id) = received.first else { - Issue.record("expected .cleared first, got \(received)") - return - } - } - - @Test("removeConversation drops messages, unread state, and selection") - @MainActor - func removeConversationDropsEverything() { - let store = ConversationStore() - let id = makeDirectConversationID("ee") - store.append(makeMessage(id: "m1", timestamp: 1), to: id) - store.markUnread(id) - store.select(id) - var received: [ConversationChange] = [] - let cancellable = store.changes.sink { received.append($0) } - defer { cancellable.cancel() } - - store.removeConversation(id) - - #expect(store.conversationsByID[id] == nil) - #expect(store.conversationIDs.isEmpty) - #expect(store.unreadConversations.isEmpty) - #expect(store.selectedConversationID == nil) - #expect(received.count == 1) - guard case .removed(id) = received.first else { - Issue.record("expected .removed, got \(received)") - return - } - - // Removing again is a no-op. - store.removeConversation(id) - #expect(received.count == 1) - } - - @Test("clearAll removes every conversation and emits removals") - @MainActor - func clearAllRemovesEverything() { - let store = ConversationStore() - let direct = makeDirectConversationID("ff") - store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh) - store.append(makeMessage(id: "m2", timestamp: 2), to: direct) - store.markUnread(direct) - store.select(direct) - var removed: [ConversationID] = [] - let cancellable = store.changes.sink { change in - if case .removed(let id) = change { removed.append(id) } - } - defer { cancellable.cancel() } - - store.clearAll() - - #expect(store.conversationsByID.isEmpty) - #expect(store.conversationIDs.isEmpty) - #expect(store.unreadConversations.isEmpty) - #expect(store.selectedConversationID == nil) - #expect(removed == [.mesh, direct]) - } - - // MARK: - Change emission - - @Test("changes are emitted after state is consistent") - @MainActor - func changesEmittedAfterStateIsConsistent() { - let store = ConversationStore() - var observedCountsAtEmission: [Int] = [] - var observedUnreadAtEmission: [Bool] = [] - let cancellable = store.changes.sink { change in - switch change { - case .appended(let id, let message): - // The appended message must already be visible at emission. - observedCountsAtEmission.append(store.conversation(for: id).messages.count) - #expect(store.conversation(for: id).containsMessage(withID: message.id)) - case .unreadChanged(let id, let isUnread): - #expect(store.unreadConversations.contains(id) == isUnread) - observedUnreadAtEmission.append(isUnread) - default: - break - } - } - defer { cancellable.cancel() } - - store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh) - store.append(makeMessage(id: "m2", timestamp: 2), to: .mesh) - store.markUnread(.mesh) - store.markRead(.mesh) - - #expect(observedCountsAtEmission == [1, 2]) - #expect(observedUnreadAtEmission == [true, false]) - } - - @Test("cap policy follows the conversation kind") - @MainActor - func capPolicyByKind() { - let store = ConversationStore() - #expect(store.conversation(for: .mesh).cap == TransportConfig.meshTimelineCap) - #expect(store.conversation(for: .geohash("u4pruyd")).cap == TransportConfig.geoTimelineCap) - #expect(store.conversation(for: makeDirectConversationID("gg")).cap == TransportConfig.privateChatCap) - } - - // MARK: - Publish isolation - - @Test("appending to one conversation does not publish another") - @MainActor - func perConversationPublishIsolation() { - let store = ConversationStore() - let a = makeDirectConversationID("aa") - let b = makeDirectConversationID("bb") - let conversationA = store.conversation(for: a) - let conversationB = store.conversation(for: b) - - var aWillChangeCount = 0 - var bWillChangeCount = 0 - var cancellables = Set() - conversationA.objectWillChange - .sink { aWillChangeCount += 1 } - .store(in: &cancellables) - conversationB.objectWillChange - .sink { bWillChangeCount += 1 } - .store(in: &cancellables) - - store.append(makeMessage(id: "m1", timestamp: 1), to: a) - store.append(makeMessage(id: "m2", timestamp: 2), to: a) - store.setDeliveryStatus(.sent, forMessageID: "m1", in: a) - store.markUnread(a) - - #expect(aWillChangeCount >= 4) - #expect(bWillChangeCount == 0) - - store.append(makeMessage(id: "m3", timestamp: 3), to: b) - #expect(bWillChangeCount > 0) - } - - // MARK: - Public timelines (mesh/geohash, ex-PublicTimelineStore behavior) - - @Test("geohash conversations are separated by geohash and from mesh") - @MainActor - func geohashConversationSeparation() { - let store = ConversationStore() - store.append(makeMessage(id: "mesh-1", timestamp: 1), to: .mesh) - store.append(makeMessage(id: "geo-a-1", timestamp: 2), to: .geohash("u4pruyd")) - store.append(makeMessage(id: "geo-b-1", timestamp: 3), to: .geohash("9q8yy")) - - #expect(store.conversation(for: .mesh).messages.map(\.id) == ["mesh-1"]) - #expect(store.conversation(for: .geohash("u4pruyd")).messages.map(\.id) == ["geo-a-1"]) - #expect(store.conversation(for: .geohash("9q8yy")).messages.map(\.id) == ["geo-b-1"]) - } - - @Test("geohash append dedups by ID and reports duplicates") - @MainActor - func geohashAppendIfAbsentContract() { - let store = ConversationStore() - let message = makeMessage(id: "geo-1", timestamp: 1) - - #expect(store.append(message, to: .geohash("u4pruyd"))) - #expect(!store.append(message, to: .geohash("u4pruyd"))) - // The same ID is still fresh in a different geohash. - #expect(store.append(message, to: .geohash("9q8yy"))) - } - - @Test("removePublicMessage searches mesh and geohash conversations only") - @MainActor - func removePublicMessageSearchesPublicConversations() { - let store = ConversationStore() - let direct = makeDirectConversationID("aa") - store.append(makeMessage(id: "mesh-1", timestamp: 1), to: .mesh) - store.append(makeMessage(id: "geo-1", timestamp: 2), to: .geohash("u4pruyd")) - store.append(makeMessage(id: "dm-1", timestamp: 3, isPrivate: true), to: direct) - - #expect(store.removePublicMessage(withID: "geo-1")?.id == "geo-1") - #expect(store.conversation(for: .geohash("u4pruyd")).messages.isEmpty) - - #expect(store.removePublicMessage(withID: "mesh-1")?.id == "mesh-1") - #expect(store.conversation(for: .mesh).messages.isEmpty) - - // Direct conversations are never touched. - #expect(store.removePublicMessage(withID: "dm-1") == nil) - #expect(store.conversation(for: direct).messages.map(\.id) == ["dm-1"]) - } - - @Test("removeMessages(from:where:) purges matches and emits per removal") - @MainActor - func removeMessagesByPredicate() { - let store = ConversationStore() - let id = ConversationID.geohash("u4pruyd") - store.append(makeMessage(id: "keep-1", timestamp: 1), to: id) - store.append(makeMessage(id: "drop-1", timestamp: 2, content: "purge me"), to: id) - store.append(makeMessage(id: "drop-2", timestamp: 3, content: "purge me"), to: id) - store.append(makeMessage(id: "keep-2", timestamp: 4), to: id) - - var removedIDs: [String] = [] - var cancellables = Set() - store.changes - .sink { change in - if case .messageRemoved(_, let messageID) = change { - removedIDs.append(messageID) - } - } - .store(in: &cancellables) - - store.removeMessages(from: id, where: { $0.content == "purge me" }) - - #expect(store.conversation(for: id).messages.map(\.id) == ["keep-1", "keep-2"]) - #expect(removedIDs == ["drop-1", "drop-2"]) - // The ID index survives the purge: dedup and removal still work. - #expect(!store.append(makeMessage(id: "keep-2", timestamp: 4), to: id)) - #expect(store.removeMessage(withID: "keep-1", from: id) != nil) - #expect(store.conversation(for: id).messages.map(\.id) == ["keep-2"]) - } - - @Test("trimmed public message IDs can return after falling off the cap") - @MainActor - func trimmedMessageIDsCanReturn() { - let store = ConversationStore() - let id = ConversationID.geohash("u4pruyd") - let conversation = store.conversation(for: id) - let first = makeMessage(id: "one", timestamp: 1) - - store.append(first, to: id) - for index in 0..() - var publishedIDs: [ConversationID] = [] - for id in [stable, ephemeral] { - store.conversation(for: id).objectWillChange - .sink { publishedIDs.append(id) } - .store(in: &cancellables) - } - var statusChangedIDs: [ConversationID] = [] - store.changes - .sink { change in - if case .statusChanged(let id, "dm-2", _) = change { statusChangedIDs.append(id) } - } - .store(in: &cancellables) - - let read = DeliveryStatus.read(by: "bob", at: Date()) - #expect(store.setDeliveryStatus(read, forMessageID: "dm-2")) - - // The shared instance is mutated once, but a view observing EITHER - // conversation must re-render, and both emit a change event. - #expect(Set(publishedIDs) == Set([stable, ephemeral])) - #expect(Set(statusChangedIDs) == Set([stable, ephemeral])) - - // A duplicate ack applies nowhere and must publish nothing. - publishedIDs.removeAll() - statusChangedIDs.removeAll() - #expect(!store.setDeliveryStatus(read, forMessageID: "dm-2")) - #expect(publishedIDs.isEmpty) - #expect(statusChangedIDs.isEmpty) - } - - @Test("peer-scoped receipt updates only authenticated direct aliases") - @MainActor - func peerScopedReceiptUpdatesOnlyAuthenticatedDirectAliases() { - let store = ConversationStore() - let ephemeralPeer = PeerID(str: "0102030405060708") - let stablePeer = PeerID(hexData: Data(repeating: 0x08, count: 32)) - let otherPeer = PeerID(str: "1112131415161718") - let ephemeral = ConversationID.directPeer(ephemeralPeer) - let stable = ConversationID.directPeer(stablePeer) - let other = ConversationID.directPeer(otherPeer) - let messageID = "scoped-receipt" - let mirrored = makeMessage( - id: messageID, - timestamp: 1, - isPrivate: true, - deliveryStatus: .sent - ) - store.upsertByID(mirrored, in: ephemeral) - store.upsertByID(mirrored, in: stable) - store.upsertByID( - makeMessage( - id: messageID, - timestamp: 1, - isPrivate: true, - deliveryStatus: .sent - ), - in: other - ) - store.upsertByID( - makeMessage(id: messageID, timestamp: 1, deliveryStatus: .sent), - in: .mesh - ) - - var cancellables = Set() - var publishedIDs: [ConversationID] = [] - for id in [ephemeral, stable, other, .mesh] { - store.conversation(for: id).objectWillChange - .sink { publishedIDs.append(id) } - .store(in: &cancellables) - } - var statusChangedIDs: [ConversationID] = [] - store.changes - .sink { change in - if case .statusChanged(let id, messageID, _) = change, - messageID == "scoped-receipt" { - statusChangedIDs.append(id) - } - } - .store(in: &cancellables) - - let delivered = DeliveryStatus.delivered(to: "bob", at: Date()) - #expect(store.setDeliveryStatus( - delivered, - forMessageID: messageID, - inDirectPeerAliases: [ephemeralPeer, stablePeer] - )) - - #expect(Set(publishedIDs) == Set([ephemeral, stable])) - #expect(Set(statusChangedIDs) == Set([ephemeral, stable])) - #expect(store.conversation(for: ephemeral).message(withID: messageID)?.deliveryStatus == delivered) - #expect(store.conversation(for: stable).message(withID: messageID)?.deliveryStatus == delivered) - #expect(store.conversation(for: other).message(withID: messageID)?.deliveryStatus == .sent) - #expect(store.conversation(for: .mesh).message(withID: messageID)?.deliveryStatus == .sent) - } - - // MARK: - Invariant audit (field observability) - - /// A store exercised through every intent family: public + geohash + - /// mirrored direct conversations, out-of-order and equal-timestamp - /// appends, upserts, delivery updates, removal, migration, unread, and - /// selection. Used as the healthy baseline for audit tests. - @MainActor - private static func makeExercisedStore() -> ConversationStore { - let store = ConversationStore() - let stable = makeDirectConversationID("stable") - let ephemeral = makeDirectConversationID("ephemeral") - - store.append(makeMessage(id: "m1", timestamp: 10), to: .mesh) - store.append(makeMessage(id: "m3", timestamp: 30), to: .mesh) - store.append(makeMessage(id: "m2", timestamp: 20), to: .mesh) // out of order - store.append(makeMessage(id: "m2b", timestamp: 20), to: .mesh) // equal timestamp - store.append(makeMessage(id: "g1", timestamp: 5), to: .geohash("u4pruyd")) - store.upsertByID(makeMessage(id: "g1", timestamp: 5, content: "edited"), in: .geohash("u4pruyd")) - - // Mirrored private copy (shared instance) across two direct chats. - let mirrored = makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent) - store.upsertByID(mirrored, in: stable) - store.upsertByID(mirrored, in: ephemeral) - store.setDeliveryStatus(.delivered(to: "bob", at: Date(timeIntervalSince1970: 2)), forMessageID: "dm-1") - - store.append(makeMessage(id: "dm-2", timestamp: 3, isPrivate: true), to: ephemeral) - store.migrateConversation(from: ephemeral, to: stable) - store.append(makeMessage(id: "dm-gone", timestamp: 4, isPrivate: true), to: stable) - store.removeMessage(withID: "dm-gone", from: stable) - - store.markUnread(stable) - store.setActiveChannel(.mesh) - return store - } - - @Test("audit reports no violations for a healthy, well-exercised store") - @MainActor - func auditHealthyStoreIsClean() { - let store = Self.makeExercisedStore() - #expect(store.auditInvariants().isEmpty) - - // Selection through both axes stays healthy. - store.setSelectedPrivatePeer(PeerID(str: "peer-stable")) - #expect(store.auditInvariants().isEmpty) - store.setSelectedPrivatePeer(nil) - #expect(store.auditInvariants().isEmpty) - } - - @Test("audit flags index entries pointing at the wrong position") - @MainActor - func auditFlagsCorruptIndexEntries() { - let store = Self.makeExercisedStore() - store.conversation(for: .mesh)._testCorruptIndexEntries() - - let violations = store.auditInvariants() - #expect(!violations.isEmpty) - #expect(violations.contains { $0.contains("mesh") && $0.contains("indexed at") }) - } - - @Test("audit flags a message missing from the per-conversation index") - @MainActor - func auditFlagsMissingIndexEntry() { - let store = Self.makeExercisedStore() - store.conversation(for: .mesh)._testRemoveIndexEntry(forMessageID: "m1") - - let violations = store.auditInvariants() - #expect(violations.contains { $0.contains("missing from index") }) - #expect(violations.contains { $0.contains("index has") }) // count mismatch - } - - @Test("audit flags timestamp-order violations") - @MainActor - func auditFlagsOrderingViolation() { - let store = Self.makeExercisedStore() - store.conversation(for: .mesh)._testCorruptOrderingPreservingIndex() - - let violations = store.auditInvariants() - #expect(violations.contains { $0.contains("timestamp order violated") }) - // The hook keeps the index consistent: no index violations leak in. - #expect(!violations.contains { $0.contains("indexed at") || $0.contains("missing from index") }) - } - - @Test("audit flags map memberships the conversation does not hold") - @MainActor - func auditFlagsPhantomMapMembership() { - let store = Self.makeExercisedStore() - store._testRegisterPhantomMessageID("ghost", in: .mesh) - - let violations = store.auditInvariants() - #expect(violations.contains { $0.contains("not present in claimed conversation") }) - #expect(violations.contains { $0.contains("memberships but conversations hold") }) - } - - @Test("audit flags map memberships claiming unknown conversations") - @MainActor - func auditFlagsUnknownConversationMembership() { - let store = Self.makeExercisedStore() - store._testRegisterPhantomMessageID("ghost", in: makeDirectConversationID("nope")) - - let violations = store.auditInvariants() - #expect(violations.contains { $0.contains("claims unknown conversation") }) - } - - @Test("audit flags conversation messages missing from the map") - @MainActor - func auditFlagsMissingMapMembership() { - let store = Self.makeExercisedStore() - store._testUnregisterMessageID("m1", from: .mesh) - - let violations = store.auditInvariants() - #expect(violations.contains { $0.contains("memberships but conversations hold") }) - } - - @Test("audit flags a conversation exceeding its cap") - @MainActor - func auditFlagsCapViolation() { - let store = ConversationStore() - let conversation = store.conversation(for: .mesh) - for index in 0.. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -struct CourierStoreTests { - - private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000) - - private func makeStore(now: Date = baseDate) -> CourierStore { - CourierStore(persistsToDisk: false, now: { now }) - } - - /// Store whose clock can be advanced by tests. - private final class Clock { - var now: Date - init(_ now: Date) { self.now = now } - } - - private func makeEnvelope( - recipientKey: Data = Data(repeating: 0xB0, count: 32), - sealedAt: Date = baseDate, - lifetime: TimeInterval = 60 * 60, - ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) }) - ) -> CourierEnvelope { - CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: recipientKey, - epochDay: CourierEnvelope.epochDay(for: sealedAt) - ), - expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000), - ciphertext: ciphertext - ) - } - - private let depositorA = Data(repeating: 0xA1, count: 32) - private let depositorB = Data(repeating: 0xA2, count: 32) - - // MARK: - Deposit and handover - - @Test func depositThenTakeForRecipient() { - let store = makeStore() - let recipientKey = Data(repeating: 0xB0, count: 32) - let envelope = makeEnvelope(recipientKey: recipientKey) - - #expect(store.deposit(envelope, from: depositorA)) - let taken = store.takeEnvelopes(for: recipientKey) - #expect(taken == [envelope]) - // Handover removes the envelope. - #expect(store.takeEnvelopes(for: recipientKey).isEmpty) - } - - @Test func takeIgnoresOtherRecipients() { - let store = makeStore() - let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32)) - store.deposit(envelope, from: depositorA) - #expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty) - #expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1) - } - - @Test func rejectedPhysicalHandoverRetainsEnvelopeUntilAcceptedRetry() { - let store = makeStore() - let recipientKey = Data(repeating: 0xB0, count: 32) - let envelope = makeEnvelope(recipientKey: recipientKey) - #expect(store.deposit(envelope, from: depositorA)) - - var rejectedOffers: [CourierEnvelope] = [] - let rejected = store.handoverEnvelopes(for: recipientKey) { offered in - rejectedOffers.append(offered) - return false - } - - #expect(rejected == 0) - #expect(rejectedOffers == [envelope]) - #expect(!store.isEmpty) - - var acceptedOffers: [CourierEnvelope] = [] - let accepted = store.handoverEnvelopes(for: recipientKey) { offered in - acceptedOffers.append(offered) - return true - } - #expect(accepted == 1) - #expect(acceptedOffers == [envelope]) - #expect(store.isEmpty) - } - - @Test func midTrainFragmentRejectionRetainsDurableEnvelope() { - let store = makeStore() - let recipientKey = Data(repeating: 0xB0, count: 32) - let envelope = makeEnvelope(recipientKey: recipientKey) - #expect(store.deposit(envelope, from: depositorA)) - - var attemptedFragments: [Int] = [] - let accepted = store.handoverEnvelopes(for: recipientKey) { _ in - BLEStrictFragmentAdmission.admitAll([0, 1, 2]) { fragment in - attemptedFragments.append(fragment) - return fragment != 1 - } - } - - #expect(accepted == 0) - #expect(attemptedFragments == [0, 1]) - #expect(!store.isEmpty) - #expect(store.takeEnvelopes(for: recipientKey) == [envelope]) - } - - @Test func duplicateDepositIsIdempotent() { - let store = makeStore() - let recipientKey = Data(repeating: 0xB0, count: 32) - let envelope = makeEnvelope(recipientKey: recipientKey) - #expect(store.deposit(envelope, from: depositorA)) - #expect(store.deposit(envelope, from: depositorA)) - #expect(store.takeEnvelopes(for: recipientKey).count == 1) - } - - // MARK: - Validity - - @Test func rejectsExpiredAndOversizedAndMalformed() { - let store = makeStore() - let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600) - #expect(!store.deposit(expired, from: depositorA)) - - let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)) - #expect(!store.deposit(oversized, from: depositorA)) - - let badTag = CourierEnvelope( - recipientTag: Data(repeating: 0, count: 4), - expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000), - ciphertext: Data(repeating: 1, count: 16) - ) - #expect(!store.deposit(badTag, from: depositorA)) - } - - @Test func rejectsExpiryBeyondPolicyLifetime() { - let store = makeStore() - let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60) - #expect(!store.deposit(pinned, from: depositorA)) - } - - // MARK: - Quotas - - @Test func perDepositorQuota() { - let store = makeStore() - for _ in 0.. give 1, keep 1). - let courierY = Data(repeating: 0xC2, count: 32) - let sprayedToY = store.takeSprayCopies(for: courierY) - #expect(sprayedToY.count == 1) - #expect(sprayedToY.first?.copies == 1) - - // Budget exhausted (carry-only): nothing left to spray. - #expect(store.takeSprayCopies(for: Data(repeating: 0xC3, count: 32)).isEmpty) - // The carried original is still deliverable. - #expect(store.takeEnvelopes(for: recipientKey).count == 1) - } - - @Test func rejectedSprayTransferPreservesBudgetAndCourierEligibility() { - let store = makeStore() - let recipientKey = Data(repeating: 0xB0, count: 32) - let courier = Data(repeating: 0xC1, count: 32) - #expect(store.deposit(makeEnvelope(recipientKey: recipientKey).withCopies(4), from: depositorA)) - - var rejectedOffers: [CourierEnvelope] = [] - let rejected = store.transferSprayCopies(to: courier) { offered in - rejectedOffers.append(offered) - return false - } - - #expect(rejected == 0) - #expect(rejectedOffers.map(\.copies) == [2]) - - // The same courier remains eligible and receives the original half - // budget, proving neither `copies` nor `sprayedTo` changed on failure. - let acceptedRetry = store.takeSprayCopies(for: courier) - #expect(acceptedRetry.map(\.copies) == [2]) - let nextCourier = store.takeSprayCopies(for: Data(repeating: 0xC2, count: 32)) - #expect(nextCourier.map(\.copies) == [1]) - } - - @Test func carryOnlyEnvelopesAreNeverSprayed() { - let store = makeStore() - #expect(store.deposit(makeEnvelope(), from: depositorA)) - #expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty) - } - - @Test func duplicateDepositKeepsLargerSprayBudget() { - let store = makeStore() - let recipientKey = Data(repeating: 0xB0, count: 32) - let ciphertext = Data(repeating: 0x42, count: 96) - let carryOnly = makeEnvelope(recipientKey: recipientKey, ciphertext: ciphertext) - #expect(store.deposit(carryOnly, from: depositorA)) - #expect(store.deposit(carryOnly.withCopies(4), from: depositorB)) - - let sprayed = store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)) - #expect(sprayed.first?.copies == 2) - } - - @Test func duplicateReplayCannotReplenishSpentSprayBudget() { - let store = makeStore() - let recipientKey = Data(repeating: 0xB0, count: 32) - let original = makeEnvelope(recipientKey: recipientKey).withCopies(8) - #expect(store.deposit(original, from: depositorA)) - - let courierX = Data(repeating: 0xC1, count: 32) - let courierY = Data(repeating: 0xC2, count: 32) - let courierZ = Data(repeating: 0xC3, count: 32) - let courierW = Data(repeating: 0xC4, count: 32) - #expect(store.takeSprayCopies(for: courierX).map(\.copies) == [4]) - - // Replaying the original signed deposit still accepts idempotently, - // but it cannot reset the local branch from 4 copies back to 8. - #expect(store.deposit(original, from: depositorA)) - #expect(store.takeSprayCopies(for: courierY).map(\.copies) == [2]) - #expect(store.deposit(original, from: depositorA)) - #expect(store.takeSprayCopies(for: courierZ).map(\.copies) == [1]) - #expect(store.deposit(original, from: depositorA)) - #expect(store.takeSprayCopies(for: courierW).isEmpty) - } - - // MARK: - Remote handover (relayed announces) - - @Test func remoteHandoverIsNonDestructiveAndCooledDown() { - let store = makeStore() - let recipientKey = Data(repeating: 0xB0, count: 32) - let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) - #expect(store.deposit(envelope, from: depositorA)) - - let first = store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600) - #expect(first.count == 1) - // The flooded copy carries no spray budget. - #expect(first.first?.copies == 1) - // Non-destructive: the envelope is still carried... - #expect(!store.isEmpty) - // ...and inside the cooldown it is not re-flooded. - #expect(store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600).isEmpty) - // A direct encounter still hands it over destructively. - #expect(store.takeEnvelopes(for: recipientKey).count == 1) - #expect(store.isEmpty) - } - - // MARK: - Legacy persistence - - @Test func legacyPersistedFileLoadsAsFavoriteCarryOnly() throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("courier-legacy-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - - // Envelope persisted by a pre-tier/pre-spray build: no tier, copies, - // or spray bookkeeping fields. - let recipientKey = Data(repeating: 0xB0, count: 32) - let envelope = makeEnvelope(recipientKey: recipientKey) - let legacy: [[String: Any]] = [[ - "recipientTag": envelope.recipientTag.base64EncodedString(), - "expiry": envelope.expiry, - "ciphertext": envelope.ciphertext.base64EncodedString(), - "depositorNoiseKey": depositorA.base64EncodedString(), - "storedAt": Self.baseDate.timeIntervalSinceReferenceDate - ]] - let data = try JSONSerialization.data(withJSONObject: legacy) - try data.write(to: fileURL) - - let store = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate }) - // Carry-only, so never sprayed... - #expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty) - // ...but still delivered on encounter. - #expect(store.takeEnvelopes(for: recipientKey).count == 1) - } -} diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift deleted file mode 100644 index 6ce23355..00000000 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ /dev/null @@ -1,921 +0,0 @@ -// -// CourierEndToEndTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -import Combine -import CoreBluetooth -import BitFoundation -@testable import bitchat - -/// Three-node courier flow exercised through real BLEService instances with -/// packets ferried in-process: Alice deposits a sealed envelope with Carol -/// while Bob is unreachable; Carol hands it over when Bob announces; Bob -/// opens it and sees Alice's message in the right DM thread. -struct CourierEndToEndTests { - - // MARK: - Helpers - - private final class PacketTap { - private let lock = NSLock() - private var packets: [BitchatPacket] = [] - - func record(_ packet: BitchatPacket) { - lock.lock(); packets.append(packet); lock.unlock() - } - - func first(ofType type: MessageType) -> BitchatPacket? { - lock.lock(); defer { lock.unlock() } - return packets.first { $0.type == type.rawValue } - } - - func count(ofType type: MessageType) -> Int { - lock.lock(); defer { lock.unlock() } - return packets.filter { $0.type == type.rawValue }.count - } - - func all(ofType type: MessageType) -> [BitchatPacket] { - lock.lock(); defer { lock.unlock() } - return packets.filter { $0.type == type.rawValue } - } - } - - private final class NoiseCaptureDelegate: BitchatDelegate { - private let lock = NSLock() - private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = [] - - func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { - lock.lock(); payloads.append((peerID, type, payload)); lock.unlock() - } - - func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] { - lock.lock(); defer { lock.unlock() } - return payloads - } - - // Unused BitchatDelegate requirements. - func didReceiveMessage(_ message: BitchatMessage) {} - func didConnectToPeer(_ peerID: PeerID) {} - func didDisconnectFromPeer(_ peerID: PeerID) {} - func didUpdatePeerList(_ peers: [PeerID]) {} - func didUpdateBluetoothState(_ state: CBManagerState) {} - func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} - } - - private func makeService(identityManager: MockIdentityManager? = nil) -> BLEService { - let keychain = MockKeychain() - let identityManager = identityManager ?? MockIdentityManager(keychain) - let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - let service = BLEService( - keychain: keychain, - idBridge: idBridge, - identityManager: identityManager, - initializeBluetoothManagers: false - ) - service.courierStore = CourierStore(persistsToDisk: false) - return service - } - - /// Handling any packet from a peer preseeds it as a connected, - /// verified entry in the receiving service's registry. - private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) { - let packet = BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data(hexString: peer.myPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data("ping".utf8), - signature: nil, - ttl: 1 - ) - service._test_handlePacket(packet, fromPeerID: peer.myPeerID) - } - - /// Establishes the Noise session that proves the direct link's peer owns - /// its announced static identity. CoreBluetooth is disabled in this suite, - /// so the handshake is ferried in-process just like courier packets. - private func establishNoiseSession(between initiator: BLEService, and responder: BLEService) throws { - let message1 = try initiator._test_noiseInitiateHandshake(with: responder.myPeerID) - let message2 = try #require( - try responder._test_noiseProcessHandshakeMessage(from: initiator.myPeerID, message: message1) - ) - let message3 = try #require( - try initiator._test_noiseProcessHandshakeMessage(from: responder.myPeerID, message: message2) - ) - _ = try responder._test_noiseProcessHandshakeMessage(from: initiator.myPeerID, message: message3) - } - - // MARK: - Tests - - @Test func courierCarriesMessageAcrossDisjointConnectivity() async throws { - let alice = makeService() - let carol = makeService() - let bob = makeService() - // Alice and Carol are mutual favorites; trust policy is exercised - // separately in depositFromUntrustedPeerIsRejected. - carol.courierDepositPolicy = { _, _ in .favorite } - - let bobDelegate = NoiseCaptureDelegate() - bob.delegate = bobDelegate - - let aliceOut = PacketTap() - alice._test_onOutboundPacket = aliceOut.record - let carolOut = PacketTap() - carol._test_onOutboundPacket = carolOut.record - let bobOut = PacketTap() - bob._test_onOutboundPacket = bobOut.record - - // Alice can see Carol; Bob is nowhere on the mesh. - preseedConnectedPeer(carol, in: alice) - - // 1. Alice seals to Bob's static key and deposits with Carol. - #expect(alice.sendCourierMessage( - "the camp moved north", - messageID: "courier-msg-1", - recipientNoiseKey: bob.noiseStaticPublicKeyData(), - via: [carol.myPeerID] - )) - let deposited = await TestHelpers.waitUntil( - { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(deposited) - let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) - - // 2. Ferry the deposit to Carol; she carries it (opaque to her). - carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) - let carried = await TestHelpers.waitUntil( - { !carol.courierStore.isEmpty }, - timeout: TestConstants.settleTimeout - ) - #expect(carried) - - // 3. Later, Bob proves link ownership and announces near Carol → - // handover fires. - try establishNoiseSession(between: carol, and: bob) - bob.sendBroadcastAnnounce() - let announced = await TestHelpers.waitUntil( - { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(announced) - let announcePacket = try #require(bobOut.first(ofType: .announce)) - carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false) - - let handedOver = await TestHelpers.waitUntil( - { carolOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(handedOver) - // With CoreBluetooth disabled there is no physical link for the send - // planner to accept, so Carol truthfully retains the durable copy even - // though the packet tap lets us ferry the attempted handover below. - #expect(!carol.courierStore.isEmpty) - let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) - #expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID) - - // 4. Ferry the handover to Bob; he opens the envelope. - bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) - let received = await TestHelpers.waitUntil( - { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.settleTimeout - ) - #expect(received) - - let delivered = try #require(bobDelegate.snapshot().first) - #expect(delivered.type == .privateMessage) - // Alice is absent from Bob's mesh, so the sender resolves to her - // full noise-key ID — the stable favorite conversation — not the - // short mesh ID (which Bob couldn't resolve to a nickname) and not - // the courier's identity. - #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) - #expect(delivered.peerID != carol.myPeerID) - let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) - #expect(message.messageID == "courier-msg-1") - #expect(message.content == "the camp moved north") - } - - @Test func courieredMailFromBlockedSenderIsDropped() async throws { - let alice = makeService() - let carol = makeService() - let bobIdentity = MockIdentityManager(MockKeychain()) - let bob = makeService(identityManager: bobIdentity) - carol.courierDepositPolicy = { _, _ in .favorite } - - let bobDelegate = NoiseCaptureDelegate() - bob.delegate = bobDelegate - let aliceOut = PacketTap() - alice._test_onOutboundPacket = aliceOut.record - let carolOut = PacketTap() - carol._test_onOutboundPacket = carolOut.record - let bobOut = PacketTap() - bob._test_onOutboundPacket = bobOut.record - - preseedConnectedPeer(carol, in: alice) - - // Bob blocked Alice by her stable Noise identity while she was away. - bobIdentity.setBlocked(alice.noiseStaticPublicKeyData().sha256Fingerprint(), isBlocked: true) - - #expect(alice.sendCourierMessage( - "you should not see this", - messageID: "courier-msg-blocked-sender", - recipientNoiseKey: bob.noiseStaticPublicKeyData(), - via: [carol.myPeerID] - )) - let deposited = await TestHelpers.waitUntil( - { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(deposited) - let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) - - carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) - let carried = await TestHelpers.waitUntil( - { !carol.courierStore.isEmpty }, - timeout: TestConstants.settleTimeout - ) - #expect(carried) - - try establishNoiseSession(between: carol, and: bob) - bob.sendBroadcastAnnounce() - let announced = await TestHelpers.waitUntil( - { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(announced) - let announcePacket = try #require(bobOut.first(ofType: .announce)) - carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false) - - let handedOver = await TestHelpers.waitUntil( - { carolOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(handedOver) - let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) - - // Bob opens the envelope — but the sealed sender is blocked, and it - // must never reach the UI. The live block check can't cover this: the - // sender is absent from Bob's registry, so no fingerprint resolves at - // delivery time. - bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) - let delivered = await TestHelpers.waitUntil( - { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!delivered) - } - - @Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws { - let alice = makeService() - let carol = makeService() - let bob = makeService() - carol.courierDepositPolicy = { _, _ in .favorite } - - let aliceOut = PacketTap() - alice._test_onOutboundPacket = aliceOut.record - let carolOut = PacketTap() - carol._test_onOutboundPacket = carolOut.record - let bobOut = PacketTap() - bob._test_onOutboundPacket = bobOut.record - - preseedConnectedPeer(carol, in: alice) - - #expect(alice.sendCourierMessage( - "hold until verified", - messageID: "courier-msg-unverified-announce", - recipientNoiseKey: bob.noiseStaticPublicKeyData(), - via: [carol.myPeerID] - )) - let deposited = await TestHelpers.waitUntil( - { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(deposited) - let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) - - carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) - let carried = await TestHelpers.waitUntil( - { !carol.courierStore.isEmpty }, - timeout: TestConstants.settleTimeout - ) - #expect(carried) - - let forgedAnnounce = try makeUnsignedAnnounce(from: bob) - carol._test_handlePacket(forgedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) - - let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil( - { carolOut.count(ofType: .courierEnvelope) > 0 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!leakedOnUnverifiedAnnounce) - #expect(!carol.courierStore.isEmpty) - - bob.sendBroadcastAnnounce() - let announced = await TestHelpers.waitUntil( - { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(announced) - let verifiedAnnounce = try #require(bobOut.first(ofType: .announce)) - carol._test_handlePacket(verifiedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) - - let handedOver = await TestHelpers.waitUntil( - { carolOut.count(ofType: .courierEnvelope) == 1 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(handedOver) - #expect(!carol.courierStore.isEmpty) - } - - @Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws { - let alice = makeService() - let carol = makeService() - let bob = makeService() - carol.courierDepositPolicy = { _, _ in .favorite } - - let aliceOut = PacketTap() - alice._test_onOutboundPacket = aliceOut.record - let carolOut = PacketTap() - carol._test_onOutboundPacket = carolOut.record - let bobOut = PacketTap() - bob._test_onOutboundPacket = bobOut.record - - preseedConnectedPeer(carol, in: alice) - - #expect(alice.sendCourierMessage( - "hold for a direct encounter", - messageID: "courier-msg-relayed-announce", - recipientNoiseKey: bob.noiseStaticPublicKeyData(), - via: [carol.myPeerID] - )) - let deposited = await TestHelpers.waitUntil( - { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(deposited) - let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) - - carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) - let carried = await TestHelpers.waitUntil( - { !carol.courierStore.isEmpty }, - timeout: TestConstants.settleTimeout - ) - #expect(carried) - - bob.sendBroadcastAnnounce() - let announced = await TestHelpers.waitUntil( - { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(announced) - let directAnnounce = try #require(bobOut.first(ofType: .announce)) - - // A relayed copy has a decremented TTL but a still-valid signature - // (TTL is excluded from announce signatures). The recipient is - // multi-hop away, so a copy floods toward them speculatively while - // the carried original stays put for a future direct encounter. - var relayedAnnounce = directAnnounce - relayedAnnounce.ttl = directAnnounce.ttl - 1 - carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) - - let remoteHandover = await TestHelpers.waitUntil( - { carolOut.count(ofType: .courierEnvelope) == 1 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(remoteHandover) - #expect(!carol.courierStore.isEmpty) - - // A second relayed announce inside the cooldown must not re-flood - // the same envelope. The original announce's dedup key is consumed - // (sender/timestamp/payload — TTL excluded), so use a fresh announce; - // wait out the 1s announce throttle first. - try await Task.sleep(nanoseconds: 1_100_000_000) - bob.sendBroadcastAnnounce() - let reannounced = await TestHelpers.waitUntil( - { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } }, - timeout: TestConstants.settleTimeout - ) - #expect(reannounced) - let freshAnnounce = try #require( - bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp } - ) - var relayedFreshAnnounce = freshAnnounce - relayedFreshAnnounce.ttl = freshAnnounce.ttl - 1 - carol._test_handlePacket(relayedFreshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) - - let refloodedInCooldown = await TestHelpers.waitUntil( - { carolOut.count(ofType: .courierEnvelope) > 1 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!refloodedInCooldown) - #expect(!carol.courierStore.isEmpty) - - // A peer-level Noise session is still insufficient without a physical - // ingress link that completed that handshake. This CoreBluetooth-free - // harness deliberately has no such link proof, so restoring the - // unsigned direct TTL cannot authorize destructive handover. - try establishNoiseSession(between: carol, and: bob) - try await Task.sleep(nanoseconds: 1_100_000_000) - bob.sendBroadcastAnnounce() - let announcedAgain = await TestHelpers.waitUntil( - { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } }, - timeout: TestConstants.settleTimeout - ) - #expect(announcedAgain) - let directAgain = try #require( - bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } - ) - carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false) - - let handedOverWithoutLinkProof = await TestHelpers.waitUntil( - { carolOut.count(ofType: .courierEnvelope) > 1 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!handedOverWithoutLinkProof) - #expect(!carol.courierStore.isEmpty) - } - - @Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws { - let alice = makeService() - let carol = makeService() - preseedConnectedPeer(carol, in: alice) - - let aliceOut = PacketTap() - alice._test_onOutboundPacket = aliceOut.record - - #expect(!alice.sendCourierMessage( - "this cannot be sealed", - messageID: "courier-msg-invalid-key", - recipientNoiseKey: Data(repeating: 0x01, count: 8), - via: [carol.myPeerID] - )) - - let queuedPacket = await TestHelpers.waitUntil( - { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!queuedPacket) - } - - @Test func depositFromUntrustedPeerIsRejected() async throws { - let carol = makeService() - carol.courierDepositPolicy = { _, _ in nil } // depositor is neither favorite nor verified - - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() - let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1")) - let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey) - let now = Date() - let envelope = CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: bobKey, - epochDay: CourierEnvelope.epochDay(for: now) - ), - expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), - ciphertext: sealed - ) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let unsigned = BitchatPacket( - type: MessageType.courierEnvelope.rawValue, - senderID: Data(hexString: alicePeerID.id) ?? Data(), - recipientID: Data(hexString: carol.myPeerID.id), - timestamp: UInt64(now.timeIntervalSince1970 * 1000), - payload: try #require(envelope.encode()), - signature: nil, - ttl: 1 - ) - let packet = try #require(alice.signPacket(unsigned)) - - carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData()) - let stored = await TestHelpers.waitUntil( - { !carol.courierStore.isEmpty }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!stored) - } - - @Test func unsignedDepositIsRejected() async throws { - let carol = makeService() - carol.courierDepositPolicy = { _, _ in .favorite } - - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() - let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m-unsigned")) - let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey) - let now = Date() - let envelope = CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: bobKey, - epochDay: CourierEnvelope.epochDay(for: now) - ), - expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), - ciphertext: sealed - ) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - // Correct sender, willing policy — but no packet signature: the - // courier cannot authenticate the depositor, so it must not carry. - let packet = BitchatPacket( - type: MessageType.courierEnvelope.rawValue, - senderID: Data(hexString: alicePeerID.id) ?? Data(), - recipientID: Data(hexString: carol.myPeerID.id), - timestamp: UInt64(now.timeIntervalSince1970 * 1000), - payload: try #require(envelope.encode()), - signature: nil, - ttl: 1 - ) - - carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData()) - let stored = await TestHelpers.waitUntil( - { !carol.courierStore.isEmpty }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!stored) - } - - @Test func courierDepositTrustUsesIngressPeerNotClaimedSender() async throws { - let alice = makeService() - let carol = makeService() - let mallory = makeService() - preseedConnectedPeer(alice, in: carol) - preseedConnectedPeer(mallory, in: carol) - - let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data() - carol.courierDepositPolicy = { depositorKey, _ in - depositorKey == trustedAliceKey ? .favorite : nil - } - - let aliceNoise = NoiseEncryptionService(keychain: MockKeychain()) - let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() - let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "spoofed", messageID: "m-spoof")) - let sealed = try aliceNoise.sealCourierPayload(typedPayload, recipientStaticKey: bobKey) - let now = Date() - let envelope = CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: bobKey, - epochDay: CourierEnvelope.epochDay(for: now) - ), - expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), - ciphertext: sealed - ) - let packet = BitchatPacket( - type: MessageType.courierEnvelope.rawValue, - senderID: Data(hexString: alice.myPeerID.id) ?? Data(), - recipientID: Data(hexString: carol.myPeerID.id), - timestamp: UInt64(now.timeIntervalSince1970 * 1000), - payload: try #require(envelope.encode()), - signature: nil, - ttl: 1 - ) - - carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false) - let stored = await TestHelpers.waitUntil( - { !carol.courierStore.isEmpty }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!stored) - } - - /// Regression for the relaunch amplification storm: redundant copies of - /// one message arrive as *distinct* envelopes (every seal uses a fresh - /// ephemeral key), so only the inner message ID can dedup them. The first - /// copy delivers; duplicates must stop right after the decrypt — no - /// second delivery, no second ack/handshake trigger downstream. - @Test func duplicateCourierCopiesDeliverInnerMessageOnce() async throws { - let alice = makeService() - let bob = makeService() - let bobDelegate = NoiseCaptureDelegate() - bob.delegate = bobDelegate - - let bobKey = bob.noiseStaticPublicKeyData() - let first = try #require(alice.sealBridgeCourierEnvelope("once", messageID: "dup-1", recipientNoiseKey: bobKey)) - let second = try #require(alice.sealBridgeCourierEnvelope("once", messageID: "dup-1", recipientNoiseKey: bobKey)) - // Distinct seals: envelope-level dedup can never catch this pair. - #expect(first.ciphertext != second.ciphertext) - - #expect(bob.openBridgedCourierEnvelope(first)) - #expect(bob.openBridgedCourierEnvelope(second)) - - let delivered = await TestHelpers.waitUntil( - { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.settleTimeout - ) - #expect(delivered) - // Give a duplicate delivery a chance to surface, then confirm the - // second copy never reached the delegate. - let duplicated = await TestHelpers.waitUntil( - { bobDelegate.snapshot().count > 1 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!duplicated) - #expect(bobDelegate.snapshot().count == 1) - } - - /// Acks for mail from absent senders (the usual couriered/bridged case) - /// queue for a future session instead of initiating a handshake - /// broadcast — otherwise every duplicate copy of every drop turns into a - /// mesh-wide handshake flood at an identity that cannot answer. - @Test func deliveryAckForAbsentPeerQueuesWithoutHandshake() async throws { - let ble = makeService() - let outbound = PacketTap() - ble._test_onOutboundPacket = outbound.record - - // Nobody by this ID on the mesh (not connected, not reachable). - ble.sendDeliveryAck(for: "msg-1", to: PeerID(str: "00000000000000ee")) - - let initiated = await TestHelpers.waitUntil( - { outbound.count(ofType: .noiseHandshake) > 0 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!initiated) - - // Control: a peer that is actually around still gets the handshake. - let present = PeerID(str: "00000000000000ef") - ble._test_seedConnectedPeer(present, nickname: "present") - ble.sendDeliveryAck(for: "msg-2", to: present) - let initiatedForPresent = await TestHelpers.waitUntil( - { outbound.count(ofType: .noiseHandshake) > 0 }, - timeout: TestConstants.settleTimeout - ) - #expect(initiatedForPresent) - } - - private func makeUnsignedAnnounce(from service: BLEService) throws -> BitchatPacket { - let announcement = AnnouncementPacket( - nickname: "Unsigned", - noisePublicKey: service.noiseStaticPublicKeyData(), - signingPublicKey: service.noiseSigningPublicKeyData(), - directNeighbors: nil - ) - let payload = try #require(announcement.encode()) - - return BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: service.myPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - } -} - -// MARK: - Router courier selection - -/// Minimal transport stub for exercising MessageRouter's courier deposit -/// logic without BLE plumbing. -private final class CourierCaptureTransport: Transport, MeshCourierTransporting { - weak var delegate: BitchatDelegate? - weak var eventDelegate: TransportEventDelegate? - weak var peerEventsDelegate: TransportPeerEventsDelegate? - - var snapshots: [TransportPeerSnapshot] = [] - private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = [] - private(set) var directSends: [String] = [] - - func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots } - - var myPeerID = PeerID(str: "00000000000000aa") - var myNickname = "stub" - func setNickname(_ nickname: String) {} - - func startServices() {} - func stopServices() {} - func emergencyDisconnectAll() {} - - func isPeerConnected(_ peerID: PeerID) -> Bool { - snapshots.contains { $0.peerID == peerID && $0.isConnected } - } - // Nostr-style reachability: claimed for peers with no live link (known - // npub), where prompt delivery additionally needs a relay connection. - var reachablePeers: Set = [] - var promptDelivery = true - func isPeerReachable(_ peerID: PeerID) -> Bool { - isPeerConnected(peerID) || reachablePeers.contains(peerID) - } - func canDeliverPromptly(to peerID: PeerID) -> Bool { - isPeerReachable(peerID) && promptDelivery - } - func peerNickname(peerID: PeerID) -> String? { nil } - func getPeerNicknames() -> [PeerID: String] { [:] } - - func getFingerprint(for peerID: PeerID) -> String? { nil } - func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } - func triggerHandshake(with peerID: PeerID) {} - - func sendMessage(_ content: String, mentions: [String]) {} - func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { - directSends.append(messageID) - } - func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {} - func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} - func sendBroadcastAnnounce() {} - func sendDeliveryAck(for messageID: String, to peerID: PeerID) {} - - func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { - courierSends.append((messageID, recipientNoiseKey, couriers)) - return true - } -} - -struct MessageRouterCourierTests { - - @Test @MainActor - func unreachablePeerMessageGoesToTrustedCouriersOnly() { - let bobKey = Data(repeating: 0xB0, count: 32) - let bobID = PeerID(publicKey: bobKey) - let carolKey = Data(repeating: 0xC0, count: 32) - let carolID = PeerID(publicKey: carolKey) - let daveKey = Data(repeating: 0xD0, count: 32) - let daveID = PeerID(publicKey: daveKey) - - let transport = CourierCaptureTransport() - transport.snapshots = [ - // Carol: connected mutual favorite → eligible courier. - TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()), - // Dave: connected but not trusted → never a courier. - TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date()) - ] - - let directory = CourierDirectory( - noiseKey: { peerID in peerID == bobID ? bobKey : nil }, - isTrustedCourier: { $0 == carolKey } - ) - let router = MessageRouter(transports: [transport], courierDirectory: directory) - var carried: [String] = [] - router.onMessageCarried = { messageID, _ in carried.append(messageID) } - - router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1") - - #expect(transport.directSends.isEmpty) - #expect(transport.courierSends.count == 1) - #expect(transport.courierSends.first?.messageID == "m1") - #expect(transport.courierSends.first?.recipientKey == bobKey) - #expect(transport.courierSends.first?.couriers == [carolID]) - #expect(carried == ["m1"]) - } - - /// Field-found: a DM sent while the recipient sits in the BLE - /// reachability retention window (radio gone, still "reachable" for a - /// minute) trusts the mesh and skips every deposit — and nothing - /// retried. The periodic sweep must publish the bridge drop once the - /// window lapses. - @Test @MainActor - func sweepDropsQueuedMessageOnceReachabilityLapses() { - let bobKey = Data(repeating: 0xB0, count: 32) - let bobID = PeerID(publicKey: bobKey) - let transport = CourierCaptureTransport() - transport.reachablePeers = [bobID] // retention window: stale but "reachable" - transport.promptDelivery = true - - let directory = CourierDirectory( - noiseKey: { peerID in peerID == bobID ? bobKey : nil }, - isTrustedCourier: { _ in false } - ) - let router = MessageRouter(transports: [transport], courierDirectory: directory) - var drops: [(content: String, messageID: String, key: Data)] = [] - router.bridgeCourierDeposit = { content, messageID, key, completion in - drops.append((content, messageID, key)) - completion(true) - } - var carried: [String] = [] - router.onMessageCarried = { messageID, _ in carried.append(messageID) } - - router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m9") - #expect(drops.isEmpty) // send trusted the (stale) mesh reachability - - // Still inside the window: the sweep must not spam relays. - router.retryBridgeCourierDeposits() - #expect(drops.isEmpty) - - // Window lapses; the sweep publishes the retained copy as a drop and - // the sender's message shows "carried" (its ack has no radio route). - transport.reachablePeers = [] - router.retryBridgeCourierDeposits() - #expect(drops.count == 1) - #expect(drops.first?.messageID == "m9") - #expect(drops.first?.content == "hi bob") - #expect(drops.first?.key == bobKey) - #expect(carried == ["m9"]) - } - - @Test @MainActor - func noCourierDepositWithoutKnownRecipientKey() { - let transport = CourierCaptureTransport() - transport.snapshots = [ - TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date()) - ] - let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true }) - let router = MessageRouter(transports: [transport], courierDirectory: directory) - var carried: [String] = [] - router.onMessageCarried = { messageID, _ in carried.append(messageID) } - - router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2") - - #expect(transport.courierSends.isEmpty) - #expect(carried.isEmpty) - } - - /// The production directory must resolve both ID forms: a 64-hex - /// noise-key ID (offline favorite row) carries the key itself, and a - /// short 16-hex ID resolves through the favorites store. - @Test @MainActor - func favoritesBackedDirectoryResolvesBothIDForms() { - let directory = CourierDirectory.favoritesBacked() - let bobKey = Data(repeating: 0xB7, count: 32) - - #expect(directory.noiseKey(PeerID(hexData: bobKey)) == bobKey) - - FavoritesPersistenceService.shared.addFavorite(peerNoisePublicKey: bobKey, peerNickname: "bob") - defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: bobKey) } - #expect(directory.noiseKey(PeerID(publicKey: bobKey)) == bobKey) - } - - @Test @MainActor - func reachablePeerSkipsCourier() { - let bobKey = Data(repeating: 0xB0, count: 32) - let bobID = PeerID(publicKey: bobKey) - let transport = CourierCaptureTransport() - transport.snapshots = [ - TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date()) - ] - let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true }) - let router = MessageRouter(transports: [transport], courierDirectory: directory) - - router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3") - - #expect(transport.directSends == ["m3"]) - #expect(transport.courierSends.isEmpty) - } - - /// A peer can be "reachable" through a transport that cannot deliver - /// promptly (Nostr claims any favorite with a known npub, even with no - /// relay connection). The queued send must not shadow the courier: a - /// sealed copy goes to connected couriers in parallel, and receivers - /// dedup by message ID if both arrive. - @Test @MainActor - func queuedReachableSendAlsoDepositsWithCourier() { - let bobKey = Data(repeating: 0xB0, count: 32) - let bobID = PeerID(publicKey: bobKey) - let carolKey = Data(repeating: 0xC0, count: 32) - let carolID = PeerID(publicKey: carolKey) - - let transport = CourierCaptureTransport() - transport.snapshots = [ - TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()) - ] - transport.reachablePeers = [bobID] - transport.promptDelivery = false - - let directory = CourierDirectory( - noiseKey: { peerID in peerID == bobID ? bobKey : nil }, - isTrustedCourier: { $0 == carolKey } - ) - let router = MessageRouter(transports: [transport], courierDirectory: directory) - var carried: [String] = [] - router.onMessageCarried = { messageID, _ in carried.append(messageID) } - - router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m4") - - #expect(transport.directSends == ["m4"]) - #expect(transport.courierSends.count == 1) - #expect(transport.courierSends.first?.messageID == "m4") - #expect(transport.courierSends.first?.couriers == [carolID]) - #expect(carried == ["m4"]) - } - - /// When the reachable transport can deliver promptly (relays up), the - /// send is trusted and no courier quota is spent. - @Test @MainActor - func promptlyDeliverableReachablePeerSkipsCourier() { - let bobKey = Data(repeating: 0xB0, count: 32) - let bobID = PeerID(publicKey: bobKey) - let carolKey = Data(repeating: 0xC0, count: 32) - let carolID = PeerID(publicKey: carolKey) - - let transport = CourierCaptureTransport() - transport.snapshots = [ - TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()) - ] - transport.reachablePeers = [bobID] - - let directory = CourierDirectory( - noiseKey: { peerID in peerID == bobID ? bobKey : nil }, - isTrustedCourier: { $0 == carolKey } - ) - let router = MessageRouter(transports: [transport], courierDirectory: directory) - var carried: [String] = [] - router.onMessageCarried = { messageID, _ in carried.append(messageID) } - - router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m5") - - #expect(transport.directSends == ["m5"]) - #expect(transport.courierSends.isEmpty) - #expect(carried.isEmpty) - } -} diff --git a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift deleted file mode 100644 index 692c8f6d..00000000 --- a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift +++ /dev/null @@ -1,404 +0,0 @@ -// -// PrekeyEndToEndTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -import CoreBluetooth -import BitFoundation -@testable import bitchat - -/// Forward-secret courier flow through real BLEService instances: Bob gossips -/// a signed prekey bundle, Alice verifies and caches it, seals to a one-time -/// prekey instead of Bob's static key, Carol carries the opaque envelope, and -/// Bob opens it with the matching prekey private. -struct PrekeyEndToEndTests { - - // MARK: - Helpers - - private final class PacketTap { - private let lock = NSLock() - private var packets: [BitchatPacket] = [] - - func record(_ packet: BitchatPacket) { - lock.lock(); packets.append(packet); lock.unlock() - } - - func first(ofType type: MessageType) -> BitchatPacket? { - lock.lock(); defer { lock.unlock() } - return packets.first { $0.type == type.rawValue } - } - } - - private final class NoiseCaptureDelegate: BitchatDelegate { - private let lock = NSLock() - private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = [] - - func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { - lock.lock(); payloads.append((peerID, type, payload)); lock.unlock() - } - - func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] { - lock.lock(); defer { lock.unlock() } - return payloads - } - - // Unused BitchatDelegate requirements. - func didReceiveMessage(_ message: BitchatMessage) {} - func didConnectToPeer(_ peerID: PeerID) {} - func didDisconnectFromPeer(_ peerID: PeerID) {} - func didUpdatePeerList(_ peers: [PeerID]) {} - func didUpdateBluetoothState(_ state: CBManagerState) {} - func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} - } - - private func makeService() -> BLEService { - let keychain = MockKeychain() - let service = BLEService( - keychain: keychain, - idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), - identityManager: MockIdentityManager(keychain), - initializeBluetoothManagers: false - ) - service.courierStore = CourierStore(persistsToDisk: false) - service.prekeyBundleStore = PrekeyBundleStore(persistsToDisk: false) - return service - } - - private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) { - let packet = BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data(hexString: peer.myPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data("ping".utf8), - signature: nil, - ttl: 1 - ) - service._test_handlePacket(packet, fromPeerID: peer.myPeerID) - } - - /// Broadcast announce + prekey bundle from `peer` and return both packets. - private func captureAnnounceAndBundle(from peer: BLEService, tap: PacketTap) async throws -> (announce: BitchatPacket, bundle: BitchatPacket) { - peer.sendBroadcastAnnounce() - let published = await TestHelpers.waitUntil( - { tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(published) - return ( - announce: try #require(tap.first(ofType: .announce)), - bundle: try #require(tap.first(ofType: .prekeyBundle)) - ) - } - - // MARK: - Tests - - @Test func prekeySealedMailTravelsViaCourierAndOpens() async throws { - let alice = makeService() - let carol = makeService() - let bob = makeService() - carol.courierDepositPolicy = { _, _ in .favorite } - - let bobDelegate = NoiseCaptureDelegate() - bob.delegate = bobDelegate - - let aliceOut = PacketTap() - alice._test_onOutboundPacket = aliceOut.record - let carolOut = PacketTap() - carol._test_onOutboundPacket = carolOut.record - let bobOut = PacketTap() - bob._test_onOutboundPacket = bobOut.record - - preseedConnectedPeer(carol, in: alice) - - // 1. While Bob is still around, Alice hears his verified announce - // (binding his signing key) and his gossiped prekey bundle. - let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) - alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) - alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) - - let cached = await TestHelpers.waitUntil( - { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.settleTimeout - ) - #expect(cached) - - // 2. Bob goes dark; Alice seals for him and deposits with Carol. - // The envelope must be v2: sealed to a one-time prekey. - #expect(alice.sendCourierMessage( - "burn after reading", - messageID: "prekey-msg-1", - recipientNoiseKey: bob.noiseStaticPublicKeyData(), - via: [carol.myPeerID] - )) - let deposited = await TestHelpers.waitUntil( - { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(deposited) - let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) - let sealedEnvelope = try #require(CourierEnvelope.decode(depositPacket.payload)) - #expect(sealedEnvelope.prekeyID != nil) - - // 3. Carol carries it (opaque, prekey or not). - carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) - let carried = await TestHelpers.waitUntil( - { !carol.courierStore.isEmpty }, - timeout: TestConstants.settleTimeout - ) - #expect(carried) - - // 4. Bob resurfaces near Carol → handover, and the v2 discriminator - // survives the store round-trip. - bob.sendBroadcastAnnounce() - let reannounced = await TestHelpers.waitUntil( - { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(reannounced) - let handoverTrigger = try #require(bobOut.first(ofType: .announce)) - carol._test_handlePacket(handoverTrigger, fromPeerID: bob.myPeerID, preseedPeer: false) - - let handedOver = await TestHelpers.waitUntil( - { carolOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(handedOver) - let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) - let handedEnvelope = try #require(CourierEnvelope.decode(handoverPacket.payload)) - #expect(handedEnvelope.prekeyID == sealedEnvelope.prekeyID) - - // 5. Bob opens it with the matching one-time prekey private and sees - // Alice as the authenticated sender. - bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) - let received = await TestHelpers.waitUntil( - { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.settleTimeout - ) - #expect(received) - - let delivered = try #require(bobDelegate.snapshot().first) - #expect(delivered.type == .privateMessage) - #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) - let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) - #expect(message.messageID == "prekey-msg-1") - #expect(message.content == "burn after reading") - - // 6. Redelivery tolerance: the same envelope arriving via another - // packet (spray-and-wait) still decrypts inside the prekey grace - // window (asserted at the crypto layer in NoisePrekeyTests), but - // the duplicate is absorbed before delivery — the receiver dedups - // on the inner message ID, so redundant courier copies never - // re-deliver (or re-ack) the same message. - let redelivery = BitchatPacket( - type: MessageType.courierEnvelope.rawValue, - senderID: Data(hexString: carol.myPeerID.id) ?? Data(), - recipientID: handoverPacket.recipientID, - timestamp: handoverPacket.timestamp + 1, - payload: handoverPacket.payload, - signature: nil, - ttl: 1 - ) - bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID) - let redelivered = await TestHelpers.waitUntil( - { bobDelegate.snapshot().count == 2 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!redelivered) - #expect(bobDelegate.snapshot().count == 1) - } - - @Test func withoutBundleSealingFallsBackToStatic() async throws { - let alice = makeService() - let bob = makeService() - - let bobDelegate = NoiseCaptureDelegate() - bob.delegate = bobDelegate - let aliceOut = PacketTap() - alice._test_onOutboundPacket = aliceOut.record - - // Bob is a connected "courier" who happens to be the recipient: the - // envelope reaches him directly and the recipient tag matches. - preseedConnectedPeer(bob, in: alice) - - // Alice never saw a bundle for Bob → v1 static-sealed envelope. - #expect(alice.sendCourierMessage( - "plain static seal", - messageID: "static-msg-1", - recipientNoiseKey: bob.noiseStaticPublicKeyData(), - via: [bob.myPeerID] - )) - let deposited = await TestHelpers.waitUntil( - { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.settleTimeout - ) - #expect(deposited) - let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) - let envelope = try #require(CourierEnvelope.decode(depositPacket.payload)) - #expect(envelope.prekeyID == nil) - - // Bob opens the v1 envelope exactly as before the prekey change. - // (No preseed: Alice is absent from Bob's mesh, so the sender should - // resolve to her full noise-key ID like the courier case.) - bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false) - let received = await TestHelpers.waitUntil( - { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.settleTimeout - ) - #expect(received) - let delivered = try #require(bobDelegate.snapshot().first) - #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) - let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) - #expect(message.content == "plain static seal") - } - - @Test func unverifiableBundleIsIgnored() async throws { - let alice = makeService() - let bob = makeService() - - let bobOut = PacketTap() - bob._test_onOutboundPacket = bobOut.record - - // Alice receives Bob's bundle but never saw a verified announce, so - // no signing key is bound to his noise key: the bundle must not be - // cached or enter Alice's gossip store. - let (_, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) - alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) - - let cached = await TestHelpers.waitUntil( - { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!cached) - } - - @Test func forgedBundleSignatureIsRejected() async throws { - let alice = makeService() - let bob = makeService() - - let bobOut = PacketTap() - bob._test_onOutboundPacket = bobOut.record - - let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) - alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) - - // Mallory tampers with the gossiped bundle in flight. - let bundle = try #require(PrekeyBundle.decode(bundlePacket.payload)) - var forgedSignature = bundle.signature - forgedSignature[0] ^= 0x01 - let forged = PrekeyBundle( - noiseStaticPublicKey: bundle.noiseStaticPublicKey, - prekeys: bundle.prekeys, - generatedAt: bundle.generatedAt, - signature: forgedSignature - ) - let forgedPacket = BitchatPacket( - type: MessageType.prekeyBundle.rawValue, - senderID: bundlePacket.senderID, - recipientID: nil, - timestamp: bundlePacket.timestamp, - payload: try #require(forged.encode()), - signature: bundlePacket.signature, - ttl: bundlePacket.ttl - ) - alice._test_handlePacket(forgedPacket, fromPeerID: bob.myPeerID, preseedPeer: false) - - let cached = await TestHelpers.waitUntil( - { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!cached) - } - - @Test func verifiedBundleEntersGossipStore() async throws { - let alice = makeService() - let bob = makeService() - - let bobOut = PacketTap() - bob._test_onOutboundPacket = bobOut.record - - let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) - alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) - alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) - - let cached = await TestHelpers.waitUntil( - { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.settleTimeout - ) - #expect(cached) - // The verified bundle now participates in Alice's sync rounds. - #expect(alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) - } - - @Test func spoofedSenderPrekeyBundleIsRejected() async throws { - let alice = makeService() - let bob = makeService() - - let bobOut = PacketTap() - bob._test_onOutboundPacket = bobOut.record - - let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) - alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) - - // A relay re-broadcasts Bob's genuine bundle under a fabricated sender - // ID (the DoS that would multiply cache/gossip entries and exhaust the - // per-owner cap). Attribution is by the bundle's own key and the outer - // signature is bound to Bob's sender ID, so the spoof is dropped — no - // cache entry, and no gossip entry under either the fake or real ID. - let fakeSender = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) - let spoofed = BitchatPacket( - type: MessageType.prekeyBundle.rawValue, - senderID: fakeSender, - recipientID: nil, - timestamp: bundlePacket.timestamp + 5_000, - payload: bundlePacket.payload, - signature: bundlePacket.signature, - ttl: bundlePacket.ttl - ) - alice._test_handlePacket(spoofed, fromPeerID: PeerID(hexData: fakeSender), preseedPeer: false) - - let cached = await TestHelpers.waitUntil( - { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!cached) - #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) - #expect(!alice._test_hasGossipPrekeyBundle(for: PeerID(hexData: fakeSender))) - } - - @Test func replayedPrekeyBundleWithFreshTimestampIsRejected() async throws { - let alice = makeService() - let bob = makeService() - - let bobOut = PacketTap() - bob._test_onOutboundPacket = bobOut.record - - let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) - alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) - - // Rewriting the outer timestamp (to defeat the freshness window) - // invalidates the packet signature, which covers senderID + timestamp. - let replay = BitchatPacket( - type: MessageType.prekeyBundle.rawValue, - senderID: bundlePacket.senderID, - recipientID: nil, - timestamp: bundlePacket.timestamp + 5_000, - payload: bundlePacket.payload, - signature: bundlePacket.signature, - ttl: bundlePacket.ttl - ) - alice._test_handlePacket(replay, fromPeerID: bob.myPeerID, preseedPeer: false) - - let cached = await TestHelpers.waitUntil( - { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!cached) - #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) - } -} diff --git a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift index d3bb2ab9..6f5c7891 100644 --- a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift @@ -9,7 +9,7 @@ import Testing import CryptoKit import struct Foundation.UUID -@testable import BitFoundation // to avoid unnecessary public's +import BitFoundation @testable import bitchat struct PrivateChatE2ETests { @@ -115,18 +115,13 @@ struct PrivateChatE2ETests { let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) - - // Manager sessions are keyed by key-derived wire IDs: handshake - // completion fails closed on IDs the remote key can't vouch for. - let aliceNoiseID = PeerID(publicKey: aliceKey.publicKey.rawRepresentation) - let bobNoiseID = PeerID(publicKey: bobKey.publicKey.rawRepresentation) - + // Establish encrypted session do { - let handshake1 = try aliceManager.initiateHandshake(with: bobNoiseID) - let handshake2 = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake1)! - let handshake3 = try aliceManager.handleIncomingHandshake(from: bobNoiseID, message: handshake2)! - _ = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake3) + let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID) + let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)! + let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)! + _ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3) } catch { Issue.record("Failed to establish Noise session: \(error)") } @@ -139,7 +134,7 @@ struct PrivateChatE2ETests { let message = BitchatMessage(packet.payload), message.isPrivate { do { - let encrypted = try aliceManager.encrypt(packet.payload, for: bobNoiseID) + let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID) let encryptedPacket = BitchatPacket( type: 0x02, // Encrypted message type senderID: packet.senderID, @@ -160,7 +155,7 @@ struct PrivateChatE2ETests { // Decrypt incoming encrypted messages if packet.type == 0x02 { do { - let decrypted = try bobManager.decrypt(packet.payload, from: aliceNoiseID) + let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID) if let message = BitchatMessage(decrypted) { #expect(message.content == TestConstants.testMessage1) #expect(message.isPrivate) diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift deleted file mode 100644 index 30f071f5..00000000 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ /dev/null @@ -1,1696 +0,0 @@ -import BitFoundation -import Combine -import CoreBluetooth -import Foundation -import Testing -@testable import bitchat - -/// Wire-level coverage for finalized DM media. The sender encrypts one typed -/// private-file payload, relays see only the outer Noise packet/fragments, and -/// the receiver reassembles, decrypts, validates, persists, and delivers it. -@Suite("Private media end to end", .serialized) -struct PrivateMediaEndToEndTests { - @Test - func privateMediaCancellationTombstonesAreCountBounded() async { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-tombstone-bound-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let service = makeService(baseDirectory: root) - - for index in 0..<600 { - service.cancelTransfer("cancelled-before-admission-\(index)") - } - - #expect(service._test_privateMediaAdmissionEntryCount() <= 512) - await service._test_drainPrivateMediaSendPipeline() - } - - @Test - func privateMediaAdmissionCapacityRejectsNewcomerWithoutEvictingActiveTransfer() async { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-admission-capacity-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let service = makeService(baseDirectory: root) - let now = Date() - let activeIDs = (0..<512).map { "capacity-active-\($0)" } - for transferId in activeIDs { - #expect(service._test_beginPrivateMediaAdmission(transferId, now: now)) - } - defer { - for transferId in activeIDs { - service._test_finishPrivateMediaAdmission(transferId) - } - } - - let overflowID = "capacity-overflow-\(UUID().uuidString)" - let rejections = TransferCancellationRecorder() - let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } - let content = Data("%PDF-1.7\ncapacity".utf8) - service.sendFilePrivate( - BitchatFilePacket( - fileName: "capacity.pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ), - to: PeerID(str: "1122334455667788"), - transferId: overflowID, - allowLegacyFallback: true - ) - - #expect(await TestHelpers.waitUntil( - { rejections.contains(overflowID) }, - timeout: TestConstants.longTimeout - )) - #expect(rejections.reason(for: overflowID) != nil) - #expect(service._test_isPrivateMediaAdmissionActive(activeIDs[0], now: now)) - #expect(service._test_privateMediaAdmissionEntryCount() == 512) - _ = cancellable - } - - @Test - func expiredActivePrivateMediaAdmissionEmitsVisibleFailure() async { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-admission-expiry-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let service = makeService(baseDirectory: root) - let transferId = "expired-active-\(UUID().uuidString)" - let admittedAt = Date(timeIntervalSince1970: 1_000) - let rejections = TransferCancellationRecorder() - let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } - - #expect(service._test_beginPrivateMediaAdmission(transferId, now: admittedAt)) - #expect(!service._test_isPrivateMediaAdmissionActive( - transferId, - now: admittedAt.addingTimeInterval(60 * 60 + 1) - )) - #expect(await TestHelpers.waitUntil( - { rejections.contains(transferId) }, - timeout: TestConstants.longTimeout - )) - #expect(rejections.reason(for: transferId) != nil) - #expect(service._test_privateMediaAdmissionEntryCount() == 0) - _ = cancellable - } - - @Test - func approvedLegacySendCancelledBeforeDeferredAdmissionDoesNotTransmit() async throws { - try await assertApprovedLegacySendCancelledBeforeAdmission(label: "cancel") - } - - @Test - func approvedLegacySendDeletedBeforeDeferredAdmissionDoesNotTransmit() async throws { - // ChatMediaTransferCoordinator.deleteMediaMessage now invokes this same - // synchronous transport cancellation before removing its mapping; its - // coordinator-level call is covered separately in the context tests. - try await assertApprovedLegacySendCancelledBeforeAdmission(label: "delete") - } - - @Test - func panicSuspensionFinishesAdmissionAtInitialDeferredSendBoundary() async { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-panic-deferred-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let service = makeService(baseDirectory: root) - let tap = PacketTap() - service._test_onOutboundPacket = tap.record - service.suspendForPanicReset() - defer { service.completePanicReset(restartServices: false) } - - let transferId = "panic-deferred-\(UUID().uuidString)" - let content = Data("%PDF-1.7\npanic-deferred".utf8) - service.sendFilePrivate( - BitchatFilePacket( - fileName: "panic-deferred.pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ), - to: PeerID(str: "1122334455667788"), - transferId: transferId, - allowLegacyFallback: true - ) - await service._test_drainPrivateMediaSendPipeline() - - let state = service._test_privateMediaTransferState(transferId: transferId) - #expect(!state.admissionActive) - #expect(!state.pendingNoise) - #expect(state.activeScheduler == 0) - #expect(state.pendingScheduler == 0) - #expect(service._test_privateMediaAdmissionEntryCount() == 0) - #expect(tap.snapshot().isEmpty) - } - - @Test - func panicSuspensionFinishesAdmissionAtBroadcastBoundary() { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-panic-broadcast-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let service = makeService(baseDirectory: root) - let tap = PacketTap() - service._test_onOutboundPacket = tap.record - service.suspendForPanicReset() - defer { service.completePanicReset(restartServices: false) } - - let transferId = "panic-broadcast-\(UUID().uuidString)" - #expect(service._test_beginPrivateMediaAdmission(transferId, now: Date())) - let packet = BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: service.myPeerID.id) ?? Data(), - recipientID: Data(hexString: "1122334455667788"), - timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), - payload: Data([NoisePayloadType.privateFile.rawValue]), - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - - service._test_broadcastPrivateMediaPacket(packet, transferId: transferId) - - #expect(!service._test_isPrivateMediaAdmissionActive(transferId, now: Date())) - #expect(service._test_privateMediaAdmissionEntryCount() == 0) - #expect(tap.snapshot().isEmpty) - } - - @Test - func legacyFallbackRequiresPerSendConsentAndConsumesItOnce() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-capability-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let aliceRoot = root.appendingPathComponent("alice", isDirectory: true) - let bobRoot = root.appendingPathComponent("bob", isDirectory: true) - let alice = makeService(baseDirectory: aliceRoot) - let bob = makeService(baseDirectory: bobRoot) - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Old Bob", - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - - let tap = PacketTap() - let delegate = MessageCaptureDelegate() - alice._test_onOutboundPacket = tap.record - bob.delegate = delegate - let content = Data("%PDF-1.7\nprivate".utf8) - let file = BitchatFilePacket( - fileName: "private.pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ) - let cancellations = TransferCancellationRecorder() - let cancellable = TransferProgressManager.shared.publisher.sink { cancellations.record($0) } - - let deniedID = "legacy-without-consent-\(UUID().uuidString)" - alice.sendFilePrivate( - file, - to: bob.myPeerID, - transferId: deniedID, - allowLegacyFallback: false - ) - let denied = await TestHelpers.waitUntil( - { cancellations.contains(deniedID) }, - timeout: TestConstants.longTimeout - ) - #expect(denied) - #expect(tap.snapshot().allSatisfy { $0.type != MessageType.fileTransfer.rawValue }) - - let allowedID = "legacy-with-consent-\(UUID().uuidString)" - alice.sendFilePrivate( - file, - to: bob.myPeerID, - transferId: allowedID, - allowLegacyFallback: true - ) - - let sent = await TestHelpers.waitUntil( - { tap.snapshot().contains { $0.type == MessageType.fileTransfer.rawValue } }, - timeout: TestConstants.longTimeout - ) - #expect(sent) - - let outbound = tap.snapshot() - let rawTransfers = outbound.filter { $0.type == MessageType.fileTransfer.rawValue } - let raw = try #require(rawTransfers.first) - #expect(rawTransfers.count == 1, "Migration fallback must never dual-send") - #expect(outbound.allSatisfy { $0.type != MessageType.noiseEncrypted.rawValue }) - #expect(raw.recipientID == Data(hexString: bob.myPeerID.toShort().id)) - #expect(raw.signature?.count == 64) - #expect(BitchatFilePacket.decode(raw.payload)?.content == content) - - // Exercise the normal raw receive path with Alice's actual signing - // key. The migration fallback is accepted because it is directed and - // signed; the handler still rejects unsigned/forged raw transfers. - bob._test_handlePacket( - raw, - fromPeerID: alice.myPeerID, - signingPublicKey: alice.noiseSigningPublicKeyData() - ) - let delivered = await TestHelpers.waitUntil( - { delegate.snapshot().count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(delivered) - #expect(delegate.snapshot().first?.isPrivate == true) - #expect(recursivelyStoredFiles(under: bobRoot).count == 1) - - // Consent is invocation-scoped, not a sticky peer preference. - let retryID = "legacy-retry-without-consent-\(UUID().uuidString)" - alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID, allowLegacyFallback: false) - let retryDenied = await TestHelpers.waitUntil( - { cancellations.contains(retryID) }, - timeout: TestConstants.longTimeout - ) - #expect(retryDenied) - #expect(tap.snapshot().filter { $0.type == MessageType.fileTransfer.rawValue }.count == 1) - _ = cancellable - } - - @Test - func authenticatedPrivateMediaCapabilityPinsAgainstRawDowngrade() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-pin-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let identity = MockIdentityManager(MockKeychain()) - let alice = makeService( - baseDirectory: root.appendingPathComponent("alice", isDirectory: true), - identityManager: identity - ) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - let bobKey = bob.noiseStaticPublicKeyData() - - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Bob", - capabilities: .privateMedia, - noisePublicKey: bobKey - ) - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof) - let bobFingerprint = bobKey.sha256Fingerprint() - #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) - - try await establishSession(alice: alice, bob: bob) - let capabilityPinned = await TestHelpers.waitUntil( - { identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) }, - timeout: TestConstants.longTimeout - ) - #expect(capabilityPinned) - - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) - - // A public no-bit announce cannot override state authenticated by the - // current session. A later authenticated no-bit state is a real - // downgrade and must block despite a caller offering legacy consent. - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Bob", - capabilities: [], - noisePublicKey: bobKey - ) - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) - let authenticatedNoBit = try authenticatedPeerStatePacket( - from: bob, - to: alice, - capabilities: [] - ) - alice._test_handlePacket(authenticatedNoBit, fromPeerID: bob.myPeerID) - let downgradeObserved = await TestHelpers.waitUntil( - { alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade }, - timeout: TestConstants.longTimeout - ) - #expect(downgradeObserved) - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade) - - let tap = PacketTap() - alice._test_onOutboundPacket = tap.record - let transferID = "pinned-downgrade-\(UUID().uuidString)" - let cancellations = TransferCancellationRecorder() - let cancellable = TransferProgressManager.shared.publisher.sink { cancellations.record($0) } - let content = Data("%PDF-1.7\nblocked".utf8) - alice.sendFilePrivate( - BitchatFilePacket( - fileName: "blocked.pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ), - to: bob.myPeerID, - transferId: transferID, - allowLegacyFallback: true - ) - - let blocked = await TestHelpers.waitUntil( - { cancellations.contains(transferID) }, - timeout: TestConstants.longTimeout - ) - #expect(blocked) - #expect(tap.snapshot().allSatisfy { $0.type != MessageType.fileTransfer.rawValue }) - _ = cancellable - } - - @Test - func unpinnedExplicitCapabilitiesWithoutPrivateMediaRequireConsent() { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-explicit-capabilities-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Modern Bob", - capabilities: [], - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) - } - - @Test - func privateMediaRetryRequiresExactAuthenticatedBit9Proof() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent( - "private-media-receipt-proof-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService( - baseDirectory: root.appendingPathComponent( - "alice", - isDirectory: true - ) - ) - let bob = makeService( - baseDirectory: root.appendingPathComponent( - "bob", - isDirectory: true - ) - ) - let bothCapabilities: PeerCapabilities = [ - .privateMedia, - .privateMediaReceipts - ] - - // A public bit-9 announce is discovery only. - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Bob", - capabilities: bothCapabilities, - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - #expect( - alice.authenticatedPrivateMediaReceiptSessionGeneration( - to: bob.myPeerID - ) == nil - ) - - let proofs = try await establishSessionCapturingPeerState( - alice: alice, - bob: bob - ) - #expect( - alice.authenticatedPrivateMediaReceiptSessionGeneration( - to: bob.myPeerID - ) == nil - ) - - // Bit 8 alone preserves encrypted transfer compatibility but cannot - // authorize automatic resend. - let privateMediaOnly = try authenticatedPeerStatePacket( - from: bob, - to: alice, - capabilities: .privateMedia - ) - alice._test_handlePacket( - privateMediaOnly, - fromPeerID: bob.myPeerID - ) - #expect(await TestHelpers.waitUntil( - { - alice.privateMediaSendPolicy(to: bob.myPeerID) - == .encrypted - }, - timeout: TestConstants.longTimeout - )) - #expect( - alice.authenticatedPrivateMediaReceiptSessionGeneration( - to: bob.myPeerID - ) == nil - ) - - let receiptCapable = try authenticatedPeerStatePacket( - from: bob, - to: alice, - capabilities: bothCapabilities - ) - alice._test_handlePacket( - receiptCapable, - fromPeerID: bob.myPeerID - ) - #expect(await TestHelpers.waitUntil( - { - alice.authenticatedPrivateMediaReceiptSessionGeneration( - to: bob.myPeerID - ) != nil - }, - timeout: TestConstants.longTimeout - )) - - bob._test_handlePacket( - proofs.alice, - fromPeerID: alice.myPeerID - ) - alice._test_onOutboundPacket = nil - bob._test_onOutboundPacket = nil - } - - @Test - func receiptRetryRechecksBit9AtDeferredTransportBoundary() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent( - "private-media-retry-proof-race-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService( - baseDirectory: root.appendingPathComponent( - "alice", - isDirectory: true - ) - ) - let bob = makeService( - baseDirectory: root.appendingPathComponent( - "bob", - isDirectory: true - ) - ) - let receiptCapabilities: PeerCapabilities = [ - .privateMedia, - .privateMediaReceipts - ] - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Bob", - capabilities: receiptCapabilities, - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - bob._test_seedConnectedPeer( - alice.myPeerID, - nickname: "Alice", - capabilities: receiptCapabilities, - noisePublicKey: alice.noiseStaticPublicKeyData() - ) - try await establishSession(alice: alice, bob: bob) - #expect( - alice.authenticatedPrivateMediaReceiptSessionGeneration( - to: bob.myPeerID - ) != nil - ) - - let privateMediaOnly = try authenticatedPeerStatePacket( - from: bob, - to: alice, - capabilities: .privateMedia - ) - let transferID = - "receipt-proof-race-\(UUID().uuidString)" - let tap = PacketTap() - let boundaryProofs = ReceiptCapabilityRecorder() - let rejections = TransferCancellationRecorder() - let cancellable = TransferProgressManager.shared.publisher.sink { - rejections.record($0) - } - alice._test_onOutboundPacket = tap.record - alice._test_beforePrivateMediaDeferredSend = { id in - guard id == transferID else { return } - boundaryProofs.record( - alice - .authenticatedPrivateMediaReceiptSessionGeneration( - to: bob.myPeerID - ) != nil - ) - } - defer { - alice._test_beforePrivateMediaDeferredSend = nil - alice._test_onOutboundPacket = nil - } - - // Rotate authenticated state before the deferred retry reaches its - // admission boundary. - alice._test_handlePacket( - privateMediaOnly, - fromPeerID: bob.myPeerID - ) - let content = Data("%PDF-1.7\nreceipt-proof-race".utf8) - alice.sendFilePrivateReceiptRetry( - BitchatFilePacket( - fileName: "receipt-proof-race.pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ), - to: bob.myPeerID, - transferId: transferID - ) - #expect(await TestHelpers.waitUntil( - { boundaryProofs.snapshot() == [false] }, - timeout: TestConstants.longTimeout - )) - await alice._test_drainPrivateMediaSendPipeline() - - #expect(await TestHelpers.waitUntil( - { rejections.contains(transferID) }, - timeout: TestConstants.longTimeout - )) - #expect(tap.snapshot().allSatisfy { - $0.type != MessageType.fileTransfer.rawValue - && !( - $0.type == MessageType.noiseEncrypted.rawValue - && $0.version == 2 - ) - }) - let state = alice._test_privateMediaTransferState( - transferId: transferID - ) - #expect(!state.admissionActive) - #expect(!state.pendingNoise) - _ = cancellable - } - - @Test - func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-poisoning-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let identity = MockIdentityManager(MockKeychain()) - let alice = makeService( - baseDirectory: root.appendingPathComponent("alice", isDirectory: true), - identityManager: identity - ) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - let bobFingerprint = bob.noiseStaticPublicKeyData().sha256Fingerprint() - let capableAnnounce = try signedAnnounce( - from: bob, - capabilities: .privateMedia - ) - alice._test_handlePacket( - capableAnnounce, - fromPeerID: bob.myPeerID, - preseedPeer: false - ) - let advertised = await TestHelpers.waitUntil( - { alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof }, - timeout: TestConstants.longTimeout - ) - #expect(advertised) - // The production signed-announce path ran, but with no authenticated - // session it must remain a no-op. Querying policy is side-effect free. - #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) - - let noBitAnnounce = try signedAnnounce( - from: bob, - capabilities: [] - ) - alice._test_handlePacket( - noBitAnnounce, - fromPeerID: bob.myPeerID, - preseedPeer: false - ) - let remainedLegacyEligible = await TestHelpers.waitUntil( - { alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent }, - timeout: TestConstants.longTimeout - ) - #expect(remainedLegacyEligible) - } - - @Test - func copiedNoiseKeyPreannounceCannotPinWhenRealOwnerAuthenticates() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-copied-static-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let identity = MockIdentityManager(MockKeychain()) - let alice = makeService( - baseDirectory: root.appendingPathComponent("alice", isDirectory: true), - identityManager: identity - ) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - let attacker = makeService(baseDirectory: root.appendingPathComponent("attacker", isDirectory: true)) - let bobKey = bob.noiseStaticPublicKeyData() - let bobFingerprint = bobKey.sha256Fingerprint() - - // Mallory copies Bob's public Noise key, advertises bit 8, supplies - // Mallory's Ed25519 key, and self-signs. This is internally consistent - // but does not prove possession of Bob's Noise private key. - let forged = try copiedStaticAnnounce( - claimedOwner: bob, - signedBy: attacker, - capabilities: .privateMedia - ) - alice._test_handlePacket(forged, fromPeerID: bob.myPeerID, preseedPeer: false) - let hintAccepted = await TestHelpers.waitUntil( - { alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof }, - timeout: TestConstants.longTimeout - ) - #expect(hintAccepted) - #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) - - let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) - #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof) - - // Only Bob's encrypted state authorizes bit 8 and replaces the forged - // announcement signing key with Bob's Noise-authenticated Ed key. - alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) - let pinned = await TestHelpers.waitUntil( - { identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) }, - timeout: TestConstants.longTimeout - ) - #expect(pinned) - #expect(identity.authenticatedSigningPublicKey(forFingerprint: bobFingerprint) - == bob.noiseSigningPublicKeyData()) - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) - - bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) - alice._test_onOutboundPacket = nil - bob._test_onOutboundPacket = nil - } - - @Test - func droppedInitiatorProofConvergesViaSingleAuthenticatedEcho() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-proof-echo-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Bob", - capabilities: .privateMedia, - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - bob._test_seedConnectedPeer( - alice.myPeerID, - nickname: "Alice", - capabilities: .privateMedia, - noisePublicKey: alice.noiseStaticPublicKeyData() - ) - - let initial = try await establishSessionCapturingPeerState(alice: alice, bob: bob) - // Model Alice's first proof racing ahead of Bob's message-3 handling - // and being dropped. Bob's proof reaches Alice; Alice must emit one - // idempotent echo that lets Bob converge without a new handshake. - _ = initial.alice - let echoTap = PacketTap() - alice._test_onOutboundPacket = echoTap.record - alice._test_handlePacket(initial.bob, fromPeerID: bob.myPeerID) - let echoed = await TestHelpers.waitUntil( - { echoTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } }, - timeout: TestConstants.longTimeout - ) - #expect(echoed) - let echo = try #require( - echoTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue } - ) - bob._test_handlePacket(echo, fromPeerID: alice.myPeerID) - let converged = await TestHelpers.waitUntil( - { - alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted - && bob.privateMediaSendPolicy(to: alice.myPeerID) == .encrypted - }, - timeout: TestConstants.longTimeout - ) - #expect(converged) - alice._test_onOutboundPacket = nil - bob._test_onOutboundPacket = nil - } - - @Test - func noProofTimeoutResolvesToConsentWithoutSendingRawMedia() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-proof-timeout-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Prerelease Bob", - capabilities: .privateMedia, - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - - _ = try await establishSessionCapturingPeerState(alice: alice, bob: bob) - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof) - let recorder = PrivateMediaPolicyRecorder() - alice.resolvePrivateMediaSendPolicy(to: bob.myPeerID) { recorder.record($0) } - let registered = await TestHelpers.waitUntil( - { alice._test_hasPendingPrivateMediaPolicyResolution(for: bob.myPeerID) }, - timeout: TestConstants.longTimeout - ) - #expect(registered) - alice._test_forcePrivateMediaProofTimeout(for: bob.myPeerID) - let resolved = await TestHelpers.waitUntil( - { recorder.snapshot() == .legacyRequiresConsent }, - timeout: TestConstants.longTimeout - ) - #expect(resolved) - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) - #expect(recorder.snapshot() != .encrypted) - alice._test_onOutboundPacket = nil - bob._test_onOutboundPacket = nil - } - - @Test - func panicDropsPendingPrivateMediaPolicyCompletion() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent( - "private-media-policy-panic-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService( - baseDirectory: root.appendingPathComponent( - "alice", - isDirectory: true - ) - ) - let bob = makeService( - baseDirectory: root.appendingPathComponent( - "bob", - isDirectory: true - ) - ) - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Prerelease Bob", - capabilities: .privateMedia, - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - - _ = try await establishSessionCapturingPeerState( - alice: alice, - bob: bob - ) - #expect( - alice.privateMediaSendPolicy(to: bob.myPeerID) - == .awaitingCapabilityProof - ) - let recorder = PrivateMediaPolicyRecorder() - alice.resolvePrivateMediaSendPolicy(to: bob.myPeerID) { - recorder.record($0) - } - let registered = await TestHelpers.waitUntil( - { - alice._test_hasPendingPrivateMediaPolicyResolution( - for: bob.myPeerID - ) - }, - timeout: TestConstants.longTimeout - ) - #expect(registered) - - alice.suspendForPanicReset() - alice.resetIdentityForPanic( - currentNickname: "anon", - restartServices: false - ) - alice._test_forcePrivateMediaProofTimeout(for: bob.myPeerID) - await Task.yield() - - #expect(recorder.snapshot() == nil) - #expect( - !alice._test_hasPendingPrivateMediaPolicyResolution( - for: bob.myPeerID - ) - ) - } - - @Test - func queuedPrivatePayloadWaitsForProofNotHandshakeCompletion() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-proof-drain-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Bob", - capabilities: .privateMedia, - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) - - let content = Data("proof-gated-private-file".utf8) - let file = BitchatFilePacket( - fileName: "proof.txt", - fileSize: UInt64(content.count), - mimeType: "text/plain", - content: content - ) - let payload = try #require(BLENoisePayloadFactory.privateFile(file)) - let transferID = "proof-gated-\(UUID().uuidString)" - alice._test_enqueuePendingNoisePayload(payload, transferId: transferID, for: bob.myPeerID) - alice._test_sendPendingNoisePayloadsAfterHandshake(for: bob.myPeerID) - - #expect(alice._test_privateMediaTransferState(transferId: transferID).pendingNoise) - alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) - let drained = await TestHelpers.waitUntil( - { !alice._test_privateMediaTransferState(transferId: transferID).pendingNoise }, - timeout: TestConstants.longTimeout - ) - #expect(drained) - bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) - alice._test_onOutboundPacket = nil - bob._test_onOutboundPacket = nil - } - - @Test - func authenticatedFingerprintMismatchCannotPoisonCapabilityPin() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-key-mismatch-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let identity = MockIdentityManager(MockKeychain()) - let alice = makeService( - baseDirectory: root.appendingPathComponent("alice", isDirectory: true), - identityManager: identity - ) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - let impostor = makeService(baseDirectory: root.appendingPathComponent("impostor", isDirectory: true)) - let impostorKey = impostor.noiseStaticPublicKeyData() - let reconciliations = PeerIDRecorder() - alice._test_onPrivateMediaSessionReconciled = reconciliations.record - - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Bob", - capabilities: .privateMedia, - noisePublicKey: impostorKey - ) - try await establishSession(alice: alice, bob: bob) - - let sessionReconciled = await TestHelpers.waitUntil( - { reconciliations.contains(bob.myPeerID) }, - timeout: TestConstants.longTimeout - ) - #expect(sessionReconciled) - #expect(!identity.hasObservedPrivateMediaCapability( - fingerprint: impostorKey.sha256Fingerprint() - )) - #expect(identity.hasObservedPrivateMediaCapability( - fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint() - )) - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Bob", - capabilities: [], - noisePublicKey: impostorKey - ) - // The exact live Noise identity remains authoritative over a later - // impostor registry rewrite. - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) - } - - @Test - func capabilityAnnounceAfterNoiseSessionStillRequiresEncryptedProof() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-race-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let identity = MockIdentityManager(MockKeychain()) - let alice = makeService( - baseDirectory: root.appendingPathComponent("alice", isDirectory: true), - identityManager: identity - ) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - - let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) - let bobKey = bob.noiseStaticPublicKeyData() - let capableAnnounce = try signedAnnounce( - from: bob, - capabilities: .privateMedia - ) - alice._test_handlePacket( - capableAnnounce, - fromPeerID: bob.myPeerID, - preseedPeer: false - ) - - let announceDidNotPin = await TestHelpers.waitUntil( - { alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof }, - timeout: TestConstants.longTimeout - ) - #expect(announceDidNotPin) - #expect(!identity.hasObservedPrivateMediaCapability( - fingerprint: bobKey.sha256Fingerprint() - )) - - alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) - - let pinned = await TestHelpers.waitUntil( - { - identity.hasObservedPrivateMediaCapability( - fingerprint: bobKey.sha256Fingerprint() - ) - }, - timeout: TestConstants.longTimeout - ) - #expect(pinned) - bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) - alice._test_onOutboundPacket = nil - bob._test_onOutboundPacket = nil - } - - @Test - func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - let oldCarol = makeService(baseDirectory: root.appendingPathComponent("carol", isDirectory: true)) - - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Bob", - capabilities: .privateMedia, - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - alice._test_seedConnectedPeer( - oldCarol.myPeerID, - nickname: "Old Carol", - noisePublicKey: oldCarol.noiseStaticPublicKeyData() - ) - try await establishSession(alice: alice, bob: bob) - - var state: UInt64 = 0x1234_5678_9ABC_DEF0 - let body = Data((0..<(130 * 1024)).map { _ in - state = state &* 6364136223846793005 &+ 1442695040888963407 - return UInt8(truncatingIfNeeded: state >> 32) - }) - let content = Data("%PDF-1.7\n".utf8) + body - let file = BitchatFilePacket( - fileName: "too-many-fragments.pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ) - - let tap = PacketTap() - alice._test_onOutboundPacket = tap.record - let rejections = TransferCancellationRecorder() - let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } - let encryptedID = "encrypted-over-256-\(UUID().uuidString)" - let legacyID = "legacy-over-256-\(UUID().uuidString)" - - alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID, allowLegacyFallback: false) - alice.sendFilePrivate( - file, - to: oldCarol.myPeerID, - transferId: legacyID, - allowLegacyFallback: true - ) - - // The directed raw-file migration fallback (Android-style peer without - // the .privateMedia capability) still honors the 256-fragment ceiling. - let legacyRejected = await TestHelpers.waitUntil( - { rejections.contains(legacyID) }, - timeout: TestConstants.longTimeout - ) - #expect(legacyRejected) - #expect(rejections.reason(for: legacyID)?.contains("256") == true) - - // Encrypted private media to a .privateMedia-capable peer is NOT forced - // down to Android's 256 cap: it uses the full receiver ceiling and - // proceeds to fragment/emit (a 130 KiB file exceeds 256 fragments). - let encryptedEmitted = await TestHelpers.waitUntil( - { !tap.snapshot().isEmpty }, - timeout: TestConstants.longTimeout - ) - #expect(encryptedEmitted, "Encrypted send to a capable peer must not be blocked by the Android cap") - #expect(!rejections.contains(encryptedID)) - _ = cancellable - } - - @Test - func queuedPrivateEncryptionFailureRejectsBoundTransfer() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-queued-failure-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - try await establishSession(alice: alice, bob: bob) - - let transferID = "queued-encryption-failure-\(UUID().uuidString)" - let rejections = TransferCancellationRecorder() - let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } - var oversizedTypedPayload = Data([NoisePayloadType.privateFile.rawValue]) - oversizedTypedPayload.append(Data( - repeating: 0x42, - count: NoiseSecurityConstants.maxPrivateFilePlaintextSize - )) - - alice._test_enqueuePendingNoisePayload( - oversizedTypedPayload, - transferId: transferID, - for: bob.myPeerID - ) - alice._test_sendPendingNoisePayloadsAfterHandshake(for: bob.myPeerID) - - let rejected = await TestHelpers.waitUntil( - { rejections.contains(transferID) }, - timeout: TestConstants.longTimeout - ) - #expect(rejected) - #expect(rejections.reason(for: transferID)?.isEmpty == false) - _ = cancellable - } - - @Test - func canonical0x20EncryptedFileIsAcceptedAcrossV1OuterPacket() async throws { - let content = Data("%PDF-1.7\nandroid-private".utf8) - try await assertInboundEncryptedPrivateMedia( - typeByte: 0x20, - content: content, - outerVersion: 1, - directoryLabel: "android-0x20" - ) - } - - @Test - func prerelease0x09LargeEncryptedFileIsAcceptedDuringMigration() async throws { - let content = Data("%PDF-1.7\nprerelease-private".utf8) - + Data(repeating: 0x39, count: 70 * 1024) - #expect(content.count > NoiseSecurityConstants.maxMessageSize) - try await assertInboundEncryptedPrivateMedia( - typeByte: NoisePayloadType.prereleasePrivateFileRawValue, - content: content, - outerVersion: 2, - directoryLabel: "prerelease-0x09" - ) - } - - @Test - func privateJPEGIsOpaqueBeforeFragmentationAndDelivers() async throws { - let marker = Data("JPEG_PRIVATE_MARKER_7f5e5eacb86f4b9a".utf8) - let content = Data([0xFF, 0xD8, 0xFF, 0xE0]) - + marker - + Data(repeating: 0x4A, count: 6 * 1024) - try await assertPrivateMediaRoundTrip( - fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg", - mimeType: "image/jpeg", - content: content, - marker: marker, - expectedMessagePrefix: "[image]" - ) - } - - @Test - func finalizedPrivateM4AIsOpaqueBeforeFragmentationAndDelivers() async throws { - let marker = Data("M4A_PRIVATE_MARKER_e0cd431b61fb4a6c".utf8) - let content = Data([0x00, 0x00, 0x00, 0x18]) - + Data("ftypM4A ".utf8) - + marker - + Data(repeating: 0x4D, count: 6 * 1024) - try await assertPrivateMediaRoundTrip( - fileName: "voice_0011223344556677.m4a", - mimeType: "audio/mp4", - content: content, - marker: marker, - expectedMessagePrefix: "[voice]" - ) - } - - @Test - func capablePeerUsesCanonicalAndroid0x20EncryptedSend() async throws { - let marker = Data("PDF_PRIVATE_MARKER_b333f84b8fc7478d".utf8) - let content = Data("%PDF-1.7\n".utf8) - + marker - + Data(repeating: 0x50, count: 6 * 1024) - let file = BitchatFilePacket( - fileName: "private.pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ) - #expect(BLENoisePayloadFactory.privateFile(file)?.first == 0x20) - try await assertPrivateMediaRoundTrip( - fileName: "private.pdf", - mimeType: "application/pdf", - content: content, - marker: marker, - expectedMessagePrefix: "[file]" - ) - } - - @Test - func privateMediaAboveOrdinaryNoiseLimitUsesV2OuterPacketAndDelivers() async throws { - let marker = Data("LARGE_PRIVATE_MARKER_1ec63f261a7041ee".utf8) - let content = Data("%PDF-1.7\n".utf8) - + marker - + Data(repeating: 0x4C, count: 70 * 1024) - try await assertPrivateMediaRoundTrip( - fileName: "large-private.pdf", - mimeType: "application/pdf", - content: content, - marker: marker, - expectedMessagePrefix: "[file]", - expectedOuterVersion: 2 - ) - } - - /// Models an already-established remote sender independently of the local - /// send policy. Exact Android b7f0b33d plaintext bytes are frozen in - /// `BLENoisePayloadFactoryTests`; this helper exercises the encrypted - /// inbound transport around that shared wire encoding. - private func assertInboundEncryptedPrivateMedia( - typeByte: UInt8, - content: Data, - outerVersion: UInt8, - directoryLabel: String - ) async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-\(directoryLabel)-\(UUID().uuidString)", isDirectory: true) - let aliceRoot = root.appendingPathComponent("alice", isDirectory: true) - let bobRoot = root.appendingPathComponent("bob", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - - let alice = makeService(baseDirectory: aliceRoot) - let bob = makeService(baseDirectory: bobRoot) - let delegate = MessageCaptureDelegate() - bob.delegate = delegate - try await establishSession(alice: alice, bob: bob) - - let file = BitchatFilePacket( - fileName: "\(directoryLabel).pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ) - let encodedFile = try #require(file.encode()) - var typedPayload = Data([typeByte]) - typedPayload.append(encodedFile) - - let encrypted = try alice._test_makeEncryptedNoisePacket(typedPayload, to: bob.myPeerID) - let remoteShapedPacket = BitchatPacket( - type: encrypted.type, - senderID: encrypted.senderID, - recipientID: encrypted.recipientID, - timestamp: encrypted.timestamp, - payload: encrypted.payload, - signature: nil, - ttl: encrypted.ttl, - version: outerVersion - ) - bob._test_handlePacket(remoteShapedPacket, fromPeerID: alice.myPeerID) - - let delivered = await TestHelpers.waitUntil( - { delegate.snapshot().count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(delivered) - #expect(delegate.snapshot().first?.isPrivate == true) - let stored = recursivelyStoredFiles(under: bobRoot) - #expect(stored.count == 1) - if let storedURL = stored.first { - #expect(try Data(contentsOf: storedURL) == content) - } - } - - private func assertApprovedLegacySendCancelledBeforeAdmission(label: String) async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-admission-\(label)-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) - let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Legacy Bob", - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - - let transferId = "approved-\(label)-\(UUID().uuidString)" - let gate = PrivateMediaDeferredSendGate() - let tap = PacketTap() - alice._test_onOutboundPacket = tap.record - alice._test_beforePrivateMediaDeferredSend = { id in - guard id == transferId else { return } - gate.pause() - } - defer { - gate.release() - alice._test_beforePrivateMediaDeferredSend = nil - } - - let content = Data("%PDF-1.7\ncancelled-before-admission".utf8) - alice.sendFilePrivate( - BitchatFilePacket( - fileName: "cancelled.pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ), - to: bob.myPeerID, - transferId: transferId, - allowLegacyFallback: true - ) - - let paused = await TestHelpers.waitUntil( - { gate.hasPaused }, - timeout: TestConstants.longTimeout - ) - #expect(paused) - - // This is the transport action used by both cancel and delete. It must - // invalidate synchronously while messageQueue is still held above. - alice.cancelTransfer(transferId) - gate.release() - await alice._test_drainPrivateMediaSendPipeline() - - let state = alice._test_privateMediaTransferState(transferId: transferId) - #expect(!state.admissionActive) - #expect(!state.pendingNoise) - #expect(state.activeScheduler == 0) - #expect(state.pendingScheduler == 0) - #expect(await TestHelpers.waitUntil( - { alice._test_privateMediaAdmissionEntryCount() == 0 }, - timeout: TestConstants.longTimeout - )) - #expect(tap.snapshot().allSatisfy { - $0.type != MessageType.fileTransfer.rawValue - && $0.type != MessageType.noiseEncrypted.rawValue - }) - } - - private func assertPrivateMediaRoundTrip( - fileName: String, - mimeType: String, - content: Data, - marker: Data, - expectedMessagePrefix: String, - expectedOuterVersion: UInt8 = 2 - ) async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("private-media-e2e-\(UUID().uuidString)", isDirectory: true) - let aliceRoot = root.appendingPathComponent("alice", isDirectory: true) - let bobRoot = root.appendingPathComponent("bob", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - - let alice = makeService(baseDirectory: aliceRoot) - let bob = makeService(baseDirectory: bobRoot) - let tap = PacketTap() - let delegate = MessageCaptureDelegate() - bob.delegate = delegate - - alice._test_seedConnectedPeer( - bob.myPeerID, - nickname: "Bob", - capabilities: .privateMedia, - noisePublicKey: bob.noiseStaticPublicKeyData() - ) - bob._test_seedConnectedPeer( - alice.myPeerID, - nickname: "Alice", - capabilities: .privateMedia, - noisePublicKey: alice.noiseStaticPublicKeyData() - ) - try await establishSession(alice: alice, bob: bob) - alice._test_onOutboundPacket = tap.record - - let file = BitchatFilePacket( - fileName: fileName, - fileSize: UInt64(content.count), - mimeType: mimeType, - content: content - ) - alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)", allowLegacyFallback: false) - - let fragmented = await TestHelpers.waitUntil( - { tap.hasCompleteFragmentTrain }, - timeout: 10 - ) - #expect(fragmented) - - let outbound = tap.snapshot() - let encryptedPackets = outbound.filter { $0.type == MessageType.noiseEncrypted.rawValue } - let fragments = outbound - .filter { $0.type == MessageType.fragment.rawValue } - .sorted { fragmentIndex($0) < fragmentIndex($1) } - - #expect(encryptedPackets.count == 1) - #expect(encryptedPackets.first?.version == expectedOuterVersion) - #expect(!fragments.isEmpty) - #expect(outbound.allSatisfy { $0.type != MessageType.fileTransfer.rawValue }) - for packet in encryptedPackets + fragments { - #expect(packet.payload.range(of: marker) == nil) - #expect(packet.payload.range(of: content) == nil) - } - - // Real BLE delivers the train at the scheduler's paced interval. Feed - // bounded batches here instead of enqueuing hundreds of synthetic - // callbacks at once, which can exhaust libdispatch worker threads as - // they wait on the fragment-assembly barrier. - for batchStart in stride(from: 0, to: fragments.count, by: 16) { - let batchEnd = min(batchStart + 16, fragments.count) - for fragment in fragments[batchStart.. BLEService { - let keychain = MockKeychain() - return BLEService( - keychain: keychain, - idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), - identityManager: identityManager ?? MockIdentityManager(keychain), - initializeBluetoothManagers: false, - incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory) - ) - } - - private func establishSession(alice: BLEService, bob: BLEService) async throws { - let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) - bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) - alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) - // Fence the message/identity mutations without assuming either test - // seeded a registry entry (inbound-only tests intentionally do not). - await alice._test_drainNoiseMessagePipeline() - await bob._test_drainNoiseMessagePipeline() - alice._test_onOutboundPacket = nil - bob._test_onOutboundPacket = nil - } - - private func establishSessionCapturingPeerState( - alice: BLEService, - bob: BLEService - ) async throws -> (alice: BitchatPacket, bob: BitchatPacket) { - let aliceTap = PacketTap() - let bobTap = PacketTap() - alice._test_onOutboundPacket = aliceTap.record - bob._test_onOutboundPacket = bobTap.record - - let first = try alice._test_noiseInitiateHandshake(with: bob.myPeerID) - let second = try #require( - try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: first) - ) - let third = try #require( - try alice._test_noiseProcessHandshakeMessage(from: bob.myPeerID, message: second) - ) - _ = try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: third) - #expect(alice.canDeliverSecurely(to: bob.myPeerID)) - #expect(bob.canDeliverSecurely(to: alice.myPeerID)) - - let emitted = await TestHelpers.waitUntil( - { - aliceTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } - && bobTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } - }, - timeout: TestConstants.longTimeout - ) - #expect(emitted) - let aliceProof = try #require( - aliceTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue } - ) - let bobProof = try #require( - bobTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue } - ) - return (aliceProof, bobProof) - } - - private func authenticatedPeerStatePacket( - from sender: BLEService, - to recipient: BLEService, - capabilities: PeerCapabilities - ) throws -> BitchatPacket { - let state = AuthenticatedPeerStatePacket( - capabilities: capabilities, - signingPublicKey: sender.noiseSigningPublicKeyData() - ) - let typed = try #require(BLENoisePayloadFactory.authenticatedPeerState(state)) - return try sender._test_makeEncryptedNoisePacket(typed, to: recipient.myPeerID) - } - - private func signedAnnounce( - from service: BLEService, - capabilities: PeerCapabilities? - ) throws -> BitchatPacket { - let announcement = AnnouncementPacket( - nickname: "Bob", - noisePublicKey: service.noiseStaticPublicKeyData(), - signingPublicKey: service.noiseSigningPublicKeyData(), - directNeighbors: nil, - capabilities: capabilities - ) - let payload = try #require(announcement.encode()) - let unsigned = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: service.myPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), - payload: payload, - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - return service.signPacketForBroadcast(unsigned) - } - - private func copiedStaticAnnounce( - claimedOwner: BLEService, - signedBy signer: BLEService, - capabilities: PeerCapabilities - ) throws -> BitchatPacket { - let announcement = AnnouncementPacket( - nickname: "Mallory-as-Bob", - noisePublicKey: claimedOwner.noiseStaticPublicKeyData(), - signingPublicKey: signer.noiseSigningPublicKeyData(), - directNeighbors: nil, - capabilities: capabilities - ) - let payload = try #require(announcement.encode()) - let unsigned = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: claimedOwner.myPeerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), - payload: payload, - signature: nil, - // Relayed shape avoids the proactive direct-hint handshake in - // this deterministic test; it does not change signature validity. - ttl: TransportConfig.messageTTLDefault - 1 - ) - return signer.signPacketForBroadcast(unsigned) - } - - private func recursivelyStoredFiles(under root: URL) -> [URL] { - guard let enumerator = FileManager.default.enumerator( - at: root, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - return enumerator.compactMap { item in - guard let url = item as? URL, - !url.pathComponents.contains(".private-media-receipts"), - url.lastPathComponent != ".private-media-receipts.json", - (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { - return nil - } - return url - } - } -} - -private func fragmentIndex(_ packet: BitchatPacket) -> Int { - guard packet.payload.count >= 10 else { return .max } - return (Int(packet.payload[8]) << 8) | Int(packet.payload[9]) -} - -private final class PacketTap: @unchecked Sendable { - private let lock = NSLock() - private var packets: [BitchatPacket] = [] - - func record(_ packet: BitchatPacket) { - lock.lock() - packets.append(packet) - lock.unlock() - } - - func snapshot() -> [BitchatPacket] { - lock.lock() - defer { lock.unlock() } - return packets - } - - var hasCompleteFragmentTrain: Bool { - let fragments = snapshot().filter { $0.type == MessageType.fragment.rawValue } - guard let first = fragments.first, first.payload.count >= 12 else { return false } - let total = (Int(first.payload[10]) << 8) | Int(first.payload[11]) - return total > 0 && fragments.count >= total - } -} - -private final class PrivateMediaDeferredSendGate: @unchecked Sendable { - private let condition = NSCondition() - private var paused = false - private var released = false - - var hasPaused: Bool { - condition.lock() - defer { condition.unlock() } - return paused - } - - func pause() { - condition.lock() - paused = true - condition.broadcast() - while !released { - condition.wait() - } - condition.unlock() - } - - func release() { - condition.lock() - released = true - condition.broadcast() - condition.unlock() - } -} - -private final class PeerIDRecorder: @unchecked Sendable { - private let lock = NSLock() - private var peerIDs: [PeerID] = [] - - func record(_ peerID: PeerID) { - lock.lock() - peerIDs.append(peerID) - lock.unlock() - } - - func contains(_ peerID: PeerID) -> Bool { - lock.lock() - defer { lock.unlock() } - return peerIDs.contains(peerID) - } -} - -private final class PrivateMediaPolicyRecorder: @unchecked Sendable { - private let lock = NSLock() - private var policy: PrivateMediaSendPolicy? - - func record(_ policy: PrivateMediaSendPolicy) { - lock.lock() - self.policy = policy - lock.unlock() - } - - func snapshot() -> PrivateMediaSendPolicy? { - lock.lock() - defer { lock.unlock() } - return policy - } -} - -private final class ReceiptCapabilityRecorder: @unchecked Sendable { - private let lock = NSLock() - private var values: [Bool] = [] - - func record(_ value: Bool) { - lock.lock() - values.append(value) - lock.unlock() - } - - func snapshot() -> [Bool] { - lock.lock() - defer { lock.unlock() } - return values - } -} - -private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable { - private let lock = NSLock() - private var messages: [BitchatMessage] = [] - - func didReceiveMessage(_ message: BitchatMessage) { - lock.lock() - messages.append(message) - lock.unlock() - } - - func snapshot() -> [BitchatMessage] { - lock.lock() - defer { lock.unlock() } - return messages - } - - func didConnectToPeer(_ peerID: PeerID) {} - func didDisconnectFromPeer(_ peerID: PeerID) {} - func didUpdatePeerList(_ peers: [PeerID]) {} - func didUpdateBluetoothState(_ state: CBManagerState) {} -} - -private final class TransferCancellationRecorder: @unchecked Sendable { - private let lock = NSLock() - private var transferIDs: Set = [] - private var rejectionReasons: [String: String] = [:] - - func record(_ event: TransferProgressManager.Event) { - let id: String - switch event { - case .cancelled(let cancelledID, _, _): - id = cancelledID - case .rejected(let rejectedID, _): - id = rejectedID - case .started, .updated, .completed: - return - } - lock.lock() - transferIDs.insert(id) - if case .rejected(_, let reason) = event { - rejectionReasons[id] = reason - } - lock.unlock() - } - - func contains(_ transferID: String) -> Bool { - lock.lock() - defer { lock.unlock() } - return transferIDs.contains(transferID) - } - - - func reason(for transferID: String) -> String? { - lock.lock() - defer { lock.unlock() } - return rejectionReasons[transferID] - } -} diff --git a/bitchatTests/EndToEnd/PublicChatE2ETests.swift b/bitchatTests/EndToEnd/PublicChatE2ETests.swift index 4dfdf53f..d380c4c5 100644 --- a/bitchatTests/EndToEnd/PublicChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PublicChatE2ETests.swift @@ -8,7 +8,7 @@ import Testing import struct Foundation.UUID -@testable import BitFoundation // to avoid unnecessary public's +import BitFoundation @testable import bitchat struct PublicChatE2ETests { @@ -18,7 +18,9 @@ struct PublicChatE2ETests { private let charlie: MockBLEService private let david: MockBLEService private let bus = MockBLEBus() - + + private var receivedMessages: [String: [BitchatMessage]] = [:] + init() { // Create mock services with unique peer IDs to avoid any collision alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus) @@ -50,7 +52,7 @@ struct PublicChatE2ETests { var bobReceivedMessage = false var charlieReceivedMessage = false - await confirmation("Both receive message", expectedCount: 2) { receiveMessage in + await confirmation("Both recieve message", expectedCount: 2) { receiveMessage in bob.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 { if !bobReceivedMessage { diff --git a/bitchatTests/Features/ImageUtilsTests.swift b/bitchatTests/Features/ImageUtilsTests.swift index a130b4ea..880403bb 100644 --- a/bitchatTests/Features/ImageUtilsTests.swift +++ b/bitchatTests/Features/ImageUtilsTests.swift @@ -11,10 +11,6 @@ private func makeTemporaryFileURL(_ name: String) -> URL { FileManager.default.temporaryDirectory.appendingPathComponent(name) } -private func makeTemporaryDirectoryURL(_ name: String) -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent(name, isDirectory: true) -} - #if os(iOS) private func makePlatformImage(size: CGSize) -> UIImage { UIGraphicsImageRenderer(size: size).image { context in @@ -59,30 +55,13 @@ struct ImageUtilsTests { @Test func processImage_writesCompressedJpeg() throws { let image = makePlatformImage(size: CGSize(width: 1024, height: 768)) - let outputDirectory = makeTemporaryDirectoryURL("image-output-\(UUID().uuidString)") - defer { try? FileManager.default.removeItem(at: outputDirectory) } - - let outputURL = try ImageUtils.processImage(image, maxDimension: 256, outputDirectory: outputDirectory) + let outputURL = try ImageUtils.processImage(image, maxDimension: 256) + defer { try? FileManager.default.removeItem(at: outputURL) } let data = try Data(contentsOf: outputURL) - #expect(outputURL.deletingLastPathComponent() == outputDirectory) #expect(outputURL.pathExtension.lowercased() == "jpg") #expect(data.starts(with: Data([0xFF, 0xD8]))) #expect(data.count > 0) } - - @Test - func processImage_usesUniqueOutputURLs() throws { - let image = makePlatformImage(size: CGSize(width: 64, height: 64)) - let outputDirectory = makeTemporaryDirectoryURL("image-output-\(UUID().uuidString)") - defer { try? FileManager.default.removeItem(at: outputDirectory) } - - let firstURL = try ImageUtils.processImage(image, maxDimension: 64, outputDirectory: outputDirectory) - let secondURL = try ImageUtils.processImage(image, maxDimension: 64, outputDirectory: outputDirectory) - - #expect(firstURL != secondURL) - #expect(FileManager.default.fileExists(atPath: firstURL.path)) - #expect(FileManager.default.fileExists(atPath: secondURL.path)) - } } diff --git a/bitchatTests/FontBitchatTests.swift b/bitchatTests/FontBitchatTests.swift index 327c5efb..729c2bf8 100644 --- a/bitchatTests/FontBitchatTests.swift +++ b/bitchatTests/FontBitchatTests.swift @@ -1,5 +1,6 @@ import SwiftUI import XCTest +@testable import bitchat final class FontBitchatTests: XCTestCase { // func testMonospacedMapping() { diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index ba0f82b7..c645b1f9 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -12,38 +12,49 @@ import CoreBluetooth import BitFoundation @testable import bitchat -@Suite("Fragmentation Tests", .serialized) struct FragmentationTests { - + + private let mockKeychain: MockKeychain + private let mockIdentityManager: MockIdentityManager + private let idBridge: NostrIdentityBridge + + init() { + mockKeychain = MockKeychain() + mockIdentityManager = MockIdentityManager(mockKeychain) + idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + } + @Test("Reassembly from fragments delivers a public message") func reassemblyFromFragmentsDeliversPublicMessage() async throws { - let ble = makeBLEService() + let ble = BLEService( + keychain: mockKeychain, + idBridge: idBridge, + identityManager: mockIdentityManager, + initializeBluetoothManagers: false + ) let capture = CaptureDelegate() ble.delegate = capture - // Construct a big SIGNED public packet (3KB) from a remote sender. Public - // messages must carry a valid signature, so the reassembled packet is - // signed and the sender's signing key is preseeded into the registry. - let signer = NoiseEncryptionService(keychain: MockKeychain()) - let signingKey = signer.getSigningPublicKeyData() + // Construct a big packet (3KB) from a remote sender (not our own ID) let remoteShortID = PeerID(str: "1122334455667788") - let original = try #require( - signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)), - "Failed to sign public packet" - ) + let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000) // Use a small fragment size to ensure multiple pieces let fragments = fragmentPacket(original, fragmentSize: 400) - // Reverse deterministically to simulate out-of-order arrival without - // making a failure depend on a random permutation. - let outOfOrder = fragments.reversed() + // Shuffle fragments to simulate out-of-order arrival + let shuffled = fragments.shuffled() - for fragment in outOfOrder { - ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey) + // Send fragments sequentially with small delays (no fire-and-forget Tasks) + for (i, fragment) in shuffled.enumerated() { + if i > 0 { + try await Task.sleep(for: .milliseconds(5)) + } + ble._test_handlePacket(fragment, fromPeerID: remoteShortID) } - await ble._test_drainFragmentPipeline() + // Wait for delegate callback with proper timeout + try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2)) #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 3_000) @@ -51,17 +62,17 @@ struct FragmentationTests { @Test("Duplicate fragment does not break reassembly") func duplicateFragmentDoesNotBreakReassembly() async throws { - let ble = makeBLEService() + let ble = BLEService( + keychain: mockKeychain, + idBridge: idBridge, + identityManager: mockIdentityManager, + initializeBluetoothManagers: false + ) let capture = CaptureDelegate() ble.delegate = capture - let signer = NoiseEncryptionService(keychain: MockKeychain()) - let signingKey = signer.getSigningPublicKeyData() let remoteShortID = PeerID(str: "A1B2C3D4E5F60708") - let original = try #require( - signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)), - "Failed to sign public packet" - ) + let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048) var frags = fragmentPacket(original, fragmentSize: 300) // Duplicate one fragment @@ -69,11 +80,16 @@ struct FragmentationTests { frags.insert(dup, at: 1) } - for fragment in frags { - ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey) + // Send fragments sequentially with small delays (no fire-and-forget Tasks) + for (i, fragment) in frags.enumerated() { + if i > 0 { + try await Task.sleep(for: .milliseconds(5)) + } + ble._test_handlePacket(fragment, fromPeerID: remoteShortID) } - await ble._test_drainFragmentPipeline() + // Wait for delegate callback with proper timeout + try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2)) #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 2048) @@ -81,15 +97,15 @@ struct FragmentationTests { @Test("Max-sized file transfer survives reassembly") func maxSizedFileTransferSurvivesReassembly() async throws { - let ble = makeBLEService() + let ble = BLEService( + keychain: mockKeychain, + idBridge: idBridge, + identityManager: mockIdentityManager, + initializeBluetoothManagers: false + ) let capture = CaptureDelegate() ble.delegate = capture - // Broadcast file transfers must carry a valid sender signature (same - // gate as public messages), so sign the packet and preseed the - // sender's signing key into the registry. - let signer = NoiseEncryptionService(keychain: MockKeychain()) - let signingKey = signer.getSigningPublicKeyData() let remoteID = PeerID(str: "CAFEBABECAFEBABE") let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes) let filePacket = BitchatFilePacket( @@ -100,28 +116,29 @@ struct FragmentationTests { ) let encoded = try #require(filePacket.encode(), "File packet encoding failed") - let packet = try #require( - signer.signPacket(BitchatPacket( - type: MessageType.fileTransfer.rawValue, - senderID: Data(hexString: remoteID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encoded, - signature: nil, - ttl: 7, - version: 2 - )), - "Failed to sign file transfer packet" + let packet = BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: Data(hexString: remoteID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encoded, + signature: nil, + ttl: 7, + version: 2 ) let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false) #expect(!fragments.isEmpty) - for fragment in fragments { - ble._test_handlePacket(fragment, fromPeerID: remoteID, signingPublicKey: signingKey) + for (i, fragment) in fragments.enumerated() { + let delay = 5 * Double(i) * 0.001 + Task { + try await sleep(delay) + ble._test_handlePacket(fragment, fromPeerID: remoteID) + } } - await ble._test_drainFragmentPipeline() + try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2)) let message = try #require(capture.receivedMessages.first, "Expected file transfer message") #expect(message.content.hasPrefix("[file]")) @@ -137,7 +154,12 @@ struct FragmentationTests { @Test("Invalid fragment header is ignored") func invalidFragmentHeaderIsIgnored() async throws { - let ble = makeBLEService() + let ble = BLEService( + keychain: mockKeychain, + idBridge: idBridge, + identityManager: mockIdentityManager, + initializeBluetoothManagers: false + ) let capture = CaptureDelegate() ble.delegate = capture @@ -161,11 +183,16 @@ struct FragmentationTests { corrupted[0] = p } - for fragment in corrupted { - ble._test_handlePacket(fragment, fromPeerID: remoteShortID) + for (i, fragment) in corrupted.enumerated() { + let delay = 5 * Double(i) * 0.001 + Task { + try await sleep(delay) + ble._test_handlePacket(fragment, fromPeerID: remoteShortID) + } } - - await ble._test_drainFragmentPipeline() + + // Allow async processing + try await sleep(0.5) // Should not deliver since one fragment is invalid and reassembly can't complete #expect(capture.publicMessages.isEmpty) @@ -173,24 +200,15 @@ struct FragmentationTests { } extension FragmentationTests { - private func makeBLEService() -> BLEService { - let mockKeychain = MockKeychain() - let mockIdentityManager = MockIdentityManager(mockKeychain) - let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - - return BLEService( - keychain: mockKeychain, - idBridge: idBridge, - identityManager: mockIdentityManager, - initializeBluetoothManagers: false - ) - } - /// Thread-safe delegate that supports awaiting message delivery private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable { private let lock = NSLock() private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = [] private var _receivedMessages: [BitchatMessage] = [] + private var publicMessageContinuation: CheckedContinuation? + private var receivedMessageContinuation: CheckedContinuation? + private var expectedPublicMessageCount: Int = 0 + private var expectedReceivedMessageCount: Int = 0 private func withLock(_ body: () -> T) -> T { lock.lock() @@ -207,11 +225,113 @@ extension FragmentationTests { } func didReceiveMessage(_ message: BitchatMessage) { - withLock { _receivedMessages.append(message) } + lock.lock() + _receivedMessages.append(message) + let count = _receivedMessages.count + let expected = expectedReceivedMessageCount + let continuation = receivedMessageContinuation + lock.unlock() + + if count >= expected, let cont = continuation { + lock.lock() + receivedMessageContinuation = nil + lock.unlock() + cont.resume() + } } func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { - withLock { _publicMessages.append((peerID, nickname, content)) } + lock.lock() + _publicMessages.append((peerID, nickname, content)) + let count = _publicMessages.count + let expected = expectedPublicMessageCount + let continuation = publicMessageContinuation + lock.unlock() + + if count >= expected, let cont = continuation { + lock.lock() + publicMessageContinuation = nil + lock.unlock() + cont.resume() + } + } + + /// Waits for the specified number of public messages to be received + func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws { + let isAlreadySatisfied = withLock { () -> Bool in + if _publicMessages.count >= count { + return true + } + expectedPublicMessageCount = count + return false + } + if isAlreadySatisfied { + return + } + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = self.withLock { + // Recheck count after acquiring lock to avoid race condition + // where message arrives between initial check and continuation install + if self._publicMessages.count >= count { + return true + } + self.publicMessageContinuation = continuation + return false + } + if shouldResumeImmediately { + continuation.resume() + } + } + } + group.addTask { + try await Task.sleep(for: timeout) + throw CancellationError() + } + try await group.next() + group.cancelAll() + } + } + + /// Waits for the specified number of received messages + func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws { + let isAlreadySatisfied = withLock { () -> Bool in + if _receivedMessages.count >= count { + return true + } + expectedReceivedMessageCount = count + return false + } + if isAlreadySatisfied { + return + } + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = self.withLock { + // Recheck count after acquiring lock to avoid race condition + // where message arrives between initial check and continuation install + if self._receivedMessages.count >= count { + return true + } + self.receivedMessageContinuation = continuation + return false + } + if shouldResumeImmediately { + continuation.resume() + } + } + } + group.addTask { + try await Task.sleep(for: timeout) + throw CancellationError() + } + try await group.next() + group.cancelAll() + } } func didConnectToPeer(_ peerID: PeerID) {} @@ -221,6 +341,7 @@ extension FragmentationTests { func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didUpdateBluetoothState(_ state: CBManagerState) {} + func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {} } // Helper: build a large message packet (unencrypted public message) diff --git a/bitchatTests/GCSFilterTests.swift b/bitchatTests/GCSFilterTests.swift index b544f843..36f91773 100644 --- a/bitchatTests/GCSFilterTests.swift +++ b/bitchatTests/GCSFilterTests.swift @@ -20,50 +20,4 @@ struct GCSFilterTests { #expect(bucket != 0) #expect(bucket < 2) } - - @Test func decodeRejectsOutOfRangeParameters() { - let junk = Data(repeating: 0xFF, count: 64) - #expect(GCSFilter.decodeToSortedSet(p: 0, m: 1000, data: junk).isEmpty) - #expect(GCSFilter.decodeToSortedSet(p: -1, m: 1000, data: junk).isEmpty) - #expect(GCSFilter.decodeToSortedSet(p: GCSFilter.maxP + 1, m: 1000, data: junk).isEmpty) - #expect(GCSFilter.decodeToSortedSet(p: 255, m: UInt32.max, data: junk).isEmpty) - #expect(GCSFilter.decodeToSortedSet(p: 8, m: 0, data: junk).isEmpty) - #expect(GCSFilter.decodeToSortedSet(p: 8, m: 1, data: junk).isEmpty) - } - - @Test func decodeOfTruncatedDataReturnsOnlyCompleteValues() { - let ids = (0..<32).map { i in Data(repeating: UInt8(i), count: 16) } - let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01) - let full = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data) - let truncated = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data.prefix(params.data.count / 2)) - #expect(truncated.count <= full.count) - // Truncation must not invent values that were not in the full set. - #expect(truncated.allSatisfy { full.contains($0) }) - } - - @Test func buildFilterReportsFullCoverageWhenBudgetFits() { - let ids = (0..<8).map { i in Data(repeating: UInt8(i), count: 16) } - let params = GCSFilter.buildFilter(ids: ids, maxBytes: 1024, targetFpr: 0.01) - #expect(params.includedCount == ids.count) - } - - @Test func buildFilterTrimsTailWhenBudgetExceeded() { - // A tight byte budget can't hold every ID, so the encoder trims from - // the input tail and reports how many it actually covered. - let ids = (0..<200).map { i in - Data((0..<16).map { UInt8((i &* 31 &+ $0) & 0xFF) }) - } - let params = GCSFilter.buildFilter(ids: ids, maxBytes: 32, targetFpr: 0.01) - #expect(params.includedCount > 0) - #expect(params.includedCount < ids.count) - #expect(params.data.count <= 32) - } - - @Test func requestSyncPacketDecodeRejectsOversizedP() { - let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02])) - #expect(RequestSyncPacket.decode(from: valid.encode()) != nil) - - let oversized = RequestSyncPacket(p: 200, m: 4096, data: Data([0x01, 0x02])) - #expect(RequestSyncPacket.decode(from: oversized.encode()) == nil) - } } diff --git a/bitchatTests/GeoChannelCoordinatorContextTests.swift b/bitchatTests/GeoChannelCoordinatorContextTests.swift deleted file mode 100644 index ac6b135f..00000000 --- a/bitchatTests/GeoChannelCoordinatorContextTests.swift +++ /dev/null @@ -1,304 +0,0 @@ -// -// GeoChannelCoordinatorContextTests.swift -// bitchatTests -// -// Exercises `GeoChannelCoordinator` against a mock `GeoChannelContext` — -// proving the coordinator works without a `ChatViewModel`, following the -// `ChatDeliveryCoordinatorContextTests` exemplar. -// -// Scope note: the location/bookmark managers are real `LocationStateManager` -// instances backed by throwaway `UserDefaults` suites and mocked CoreLocation -// seams. `TorManager` has no test seam (private init singleton); sampling -// tests pin `TorManager.shared` to foreground (its default) so the -// begin-sampling branch is deterministic. -// - -import Testing -import Combine -import Foundation -import CoreLocation -import Tor -@testable import bitchat - -// MARK: - Mock Context - -/// Lightweight stand-in for `GeoChannelContext` proving that -/// `GeoChannelCoordinator` is testable without a `ChatViewModel`. -@MainActor -private final class MockGeoChannelContext: GeoChannelContext { - private(set) var switchedChannels: [ChannelID] = [] - private(set) var beginSamplingCalls: [[String]] = [] - private(set) var endSamplingCount = 0 - - func switchLocationChannel(to channel: ChannelID) { switchedChannels.append(channel) } - func beginGeohashSampling(for geohashes: [String]) { beginSamplingCalls.append(geohashes.sorted()) } - func endGeohashSampling() { endSamplingCount += 1 } -} - -// MARK: - CoreLocation Seams - -private final class StubLocationManaging: LocationStateManaging { - weak var delegate: CLLocationManagerDelegate? - var desiredAccuracy: CLLocationAccuracy = 0 - var distanceFilter: CLLocationDistance = 0 - var authorizationStatus: CLAuthorizationStatus - private(set) var stopUpdatingLocationCallCount = 0 - - init(authorizationStatus: CLAuthorizationStatus = .denied) { - self.authorizationStatus = authorizationStatus - } - - func requestWhenInUseAuthorization() {} - func requestLocation() {} - func startUpdatingLocation() {} - func stopUpdatingLocation() { stopUpdatingLocationCallCount += 1 } -} - -private final class StubLocationGeocoder: LocationStateGeocoding { - func cancelGeocode() {} - func reverseGeocodeLocation( - _ location: CLLocation, - completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void - ) { - completionHandler(nil, nil) - } -} - -// MARK: - Helpers - -@MainActor -private func makeLocationManager( - storage: UserDefaults? = nil, - authorizationStatus: CLAuthorizationStatus = .denied, - shouldInitializeCoreLocation: Bool = false -) -> LocationStateManager { - let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)" - let defaults = storage ?? UserDefaults(suiteName: suiteName)! - if storage == nil { - defaults.removePersistentDomain(forName: suiteName) - } - return LocationStateManager( - storage: defaults, - locationManager: StubLocationManaging(authorizationStatus: authorizationStatus), - geocoder: StubLocationGeocoder(), - shouldInitializeCoreLocation: shouldInitializeCoreLocation - ) -} - -/// Polls until `condition` holds, letting main-actor tasks and main-queue -/// Combine hops drain in between. -@MainActor -private func waitUntil(_ condition: () -> Bool) async -> Bool { - for _ in 0..<100 { - if condition() { return true } - await Task.yield() - try? await Task.sleep(nanoseconds: 10_000_000) - } - return condition() -} - -// MARK: - Coordinator Tests Against Mock Context - -/// Exercises `GeoChannelCoordinator` against `MockGeoChannelContext` with no -/// `ChatViewModel`. -struct GeoChannelCoordinatorContextTests { - - @Test @MainActor - func start_publishesPersistedChannelAndEndsSamplingWithoutGeohashes() async throws { - let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)" - let storage = UserDefaults(suiteName: suiteName)! - storage.removePersistentDomain(forName: suiteName) - let persisted = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru")) - storage.set(try JSONEncoder().encode(persisted), forKey: "locationChannel.selected") - - let locationManager = makeLocationManager(storage: storage) - let context = MockGeoChannelContext() - let coordinator = GeoChannelCoordinator( - locationManager: locationManager, - bookmarksStore: locationManager, - torManager: TorManager.shared, - context: context - ) - defer { withExtendedLifetime(coordinator) {} } - - // The persisted selection is announced and, with no regional or - // bookmarked geohashes, sampling ends rather than begins. - #expect(await waitUntil { !context.switchedChannels.isEmpty && context.endSamplingCount > 0 }) - #expect(context.switchedChannels.allSatisfy { $0 == persisted }) - #expect(context.beginSamplingCalls.isEmpty) - } - - @Test @MainActor - func selectingChannel_propagatesSwitchToContext() async { - let locationManager = makeLocationManager() - let context = MockGeoChannelContext() - let coordinator = GeoChannelCoordinator( - locationManager: locationManager, - bookmarksStore: locationManager, - torManager: TorManager.shared, - context: context - ) - defer { withExtendedLifetime(coordinator) {} } - - #expect(await waitUntil { context.switchedChannels.contains(.mesh) }) - - let target = ChannelID.location(GeohashChannel(level: .neighborhood, geohash: "u4pruydq")) - locationManager.select(target) - #expect(await waitUntil { context.switchedChannels.last == target }) - } - - @Test @MainActor - func bookmarkChanges_beginAndEndGeohashSampling() async { - TorManager.shared.setAppForeground(true) - let locationManager = makeLocationManager() - let context = MockGeoChannelContext() - let coordinator = GeoChannelCoordinator( - locationManager: locationManager, - bookmarksStore: locationManager, - torManager: TorManager.shared, - context: context - ) - - // No geohashes yet: only end-sampling has run. - #expect(await waitUntil { context.endSamplingCount > 0 }) - #expect(context.beginSamplingCalls.isEmpty) - - // Bookmarking a geohash starts sampling it. - locationManager.toggleBookmark("u4pruydq") - #expect(await waitUntil { context.beginSamplingCalls.last == ["u4pruydq"] }) - - // Removing the last bookmark ends sampling again, and a manual - // refresh keeps reporting the empty state. - let endCountBeforeRemoval = context.endSamplingCount - locationManager.toggleBookmark("u4pruydq") - #expect(await waitUntil { context.endSamplingCount > endCountBeforeRemoval }) - - let endCountBeforeRefresh = context.endSamplingCount - coordinator.refreshSampling() - #expect(await waitUntil { context.endSamplingCount > endCountBeforeRefresh }) - } - - @Test @MainActor - func buildingCellJoinsSamplingOnlyAfterNotesReveal() async { - TorManager.shared.setAppForeground(true) - let locationManager = makeLocationManager( - authorizationStatus: .authorizedAlways, - shouldInitializeCoreLocation: true - ) - #expect(await waitUntil { locationManager.permissionState == .authorized }) - let context = MockGeoChannelContext() - let revealed = CurrentValueSubject(false) - let notesEnabled = CurrentValueSubject(true) - let coordinator = GeoChannelCoordinator( - locationManager: locationManager, - bookmarksStore: locationManager, - torManager: TorManager.shared, - notesRevealed: revealed.eraseToAnyPublisher(), - locationNotesEnabled: true, - locationNotesSettings: notesEnabled.eraseToAnyPublisher(), - context: context - ) - defer { withExtendedLifetime(coordinator) {} } - - // A location fix yields all six channel levels… - locationManager.locationManager( - CLLocationManager(), - didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)] - ) - #expect(await waitUntil { - locationManager.availableChannels.contains { $0.level == .building } - }) - let building = locationManager.availableChannels.first { $0.level == .building }!.geohash - - // …but pre-reveal sampling must exclude the building-precision cell: - // a passive precision-8 REQ identifies a single address. - #expect(await waitUntil { - (context.beginSamplingCalls.last?.count ?? 0) == GeohashChannelLevel.allCases.count - 1 - }) - #expect(context.beginSamplingCalls.allSatisfy { !$0.contains(building) }) - - // The explicit notes act widens sampling to include it. - revealed.send(true) - #expect(await waitUntil { context.beginSamplingCalls.last?.contains(building) == true }) - #expect(context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count) - - // The app-info kill switch must narrow the already-live sampling set - // immediately, without waiting for another location update. - notesEnabled.send(false) - #expect(await waitUntil { - context.beginSamplingCalls.last?.contains(building) == false && - context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count - 1 - }) - } - - @Test @MainActor - func permissionRevocationEndsCachedRegionalSampling_butBookmarksRemainEligible() async { - TorManager.shared.setAppForeground(true) - let locationManager = makeLocationManager( - authorizationStatus: .authorizedAlways, - shouldInitializeCoreLocation: true - ) - #expect(await waitUntil { locationManager.permissionState == .authorized }) - let context = MockGeoChannelContext() - let revealed = CurrentValueSubject(true) - let coordinator = GeoChannelCoordinator( - locationManager: locationManager, - bookmarksStore: locationManager, - torManager: TorManager.shared, - notesRevealed: revealed.eraseToAnyPublisher(), - locationNotesEnabled: true, - locationNotesSettings: Empty().eraseToAnyPublisher(), - context: context - ) - defer { withExtendedLifetime(coordinator) {} } - - locationManager.locationManager( - CLLocationManager(), - didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)] - ) - #expect(await waitUntil { - context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count - }) - let cachedChannels = locationManager.availableChannels - let endCountBeforeRevocation = context.endSamplingCount - - locationManager.locationManager(CLLocationManager(), didChangeAuthorization: .denied) - - #expect(await waitUntil { - locationManager.permissionState == .denied && - context.endSamplingCount > endCountBeforeRevocation - }) - #expect(locationManager.availableChannels == cachedChannels) - - // A bookmark is an explicit remote scope and does not derive from - // the now-revoked device location. - locationManager.addBookmark("u4pru") - #expect(await waitUntil { context.beginSamplingCalls.last == ["u4pru"] }) - } - - @Test @MainActor - func releasedContext_isHeldWeaklyAndSafelyIgnored() async { - let locationManager = makeLocationManager() - var context: MockGeoChannelContext? = MockGeoChannelContext() - let weakContext = { [weak context] in context } - let coordinator = GeoChannelCoordinator( - locationManager: locationManager, - bookmarksStore: locationManager, - torManager: TorManager.shared, - context: context! - ) - - #expect(await waitUntil { context?.switchedChannels.isEmpty == false }) - - // The coordinator must not keep the owner alive (it is owned by it). - context = nil - #expect(weakContext() == nil) - - // Events after the owner is gone are safely dropped. - locationManager.select(.location(GeohashChannel(level: .city, geohash: "u4pru"))) - locationManager.toggleBookmark("u4pruydq") - coordinator.refreshSampling() - for _ in 0..<10 { await Task.yield() } - try? await Task.sleep(nanoseconds: 50_000_000) - } -} diff --git a/bitchatTests/GeohashPresenceTests.swift b/bitchatTests/GeohashPresenceTests.swift index e0d43169..dcb5e3bc 100644 --- a/bitchatTests/GeohashPresenceTests.swift +++ b/bitchatTests/GeohashPresenceTests.swift @@ -278,60 +278,6 @@ struct ChatViewModelPresenceHandlingTests { #expect(count >= 1) } - @Test func subscribeNostrEvent_samplingDeduplicatesSameEventAcrossGeohashes() async throws { - let (viewModel, _) = makeTestableViewModel() - let firstGeohash = "u4pru" - let secondGeohash = "u4pruy" - let identity = try NostrIdentity.generate() - let event = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: Date(), - kind: .geohashPresence, - tags: [["g", secondGeohash]], - content: "" - ) - let signed = try event.sign(with: identity.schnorrSigningKey()) - - viewModel.subscribeNostrEvent(signed, gh: firstGeohash) - viewModel.subscribeNostrEvent(signed, gh: secondGeohash) - - #expect(viewModel.geohashParticipantCount(for: firstGeohash) == 1) - #expect(viewModel.geohashParticipantCount(for: secondGeohash) == 0) - } - - @Test func subscribeNostrEvent_samplingDedupDoesNotBlockActiveChannelProcessing() async throws { - let (viewModel, _) = makeTestableViewModel() - let sampleGeohash = "u4pru" - let activeGeohash = "u4pruy" - let identity = try NostrIdentity.generate() - - viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash))) - - let event = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: Date(), - kind: .geohashPresence, - tags: [["g", sampleGeohash]], - content: "" - ) - let signed = try event.sign(with: identity.schnorrSigningKey()) - - viewModel.subscribeNostrEvent(signed, gh: sampleGeohash) - #expect(!viewModel.deduplicationService.hasProcessedNostrEvent(signed.id)) - - viewModel.subscribeNostrEvent(signed) - - #expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1) - #expect(viewModel.deduplicationService.hasProcessedNostrEvent(signed.id)) - #expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1) - } - - // NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning - // invariant) is enforced once, off the main actor, at the relay boundary — - // the sampling path only ever sees verified events. See - // NostrRelayManagerTests - // `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`. - // MARK: - Test Helper private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index e0f3580c..9d45da98 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -37,7 +37,7 @@ struct GossipSyncManagerTests { } manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0) - try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.settleTimeout) + try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout) } let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent") @@ -91,47 +91,6 @@ struct GossipSyncManagerTests { #expect(manager._messageCount(for: PeerID(str: peerHex)) == 0) } - @Test func removePublicMessagesPurgesOnlyThatSender() throws { - // Block-time archive hygiene: purging a blocked sender's carried - // public messages must not touch other senders' messages or the - // blocked sender's announcement. - let requestSyncManager = RequestSyncManager() - let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager) - let blockedHex = "00112233445566aa" - let otherHex = "00112233445566bb" - let blockedData = try #require(Data(hexString: blockedHex)) - let otherData = try #require(Data(hexString: otherHex)) - let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - - manager.onPublicPacketSeen(BitchatPacket( - type: MessageType.announce.rawValue, - senderID: blockedData, - recipientID: nil, - timestamp: nowMs, - payload: Data(), - signature: nil, - ttl: 1 - )) - for (index, sender) in [blockedData, blockedData, otherData].enumerated() { - manager.onPublicPacketSeen(BitchatPacket( - type: MessageType.message.rawValue, - senderID: sender, - recipientID: nil, - timestamp: nowMs + UInt64(index), - payload: Data([UInt8(index)]), - signature: nil, - ttl: 1 - )) - } - #expect(manager._messageCount(for: PeerID(str: blockedHex)) == 2) - - manager.removePublicMessages(from: PeerID(str: blockedHex)) - - #expect(manager._messageCount(for: PeerID(str: blockedHex)) == 0) - #expect(manager._messageCount(for: PeerID(str: otherHex)) == 1) - #expect(manager._hasAnnouncement(for: PeerID(str: blockedHex))) - } - @Test func ignoresAnnounceOlderThanStaleTimeout() throws { var config = GossipSyncManager.Config() config.stalePeerTimeoutSeconds = 5 @@ -180,7 +139,6 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 1 config.fragmentSyncIntervalSeconds = 1 config.fileTransferSyncIntervalSeconds = 1 - config.prekeyBundleSyncIntervalSeconds = 1 config.maintenanceIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() @@ -236,277 +194,13 @@ struct GossipSyncManagerTests { manager._performMaintenanceSynchronously(now: Date()) - // One request per due schedule so each type group gets the full - // filter capacity: publicMessages, fragment, fileTransfer, and - // prekeyBundle. let sentPackets = delegate.packets - #expect(sentPackets.count == 4) + #expect(sentPackets.count == 3) let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) } - #expect(decoded.count == 4) - let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) } - #expect(allTypes.contains(.announce)) - #expect(allTypes.contains(.message)) - #expect(allTypes.contains(.fragment)) - #expect(allTypes.contains(.fileTransfer)) - #expect(allTypes.contains(.prekeyBundle)) - #expect(allTypes.contains(.groupMessage)) - // The message schedule also asks for group messages (bit 10); - // responders that don't know the bit just ignore it. - #expect(decoded.contains { $0.types == SyncTypeFlags.publicMessages.union(.groupMessage) }) - #expect(decoded.contains { $0.types == .fragment }) - #expect(decoded.contains { $0.types == .fileTransfer }) - #expect(decoded.contains { $0.types == .prekeyBundle }) - } - - @Test func truncatedFilterCarriesSinceCursor() throws { - var config = GossipSyncManager.Config() - config.seenCapacity = 100 - config.gcsMaxBytes = 32 // caps the filter at 28 IDs (256 bits / 9 bits per element) - config.messageSyncIntervalSeconds = 1 - config.fragmentSyncIntervalSeconds = 0 - config.fileTransferSyncIntervalSeconds = 0 - config.maintenanceIntervalSeconds = 0 - - let requestSyncManager = RequestSyncManager() - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) - let delegate = RecordingDelegate() - manager.delegate = delegate - - let sender = try #require(Data(hexString: "1122334455667788")) - let baseTimestamp = UInt64(Date().timeIntervalSince1970 * 1000) - let totalMessages = 40 - for i in 0..= baseTimestamp + 12) - #expect(since < baseTimestamp + UInt64(totalMessages)) - } - - @Test func fullCoverageFilterOmitsSinceCursor() throws { - var config = GossipSyncManager.Config() - config.seenCapacity = 100 - config.messageSyncIntervalSeconds = 1 - config.fragmentSyncIntervalSeconds = 0 - config.fileTransferSyncIntervalSeconds = 0 - config.maintenanceIntervalSeconds = 0 - - let requestSyncManager = RequestSyncManager() - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) - let delegate = RecordingDelegate() - manager.delegate = delegate - - let sender = try #require(Data(hexString: "1122334455667788")) - let packet = BitchatPacket( - type: MessageType.message.rawValue, - senderID: sender, - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data([0x01]), - signature: nil, - ttl: 1 - ) - manager.onPublicPacketSeen(packet) - - manager._performMaintenanceSynchronously(now: Date()) - - let sent = try #require(delegate.packets.first) - let request = try #require(RequestSyncPacket.decode(from: sent.payload)) - #expect(request.sinceTimestamp == nil) - } - - @Test func handleRequestSyncHonorsSinceCursorButAlwaysSendsAnnounces() async throws { - var config = GossipSyncManager.Config() - config.seenCapacity = 5 - config.messageSyncIntervalSeconds = 0 - config.fragmentSyncIntervalSeconds = 0 - config.fileTransferSyncIntervalSeconds = 0 - config.prekeyBundleSyncIntervalSeconds = 0 - - let requestSyncManager = RequestSyncManager() - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) - let delegate = RecordingDelegate() - manager.delegate = delegate - - let sender = try #require(Data(hexString: "aabbccddeeff0011")) - let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - - // Announce older than the cursor: must still be sent (identity is - // needed to verify everything else). - let announcePacket = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: sender, - recipientID: nil, - timestamp: nowMs - 50_000, - payload: Data(), - signature: nil, - ttl: 1 - ) - let oldMessage = BitchatPacket( - type: MessageType.message.rawValue, - senderID: sender, - recipientID: nil, - timestamp: nowMs - 60_000, - payload: Data([0x01]), - signature: nil, - ttl: 1 - ) - let newMessage = BitchatPacket( - type: MessageType.message.rawValue, - senderID: sender, - recipientID: nil, - timestamp: nowMs, - payload: Data([0x02]), - signature: nil, - ttl: 1 - ) - - manager.onPublicPacketSeen(announcePacket) - manager.onPublicPacketSeen(oldMessage) - manager.onPublicPacketSeen(newMessage) - - let peer = PeerID(str: "FFFFFFFFFFFFFFFF") - let request = RequestSyncPacket( - p: 7, - m: 1, - data: Data(), - types: .publicMessages, - sinceTimestamp: nowMs - 30_000 - ) - manager.handleRequestSync(from: peer, request: request) - - try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.settleTimeout) - // Barrier: flush the sync queue so a late third packet would be visible. - manager._performMaintenanceSynchronously(now: Date()) - let sentPackets = delegate.packets - #expect(sentPackets.count == 2) - #expect(sentPackets.contains { $0.type == MessageType.announce.rawValue }) - let sentMessages = sentPackets.filter { $0.type == MessageType.message.rawValue } - #expect(sentMessages.count == 1) - #expect(sentMessages.first?.payload == Data([0x02])) - #expect(sentPackets.allSatisfy { $0.isRSR }) - } - - @Test func handleRequestSyncSkipsAnnounceAlreadyInFilter() async throws { - var config = GossipSyncManager.Config() - config.messageSyncIntervalSeconds = 0 - config.fragmentSyncIntervalSeconds = 0 - config.fileTransferSyncIntervalSeconds = 0 - config.prekeyBundleSyncIntervalSeconds = 0 - - let requestSyncManager = RequestSyncManager() - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) - let delegate = RecordingDelegate() - manager.delegate = delegate - - let sender = try #require(Data(hexString: "aabbccddeeff0011")) - let announcePacket = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: sender, - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data(), - signature: nil, - ttl: 1 - ) - manager.onPublicPacketSeen(announcePacket) - - // A filter that already contains the announce's canonical ID must - // suppress the response — this only holds if the responder recomputes - // the ID the same way the filter was built (the dual-path bug would - // diff a stored hex string instead). - let announceID = PacketIdUtil.computeId(announcePacket) - let params = GCSFilter.buildFilter(ids: [announceID], maxBytes: 256, targetFpr: 0.01) - let request = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: .announce) - - let peer = PeerID(str: "FFFFFFFFFFFFFFFF") - manager.handleRequestSync(from: peer, request: request) - // Barrier: the async handler is enqueued, so this sync flush runs after it. - manager._performMaintenanceSynchronously(now: Date()) - #expect(delegate.packets.isEmpty) - } - - @Test func handleRequestSyncIsRateLimitedPerPeer() async throws { - var config = GossipSyncManager.Config() - config.seenCapacity = 5 - config.messageSyncIntervalSeconds = 0 - config.fragmentSyncIntervalSeconds = 0 - config.fileTransferSyncIntervalSeconds = 0 - config.prekeyBundleSyncIntervalSeconds = 0 - config.responseRateLimitMaxResponses = 1 - config.responseRateLimitWindowSeconds = 60 - - let requestSyncManager = RequestSyncManager() - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) - let delegate = RecordingDelegate() - manager.delegate = delegate - - let sender = try #require(Data(hexString: "aabbccddeeff0011")) - let messagePacket = BitchatPacket( - type: MessageType.message.rawValue, - senderID: sender, - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data([0x10]), - signature: nil, - ttl: 1 - ) - manager.onPublicPacketSeen(messagePacket) - - let peer = PeerID(str: "FFFFFFFFFFFFFFFF") - let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .message) - manager.handleRequestSync(from: peer, request: request) - manager.handleRequestSync(from: peer, request: request) - - try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.settleTimeout) - // Barrier: both requests have been processed once this returns. - manager._performMaintenanceSynchronously(now: Date()) - #expect(delegate.packets.count == 1) - } - - @Test func initialSyncCoalescesEnabledTypes() async throws { - var config = GossipSyncManager.Config() - config.seenCapacity = 10 - config.fragmentCapacity = 5 - config.fileTransferCapacity = 4 - config.fragmentSyncIntervalSeconds = 1 - config.fileTransferSyncIntervalSeconds = 1 - - let requestSyncManager = RequestSyncManager() - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) - let delegate = RecordingDelegate() - manager.delegate = delegate - - manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0) - - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) - let packet = try #require(delegate.packets.first) - let request = try #require(RequestSyncPacket.decode(from: packet.payload)) - let types = try #require(request.types) - #expect(types.contains(.announce)) - #expect(types.contains(.message)) - #expect(types.contains(.fragment)) - #expect(types.contains(.fileTransfer)) - #expect(types.contains(.prekeyBundle)) + #expect(decoded.count == 3) + #expect(decoded[0].types == .publicMessages) + #expect(decoded[1].types == .fragment) + #expect(decoded[2].types == .fileTransfer) } @Test func handleRequestSyncHonorsTypeFilter() async throws { @@ -553,307 +247,15 @@ struct GossipSyncManagerTests { let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment) manager.handleRequestSync(from: peer, request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) let sentPackets = delegate.packets #expect(sentPackets.count == 1) #expect(sentPackets[0].type == MessageType.fragment.rawValue) } - - // MARK: - Fragment-ID filter (targeted resync) - - private func makeFragmentPacket(sender: Data, fragmentID: Data, index: UInt16, timestamp: UInt64) -> BitchatPacket { - // Fragment payload: 8-byte stream ID + index + total + original type. - var payload = fragmentID - payload.append(contentsOf: withUnsafeBytes(of: index.bigEndian) { Data($0) }) - payload.append(contentsOf: withUnsafeBytes(of: UInt16(4).bigEndian) { Data($0) }) - payload.append(MessageType.fileTransfer.rawValue) - payload.append(Data([0xEE])) - return BitchatPacket( - type: MessageType.fragment.rawValue, - senderID: sender, - recipientID: nil, - timestamp: timestamp, - payload: payload, - signature: nil, - ttl: 1 - ) - } - - @Test func handleRequestSyncHonorsFragmentIdFilter() async throws { - var config = GossipSyncManager.Config() - config.fragmentCapacity = 10 - config.messageSyncIntervalSeconds = 0 - config.fragmentSyncIntervalSeconds = 0 - config.fileTransferSyncIntervalSeconds = 0 - config.prekeyBundleSyncIntervalSeconds = 0 - - let requestSyncManager = RequestSyncManager() - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) - let delegate = RecordingDelegate() - manager.delegate = delegate - - let sender = try #require(Data(hexString: "aabbccddeeff0011")) - let wantedID = try #require(Data(hexString: "0102030405060708")) - let otherID = try #require(Data(hexString: "1112131415161718")) - let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - - let wanted = makeFragmentPacket(sender: sender, fragmentID: wantedID, index: 1, timestamp: nowMs - 60_000) - let other = makeFragmentPacket(sender: sender, fragmentID: otherID, index: 2, timestamp: nowMs) - manager.onPublicPacketSeen(wanted) - manager.onPublicPacketSeen(other) - - // The since-cursor sits after both fragments; without the filter the - // responder would send nothing for `wanted`. The filter both bypasses - // the cursor and restricts the diff to exactly the named stream. - let request = RequestSyncPacket( - p: 7, - m: 1, - data: Data(), - types: .fragment, - sinceTimestamp: nowMs + 1, - fragmentIdFilter: RequestSyncPacket.encodeFragmentIdFilter([wantedID]) - ) - manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) - - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) - // Barrier: flush the sync queue so a late second packet would be visible. - manager._performMaintenanceSynchronously(now: Date()) - let sentPackets = delegate.packets - #expect(sentPackets.count == 1) - let sent = try #require(sentPackets.first) - #expect(sent.type == MessageType.fragment.rawValue) - #expect(sent.payload.prefix(8) == wantedID) - #expect(sent.ttl == 0) - #expect(sent.isRSR) - } - - @Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws { - var config = GossipSyncManager.Config() - config.messageSyncIntervalSeconds = 0 - config.fragmentSyncIntervalSeconds = 0 - config.fileTransferSyncIntervalSeconds = 0 - let requestSyncManager = RequestSyncManager() - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) - let delegate = RecordingDelegate() - delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")] - manager.delegate = delegate - - let stalledID = try #require(Data(hexString: "0102030405060708")) - manager.requestMissingFragments(fragmentIDs: [stalledID]) - - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) - let sent = try #require(delegate.packets.first) - #expect(sent.type == MessageType.requestSync.rawValue) - #expect(sent.ttl == 0) - let request = try #require(RequestSyncPacket.decode(from: sent.payload)) - #expect(request.types == .fragment) - let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter)) - #expect(ids == Set([stalledID])) - } - - @Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws { - var config = GossipSyncManager.Config() - config.messageSyncIntervalSeconds = 0 - config.fragmentSyncIntervalSeconds = 0 - config.fileTransferSyncIntervalSeconds = 0 - config.prekeyBundleSyncIntervalSeconds = 0 - config.stalePeerCleanupIntervalSeconds = 0 - config.stalePeerTimeoutSeconds = 5 - - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) - let delegate = RecordingDelegate() - manager.delegate = delegate - - // Bundles are keyed by their authenticated identity (the noise static - // key), not the packet senderID, so the payload must be a real bundle. - let noiseKey = Data(repeating: 0xAB, count: 32) - let senderPeer = PeerID(publicKey: noiseKey) - let sender = try #require(Data(hexString: senderPeer.id)) - let bundle = PrekeyBundle( - noiseStaticPublicKey: noiseKey, - prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x11, count: 32))], - generatedAt: UInt64(Date().timeIntervalSince1970 * 1000), - signature: Data(count: PrekeyBundle.signatureLength) - ) - let bundlePacket = BitchatPacket( - type: MessageType.prekeyBundle.rawValue, - senderID: sender, - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: try #require(bundle.encode()), - signature: nil, - ttl: 1 - ) - manager.onPublicPacketSeen(bundlePacket) - manager._performMaintenanceSynchronously(now: Date()) - #expect(manager._hasPrekeyBundle(for: senderPeer)) - - // Bundles outlive the owner's announce: a leave plus stale cleanup - // must not drop them (they exist to reach offline owners). - manager.removeAnnouncementForPeer(senderPeer) - manager._performMaintenanceSynchronously(now: Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)) - #expect(manager._hasPrekeyBundle(for: senderPeer)) - - // And a .prekeyBundle sync request is answered with the stored packet. - let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle) - manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) - let served = try #require(delegate.packets.first) - #expect(served.type == MessageType.prekeyBundle.rawValue) - #expect(served.isRSR) - } - - @Test func prekeyBundleGossipIsKeyedByOwnerNotSenderID() { - // One valid bundle re-broadcast under many fabricated sender IDs must - // collapse to a single entry keyed by the bundle's own identity — the - // spray-to-exhaust-the-cap DoS produces one entry, not N. - let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: RequestSyncManager()) - let noiseKey = Data(repeating: 0xCD, count: 32) - let ownerPeer = PeerID(publicKey: noiseKey) - let bundle = PrekeyBundle( - noiseStaticPublicKey: noiseKey, - prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x22, count: 32))], - generatedAt: UInt64(Date().timeIntervalSince1970 * 1000), - signature: Data(count: PrekeyBundle.signatureLength) - ) - guard let payload = bundle.encode() else { return } - - for i in 0..<5 { - let fakeSender = Data((0..<8).map { j in UInt8(truncatingIfNeeded: i * 31 + j) }) - let packet = BitchatPacket( - type: MessageType.prekeyBundle.rawValue, - senderID: fakeSender, - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(i), - payload: payload, - signature: nil, - ttl: 1 - ) - manager.onPublicPacketSeen(packet) - manager._performMaintenanceSynchronously(now: Date()) - // No fabricated sender ID ever creates its own entry. - #expect(!manager._hasPrekeyBundle(for: PeerID(hexData: fakeSender))) - } - // Exactly the owner-keyed entry exists. - #expect(manager._hasPrekeyBundle(for: ownerPeer)) - } - - // MARK: - Archive persistence - - @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let senderID = try #require(Data(hexString: "1122334455667788")) - let packet = BitchatPacket( - type: MessageType.message.rawValue, - senderID: senderID, - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data([0x01, 0x02]), - signature: nil, - ttl: 1 - ) - - let first = GossipSyncManager( - myPeerID: myPeerID, - requestSyncManager: RequestSyncManager(), - archive: GossipMessageArchive(fileURL: fileURL) - ) - first.onPublicPacketSeen(packet) - // Maintenance persists the dirty store to disk. - first._performMaintenanceSynchronously(now: Date()) - #expect(FileManager.default.fileExists(atPath: fileURL.path)) - - // "App restart": a fresh manager over the same archive re-serves it. - let second = GossipSyncManager( - myPeerID: myPeerID, - requestSyncManager: RequestSyncManager(), - archive: GossipMessageArchive(fileURL: fileURL) - ) - let restored = await TestHelpers.waitUntil( - { second._messageCount(for: PeerID(hexData: senderID)) == 1 }, - timeout: TestConstants.settleTimeout - ) - #expect(restored) - } - - @Test func archiveDropsMessagesOlderThanPublicWindow() throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - - var config = GossipSyncManager.Config() - config.publicMessageMaxAgeSeconds = 60 - - let senderID = try #require(Data(hexString: "1122334455667788")) - let stale = BitchatPacket( - type: MessageType.message.rawValue, - senderID: senderID, - recipientID: nil, - timestamp: UInt64((Date().timeIntervalSince1970 - 120) * 1000), - payload: Data([0x01]), - signature: nil, - ttl: 1 - ) - let archive = GossipMessageArchive(fileURL: fileURL) - archive.save([stale.toBinaryData(padding: false)!]) - - let manager = GossipSyncManager( - myPeerID: myPeerID, - config: config, - requestSyncManager: RequestSyncManager(), - archive: archive - ) - manager._performMaintenanceSynchronously(now: Date()) - #expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0) - } - - /// Clearing the mesh timeline must leave nothing behind on disk: a - /// relaunch that restored the archive would undo the clear. - @Test func removeAllPublicMessagesErasesTheArchiveOnDisk() async throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let senderID = try #require(Data(hexString: "1122334455667788")) - let packet = BitchatPacket( - type: MessageType.message.rawValue, - senderID: senderID, - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data([0x01, 0x02]), - signature: nil, - ttl: 1 - ) - - let manager = GossipSyncManager( - myPeerID: myPeerID, - requestSyncManager: RequestSyncManager(), - archive: GossipMessageArchive(fileURL: fileURL) - ) - manager.onPublicPacketSeen(packet) - manager._performMaintenanceSynchronously(now: Date()) - #expect(FileManager.default.fileExists(atPath: fileURL.path)) - - manager.removeAllPublicMessages() - - let erased = await TestHelpers.waitUntil( - { - !FileManager.default.fileExists(atPath: fileURL.path) - && manager._messageCount(for: PeerID(hexData: senderID)) == 0 - }, - timeout: TestConstants.settleTimeout - ) - #expect(erased) - } - } private final class RecordingDelegate: GossipSyncManager.Delegate { var onSend: (() -> Void)? - var connectedPeers: [PeerID] = [] private(set) var lastPacket: BitchatPacket? private(set) var packets: [BitchatPacket] = [] private let lock = NSLock() @@ -875,6 +277,6 @@ private final class RecordingDelegate: GossipSyncManager.Delegate { } func getConnectedPeers() -> [PeerID] { - return connectedPeers + return [] } } diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 70311b49..1907cf33 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -9,19 +9,18 @@ import Foundation import CryptoKit import Testing -@testable import BitFoundation // to avoid unnecessary public's +import BitFoundation @testable import bitchat -@Suite("Integration Tests", .serialized) struct IntegrationTests { private var helper = TestNetworkHelper() init() { - helper.createNode("Alice") - helper.createNode("Bob") - helper.createNode("Charlie") - helper.createNode("David") + helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString)) + helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString)) + helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString)) + helper.createNode("David", peerID: PeerID(str: UUID().uuidString)) } // MARK: - Multi-Peer Scenarios @@ -175,7 +174,7 @@ struct IntegrationTests { } // Encrypted path: use NoiseSessionManager explicitly - let plaintext = Data("Encrypted message".utf8) + let plaintext = "Encrypted message".data(using: .utf8)! let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID) helper.nodes["Bob"]!.packetDeliveryHandler = { packet in @@ -207,7 +206,7 @@ struct IntegrationTests { try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in // David tracks received messages - helper.nodes["David"]!.messageDeliveryHandler = { _ in + helper.nodes["David"]!.messageDeliveryHandler = { message in completion() } @@ -266,27 +265,15 @@ struct IntegrationTests { helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob") } - // Simulate Bob restart by recreating his Noise manager. A new static - // key means a new key-derived wire ID, just like production. + // Simulate Bob restart by recreating his Noise manager let bobKey = Curve25519.KeyAgreement.PrivateKey() helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain) - helper.nodes["Bob"]!.myPeerID = PeerID(publicKey: bobKey.publicKey.rawRepresentation) // Re-establish Noise handshake explicitly via managers do { let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID) - let m2 = try #require( - try helper.noiseManagers["Alice"]!.handleIncomingHandshake( - from: helper.nodes["Bob"]!.peerID, - message: m1 - ) - ) - let m3 = try #require( - try helper.noiseManagers["Bob"]!.handleIncomingHandshake( - from: helper.nodes["Alice"]!.peerID, - message: m2 - ) - ) + let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)! + let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)! _ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3) } catch { Issue.record("Failed to re-establish Noise session after restart: \(error)") @@ -301,7 +288,7 @@ struct IntegrationTests { } do { - let plaintext = Data("After restart success".utf8) + let plaintext = "After restart success".data(using: .utf8)! let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID) let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext) helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in @@ -322,7 +309,7 @@ struct IntegrationTests { @Test func largeScaleNetwork() async throws { // Create larger network for i in 5...10 { - helper.createNode("Node\(i)") + helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)")) } // Connect in ring topology with cross-connections diff --git a/bitchatTests/Integration/LargeTopologyTests.swift b/bitchatTests/Integration/LargeTopologyTests.swift deleted file mode 100644 index a87be438..00000000 --- a/bitchatTests/Integration/LargeTopologyTests.swift +++ /dev/null @@ -1,220 +0,0 @@ -// -// LargeTopologyTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -@testable import BitFoundation // to avoid unnecessary public's -@testable import bitchat - -/// Production-shaped topology tests: deep relay chains, larger partial meshes, -/// partitions, and churn. -/// -/// Determinism: `MockBLEService` flooding is fully synchronous — `sendMessage` -/// recurses through `simulateIncomingPacket` across the whole connected -/// component before returning — so every test here sends, then asserts, with -/// no waits, sleeps, or confirmations needed. -struct LargeTopologyTests { - - private let helper = TestNetworkHelper() - - /// content -> receiver name -> delivery count. - /// Delivery in the mock harness is single-threaded and synchronous, so - /// plain (unlocked) mutation is safe here. - private final class DeliveryLog { - private(set) var counts: [String: [String: Int]] = [:] - func record(receiver: String, content: String) { - counts[content, default: [:]][receiver, default: 0] += 1 - } - func deliveries(of content: String) -> [String: Int] { - counts[content] ?? [:] - } - } - - private func makeNodes(_ names: [String]) { - for name in names { - helper.createNode(name) - } - } - - /// Installs a counting message handler on every node. Local echo does not - /// trigger `messageDeliveryHandler`, so the sender of a broadcast records - /// no delivery for its own message. - private func installDeliveryLog() -> DeliveryLog { - let log = DeliveryLog() - for (name, node) in helper.nodes { - node.messageDeliveryHandler = { message in - log.record(receiver: name, content: message.content) - } - } - return log - } - - // MARK: - Deep chain - - @Test func deepChainBroadcastTraversesAllHopsWithTTLDecay() { - // A - B - C - D - E - F - G - H (7 hops end to end) - let chain = ["A", "B", "C", "D", "E", "F", "G", "H"] - makeNodes(chain) - for i in 0..<(chain.count - 1) { - helper.connect(chain[i], chain[i + 1]) - } - - let log = installDeliveryLog() - - // Record the highest TTL each node observes for the broadcast. The - // shortest-path arrival carries the highest TTL (back-floods from - // further down the chain have decayed further), and unlike - // "first handler call" this is independent of recursion order — the - // mock invokes packetDeliveryHandler after its recursive flood, so - // innermost calls fire first. - var maxSeenTTL: [String: UInt8] = [:] - for name in chain.dropFirst() { - helper.nodes[name]!.packetDeliveryHandler = { packet in - if let message = BitchatMessage(packet.payload), - message.content == "deep chain probe" { - maxSeenTTL[name] = max(maxSeenTTL[name] ?? 0, packet.ttl) - } - } - } - - helper.nodes["A"]!.sendMessage("deep chain probe") - - // Reachability: the broadcast relays across all 7 hops and lands - // exactly once at every other node (seenMessageIDs dedup). - let deliveries = log.deliveries(of: "deep chain probe") - for name in chain.dropFirst() { - #expect(deliveries[name] == 1, "\(name) should receive the broadcast exactly once") - } - #expect(deliveries["A"] == nil, "sender must not receive its own broadcast") - - // TTL-modeling gap: the mock decrements and clamps TTL while flooding, - // but it intentionally does NOT stop relaying when TTL reaches 0 — - // MockBLEService floods the entire connected component and relies on - // seenMessageIDs dedup (see the comment in simulateIncomingPacket). - // Production BLEService enforces TTL and drops packets at 0 (max 7 - // hops), so this 7-hop chain only succeeds end to end because the mock - // does not enforce TTL. We therefore assert decay and clamping here, - // not enforcement. - let initialTTL = helper.nodes["A"]!.sentPackets.last!.ttl - var previousTTL = initialTTL - for (hop, name) in chain.dropFirst().enumerated() { - // The first hop carries the original TTL (sendMessage delivers the - // packet unmodified); each subsequent relay decrements, clamped at 0. - let expected = initialTTL > UInt8(hop) ? initialTTL - UInt8(hop) : 0 - let observed = maxSeenTTL[name] - #expect(observed == expected, "\(name) expected shortest-path TTL \(expected), got \(String(describing: observed))") - if let observed { - #expect(observed <= initialTTL, "TTL must never exceed the initial TTL") - #expect(observed <= previousTTL, "TTL must be non-increasing along the chain") - previousTTL = observed - } - } - } - - // MARK: - Larger partial mesh - - @Test func partialMeshBroadcastReachesAllExactlyOnce() { - // 14 peers in a ring with chord edges: redundant paths create cycles, - // so duplicate suppression (seenMessageIDs) is genuinely exercised. - let names = (0..<14).map { String(format: "M%02d", $0) } - makeNodes(names) - for i in 0.. - // components {C8, C9, C0, C1, C2} and {C3, C4, C5, C6, C7}. - helper.disconnect("C2", "C3") - helper.disconnect("C7", "C8") - helper.nodes["C0"]!.sendMessage("split round") - var deliveries = log.deliveries(of: "split round") - for name in ["C1", "C2", "C8", "C9"] { - #expect(deliveries[name] == 1, "\(name) is in C0's component and should receive exactly once") - } - for i in 3...7 { #expect(deliveries["C\(i)"] == nil, "C\(i) is in the other component") } - - // Round 2: restore the ring; a broadcast from the far side reaches all. - helper.connect("C2", "C3") - helper.connect("C7", "C8") - helper.nodes["C5"]!.sendMessage("healed round") - deliveries = log.deliveries(of: "healed round") - for name in names where name != "C5" { - #expect(deliveries[name] == 1, "\(name) should receive exactly once after the ring heals") - } - #expect(deliveries["C5"] == nil, "sender must not receive its own broadcast") - - // Round 3: fully isolate C9; everyone else still receives. - helper.disconnect("C8", "C9") - helper.disconnect("C9", "C0") - helper.nodes["C0"]!.sendMessage("isolated round") - deliveries = log.deliveries(of: "isolated round") - for i in 1...8 { #expect(deliveries["C\(i)"] == 1, "C\(i) remains connected and should receive") } - #expect(deliveries["C9"] == nil, "isolated node must not receive") - - // Round 4: rejoin C9; a broadcast from the rejoined node reaches all. - helper.connect("C9", "C0") - helper.nodes["C9"]!.sendMessage("rejoined round") - deliveries = log.deliveries(of: "rejoined round") - for i in 0...8 { #expect(deliveries["C\(i)"] == 1, "C\(i) should receive from the rejoined node") } - #expect(deliveries["C9"] == nil, "sender must not receive its own broadcast") - } -} diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index e11d1917..3b633e54 100644 --- a/bitchatTests/Integration/TestNetworkHelper.swift +++ b/bitchatTests/Integration/TestNetworkHelper.swift @@ -8,8 +8,7 @@ import Foundation import CryptoKit -import Testing -@testable import BitFoundation // to avoid unnecessary public's +import BitFoundation @testable import bitchat final class TestNetworkHelper { @@ -22,25 +21,26 @@ final class TestNetworkHelper { // MARK: - Node/Manager management @discardableResult - func createNode(_ name: String) -> MockBLEService { + func createNode(_ name: String, peerID: PeerID) -> MockBLEService { let node = MockBLEService(bus: bus) - // Wire IDs must derive from the node's Noise static key: handshake - // completion fails closed on IDs the remote key can't vouch for. - let key = Curve25519.KeyAgreement.PrivateKey() - node.myPeerID = PeerID(publicKey: key.publicKey.rawRepresentation) + node.myPeerID = peerID node.mockNickname = name nodes[name] = node - - // This synchronous helper directly drives all three XX messages and - // has no transport callback loop for delayed collision recovery. - noiseManagers[name] = NoiseSessionManager( - localStaticKey: key, - keychain: mockKeychain, - recentInitiatorCompletionGracePeriod: 0 - ) + + // Create/replace Noise manager for this node + let key = Curve25519.KeyAgreement.PrivateKey() + noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain) return node } + func getNode(_ name: String) -> MockBLEService? { + nodes[name] + } + + func getManager(_ name: String) -> NoiseSessionManager? { + noiseManagers[name] + } + // MARK: - Topology func connect(_ a: String, _ b: String) { @@ -116,18 +116,9 @@ final class TestNetworkHelper { let peer2ID = nodes[node2]?.peerID else { return } let msg1 = try manager1.initiateHandshake(with: peer2ID) - let msg2 = try #require( - try manager2.handleIncomingHandshake( - from: peer1ID, - message: msg1 - ) - ) - let msg3 = try #require( - try manager1.handleIncomingHandshake( - from: peer2ID, - message: msg2 - ) - ) + let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)! + let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)! _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) } } + diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/KeychainErrorHandlingTests.swift b/bitchatTests/KeychainErrorHandlingTests.swift similarity index 67% rename from localPackages/BitFoundation/Tests/BitFoundationTests/KeychainErrorHandlingTests.swift rename to bitchatTests/KeychainErrorHandlingTests.swift index 5d5f94f6..ad48c1e3 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/KeychainErrorHandlingTests.swift +++ b/bitchatTests/KeychainErrorHandlingTests.swift @@ -9,7 +9,7 @@ import Testing import Foundation -import BitFoundation +@testable import bitchat struct KeychainErrorHandlingTests { @@ -145,6 +145,67 @@ struct KeychainErrorHandlingTests { throw KeychainTestError("Expected save success, got \(saveResult)") } } + + // MARK: - NoiseEncryptionService Integration Tests + + @Test func noiseEncryptionService_generatesNewIdentityWhenMissing() throws { + let keychain = MockKeychain() + + // Create service with empty keychain - should generate new identity + let service = NoiseEncryptionService(keychain: keychain) + + // Should have generated and saved keys + #expect(service.getStaticPublicKeyData().count == 32) + #expect(service.getSigningPublicKeyData().count == 32) + + // Keys should be persisted + let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") + switch noiseKeyResult { + case .success: + // Expected - key was saved + break + default: + throw KeychainTestError("Expected noise key to be saved") + } + } + + @Test func noiseEncryptionService_loadsExistingIdentity() throws { + let keychain = MockKeychain() + + // Create first service to generate identity + let service1 = NoiseEncryptionService(keychain: keychain) + let originalPublicKey = service1.getStaticPublicKeyData() + let originalSigningKey = service1.getSigningPublicKeyData() + + // Create second service - should load same identity + let service2 = NoiseEncryptionService(keychain: keychain) + + #expect(service2.getStaticPublicKeyData() == originalPublicKey) + #expect(service2.getSigningPublicKeyData() == originalSigningKey) + } + + @Test func noiseEncryptionService_handlesAccessDeniedGracefully() throws { + let keychain = MockKeychain() + keychain.simulatedReadError = .accessDenied + + // Service should still initialize with ephemeral key + let service = NoiseEncryptionService(keychain: keychain) + + // Should have an identity (ephemeral) + #expect(service.getStaticPublicKeyData().count == 32) + #expect(service.getSigningPublicKeyData().count == 32) + } + + @Test func noiseEncryptionService_handlesDeviceLockedGracefully() throws { + let keychain = MockKeychain() + keychain.simulatedReadError = .deviceLocked + + // Service should still initialize with ephemeral key + let service = NoiseEncryptionService(keychain: keychain) + + // Should have an identity (ephemeral) + #expect(service.getStaticPublicKeyData().count == 32) + } } // Helper error type for tests diff --git a/bitchatTests/LocalizationCoverageTests.swift b/bitchatTests/LocalizationCoverageTests.swift deleted file mode 100644 index 8748b960..00000000 --- a/bitchatTests/LocalizationCoverageTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Testing -import Foundation - -/// Guards against locale gaps in the string catalogs: every translatable key -/// must have a localization for every supported locale, so no user ever sees -/// an English fallback (see PR #1391 review). -struct LocalizationCoverageTests { - private static let repoRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // bitchatTests - .deletingLastPathComponent() // repo root - - private struct Catalog { - /// key -> set of locales with a localization entry - let coverage: [String: Set] - /// all locales appearing anywhere in the catalog - var allLocales: Set { coverage.values.reduce(into: []) { $0.formUnion($1) } } - } - - private static func loadCatalog(_ relativePath: String) throws -> Catalog { - let url = repoRoot.appendingPathComponent(relativePath) - let data = try Data(contentsOf: url) - let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - let strings = try #require(root["strings"] as? [String: Any]) - - var coverage: [String: Set] = [:] - for (key, value) in strings { - guard let entry = value as? [String: Any] else { continue } - if entry["shouldTranslate"] as? Bool == false { continue } - let localizations = entry["localizations"] as? [String: Any] ?? [:] - var locales: Set = [] - for (locale, loc) in localizations { - guard let loc = loc as? [String: Any] else { continue } - // A localization counts if it has a non-empty stringUnit value - // or uses variations/substitutions (plural forms). - if let unit = loc["stringUnit"] as? [String: Any], - let unitValue = unit["value"] as? String, !unitValue.isEmpty { - locales.insert(locale) - } else if loc["variations"] != nil || loc["substitutions"] != nil { - locales.insert(locale) - } - } - coverage[key] = locales - } - return Catalog(coverage: coverage) - } - - @Test func mainCatalogCoversAllLocalesForEveryKey() throws { - let catalog = try Self.loadCatalog("bitchat/Localizable.xcstrings") - let expected = catalog.allLocales - #expect(expected.count > 1, "catalog should declare more locales than the source language") - for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) { - let missing = expected.subtracting(locales).sorted() - #expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))") - } - } - - @Test func shareExtensionCatalogCoversAllLocalesForEveryKey() throws { - let catalog = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings") - let expected = catalog.allLocales - for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) { - let missing = expected.subtracting(locales).sorted() - #expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))") - } - } - - @Test func shareExtensionSupportsSameLocalesAsMainApp() throws { - let main = try Self.loadCatalog("bitchat/Localizable.xcstrings") - let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings") - let missing = main.allLocales.subtracting(shareExt.allLocales).sorted() - #expect(missing.isEmpty, "share extension is missing locales: \(missing.joined(separator: ", "))") - } - - /// The catalog tests above validate the CATALOG; this one validates the - /// CODE. A `String(localized:)` whose key is absent from every catalog - /// compiles and runs fine — it just silently ships its English - /// `defaultValue` to all 29 non-source locales. That blind spot let the - /// entire notices/board composer (10 keys), two delivery states, and two - /// media-failure reasons go untranslated while the coverage tests stayed - /// green. Interpolated keys can't be checked statically and are skipped; - /// every literal key must resolve. - @Test func everyCodeReferencedKeyExistsInACatalog() throws { - let main = try Self.loadCatalog("bitchat/Localizable.xcstrings") - let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings") - let knownKeys = Set(main.coverage.keys).union(shareExt.coverage.keys) - - let pattern = try NSRegularExpression(pattern: #"String\(\s*localized:\s*"([^"\\]+)""#) - var missing: [String] = [] - - for sourceRoot in ["bitchat", "bitchatShareExtension"] { - let rootURL = Self.repoRoot.appendingPathComponent(sourceRoot) - let enumerator = try #require(FileManager.default.enumerator( - at: rootURL, - includingPropertiesForKeys: nil - )) - for case let fileURL as URL in enumerator where fileURL.pathExtension == "swift" { - let source = try String(contentsOf: fileURL, encoding: .utf8) - let range = NSRange(source.startIndex..., in: source) - pattern.enumerateMatches(in: source, range: range) { match, _, _ in - guard let match, let keyRange = Range(match.range(at: 1), in: source) else { return } - let key = String(source[keyRange]) - if !knownKeys.contains(key) { - missing.append("\(key) (\(fileURL.lastPathComponent))") - } - } - } - } - - #expect( - missing.isEmpty, - "keys referenced in code but absent from every catalog — these ship English to all non-source locales: \(missing.sorted().joined(separator: ", "))" - ) - } -} diff --git a/bitchatTests/LocationNotesManagerTests.swift b/bitchatTests/LocationNotesManagerTests.swift index abd16e3d..f8efb50a 100644 --- a/bitchatTests/LocationNotesManagerTests.swift +++ b/bitchatTests/LocationNotesManagerTests.swift @@ -1,5 +1,4 @@ import Testing -import Combine import Foundation @testable import bitchat @@ -27,41 +26,6 @@ struct LocationNotesManagerTests { #expect(manager.errorMessage == String(localized: "location_notes.error.no_relays")) } - @Test - func noRelays_resubscribesWhenDirectoryRefreshes() async throws { - var relays: [String] = [] - var subscribeCount = 0 - let directorySubject = PassthroughSubject() - let deps = LocationNotesDependencies( - relayLookup: { _, _ in relays }, - subscribe: { _, _, _, _, _ in - subscribeCount += 1 - }, - unsubscribe: { _ in }, - sendEvent: { _, _ in }, - deriveIdentity: { _ in try NostrIdentity.generate() }, - now: { Date() }, - relayDirectoryUpdates: directorySubject.eraseToAnyPublisher() - ) - - let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) - #expect(manager.state == .noRelays) - #expect(subscribeCount == 0) - - // Directory loads later (e.g. remote fetch finished after Tor came up). - relays = ["wss://relay.one"] - directorySubject.send(()) - - let deadline = Date().addingTimeInterval(1.0) - while manager.state == .noRelays && Date() < deadline { - try await Task.sleep(nanoseconds: 10_000_000) - } - - #expect(subscribeCount == 1) - #expect(manager.state == .loading) - #expect(manager.errorMessage == nil) - } - @Test func sendWithoutRelays_surfacesNoRelaysError() { var sendCalled = false @@ -122,6 +86,27 @@ struct LocationNotesManagerTests { #expect(manager.notes.first?.content == "hi") } + @Test + func setGeohash_invalidValueIsIgnored() { + var subscribeCount = 0 + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, _ in + subscribeCount += 1 + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in try NostrIdentity.generate() }, + now: { Date() } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + manager.setGeohash("not-valid") + + #expect(manager.geohash == "u4pruydq") + #expect(subscribeCount == 1) + } + @Test func refreshAndCancel_manageSubscriptions() { var subscribeIDs: [String] = [] @@ -195,199 +180,6 @@ struct LocationNotesManagerTests { #expect(manager.errorMessage == nil) } - @Test - func ingestDropsExpiredNotesAndKeepsUnexpiredOnes() throws { - var storedHandler: ((NostrEvent) -> Void)? - let now = Date(timeIntervalSince1970: 1_700_000_000) - let deps = LocationNotesDependencies( - relayLookup: { _, _ in ["wss://relay.one"] }, - subscribe: { _, _, _, handler, _ in - storedHandler = handler - }, - unsubscribe: { _ in }, - sendEvent: { _, _ in }, - deriveIdentity: { _ in throw TestError.shouldNotDerive }, - now: { now } - ) - - let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) - let identity = try NostrIdentity.generate() - - let expired = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: now.addingTimeInterval(-3600), - kind: .textNote, - tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) - 60)]], - content: "gone" - ) - storedHandler?(try expired.sign(with: identity.schnorrSigningKey())) - - let live = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: now.addingTimeInterval(-3600), - kind: .textNote, - tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) + 3600)]], - content: "still here" - ) - storedHandler?(try live.sign(with: identity.schnorrSigningKey())) - - #expect(manager.notes.count == 1) - #expect(manager.notes.first?.content == "still here") - #expect(manager.notes.first?.expiresAt == Date(timeIntervalSince1970: TimeInterval(Int(now.timeIntervalSince1970) + 3600))) - } - - @Test - func postDrop_sendsExpiringNoteToGeoRelays() throws { - var sentEvents: [NostrEvent] = [] - let now = Date(timeIntervalSince1970: 1_700_000_000) - let identity = try NostrIdentity.generate() - let deps = LocationNotesDependencies( - relayLookup: { _, _ in ["wss://relay.one"] }, - subscribe: { _, _, _, _, _ in }, - unsubscribe: { _ in }, - sendEvent: { event, _ in sentEvents.append(event) }, - deriveIdentity: { _ in identity }, - now: { now } - ) - - let posted = LocationNotesManager.postDrop( - content: " the coffee here is great ", - nickname: "scout", - geohash: "u4pruydq", - dependencies: deps - ) - - #expect(posted) - #expect(sentEvents.count == 1) - let event = try #require(sentEvents.first) - #expect(event.kind == NostrProtocol.EventKind.textNote.rawValue) - #expect(event.content == "the coffee here is great") - #expect(event.tags.contains(["g", "u4pruydq"])) - let expiration = event.tags.first { $0.first == "expiration" }?.last - let expected = Int(now.addingTimeInterval(TransportConfig.locationDropExpirySeconds).timeIntervalSince1970) - #expect(expiration == String(expected)) - } - - @Test - func postDrop_failsWithoutRelays() { - let deps = LocationNotesDependencies( - relayLookup: { _, _ in [] }, - subscribe: { _, _, _, _, _ in }, - unsubscribe: { _ in }, - sendEvent: { _, _ in }, - deriveIdentity: { _ in throw TestError.shouldNotDerive }, - now: { Date() } - ) - - #expect(!LocationNotesManager.postDrop(content: "hi", nickname: "x", geohash: "u4pruydq", dependencies: deps)) - } - - @Test - func pruneExpiredNotes_dropsNotesWhoseExpiryPassed() throws { - var storedHandler: ((NostrEvent) -> Void)? - var currentNow = Date(timeIntervalSince1970: 1_700_000_000) - let deps = LocationNotesDependencies( - relayLookup: { _, _ in ["wss://relay.one"] }, - subscribe: { _, _, _, handler, _ in - storedHandler = handler - }, - unsubscribe: { _ in }, - sendEvent: { _, _ in }, - deriveIdentity: { _ in throw TestError.shouldNotDerive }, - now: { currentNow } - ) - - let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) - let identity = try NostrIdentity.generate() - let note = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: currentNow, - kind: .textNote, - tags: [["g", "u4pruydq"], ["expiration", String(Int(currentNow.timeIntervalSince1970) + 60)]], - content: "short lived" - ) - storedHandler?(try note.sign(with: identity.schnorrSigningKey())) - #expect(manager.notes.count == 1) - - currentNow = currentNow.addingTimeInterval(120) - manager.pruneExpiredNotes() - - #expect(manager.notes.isEmpty) - } - - @Test - func eoseWithoutConnectedRelays_showsConnectingInsteadOfEmpty() { - var storedEOSE: (() -> Void)? - var deps = LocationNotesDependencies( - relayLookup: { _, _ in ["wss://relay.one"] }, - subscribe: { _, _, _, _, eose in storedEOSE = eose }, - unsubscribe: { _ in }, - sendEvent: { _, _ in }, - deriveIdentity: { _ in throw TestError.shouldNotDerive }, - now: { Date() } - ) - deps.anyRelayConnected = { _ in false } - - let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) - storedEOSE?() - - #expect(manager.state == .connecting) - #expect(manager.initialLoadComplete) - } - - @Test - func eoseWithConnectedRelayAndNoNotes_isReadyEmpty() { - var storedEOSE: (() -> Void)? - var deps = LocationNotesDependencies( - relayLookup: { _, _ in ["wss://relay.one"] }, - subscribe: { _, _, _, _, eose in storedEOSE = eose }, - unsubscribe: { _ in }, - sendEvent: { _, _ in }, - deriveIdentity: { _ in throw TestError.shouldNotDerive }, - now: { Date() } - ) - deps.anyRelayConnected = { _ in true } - - let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) - storedEOSE?() - - #expect(manager.state == .ready) - } - - @Test - func connectingState_retriesOnceARelayComesUp() { - var storedEOSE: (() -> Void)? - var subscribeCount = 0 - var relayUp = false - var deps = LocationNotesDependencies( - relayLookup: { _, _ in ["wss://relay.one"] }, - subscribe: { _, _, _, _, eose in - subscribeCount += 1 - storedEOSE = eose - }, - unsubscribe: { _ in }, - sendEvent: { _, _ in }, - deriveIdentity: { _ in throw TestError.shouldNotDerive }, - now: { Date() } - ) - deps.anyRelayConnected = { _ in relayUp } - - let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) - #expect(subscribeCount == 1) - storedEOSE?() - #expect(manager.state == .connecting) - - // Relay still down: no retry. - manager.retryIfRelaysAvailable(relays: ["wss://relay.one"]) - #expect(subscribeCount == 1) - - // Relay up: re-subscribes for a fresh initial fetch. - relayUp = true - manager.retryIfRelaysAvailable(relays: ["wss://relay.one"]) - #expect(subscribeCount == 2) - #expect(manager.state == .loading) - } - private enum TestError: Error { case shouldNotDerive } diff --git a/bitchatTests/MessageDeduplicationServiceTests.swift b/bitchatTests/MessageDeduplicationServiceTests.swift index 971c17f0..251fe808 100644 --- a/bitchatTests/MessageDeduplicationServiceTests.swift +++ b/bitchatTests/MessageDeduplicationServiceTests.swift @@ -312,30 +312,6 @@ struct MessageDeduplicationServiceTests { #expect(service.contentTimestamp(forKey: key) == now) } - @Test func forgetContent_allowsAuthenticatedReplacementThroughNextBatch() { - let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) - let content = "bridge alias payload" - let timestamp = Date() - service.recordContent(content, timestamp: timestamp) - - service.forgetContent(content, ifRecordedAt: timestamp) - - #expect(service.contentTimestamp(for: content) == nil) - } - - @Test func forgetContent_doesNotEraseNewerSameContentMarker() { - let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) - let content = "repeated payload" - let old = Date(timeIntervalSince1970: 1_000) - let newer = Date(timeIntervalSince1970: 2_000) - service.recordContent(content, timestamp: old) - service.recordContent(content, timestamp: newer) - - service.forgetContent(content, ifRecordedAt: old) - - #expect(service.contentTimestamp(for: content) == newer) - } - @Test func normalizedContentKey_consistentWithNormalizer() { let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100) let content = "Hello World" diff --git a/bitchatTests/MessageFormattingEngineTests.swift b/bitchatTests/MessageFormattingEngineTests.swift index 5de2adea..602ad38d 100644 --- a/bitchatTests/MessageFormattingEngineTests.swift +++ b/bitchatTests/MessageFormattingEngineTests.swift @@ -323,32 +323,6 @@ struct MessageFormattingEngineTests { // Exactly at threshold DOES trigger (uses >= comparison) #expect(content.hasVeryLongToken(threshold: 50)) } - - @Test func isLongForDisplay_doesNotIgnoreCashuLinks() { - let cashu = "cashuA" + String(repeating: "a", count: 40) - let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu - - #expect(content.extractCashuLinks().count == 1) - #expect(content.isLongForDisplay()) - } - - @MainActor - @Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() { - let context = MockMessageFormattingContext(nickname: "carol") - let cashu = "cashuA" + String(repeating: "a", count: 40) - let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100) - let message = BitchatMessage( - id: "long-cashu", - sender: "alice", - content: longContent, - timestamp: Date(timeIntervalSince1970: 1_700_000_999), - isRelay: false - ) - - let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) - - #expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]") - } } @MainActor diff --git a/bitchatTests/MessageRateLimiterTests.swift b/bitchatTests/MessageRateLimiterTests.swift deleted file mode 100644 index d9025beb..00000000 --- a/bitchatTests/MessageRateLimiterTests.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// MessageRateLimiterTests.swift -// bitchatTests -// -// Tests for the public-intake token buckets, including the NIP-13 -// proof-of-work relaxation of the per-sender bucket. -// - -import Foundation -import Testing -@testable import bitchat - -struct MessageRateLimiterTests { - - private func makeLimiter( - senderCapacity: Double = 2, - contentCapacity: Double = 100 - ) -> MessageRateLimiter { - MessageRateLimiter( - senderCapacity: senderCapacity, - senderRefillPerSec: 0.0001, - contentCapacity: contentCapacity, - contentRefillPerSec: 0.0001 - ) - } - - @Test func senderBucketBlocksAfterCapacity() { - var limiter = makeLimiter() - let now = Date() - - let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) - let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now) - let third = limiter.allow(senderKey: "s", contentKey: "c3", now: now) - let otherSender = limiter.allow(senderKey: "other", contentKey: "c4", now: now) - - #expect(first) - #expect(second) - #expect(!third) - #expect(otherSender) - } - - @Test func validPoWBypassesExhaustedSenderBucket() { - var limiter = makeLimiter() - let now = Date() - - // Exhaust the sender bucket with plain (no-PoW) messages. - let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) - let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now) - let exhausted = limiter.allow(senderKey: "s", contentKey: "c3", now: now) - - // A message carrying sufficient validated PoW still passes, and so - // does more-than-sufficient PoW; plain messages stay blocked. - let powExact = limiter.allow( - senderKey: "s", - contentKey: "c4", - powBits: NostrPoW.rateLimitBypassBits, - now: now - ) - let powHigh = limiter.allow(senderKey: "s", contentKey: "c5", powBits: 20, now: now) - let plainAgain = limiter.allow(senderKey: "s", contentKey: "c6", now: now) - - #expect(first) - #expect(second) - #expect(!exhausted) - #expect(powExact) - #expect(powHigh) - #expect(!plainAgain) - } - - @Test func lowPoWDoesNotBypassSenderBucket() { - var limiter = makeLimiter(senderCapacity: 1) - let now = Date() - - let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) - let lowPow = limiter.allow( - senderKey: "s", - contentKey: "c2", - powBits: NostrPoW.rateLimitBypassBits - 1, - now: now - ) - let zeroPow = limiter.allow(senderKey: "s", contentKey: "c3", powBits: 0, now: now) - - #expect(first) - #expect(!lowPow) - #expect(!zeroPow) - } - - @Test func powDoesNotBypassContentFloodBucket() { - var limiter = makeLimiter(senderCapacity: 100, contentCapacity: 1) - let now = Date() - - let first = limiter.allow(senderKey: "a", contentKey: "same", now: now) - // Identical content spammed with PoW is still throttled by the - // content bucket: PoW only relaxes the per-sender limit. - let powSameContent = limiter.allow(senderKey: "b", contentKey: "same", powBits: 20, now: now) - let powNewContent = limiter.allow(senderKey: "b", contentKey: "different", powBits: 20, now: now) - - #expect(first) - #expect(!powSameContent) - #expect(powNewContent) - } - - @Test func powBypassDoesNotDrainSenderBucket() { - var limiter = makeLimiter(senderCapacity: 1) - let now = Date() - - // PoW messages don't consume sender tokens, so a subsequent plain - // message still has its full budget. - let powFirst = limiter.allow(senderKey: "s", contentKey: "c1", powBits: 20, now: now) - let powSecond = limiter.allow(senderKey: "s", contentKey: "c2", powBits: 20, now: now) - let plain = limiter.allow(senderKey: "s", contentKey: "c3", now: now) - let plainExhausted = limiter.allow(senderKey: "s", contentKey: "c4", now: now) - - #expect(powFirst) - #expect(powSecond) - #expect(plain) - #expect(!plainExhausted) - } - - @Test("Content buckets do not grow when sender is rate limited") - func contentBucketsDoNotGrowAfterSenderLimit() { - var limiter = MessageRateLimiter( - senderCapacity: 1, - senderRefillPerSec: 0, - contentCapacity: 1, - contentRefillPerSec: 0, - maxSenderBuckets: 10, - maxContentBuckets: 10, - bucketIdleTTL: 60 - ) - let now = Date() - - let first = limiter.allow(senderKey: "sender", contentKey: "content-0", now: now) - var rejected = true - for index in 1...100 { - if limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now) { - rejected = false - } - } - - #expect(first) - #expect(rejected) - #expect(limiter.bucketCountsForTesting.sender == 1) - #expect(limiter.bucketCountsForTesting.content == 1) - } - - @Test("Bucket maps evict entries at configured caps") - func bucketMapsEvictAtConfiguredCaps() { - let maxEntries = 3 - var limiter = MessageRateLimiter( - senderCapacity: 1, - senderRefillPerSec: 0, - contentCapacity: 1, - contentRefillPerSec: 0, - maxSenderBuckets: maxEntries, - maxContentBuckets: maxEntries, - bucketIdleTTL: 60 - ) - let now = Date() - - for index in 0..<25 { - _ = limiter.allow( - senderKey: "sender-\(index)", - contentKey: "content-\(index)", - now: now.addingTimeInterval(TimeInterval(index)) - ) - } - - #expect(limiter.bucketCountsForTesting.sender == maxEntries) - #expect(limiter.bucketCountsForTesting.content == maxEntries) - } - - @Test("PoW bypass still creates content buckets under the cap") - func powBypassCreatesBoundedContentBuckets() { - let maxEntries = 3 - var limiter = MessageRateLimiter( - senderCapacity: 1, - senderRefillPerSec: 0, - contentCapacity: 100, - contentRefillPerSec: 0, - maxSenderBuckets: maxEntries, - maxContentBuckets: maxEntries, - bucketIdleTTL: 60 - ) - let now = Date() - - var allAllowed = true - for index in 0..<10 { - let allowed = limiter.allow( - senderKey: "sender", - contentKey: "content-\(index)", - powBits: NostrPoW.rateLimitBypassBits, - now: now.addingTimeInterval(TimeInterval(index)) - ) - if !allowed { allAllowed = false } - } - - #expect(allAllowed) - #expect(limiter.bucketCountsForTesting.sender == 0) - #expect(limiter.bucketCountsForTesting.content == maxEntries) - } -} diff --git a/bitchatTests/MimeTypeTests.swift b/bitchatTests/MimeTypeTests.swift index b30436f3..a6b20b10 100644 --- a/bitchatTests/MimeTypeTests.swift +++ b/bitchatTests/MimeTypeTests.swift @@ -52,19 +52,19 @@ struct MimeTypeTests { @Test(arguments: [ // === Image types === (MimeType.jpeg, [0xFF, 0xD8, 0xFF]), - (MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), - (MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a" + (MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + (MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a" (MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP" // === Audio types === - (MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3" - (MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, + (MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3" + (MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE" - (MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS" + (MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS" // === Application types === - (MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF" + (MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF" ]) func validSignatures(mime: MimeType, bytes: [UInt8]) throws { let data = Data(bytes) diff --git a/bitchatTests/Mocks/BLEEngineManualScheduler.swift b/bitchatTests/Mocks/BLEEngineManualScheduler.swift deleted file mode 100644 index cfe4d712..00000000 --- a/bitchatTests/Mocks/BLEEngineManualScheduler.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -@testable import bitchat - -/// Manually advanced engine scheduler: deferred work runs when the test -/// advances the clock past its deadline, on the real engine queue (deferred -/// bodies touch engine-confined state), and `advance` returns only after -/// the released work has finished — so assertions that follow observe its -/// engine-side effects without polling. -final class BLEEngineManualScheduler: BLEEngineScheduling, @unchecked Sendable { - private let lock = NSLock() - private var engineQueue: DispatchQueue? - private var now: TimeInterval = 0 - private var pending: [(deadline: TimeInterval, work: DispatchWorkItem)] = [] - - func activate(engineQueue: DispatchQueue) { - lock.withLock { self.engineQueue = engineQueue } - } - - func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) { - lock.withLock { pending.append((now + delay, work)) } - } - - var pendingCount: Int { - lock.withLock { pending.count } - } - - /// Advances the clock, releasing due work in deadline order. - /// Cancellation keeps its production semantics: dispatch skips a - /// cancelled `DispatchWorkItem` at execution. - func advance(by interval: TimeInterval) { - let (due, queue): ([DispatchWorkItem], DispatchQueue?) = lock.withLock { - now += interval - let cutoff = now - let released = pending - .filter { $0.deadline <= cutoff } - .sorted { $0.deadline < $1.deadline } - .map(\.work) - pending.removeAll { $0.deadline <= cutoff } - return (released, engineQueue) - } - guard let queue else { return } - for work in due { - queue.async(execute: work) - } - // Fence: released work (and anything it enqueued) has run before - // the test's next assertion. - queue.sync {} - } -} diff --git a/bitchatTests/Mocks/MockBLEBus.swift b/bitchatTests/Mocks/MockBLEBus.swift index 7496d8db..c4b5cb24 100644 --- a/bitchatTests/Mocks/MockBLEBus.swift +++ b/bitchatTests/Mocks/MockBLEBus.swift @@ -8,6 +8,7 @@ import Foundation import BitFoundation +@testable import bitchat final class MockBLEBus { private var registry: [PeerID: MockBLEService] = [:] diff --git a/bitchatTests/Mocks/MockBLEService.swift b/bitchatTests/Mocks/MockBLEService.swift index a127b3dd..352efa90 100644 --- a/bitchatTests/Mocks/MockBLEService.swift +++ b/bitchatTests/Mocks/MockBLEService.swift @@ -8,7 +8,7 @@ import Foundation import CoreBluetooth -@testable import BitFoundation // to avoid unnecessary public's +import BitFoundation @testable import bitchat /// In-memory BLE test harness used by E2E/Integration tests. @@ -34,7 +34,9 @@ final class MockBLEService: NSObject { weak var delegate: BitchatDelegate? var myPeerID = PeerID(str: "MOCK1234") var myNickname: String = "MockUser" - + + private let mockKeychain = MockKeychain() + // Test-specific properties var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = [] var sentPackets: [BitchatPacket] = [] @@ -48,6 +50,10 @@ final class MockBLEService: NSObject { set { myNickname = newValue } } + var nickname: String { + return myNickname + } + var peerID: PeerID { return myPeerID } @@ -58,6 +64,12 @@ final class MockBLEService: NSObject { self.bus = bus } + // MARK: - Methods matching BLEService + + func setNickname(_ nickname: String) { + self.myNickname = nickname + } + // MARK: - In-memory test bus (for E2E/Integration) /// Registers this instance on first use. @@ -82,6 +94,10 @@ final class MockBLEService: NSObject { return connectedPeers.contains(peerID) } + func peerNickname(peerID: String) -> String? { + "MockPeer_\(peerID)" + } + func getPeerNicknames() -> [PeerID: String] { var nicknames: [PeerID: String] = [:] for peer in connectedPeers { @@ -89,6 +105,10 @@ final class MockBLEService: NSObject { } return nicknames } + + func getPeers() -> [PeerID: String] { + return getPeerNicknames() + } /// Keep local echo synchronous so Swift Testing confirmations observe it deterministically. private func deliverLocalEcho(_ message: BitchatMessage) { @@ -137,6 +157,14 @@ final class MockBLEService: NSObject { } } + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { + // Tests currently ignore file transfer flows; keep stub for protocol conformance. + } + + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { + // Tests currently ignore file transfer flows; keep stub for protocol conformance. + } + func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) { let message = BitchatMessage( id: messageID, @@ -183,6 +211,43 @@ final class MockBLEService: NSObject { } } + func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { + // Mock implementation + } + + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { + // Mock implementation + } + + func sendBroadcastAnnounce() { + // Mock implementation + } + + func getPeerFingerprint(_ peerID: String) -> String? { + return nil + } + + func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { + return .none + } + + func triggerHandshake(with peerID: String) { + // Mock implementation + } + + func emergencyDisconnectAll() { + connectedPeers.removeAll() + delegate?.didUpdatePeerList([]) + } + + func getNoiseService() -> NoiseEncryptionService { + return NoiseEncryptionService(keychain: mockKeychain) + } + + func getFingerprint(for peerID: String) -> String? { + return nil + } + // MARK: - Test Helper Methods func simulateConnectedPeer(_ peerID: PeerID) { @@ -255,6 +320,9 @@ final class MockBLEService: NSObject { } } +// Backward compatibility for older tests +typealias MockSimplifiedBluetoothService = MockBLEService + // MARK: - Helpers extension MockBLEService { diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index 930d57ff..633c388b 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -11,13 +11,18 @@ import BitFoundation @testable import bitchat final class MockIdentityManager: SecureIdentityStateManagerProtocol { + private let keychain: KeychainManagerProtocol private var blockedFingerprints: Set = [] private var blockedNostrPubkeys: Set = [] private var socialIdentities: [String: SocialIdentity] = [:] - private var privateMediaCapableFingerprints: Set = [] - private var authenticatedSigningKeys: [String: Data] = [:] - - init(_: KeychainManagerProtocol) {} + + init(_ keychain: KeychainManagerProtocol) { + self.keychain = keychain + } + + func loadIdentityCache() {} + + func saveIdentityCache() {} func forceSave() {} @@ -40,6 +45,12 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { } } + func getFavorites() -> Set { + Set() + } + + func setFavorite(_ fingerprint: String, isFavorite: Bool) {} + func isFavorite(fingerprint: String) -> Bool { false } @@ -88,76 +99,20 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { } func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} - - func clearAllIdentityData() { - privateMediaCapableFingerprints.removeAll() - authenticatedSigningKeys.removeAll() - } + + func updateHandshakeState(peerID: PeerID, state: HandshakeState) {} + + func clearAllIdentityData() {} func removeEphemeralSession(peerID: PeerID) {} func setVerified(fingerprint: String, verified: Bool) {} - + func isVerified(fingerprint: String) -> Bool { true } - + func getVerifiedFingerprints() -> Set { Set() } - - func markPrivateMediaCapable(fingerprint: String) { - privateMediaCapableFingerprints.insert(fingerprint) - } - - func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { - privateMediaCapableFingerprints.contains(fingerprint) - } - - func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) { - authenticatedSigningKeys[fingerprint] = signingPublicKey - } - - func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { - authenticatedSigningKeys[fingerprint] - } - - // MARK: Vouching (transitive verification) - - private var vouchesByVouchee: [String: [VouchRecord]] = [:] - private var vouchBatchSentAt: [String: Date] = [:] - - @discardableResult - func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { - guard voucheeFingerprint != voucherFingerprint else { return false } - var records = vouchesByVouchee[voucheeFingerprint] ?? [] - records.removeAll { $0.voucherFingerprint == voucherFingerprint } - records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp)) - vouchesByVouchee[voucheeFingerprint] = records - return true - } - - func validVouchers(for fingerprint: String) -> [VouchRecord] { - vouchesByVouchee[fingerprint] ?? [] - } - - func isVouched(fingerprint: String) -> Bool { - !(vouchesByVouchee[fingerprint] ?? []).isEmpty - } - - func lastVouchBatchSent(to fingerprint: String) -> Date? { - vouchBatchSentAt[fingerprint] - } - - func markVouchBatchSent(to fingerprint: String, at date: Date) { - vouchBatchSentAt[fingerprint] = date - } - - func signingPublicKey(forFingerprint fingerprint: String) -> Data? { - nil - } - - func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { - [] - } } diff --git a/bitchatTests/Mocks/MockKeychain.swift b/bitchatTests/Mocks/MockKeychain.swift index d5e99f1d..76b32952 100644 --- a/bitchatTests/Mocks/MockKeychain.swift +++ b/bitchatTests/Mocks/MockKeychain.swift @@ -7,7 +7,6 @@ // import Foundation -import BitFoundation @testable import bitchat final class MockKeychain: KeychainManagerProtocol { @@ -17,9 +16,6 @@ final class MockKeychain: KeychainManagerProtocol { // BCH-01-009: Configurable error simulation for testing var simulatedReadError: KeychainReadResult? var simulatedSaveError: KeychainSaveResult? - var simulatedGenericReadError: KeychainReadResult? - var simulatedDeleteAllResult = true - private(set) var deleteAllCallCount = 0 func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { storage[key] = keyData @@ -36,8 +32,6 @@ final class MockKeychain: KeychainManagerProtocol { } func deleteAllKeychainData() -> Bool { - deleteAllCallCount += 1 - guard simulatedDeleteAllResult else { return false } storage.removeAll() serviceStorage.removeAll() return true @@ -87,23 +81,9 @@ final class MockKeychain: KeychainManagerProtocol { serviceStorage[service]?[key] } - func loadWithResult(key: String, service: String) -> KeychainReadResult { - if let simulatedGenericReadError { - return simulatedGenericReadError - } - if let data = serviceStorage[service]?[key] { - return .success(data) - } - return .itemNotFound - } - func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } - - func deleteAll(service: String) { - serviceStorage.removeValue(forKey: service) - } } /// Typealias for backwards compatibility with tests using MockKeychainHelper @@ -217,8 +197,4 @@ final class TrackingMockKeychain: KeychainManagerProtocol { func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } - - func deleteAll(service: String) { - serviceStorage.removeValue(forKey: service) - } } diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 9587e86f..2f077ed2 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -14,21 +14,20 @@ import BitFoundation /// Mock Transport implementation for testing ChatViewModel in isolation. /// Records all method calls and allows test code to verify interactions. -final class MockTransport: Transport, PrivateMediaDeletionPersisting, - MeshFileTransferring, MeshVerifying, MeshCourierTransporting, - MeshDiagnosing, MeshPublicArchiving, MeshVoiceStreaming, - MeshGroupMessaging, MeshBoardBroadcasting { +final class MockTransport: Transport { // MARK: - Protocol Properties weak var delegate: BitchatDelegate? - weak var eventDelegate: TransportEventDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? var myPeerID: PeerID = PeerID(str: "TESTPEER") var myNickname: String = "TestUser" private let peerSnapshotSubject = CurrentValueSubject<[TransportPeerSnapshot], Never>([]) + var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { + peerSnapshotSubject.eraseToAnyPublisher() + } // MARK: - Recording Properties (for test assertions) @@ -39,44 +38,22 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting, private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = [] private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = [] - private(set) var sentPrivateFileLegacyAllowances: [Bool] = [] private(set) var cancelledTransfers: [String] = [] - private(set) var deletedPrivateMediaMessageIDBatches: [[String]] = [] - private(set) var deletedPrivateMediaRelativePaths: [ - [String: String] - ] = [] - private(set) var protectedPrivateMediaRelativePaths: [Set] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] - private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = [] private(set) var startServicesCallCount = 0 private(set) var stopServicesCallCount = 0 private(set) var emergencyDisconnectCallCount = 0 private(set) var broadcastAnnounceCallCount = 0 private(set) var triggeredHandshakes: [PeerID] = [] - private(set) var purgedArchivePeers: [PeerID] = [] // MARK: - Configurable Mock State var connectedPeers: Set = [] var reachablePeers: Set = [] - /// Peers with an established secure session. `nil` mirrors the protocol - /// default (prompt delivery), so connected peers stay "secure" for tests - /// that never care about the distinction. - var securePeers: Set? var peerNicknames: [PeerID: String] = [:] var peerFingerprints: [PeerID: String] = [:] var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] - var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:] - var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:] - var persistDeletedPrivateMediaResult = true - var deferDeletedPrivateMediaPersistence = false - private var pendingDeletedPrivateMediaCompletions: [ - @MainActor (Bool) -> Void - ] = [] - /// Optional synchronous hook for send-ordering tests (for example, an ack - /// arriving before the router's send call returns). - var onSendPrivateMessage: (@MainActor (_ messageID: String) -> Void)? private let mockKeychain = MockKeychain() // MARK: - Transport Protocol Implementation @@ -111,10 +88,6 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting, reachablePeers.contains(peerID) || connectedPeers.contains(peerID) } - func canDeliverSecurely(to peerID: PeerID) -> Bool { - securePeers?.contains(peerID) ?? canDeliverPromptly(to: peerID) - } - func peerNickname(peerID: PeerID) -> String? { peerNicknames[peerID] } @@ -135,38 +108,8 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting, triggeredHandshakes.append(peerID) } - func purgeArchivedPublicMessages(from peerID: PeerID) { - purgedArchivePeers.append(peerID) - } - - // Noise identity wrappers backed by a mock-keychain encryption service - // (mirrors the previous `getNoiseService()` placeholder behavior: a real - // identity, but no peer sessions). Exposed so tests can assert against - // the same identity the wrappers use. - private(set) lazy var mockNoiseService = NoiseEncryptionService(keychain: mockKeychain) - - func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { - mockNoiseService.getPeerPublicKeyData(peerID) - } - - func noiseIdentityFingerprint() -> String { - mockNoiseService.getIdentityFingerprint() - } - - func noiseStaticPublicKeyData() -> Data { - mockNoiseService.getStaticPublicKeyData() - } - - func noiseSigningPublicKeyData() -> Data { - mockNoiseService.getSigningPublicKeyData() - } - - func noiseSignData(_ data: Data) -> Data? { - mockNoiseService.signData(data) - } - - func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { - mockNoiseService.verifySignature(signature, for: data, publicKey: publicKey) + func getNoiseService() -> NoiseEncryptionService { + NoiseEncryptionService(keychain: mockKeychain) } // MARK: - Messaging @@ -181,11 +124,6 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting, func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { sentPrivateMessages.append((content, peerID, recipientNickname, messageID)) - if let onSendPrivateMessage { - MainActor.assumeIsolated { - onSendPrivateMessage(messageID) - } - } } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { @@ -208,87 +146,14 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting, sentBroadcastFiles.append((packet, transferId)) } - func sendFilePrivate( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool - ) { + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { sentPrivateFiles.append((packet, peerID, transferId)) - sentPrivateFileLegacyAllowances.append(allowLegacyFallback) - } - - func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { - privateMediaPolicies[peerID] ?? .encrypted - } - - func authenticatedPrivateMediaReceiptSessionGeneration( - to peerID: PeerID - ) -> UUID? { - privateMediaReceiptSessionGenerations[peerID] - } - - func resolvePrivateMediaSendPolicy( - to peerID: PeerID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) { - let policy = privateMediaPolicies[peerID] ?? .encrypted - Task { @MainActor in completion(policy) } } func cancelTransfer(_ transferId: String) { cancelledTransfers.append(transferId) } - private(set) var sentFileReceiptRetries: [(BitchatFilePacket, PeerID, String)] = [] - func sendFilePrivateReceiptRetry( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String - ) { - sentFileReceiptRetries.append((packet, peerID, transferId)) - } - - @MainActor - func persistDeletedPrivateMedia( - messageIDs: [String], - payloadRelativePaths: [String: String], - protectedPayloadRelativePaths: Set, - completion: @escaping @MainActor (Bool) -> Void - ) { - deletedPrivateMediaMessageIDBatches.append(messageIDs) - deletedPrivateMediaRelativePaths.append(payloadRelativePaths) - protectedPrivateMediaRelativePaths.append( - protectedPayloadRelativePaths - ) - if deferDeletedPrivateMediaPersistence { - pendingDeletedPrivateMediaCompletions.append(completion) - } else { - completion(persistDeletedPrivateMediaResult) - } - } - - @MainActor - func resolveNextDeletedPrivateMediaPersistence( - _ result: Bool? = nil - ) { - guard !pendingDeletedPrivateMediaCompletions.isEmpty else { return } - let completion = pendingDeletedPrivateMediaCompletions.removeFirst() - completion(result ?? persistDeletedPrivateMediaResult) - } - - /// Real store instance so view-model tests exercise the gated legacy - /// unlink end to end (same Application Support tree the tests write to). - let legacyIncomingFileStore = BLEIncomingFileStore() - private(set) var removedLegacyPrivateMediaPaths: [String] = [] - - func removeLegacyPrivateMediaPayload(relativePath: String) { - removedLegacyPrivateMediaPaths.append(relativePath) - legacyIncomingFileStore.removeLegacyIncomingFile( - relativePath: relativePath - ) - } - func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA)) } @@ -297,77 +162,6 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting, sentVerifyResponses.append((peerID, noiseKeyHex, nonceA)) } - var courierSendResult = true - func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { - sentCourierMessages.append((content, messageID, recipientNoiseKey, couriers)) - return courierSendResult - } - - // MARK: - Mesh Diagnostics - - private(set) var sentMeshPings: [PeerID] = [] - var meshPingResult: MeshPingResult? - var meshPaths: [PeerID: [PeerID]] = [:] - var meshTopologySnapshot: MeshTopologySnapshot? - - func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { - sentMeshPings.append(peerID) - let result = meshPingResult - Task { @MainActor in completion(result) } - } - - func computeMeshPath(to peerID: PeerID) -> [PeerID]? { - meshPaths[peerID] - } - - func currentMeshTopology() -> MeshTopologySnapshot? { - meshTopologySnapshot - } - - // MARK: - Remaining mesh capabilities (recording stubs) - - private(set) var sentVouchAttestations: [(Data, PeerID)] = [] - func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { - sentVouchAttestations.append((payload, peerID)) - } - - var archivedPublicMessages: [ArchivedPublicMessage] = [] - private(set) var purgedAllArchived = false - func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { - let archived = archivedPublicMessages - Task { @MainActor in completion(archived) } - } - func purgeAllArchivedPublicMessages() { - purgedAllArchived = true - } - - private(set) var sentVoiceFrames: [(Data, PeerID)] = [] - private(set) var sentVoiceBroadcasts: [Data] = [] - func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) { - sentVoiceFrames.append((burstContent, peerID)) - } - func sendVoiceFrameBroadcast(_ burstContent: Data) { - sentVoiceBroadcasts.append(burstContent) - } - - private(set) var sentGroupInvites: [(Data, PeerID)] = [] - private(set) var sentGroupKeyUpdates: [(Data, PeerID)] = [] - private(set) var broadcastGroupMessages: [Data] = [] - func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) { - sentGroupInvites.append((statePayload, peerID)) - } - func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) { - sentGroupKeyUpdates.append((statePayload, peerID)) - } - func broadcastGroupMessage(_ envelope: Data) { - broadcastGroupMessages.append(envelope) - } - - private(set) var sentBoardPayloads: [Data] = [] - func sendBoardPayload(_ payload: Data) { - sentBoardPayloads.append(payload) - } - // MARK: - Test Helpers /// Clears all recorded method calls for fresh assertions @@ -380,9 +174,6 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting, sentBroadcastFiles.removeAll() sentPrivateFiles.removeAll() cancelledTransfers.removeAll() - deletedPrivateMediaMessageIDBatches.removeAll() - deletedPrivateMediaRelativePaths.removeAll() - protectedPrivateMediaRelativePaths.removeAll() sentVerifyChallenges.removeAll() sentVerifyResponses.removeAll() startServicesCallCount = 0 diff --git a/bitchatTests/NearbyNotesCounterTests.swift b/bitchatTests/NearbyNotesCounterTests.swift deleted file mode 100644 index 5e328197..00000000 --- a/bitchatTests/NearbyNotesCounterTests.swift +++ /dev/null @@ -1,476 +0,0 @@ -import Combine -import CoreLocation -import XCTest -@testable import bitchat - -/// Tap-to-reveal privacy contract: the nearby-notes counter must not open a -/// building-precision relay REQ until one explicit act calls `reveal()`, and -/// the pooled subscription must come up exactly once and go down exactly once. -@MainActor -final class NearbyNotesCounterTests: XCTestCase { - func test_counterOnlySubscribesAfterReveal_countsUnexpiredNotes_andUnsubscribesOnDeactivate() async throws { - let relays = SubscriptionRecorder() - let settings = LocationNotesSettingsStub() - let locationManager = try await makeAuthorizedLocationManager() - let buildingGeohash = try XCTUnwrap( - locationManager.availableChannels.first(where: { $0.level == .building })?.geohash - ) - - let counter = NearbyNotesCounter( - locationManager: locationManager, - managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, - releaseManager: { $0?.cancel() }, - locationNotesEnabled: { settings.enabled }, - locationNotesSettings: settings.changes - ) - - counter.activate() - // Let the availableChannels replay and any queued retargets land: - // being active is not consent, so still no REQ. - try await Task.sleep(nanoseconds: 50_000_000) - XCTAssertEqual(relays.subscribeCount, 0) - XCTAssertFalse(counter.revealed) - - counter.reveal() - - XCTAssertTrue(counter.revealed) - XCTAssertEqual(relays.subscribeCount, 1) - let gTags = try XCTUnwrap(geohashTagFilter(of: XCTUnwrap(relays.lastFilter))) - XCTAssertEqual(gTags.count, 9) - XCTAssertEqual( - Set(gTags), - Set([buildingGeohash] + Geohash.neighbors(of: buildingGeohash)), - "REQ must cover the building geohash plus its 8 neighbors" - ) - - // An expired NIP-40 note must never count. - let identity = try NostrIdentity.generate() - let now = Date() - let expired = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: now.addingTimeInterval(-3600), - kind: .textNote, - tags: [["g", buildingGeohash], ["expiration", String(Int(now.timeIntervalSince1970) - 60)]], - content: "gone" - ) - relays.lastHandler?(try expired.sign(with: identity.schnorrSigningKey())) - try await Task.sleep(nanoseconds: 50_000_000) - XCTAssertEqual(counter.noteCount, 0) - - // A live matching kind-1 note drives the count to 1. - let live = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: now, - kind: .textNote, - tags: [["g", buildingGeohash]], - content: "still here" - ) - relays.lastHandler?(try live.sign(with: identity.schnorrSigningKey())) - let counted = await waitUntil { counter.noteCount == 1 } - XCTAssertTrue(counted) - // Still exactly one REQ after all the async retarget re-entries. - XCTAssertEqual(relays.subscribeCount, 1) - - counter.deactivate() - - XCTAssertEqual(relays.unsubscribeCount, 1) - XCTAssertEqual(counter.noteCount, 0) - } - - func test_permissionRevocation_releasesBuildingSubscriptionDespiteCachedChannels() async throws { - let relays = SubscriptionRecorder() - let settings = LocationNotesSettingsStub() - let locationManager = try await makeAuthorizedLocationManager() - let counter = NearbyNotesCounter( - locationManager: locationManager, - managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, - releaseManager: { $0?.cancel() }, - locationNotesEnabled: { settings.enabled }, - locationNotesSettings: settings.changes - ) - - counter.activate() - counter.reveal() - XCTAssertEqual(relays.subscribeCount, 1) - - locationManager.locationManager(CLLocationManager(), didChangeAuthorization: .denied) - - let denied = await waitUntil { locationManager.permissionState == .denied } - XCTAssertTrue(denied) - let released = await waitUntil { relays.unsubscribeCount == 1 } - XCTAssertTrue(released) - XCTAssertTrue( - locationManager.availableChannels.contains { $0.level == .building }, - "the privacy boundary must not depend on cached channels being cleared" - ) - XCTAssertEqual(counter.noteCount, 0) - - counter.deactivate() - XCTAssertEqual(relays.unsubscribeCount, 1, "revocation already released the manager") - } - - func test_locationNotesKillSwitch_releasesAndCanReacquireBuildingSubscription() async throws { - let relays = SubscriptionRecorder() - let settings = LocationNotesSettingsStub() - let locationManager = try await makeAuthorizedLocationManager() - let counter = NearbyNotesCounter( - locationManager: locationManager, - managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, - releaseManager: { $0?.cancel() }, - locationNotesEnabled: { settings.enabled }, - locationNotesSettings: settings.changes - ) - - counter.activate() - counter.reveal() - XCTAssertEqual(relays.subscribeCount, 1) - - settings.setEnabled(false) - - let released = await waitUntil { relays.unsubscribeCount == 1 } - XCTAssertTrue(released) - XCTAssertEqual(counter.noteCount, 0) - - settings.setEnabled(true) - - let reacquired = await waitUntil { relays.subscribeCount == 2 } - XCTAssertTrue(reacquired) - counter.deactivate() - XCTAssertEqual(relays.unsubscribeCount, 2) - } - - func test_checkNotesHint_requiresAuthorizedLocationPermission() { - let relays = SubscriptionRecorder() - let settings = LocationNotesSettingsStub() - let counter = NearbyNotesCounter( - locationManager: makeBareLocationManager(), - managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, - releaseManager: { $0?.cancel() }, - locationNotesEnabled: { settings.enabled }, - locationNotesSettings: settings.changes - ) - - // An unauthorized install must never see the hint: the tap can't - // subscribe (retarget requires authorization) and must not prompt, - // so offering it would be a silent dead-end for the session. - XCTAssertFalse(counter.offersRevealHint(permissionState: .notDetermined)) - XCTAssertFalse(counter.offersRevealHint(permissionState: .denied)) - XCTAssertFalse(counter.offersRevealHint(permissionState: .restricted)) - XCTAssertTrue(counter.offersRevealHint(permissionState: .authorized)) - - // The app-info kill switch hides it too. - settings.setEnabled(false) - XCTAssertFalse(counter.offersRevealHint(permissionState: .authorized)) - settings.setEnabled(true) - - // Once revealed, the hint yields to the live strip and count. - counter.reveal() - XCTAssertFalse(counter.offersRevealHint(permissionState: .authorized)) - XCTAssertEqual(relays.subscribeCount, 0, "reveal without authorization must not open a REQ") - } - - func test_noticesSheet_revealsOnlyOnExplicitGeoTabSelectionWithScope() { - // The sheet reveals the local counter only when the person actively - // picks the geo tab and the sheet actually has a geo scope — - // auto-landing on geo (initial tab) never calls this path at all. - XCTAssertTrue(NoticesView.revealsNearbyNotes(onSwitchingTo: .geo, geoGeohash: "u4pruydq")) - XCTAssertFalse(NoticesView.revealsNearbyNotes(onSwitchingTo: .geo, geoGeohash: nil)) - XCTAssertFalse(NoticesView.revealsNearbyNotes(onSwitchingTo: .mesh, geoGeohash: "u4pruydq")) - } - - func test_noticesGeoPresentation_disabledOverridesSelectedScopeAndHidesOnlyGeoComposer() { - XCTAssertEqual( - NoticesView.geoPresentationState(notesEnabled: false, geohash: "9q8yyk8y"), - .disabled, - "a selected remote channel must not leave a blank manager-less notes view" - ) - XCTAssertNil( - NoticesView.composerGeohash( - tab: .geo, - notesEnabled: false, - geoGeohash: "9q8yyk8y" - ), - "the dead geo composer must be hidden while the notes kill switch is off" - ) - XCTAssertEqual( - NoticesView.composerGeohash(tab: .mesh, notesEnabled: false, geoGeohash: nil), - "", - "the location-notes setting must not disable mesh notices" - ) - } - - func test_noticesGeoSession_revocationEndsLiveRefreshAndReleasesDeviceScope() { - let relays = SubscriptionRecorder() - let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: relays.dependencies) - var beginCount = 0 - var endCount = 0 - var releaseCount = 0 - - let next = NoticesView.reconcileGeoSession( - tab: .geo, - needsDeviceLocation: true, - permissionState: .denied, - notesEnabled: true, - geohash: "u4pruydq", - manager: manager, - ownsLiveRefresh: true, - beginLiveRefresh: { beginCount += 1 }, - endLiveRefresh: { endCount += 1 }, - acquire: { _ in XCTFail("revocation must not acquire"); return manager }, - release: { - releaseCount += 1 - $0?.cancel() - } - ) - - XCTAssertNil(next.manager) - XCTAssertFalse(next.ownsLiveRefresh) - XCTAssertEqual(beginCount, 0) - XCTAssertEqual(endCount, 1) - XCTAssertEqual(releaseCount, 1) - XCTAssertEqual(relays.unsubscribeCount, 1) - } - - func test_noticesGeoSession_killSwitchClosesLocalScope_butDeniedRemoteScopeRemainsUsable() { - let relays = SubscriptionRecorder() - let localManager = LocationNotesManager(geohash: "u4pruydq", dependencies: relays.dependencies) - var endCount = 0 - var releaseCount = 0 - - let disabled = NoticesView.reconcileGeoSession( - tab: .geo, - needsDeviceLocation: true, - permissionState: .authorized, - notesEnabled: false, - geohash: "u4pruydq", - manager: localManager, - ownsLiveRefresh: true, - beginLiveRefresh: {}, - endLiveRefresh: { endCount += 1 }, - acquire: { _ in XCTFail("disabled notes must not acquire"); return localManager }, - release: { - releaseCount += 1 - $0?.cancel() - } - ) - - XCTAssertNil(disabled.manager) - XCTAssertFalse(disabled.ownsLiveRefresh) - XCTAssertEqual(endCount, 1) - XCTAssertEqual(releaseCount, 1) - - let remoteManager = LocationNotesManager(geohash: "9q8yyk8y", dependencies: relays.dependencies) - let remote = NoticesView.reconcileGeoSession( - tab: .geo, - needsDeviceLocation: false, - permissionState: .denied, - notesEnabled: true, - geohash: "9q8yyk8y", - manager: remoteManager, - ownsLiveRefresh: false, - beginLiveRefresh: {}, - endLiveRefresh: { endCount += 1 }, - acquire: { _ in XCTFail("matching remote manager should be reused"); return remoteManager }, - release: { _ in XCTFail("device permission must not release an explicit remote scope") } - ) - - XCTAssertTrue(remote.manager === remoteManager) - XCTAssertFalse(remote.ownsLiveRefresh) - XCTAssertEqual(endCount, 1) - } - - func test_pool_sharesOneManagerPerGeohash_andCancelsOnLastRelease() { - let relays = SubscriptionRecorder() - let pool = LocationNotesPool( - makeManager: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) } - ) - - let first = pool.acquire("u4pruydq") - let second = pool.acquire("U4PRUYDQ") - - XCTAssertTrue(first === second, "same geohash (case-insensitive) must share one manager") - XCTAssertEqual(relays.subscribeCount, 1) - - pool.release(first) - - XCTAssertEqual(relays.unsubscribeCount, 0, "first release keeps the shared REQ live") - XCTAssertNotEqual(first.state, .idle) - - pool.release(second) - - XCTAssertEqual(relays.unsubscribeCount, 1) - XCTAssertEqual(first.state, .idle) - - // An instance the pool never owned (test-injected) degrades to cancel. - let stray = LocationNotesManager(geohash: "u4pruydp", dependencies: relays.dependencies) - pool.release(stray) - XCTAssertEqual(relays.unsubscribeCount, 2) - XCTAssertEqual(stray.state, .idle) - } - - func test_pool_reacquireAfterFullRelease_bringsSubscriptionBackUp() { - let relays = SubscriptionRecorder() - let pool = LocationNotesPool( - makeManager: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) } - ) - - // The notices sheet's geo → mesh → geo cycle: switching to mesh - // releases (REQ goes down), switching back re-acquires (fresh REQ), - // with exactly one live REQ at any point. - let first = pool.acquire("u4pruydq") - XCTAssertEqual(relays.subscribeCount, 1) - - pool.release(first) - XCTAssertEqual(relays.unsubscribeCount, 1) - XCTAssertEqual(first.state, .idle) - - let second = pool.acquire("u4pruydq") - XCTAssertEqual(relays.subscribeCount, 2) - XCTAssertNotEqual(second.state, .idle) - - // Dismissal after the round trip releases once more — no double - // unsubscribe from the earlier tab-switch release. - pool.release(second) - XCTAssertEqual(relays.unsubscribeCount, 2) - } - - // MARK: - Helpers - - /// A LocationStateManager that never touches CoreLocation; for tests - /// that don't need channels or authorization. - private func makeBareLocationManager() -> LocationStateManager { - let suiteName = "NearbyNotesCounterTests-\(UUID().uuidString)" - let storage = UserDefaults(suiteName: suiteName)! - storage.removePersistentDomain(forName: suiteName) - addTeardownBlock { - storage.removePersistentDomain(forName: suiteName) - } - return LocationStateManager( - storage: storage, - locationManager: MockLocationManager(authorizationStatus: .denied), - geocoder: MockLocationGeocoder(), - shouldInitializeCoreLocation: false - ) - } - - /// An authorized LocationStateManager whose availableChannels carry a - /// real building-level geohash (Honolulu), built over CoreLocation mocks. - private func makeAuthorizedLocationManager() async throws -> LocationStateManager { - let suiteName = "NearbyNotesCounterTests-\(UUID().uuidString)" - let storage = UserDefaults(suiteName: suiteName)! - storage.removePersistentDomain(forName: suiteName) - addTeardownBlock { - storage.removePersistentDomain(forName: suiteName) - } - - let manager = LocationStateManager( - storage: storage, - locationManager: MockLocationManager(authorizationStatus: .authorizedAlways), - geocoder: MockLocationGeocoder(), - shouldInitializeCoreLocation: true - ) - let authorized = await waitUntil { manager.permissionState == .authorized } - XCTAssertTrue(authorized) - - manager.locationManager( - CLLocationManager(), - didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)] - ) - let channelsLoaded = await waitUntil { - manager.availableChannels.contains { $0.level == .building } - } - XCTAssertTrue(channelsLoaded) - return manager - } - - /// The filter's `g` tag values, read through its NIP-01 wire encoding - /// (the stored tag filters aren't visible outside the Nostr layer). - private func geohashTagFilter(of filter: NostrFilter) throws -> [String]? { - let data = try JSONEncoder().encode(filter) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - return json["#g"] as? [String] - } - - private func waitUntil( - timeout: TimeInterval = TestConstants.settleTimeout, - condition: @escaping @MainActor () -> Bool - ) async -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if condition() { - return true - } - try? await Task.sleep(nanoseconds: 10_000_000) - } - return condition() - } -} - -@MainActor -private final class LocationNotesSettingsStub { - private let changesSubject = PassthroughSubject() - private(set) var enabled = true - - var changes: AnyPublisher { - changesSubject.eraseToAnyPublisher() - } - - func setEnabled(_ enabled: Bool) { - self.enabled = enabled - changesSubject.send(()) - } -} - -/// Stub relay layer: counts REQs, captures the last filter/handler, and never -/// touches the network. -@MainActor -private final class SubscriptionRecorder { - private(set) var subscribeCount = 0 - private(set) var unsubscribeCount = 0 - private(set) var lastFilter: NostrFilter? - private(set) var lastHandler: ((NostrEvent) -> Void)? - - var dependencies: LocationNotesDependencies { - LocationNotesDependencies( - relayLookup: { _, _ in ["wss://relay.one"] }, - subscribe: { [weak self] filter, _, _, handler, _ in - self?.subscribeCount += 1 - self?.lastFilter = filter - self?.lastHandler = handler - }, - unsubscribe: { [weak self] _ in - self?.unsubscribeCount += 1 - }, - sendEvent: { _, _ in }, - deriveIdentity: { _ in try NostrIdentity.generate() }, - now: { Date() } - ) - } -} - -private final class MockLocationManager: LocationStateManaging { - weak var delegate: CLLocationManagerDelegate? - var desiredAccuracy: CLLocationAccuracy = 0 - var distanceFilter: CLLocationDistance = 0 - var authorizationStatus: CLAuthorizationStatus - - init(authorizationStatus: CLAuthorizationStatus) { - self.authorizationStatus = authorizationStatus - } - - func requestWhenInUseAuthorization() {} - func requestLocation() {} - func startUpdatingLocation() {} - func stopUpdatingLocation() {} -} - -private final class MockLocationGeocoder: LocationStateGeocoding { - func cancelGeocode() {} - - func reverseGeocodeLocation( - _ location: CLLocation, - completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void - ) { - completionHandler(nil, nil) - } -} diff --git a/bitchatTests/NicknameNormalizationTests.swift b/bitchatTests/NicknameNormalizationTests.swift deleted file mode 100644 index a32225b9..00000000 --- a/bitchatTests/NicknameNormalizationTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// NicknameNormalizationTests.swift -// bitchatTests -// -// Nicknames must compare equal regardless of how the user's keyboard -// produced them: "café" as precomposed U+00E9 and as "e" + combining -// U+0301 are canonically equivalent but bytewise different, which broke -// mention matching, DM resolution, and autocomplete (#214). Storage and -// comparison both canonicalize to NFC via String.normalizedNickname. -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -@testable import bitchat - -struct NicknameNormalizationTests { - /// "café" with a combining acute accent (NFD form) - private let decomposed = "cafe\u{0301}" - /// "café" with precomposed é (NFC form) - private let precomposed = "caf\u{00E9}" - - @Test - func canonicallyEquivalentFormsNormalizeIdentically() { - // Sanity: the raw forms really are different strings byte-wise … - #expect(decomposed.unicodeScalars.count != precomposed.unicodeScalars.count) - // … and normalization unifies them. - #expect(decomposed.normalizedNickname == precomposed.normalizedNickname) - #expect(decomposed.normalizedNickname == precomposed) - } - - @Test - func asciiNicknamesPassThroughUnchanged() { - #expect("alice_42".normalizedNickname == "alice_42") - #expect("".normalizedNickname == "") - } - - @Test - func validateNicknameReturnsCanonicalForm() { - #expect(InputValidator.validateNickname(decomposed) == precomposed) - #expect(InputValidator.validateNickname(" \(decomposed) ") == precomposed) - // Validation behavior is otherwise unchanged. - #expect(InputValidator.validateNickname(" ") == nil) - } - - @Test - func collisionSuffixSplittingSurvivesNormalization() { - let (base, suffix) = (decomposed.normalizedNickname + "#ab12").splitSuffix() - #expect(base == precomposed) - #expect(suffix == "#ab12") - } -} diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index 763f5816..601b7061 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -5,22 +5,15 @@ import BitFoundation @testable import bitchat -@Suite("Noise Coverage Tests", .serialized) +@Suite("Noise Coverage Tests") struct NoiseCoverageTests { private let keychain = MockKeychain() private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey() private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey() private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey() - // Manager test dictionaries are keyed by the remote peer. Keep the - // historical names, but derive each wire ID from the static key that the - // corresponding manager authenticates during the handshake. - private var alicePeerID: PeerID { - PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation) - } - private var bobPeerID: PeerID { - PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation) - } + private let alicePeerID = PeerID(str: "0011223344556677") + private let bobPeerID = PeerID(str: "8899aabbccddeeff") private let charliePeerID = PeerID(str: "fedcba9876543210") @Test("Protocol metadata and handshake patterns expose expected values") @@ -179,7 +172,7 @@ struct NoiseCoverageTests { Data(), Data(repeating: 0x00, count: 32), Data([0x01] + Array(repeating: 0x00, count: 31)), - Data(repeating: 0xFF, count: 32) + Data(repeating: 0xFF, count: 32), ] for invalidKey in invalidKeys { @@ -542,18 +535,14 @@ struct NoiseCoverageTests { let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) - aliceManager.onSessionEstablished = establishedRecorder.recordEstablished( - peerID:remoteKey:sessionGeneration: - ) - bobManager.onSessionEstablished = establishedRecorder.recordEstablished( - peerID:remoteKey:sessionGeneration: - ) + aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) + bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) let didEstablish = await TestHelpers.waitUntil( { establishedRecorder.establishedCount == 2 }, - timeout: 5.0 + timeout: 0.5 ) #expect(didEstablish) #expect(establishedRecorder.establishedPeerIDs.contains(alicePeerID)) @@ -572,32 +561,12 @@ struct NoiseCoverageTests { let didFail = await TestHelpers.waitUntil( { failureRecorder.failureCount == 1 }, - timeout: 5.0 + timeout: 0.5 ) #expect(didFail) #expect(failingManager.getSession(for: charliePeerID) == nil) } - @Test("Handshake completion fails closed on non-wire peer IDs") - func handshakeCompletionRejectsNonWirePeerIDs() throws { - let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) - let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) - - // Alice addresses Bob by an identifier no static key can vouch for: - // neither a 16-hex wire ID nor a full Noise-key ID. Completion must - // reject it rather than accept any remote static key. - let nonWireID = PeerID(str: "not-a-wire-identifier") - let msg1 = try aliceManager.initiateHandshake(with: nonWireID) - let msg2 = try #require( - try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1) - ) - - #expect(throws: (any Error).self) { - try aliceManager.handleIncomingHandshake(from: nonWireID, message: msg2) - } - #expect(aliceManager.getSession(for: nonWireID)?.isEstablished() != true) - } - @Test("Session manager cleans up initiator sessions after start-handshake failures") func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() { let manager = NoiseSessionManager( @@ -653,16 +622,8 @@ struct NoiseCoverageTests { ) let replacementSession = try #require(manager.getSession(for: alicePeerID)) - let localPeerID = PeerID( - publicKey: aliceStaticKey.publicKey.rawRepresentation - ) - if localPeerID < alicePeerID { - #expect(replacementResponse == nil) - #expect(replacementSession === restartedSession) - } else { - #expect(replacementResponse != nil) - #expect(replacementSession !== restartedSession) - } + #expect(replacementResponse != nil) + #expect(replacementSession !== restartedSession) let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) @@ -682,132 +643,13 @@ struct NoiseCoverageTests { try aliceManager.initiateHandshake(with: alicePeerID) } - let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID) - let rekeyHandshake = try #require( - aliceManager.claimHandshakeInitiation( - rekeyInitiation, - for: alicePeerID - ) - ) - #expect(!rekeyHandshake.isEmpty) + try aliceManager.initiateRekey(for: alicePeerID) let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID)) #expect(rekeyedSession !== establishedSession) #expect(rekeyedSession.getState() == .handshaking) } - @Test("A stale decrypt generation cannot commit across session promotion") - func staleDecryptGenerationCannotCommitAcrossPromotion() throws { - let aliceManager = NoiseSessionManager( - localStaticKey: aliceStaticKey, - keychain: keychain, - recentInitiatorCompletionGracePeriod: 0, - sessionFactory: { peerID, role in - BlockingDecryptNoiseSession( - peerID: peerID, - role: role, - keychain: self.keychain, - localStaticKey: self.aliceStaticKey - ) - } - ) - let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) - try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) - - let oldSession = try #require( - aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession - ) - let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID)) - - // Prepare a fully authenticated responder candidate without promoting - // it yet. Its final XX message is the exact operation that replaces - // the old `sessions[peerID]` entry. - let replacementInitiator = NoiseSession( - peerID: bobPeerID, - role: .initiator, - keychain: keychain, - localStaticKey: bobStaticKey - ) - let message1 = try replacementInitiator.startHandshake() - let message2 = try #require( - try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1) - ) - let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2)) - - let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID) - oldSession.pauseNextDecrypt() - - let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>() - var promotionResultForCleanup: ConcurrentTestResult? - defer { - // A failed startup requirement must not strand a late thread in - // the blocking test double after the test has returned. - oldSession.resumeDecrypt() - _ = decryptResult.wait(timeout: TestConstants.settleTimeout) - if let promotionResultForCleanup { - _ = promotionResultForCleanup.wait(timeout: TestConstants.settleTimeout) - } - } - - let decryptThread = Thread { - decryptResult.capture { - try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID) - } - } - decryptThread.name = "NoiseCoverageTests.staleDecrypt.decrypt" - decryptThread.qualityOfService = .userInitiated - decryptThread.start() - try #require(oldSession.waitForDecryptStart(timeout: 5)) - - let promotionStarted = DispatchSemaphore(value: 0) - let promotionResult = ConcurrentTestResult() - promotionResultForCleanup = promotionResult - let promotionThread = Thread { - promotionStarted.signal() - promotionResult.capture { - try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3) - } - } - promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote" - promotionThread.qualityOfService = .userInitiated - promotionThread.start() - try #require(promotionStarted.wait(timeout: .now() + TestConstants.settleTimeout) == .success) - #expect( - // test-timing-ok: a NEGATIVE wait — it asserts the promotion has - // NOT completed yet, so a long deadline would only make the suite - // slow while still passing. A starved runner can only make this - // more likely to hold, never less. - promotionResult.wait(timeout: 0.05) == nil, - "Promotion must wait for the exact decrypting-session lease" - ) - - oldSession.resumeDecrypt() - let decrypted = try #require(decryptResult.wait(timeout: TestConstants.settleTimeout)).get() - _ = try #require(promotionResult.wait(timeout: TestConstants.settleTimeout)).get() - - #expect(decrypted.plaintext == Data("old session".utf8)) - #expect(decrypted.sessionGeneration == oldGeneration) - #expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration) - #expect(throws: NoiseEncryptionError.sessionNotEstablished) { - try aliceManager.encrypt( - Data("stale send".utf8), - for: alicePeerID, - expectedSessionGeneration: oldGeneration - ) - } - - var staleCommitRan = false - let staleCommit = aliceManager.withCurrentSessionGeneration( - for: alicePeerID, - expected: decrypted.sessionGeneration - ) { - staleCommitRan = true - return true - } - #expect(staleCommit == nil) - #expect(!staleCommitRan) - } - @Test("Secure noise sessions enforce limits and renegotiation thresholds") func secureNoiseSessionsEnforceLimitsAndThresholds() throws { let initiator = SecureNoiseSession( @@ -1002,11 +844,7 @@ private final class SessionCallbackRecorder: @unchecked Sendable { return establishedEntries.map(\.0) } - func recordEstablished( - peerID: PeerID, - remoteKey: Curve25519.KeyAgreement.PublicKey, - sessionGeneration _: UUID - ) { + func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) { lock.lock() establishedEntries.append((peerID, remoteKey.rawRepresentation)) lock.unlock() @@ -1028,62 +866,3 @@ private final class FailingNoiseSession: NoiseSession { throw Error.synthetic } } - -private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable { - private let controlLock = NSLock() - private var shouldPauseNextDecrypt = false - private let decryptStarted = DispatchSemaphore(value: 0) - private let resumeDecryptSemaphore = DispatchSemaphore(value: 0) - - func pauseNextDecrypt() { - controlLock.lock() - shouldPauseNextDecrypt = true - controlLock.unlock() - } - - func waitForDecryptStart(timeout: TimeInterval) -> Bool { - decryptStarted.wait(timeout: .now() + timeout) == .success - } - - func resumeDecrypt() { - resumeDecryptSemaphore.signal() - } - - override func decrypt(_ ciphertext: Data) throws -> Data { - controlLock.lock() - let shouldPause = shouldPauseNextDecrypt - shouldPauseNextDecrypt = false - controlLock.unlock() - - if shouldPause { - decryptStarted.signal() - resumeDecryptSemaphore.wait() - } - return try super.decrypt(ciphertext) - } -} - -private final class ConcurrentTestResult: @unchecked Sendable { - private let lock = NSLock() - private let completed = DispatchGroup() - private var storedResult: Result? - - init() { - completed.enter() - } - - func capture(_ operation: () throws -> Value) { - let result = Result(catching: operation) - lock.lock() - storedResult = result - lock.unlock() - completed.leave() - } - - func wait(timeout: TimeInterval) -> Result? { - guard completed.wait(timeout: .now() + timeout) == .success else { return nil } - lock.lock() - defer { lock.unlock() } - return storedResult - } -} diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index 007bbef0..6ba8dbfa 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -14,25 +14,16 @@ import BitFoundation // MARK: - Test Vector Support -/// Official Noise test vectors (NoiseTestVectors.json) for -/// `Noise_XX_25519_ChaChaPoly_SHA256` — the exact protocol this app speaks -/// (see `NoiseProtocolName` / `NoisePattern.XX`). Embedded byte-for-byte from -/// the two canonical community vector suites: -/// - cacophony: https://raw.githubusercontent.com/haskell-cryptography/cacophony/master/vectors/cacophony.txt -/// (6 messages: full XX handshake transcript + 3 transport messages, with -/// `handshake_hash`) -/// - snow: https://raw.githubusercontent.com/mcginty/snow/main/tests/vectors/snow.txt -/// (5 messages: full XX handshake transcript + 2 transport messages) -/// Plain XX has no PSKs and no pre-message keys; prologue is part of both -/// vectors and is mixed via `NoiseHandshakeState(prologue:)`. Fixed ephemerals -/// come in through the `predeterminedEphemeralKey` test seam. struct NoiseTestVector: Codable { let protocol_name: String let init_prologue: String let init_static: String let init_ephemeral: String + let init_psks: [String]? + let resp_prologue: String let resp_static: String let resp_ephemeral: String + let resp_psks: [String]? let handshake_hash: String? let messages: [TestMessage] @@ -68,29 +59,22 @@ struct NoiseProtocolTests { private let bobKey = Curve25519.KeyAgreement.PrivateKey() private let mockKeychain = MockKeychain() - // Manager sessions are keyed by the remote peer. Keep the historical - // names, but derive each wire ID from the static key that the - // corresponding manager authenticates during the handshake. - private var alicePeerID: PeerID { - PeerID(publicKey: bobKey.publicKey.rawRepresentation) - } - private var bobPeerID: PeerID { - PeerID(publicKey: aliceKey.publicKey.rawRepresentation) - } - + private let alicePeerID = PeerID(str: UUID().uuidString) + private let bobPeerID = PeerID(str: UUID().uuidString) + private let aliceSession: NoiseSession private let bobSession: NoiseSession - + init() { aliceSession = NoiseSession( - peerID: PeerID(publicKey: bobKey.publicKey.rawRepresentation), + peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey ) - + bobSession = NoiseSession( - peerID: PeerID(publicKey: aliceKey.publicKey.rawRepresentation), + peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey @@ -155,7 +139,7 @@ struct NoiseProtocolTests { @Test func basicEncryptionDecryption() throws { try performHandshake(initiator: aliceSession, responder: bobSession) - let plaintext = Data("Hello, Bob!".utf8) + let plaintext = "Hello, Bob!".data(using: .utf8)! // Alice encrypts let ciphertext = try aliceSession.encrypt(plaintext) @@ -171,13 +155,13 @@ struct NoiseProtocolTests { try performHandshake(initiator: aliceSession, responder: bobSession) // Alice -> Bob - let aliceMessage = Data("Hello from Alice".utf8) + let aliceMessage = "Hello from Alice".data(using: .utf8)! let aliceCiphertext = try aliceSession.encrypt(aliceMessage) let bobReceived = try bobSession.decrypt(aliceCiphertext) #expect(bobReceived == aliceMessage) // Bob -> Alice - let bobMessage = Data("Hello from Bob".utf8) + let bobMessage = "Hello from Bob".data(using: .utf8)! let bobCiphertext = try bobSession.encrypt(bobMessage) let aliceReceived = try aliceSession.decrypt(bobCiphertext) #expect(aliceReceived == bobMessage) @@ -197,7 +181,7 @@ struct NoiseProtocolTests { } @Test func encryptionBeforeHandshake() { - let plaintext = Data("test".utf8) + let plaintext = "test".data(using: .utf8)! #expect(throws: NoiseSessionError.notEstablished) { try aliceSession.encrypt(plaintext) @@ -274,7 +258,7 @@ struct NoiseProtocolTests { try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) // Encrypt with manager - let plaintext = Data("Test message".utf8) + let plaintext = "Test message".data(using: .utf8)! let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID) // Decrypt with manager @@ -287,7 +271,7 @@ struct NoiseProtocolTests { @Test func tamperedCiphertextDetection() throws { try performHandshake(initiator: aliceSession, responder: bobSession) - let plaintext = Data("Secret message".utf8) + let plaintext = "Secret message".data(using: .utf8)! var ciphertext = try aliceSession.encrypt(plaintext) // Tamper with ciphertext @@ -308,7 +292,7 @@ struct NoiseProtocolTests { @Test func replayPrevention() throws { try performHandshake(initiator: aliceSession, responder: bobSession) - let plaintext = Data("Test message".utf8) + let plaintext = "Test message".data(using: .utf8)! let ciphertext = try aliceSession.encrypt(plaintext) // First decryption should succeed @@ -341,7 +325,7 @@ struct NoiseProtocolTests { try performHandshake(initiator: aliceSession2, responder: bobSession2) // Encrypt with session 1 - let plaintext = Data("Secret".utf8) + let plaintext = "Secret".data(using: .utf8)! let ciphertext1 = try aliceSession1.encrypt(plaintext) // Should not be able to decrypt with session 2 @@ -364,26 +348,16 @@ struct NoiseProtocolTests { @Test func peerRestartDetection() throws { // Establish initial sessions - // This test explicitly drives the three synchronous XX messages and - // does not exercise the transport's delayed collision recovery. - let aliceManager = NoiseSessionManager( - localStaticKey: aliceKey, - keychain: mockKeychain, - recentInitiatorCompletionGracePeriod: 0 - ) - let bobManager = NoiseSessionManager( - localStaticKey: bobKey, - keychain: mockKeychain, - recentInitiatorCompletionGracePeriod: 0 - ) + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) + let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) // Exchange some messages to establish nonce state - let message1 = try aliceManager.encrypt(Data("Hello".utf8), for: alicePeerID) + let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID) _ = try bobManager.decrypt(message1, from: bobPeerID) - let message2 = try bobManager.encrypt(Data("World".utf8), for: bobPeerID) + let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID) _ = try aliceManager.decrypt(message2, from: alicePeerID) // Simulate Bob restart by creating new manager with same key @@ -394,27 +368,18 @@ struct NoiseProtocolTests { let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID) // Alice should accept the new handshake (clearing old session) - let newHandshake2 = try #require( - try aliceManager.handleIncomingHandshake( - from: alicePeerID, - message: newHandshake1 - ) - ) + let newHandshake2 = try aliceManager.handleIncomingHandshake( + from: alicePeerID, message: newHandshake1) + #expect(newHandshake2 != nil) // Complete the new handshake - let newHandshake3 = try #require( - try bobManagerRestarted.handleIncomingHandshake( - from: bobPeerID, - message: newHandshake2 - ) - ) - _ = try aliceManager.handleIncomingHandshake( - from: alicePeerID, - message: newHandshake3 - ) + let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake( + from: bobPeerID, message: newHandshake2!) + #expect(newHandshake3 != nil) + _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) // Should be able to exchange messages with new sessions - let testMessage = Data("After restart".utf8) + let testMessage = "After restart".data(using: .utf8)! let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID) let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID) #expect(decrypted == testMessage) @@ -432,17 +397,17 @@ struct NoiseProtocolTests { // Exchange messages to advance nonces for i in 0..<5 { - let msg = try aliceSession.encrypt(Data("Message \(i)".utf8)) + let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!) _ = try bobSession.decrypt(msg) } // Simulate desynchronization by encrypting but not decrypting for i in 0..<3 { - _ = try aliceSession.encrypt(Data("Lost message \(i)".utf8)) + _ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!) } // With per-packet nonce carried, decryption should not throw here - let desyncMessage = try aliceSession.encrypt(Data("This now succeeds".utf8)) + let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!) #expect(throws: Never.self) { try bobSession.decrypt(desyncMessage) } @@ -457,11 +422,12 @@ struct NoiseProtocolTests { let messageCount = 100 - try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in + try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) + { completion in var encryptedMessages: [Int: Data] = [:] // Encrypt messages sequentially to avoid nonce races in manager for i in 0.. [NoiseTestVector] { - // SwiftPM puts processed resources in the module bundle; the Xcode - // test target puts them in the test bundle itself. - #if SWIFT_PACKAGE - let testBundle = Bundle.module - #else + // Try to load from test bundle let testBundle = Bundle(for: MockKeychain.self) - #endif guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json") else { throw NSError( @@ -942,20 +876,20 @@ struct NoiseProtocolTests { #expect(trackingKeychain.secureClearDataCallCount > 0) // Test encryption from Alice to Bob - let plaintext1 = Data("Hello from Alice after secureClear!".utf8) + let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)! let ciphertext1 = try alice.encrypt(plaintext1) let decrypted1 = try bob.decrypt(ciphertext1) #expect(decrypted1 == plaintext1) // Test encryption from Bob to Alice - let plaintext2 = Data("Hello from Bob after secureClear!".utf8) + let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)! let ciphertext2 = try bob.encrypt(plaintext2) let decrypted2 = try alice.decrypt(ciphertext2) #expect(decrypted2 == plaintext2) // Test multiple messages to verify cipher state is correct for i in 1...10 { - let msg = Data("Message \(i) from Alice".utf8) + let msg = "Message \(i) from Alice".data(using: .utf8)! let cipher = try alice.encrypt(msg) let dec = try bob.decrypt(cipher) #expect(dec == msg) diff --git a/bitchatTests/NoiseCourierTests.swift b/bitchatTests/NoiseCourierTests.swift deleted file mode 100644 index 1403e520..00000000 --- a/bitchatTests/NoiseCourierTests.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// NoiseCourierTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -@testable import bitchat - -/// One-way Noise X envelopes: encryption to a known static key without an -/// interactive handshake, used by the courier store-and-forward path. -struct NoiseCourierTests { - - @Test func sealAndOpenRoundTrip() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - - let payload = Data("meet at the north gate".utf8) - let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) - - let opened = try bob.openCourierPayload(sealed) - #expect(opened.payload == payload) - // The X pattern authenticates the sender: Bob learns Alice's real static key. - #expect(opened.senderStaticKey == alice.getStaticPublicKeyData()) - } - - @Test func wrongRecipientCannotOpen() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let carol = NoiseEncryptionService(keychain: MockKeychain()) - - let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) - - #expect(throws: (any Error).self) { - _ = try carol.openCourierPayload(sealed) - } - } - - @Test func tamperedEnvelopeFailsToOpen() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - - var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) - sealed[sealed.count - 1] ^= 0x01 - - #expect(throws: (any Error).self) { - _ = try bob.openCourierPayload(sealed) - } - } - - @Test func senderIdentityCannotBeForged() throws { - // The encrypted static key inside the envelope is bound by the ss DH; - // splicing one envelope's ephemeral prefix onto another must fail. - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let mallory = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - - let bobKey = bob.getStaticPublicKeyData() - let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey) - let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey) - - // e (32 bytes) from Mallory's envelope + rest from Alice's. - let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32) - #expect(throws: (any Error).self) { - _ = try bob.openCourierPayload(Data(spliced)) - } - } - - @Test func sealRejectsInvalidRecipientKey() { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - #expect(throws: (any Error).self) { - _ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32)) - } - #expect(throws: (any Error).self) { - _ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8)) - } - } - - @Test func emptyAndLargePayloadsRoundTrip() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let bobKey = bob.getStaticPublicKeyData() - - let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey) - #expect(try bob.openCourierPayload(empty).payload.isEmpty) - - let large = Data((0..<8192).map { UInt8($0 % 251) }) - let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey) - #expect(try bob.openCourierPayload(sealed).payload == large) - } - - @Test func envelopesAreNotLinkableAcrossSends() throws { - // Fresh ephemeral per seal: same payload to the same recipient must - // produce entirely different ciphertexts. - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let payload = Data("same message".utf8) - - let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) - let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) - #expect(a != b) - #expect(a.prefix(32) != b.prefix(32)) - } -} diff --git a/bitchatTests/NoiseEncryptionTests.swift b/bitchatTests/NoiseEncryptionTests.swift deleted file mode 100644 index a1cb5780..00000000 --- a/bitchatTests/NoiseEncryptionTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// NoiseEncryptionTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -@testable import bitchat - -struct NoiseEncryptionTests { - @Test func generatesNewIdentityWhenMissing() throws { - let keychain = MockKeychain() - - // Create service with empty keychain - should generate new identity - let service = NoiseEncryptionService(keychain: keychain) - - // Should have generated and saved keys - #expect(service.getStaticPublicKeyData().count == 32) - #expect(service.getSigningPublicKeyData().count == 32) - - // Keys should be persisted - let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") - switch noiseKeyResult { - case .success: - // Expected - key was saved - break - default: - throw KeychainTestError("Expected noise key to be saved") - } - } - - @Test func loadsExistingIdentity() throws { - let keychain = MockKeychain() - - // Create first service to generate identity - let service1 = NoiseEncryptionService(keychain: keychain) - let originalPublicKey = service1.getStaticPublicKeyData() - let originalSigningKey = service1.getSigningPublicKeyData() - - // Create second service - should load same identity - let service2 = NoiseEncryptionService(keychain: keychain) - - #expect(service2.getStaticPublicKeyData() == originalPublicKey) - #expect(service2.getSigningPublicKeyData() == originalSigningKey) - } - - @Test func handlesAccessDeniedGracefully() throws { - let keychain = MockKeychain() - keychain.simulatedReadError = .accessDenied - - // Service should still initialize with ephemeral key - let service = NoiseEncryptionService(keychain: keychain) - - // Should have an identity (ephemeral) - #expect(service.getStaticPublicKeyData().count == 32) - #expect(service.getSigningPublicKeyData().count == 32) - } - - @Test func handlesDeviceLockedGracefully() throws { - let keychain = MockKeychain() - keychain.simulatedReadError = .deviceLocked - - // Service should still initialize with ephemeral key - let service = NoiseEncryptionService(keychain: keychain) - - // Should have an identity (ephemeral) - #expect(service.getStaticPublicKeyData().count == 32) - } -} - -// Local error type for the keychain failure cases in this suite. -private struct KeychainTestError: Error, CustomStringConvertible { - let message: String - init(_ message: String) { self.message = message } - var description: String { message } -} diff --git a/bitchatTests/Nostr/Base64URLCodingTests.swift b/bitchatTests/Nostr/Base64URLCodingTests.swift deleted file mode 100644 index 2203addf..00000000 --- a/bitchatTests/Nostr/Base64URLCodingTests.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -struct Base64URLCodingTests { - @Test - func encodesWithoutPaddingOrURLUnsafeCharacters() { - let encoded = Base64URLCoding.encode(Data([0xff, 0xee, 0xdd, 0xcc])) - - #expect(!encoded.contains("=")) - #expect(!encoded.contains("+")) - #expect(!encoded.contains("/")) - } - - @Test - func decodesUnpaddedValue() throws { - let data = try #require(Base64URLCoding.decode("_-7dzA")) - - #expect(data == Data([0xff, 0xee, 0xdd, 0xcc])) - } - - @Test - func roundTripsData() throws { - let original = Data("hello bitchat".utf8) - - let decoded = try #require(Base64URLCoding.decode(Base64URLCoding.encode(original))) - - #expect(decoded == original) - } -} diff --git a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33d.json b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33d.json deleted file mode 100644 index 692739cf..00000000 --- a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33d.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"8f4ac4680de5f972a586267bc7b6b5102ba548a34f618bdee47edd08929ee1e8","pubkey":"6981231b5745520fd982f66f485fa1b42f8f91ad25ab32242d4afb839d696b0a","created_at":1783727308,"kind":1059,"tags":[["p","c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"]],"content":"v2:ETvtGOrkDQ2JIiiMFMdxBlrYNLh07-QKmLds0xsjkgs-v54CP4E4uoKl1L_VvKvaREg-EviPQmksd3DOYHrBVAd5E6xuRn1o9vkoss4MYV7I71mu4RfADRt77ohZaNbc-KhrmgyRTgE-WnEsqErax8LUN6GUukjkKncVA9MAmC1WUB1MI1AR8c4BQ0IOc_ubrEqi640a8AeBZaCZOYutCb3ttqNSPBxR63XErE761KNg1uiq5pgBVo8iKvLO-N2ei7IWvQhmDTapBaEU7LexeIdHDEwMdXDoAtr5Nmd54H1VN12tBvk1wXvHnENgZCOLOR5J2E1eSwrFXXBto-ohrNpBaLKIXBTPGEoepa7x0gC0Vrh1OTf4tCI7JJ5UWnkUAQFGUgPGlTRYC8MdESgEthqmdgKT3Jc0N6sylTmv6zSVx5dXqO4fvSLHC6_7it8F_V_8-uAxUYstJz65oK4F9CwOEVglUuUdfn2mN_3cMBzASLOlKvL8jbkwwo5aMBVrShGiEwDix02hfGMNMKf7OKsLlfNiBAVSPh6MQSvAWhxbDCDnWN-yHKWF4TYxbnH70X2KGl_ZD9pXTphKVIpFuRmP7UNM-01oG53NKcv9puBSxAkLbTZd122uFL_zQebxid1ukOXT8WgSB_WYJYoAlQekeu4ITryB4I60vAAzMAa4AppCUNnf6T8jwWxuC-8G0TPf18MTxpeNkzcSpPVyVE0jtLaOwsJDXs_Pg82Id03Qc-b_fWMm9V07UzGmEnMqQ1gBMLmEXHb_4Ebg5X7TdzuXy87O36CJzau7Dm5ZfoalryF-16Z4MxzQOyXb1G61yFthRsGCT6sYi-68YkhPScMf7u_BobtMuGWiMiWoBqN_IrQ_ecMHVfaeEYvpCz5NYlrE26iAktNmzCBUDNcIr6P_nHdb6I3Q1rOOmWwEF7jsLbvnU_w_82_nXE_yfdGsoly24A2wB0L0SpdnyyEWgvKWjjfS3J3vkIVW4_iM0FT0jNelANc2X_ryb3EPTmGenlqm_qRGh86PYk_R07hKYu3ULNEgLzDTijeZ9-bP23tsMXUDdJS2VR7LP5063ygVuSC0J-GL1FmQ2c-DmJaWQSeqp0NN3sCND3pSIRzwQRwChnMNjVB65mJj","sig":"c53f339b19b9de021765588b0ee4f5f1e0c2423a34d35fe2cbcc6ce89e12e2fedbdf88a0b6a9d719c2c8580002c7574f1ace93aedeab1e255b6231d4ebb6f9b2"} diff --git a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt deleted file mode 100644 index 36f57ee7..00000000 --- a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt +++ /dev/null @@ -1,29 +0,0 @@ -diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt -index a5bd956..544a29b 100644 ---- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt -+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt -@@ -10,0 +11,21 @@ class NostrProtocolTest { -+ @Test -+ fun emitCrossPlatformFixtures() { -+ val sender = NostrIdentity.fromPrivateKey( -+ "0000000000000000000000000000000000000000000000000000000000000001" -+ ) -+ val recipient = NostrIdentity.fromPrivateKey( -+ "0000000000000000000000000000000000000000000000000000000000000002" -+ ) -+ val giftWrap = NostrProtocol.createPrivateMessage( -+ content = "legacy fixture from Android b7f0b33d", -+ recipientPubkey = recipient.publicKeyHex, -+ senderIdentity = sender -+ ).single() -+ -+ assertEquals(NostrKind.GIFT_WRAP, giftWrap.kind) -+ assertEquals(listOf(listOf("p", recipient.publicKeyHex)), giftWrap.tags) -+ println("BITCHAT_FIXTURE_EVENT=${gson.toJson(giftWrap)}") -+ println("BITCHAT_FIXTURE_RECIPIENT_PRIVATE_KEY=${recipient.privateKeyHex}") -+ println("BITCHAT_FIXTURE_SENDER_PUBLIC_KEY=${sender.publicKeyHex}") -+ } -+ - @Test - fun decryptPrivateMessage_acceptsAuthenticatedSeal() { - val sender = NostrIdentity.generate() diff --git a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dMetadata.json b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dMetadata.json deleted file mode 100644 index 7538f946..00000000 --- a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dMetadata.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "android_commit": "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db", - "generator": "NostrProtocol.createPrivateMessage", - "generator_patch": "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt", - "generator_patch_sha256": "e7ad29d6a247638cc16fb2ec06937266e6a03e7a115ae00913330a86a88fa56a", - "gradle_test": "ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew testDebugUnitTest --tests com.bitchat.android.nostr.NostrProtocolTest.emitCrossPlatformFixtures --info", - "fixture_sha256": "d2df3d0b7ffd84c5cb25e0b3f4a89ad14f72a105bddadab77081f98c661b080e", - "recipient_private_key": "0000000000000000000000000000000000000000000000000000000000000002", - "sender_public_key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" -} diff --git a/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bb.json b/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bb.json deleted file mode 100644 index 21dc126a..00000000 --- a/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bb.json +++ /dev/null @@ -1 +0,0 @@ -{"content":"v2:SorfnTaoQ_Rv0XLKA8b3FZojgaFvnWgx66JR6Gj20ztnorQyL-hUYBZwAa5ohFC6ioR9hlJARJm0cgNTvWgSZuNTcfSAvoMdSG6mXK73kzsp99x351zUB_lc1_5ZXm0qqePAEz3Dl5jj5EsfAb8ZzsCd-BZENCsTwqjqC_hCFl7RitlRfIL2Tq_n4TUFEFandDCplNz3W4L7V07V89aGEoUlhzcwPU44BcxfvQjeqVKq0IRf2AetypoOduPrjSqkv674ubWaRcHtw3Asrqgo5XSYbpOlSk1PF_TttrQSPZGViNe5MuiK2P-7d-XqKXuDf_bUgAzW884KXogbct-wtIJZJbVM-utMd-dHrpC1mY81lgpS4_kPuhj0Z6Ro9hU5nCcEk2K4_vNoSM9m9QbcXP34h47qSPsw9ikmz8UoD00-1fXQVB4YJcBVUSVI06IzbZEWulo8SPXvQ4pJjV3nwPgYqRgwnrNWMNfeuKojlF8yA17UNOWvD2U7r1cs84HL8dxztzX9NdN0DGxvjMILvt3D4eWrMbcSrsIkBgyvV-uskEPd4eX3fc9GmX8MPkMgxRErcxbuq6JBUNXikOJXNH_qspOt4UIw5dCIajrHsKycKd8A_3rSgLEQteirOWMGaD2gOJEzpbe4iT72dmPkvRq7k-wDjLbO5dOSuUvpFM-ipkB07ATJz_1uUcRpl8fD_oSlcdAzdPjGKG5Y-tNv3AcUstkqCi52E5x-aO8EDUNBFi_OCbD86nkTUv1jOpAQwejowSiiOnCZ91zUEg5pRQyhBV0Ozib-j0Wf8S8VAz9M8bqQQaKedPCEowDn6csOKbFdBtqSeROf1XWKCkm1mSJGHWW9V44MiMiHebVrRUpbP0PqvxiIeJE0","created_at":1783710443,"id":"46425f8d4e8007af43abb67b3668b59604bd34c86dee1b1c3702559086927cb9","kind":1059,"pubkey":"960e391e314a7fb00bbdd85eccb0a93c17e981b6fed38487cf891f1ed6b66aeb","sig":"975c88c1c4d11f0b623603f8ecc7c181fea7385e8feacdb4a64a6b4f7536b1337ff556aee165ca337c9ec501cfab3e9703026c6df2b12bb8c702378875f00722","tags":[["p","1c108500bf53da288d30530718bac4bf80d661d1fe38854060c5b5c79eb77755"]]} diff --git a/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bbRecipientKey.json b/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bbRecipientKey.json deleted file mode 100644 index 2b57bf90..00000000 --- a/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bbRecipientKey.json +++ /dev/null @@ -1 +0,0 @@ -{"recipient_private_key":"8355a5c110cdfef2e644f4ad5d51c39f253b2c2c80ebb6856379fb16531dc1fa"} diff --git a/bitchatTests/Nostr/GeoRelayDirectoryTests.swift b/bitchatTests/Nostr/GeoRelayDirectoryTests.swift index 1a4ce385..b516f708 100644 --- a/bitchatTests/Nostr/GeoRelayDirectoryTests.swift +++ b/bitchatTests/Nostr/GeoRelayDirectoryTests.swift @@ -5,25 +5,17 @@ import XCTest @MainActor final class GeoRelayDirectoryTests: XCTestCase { - private func parse(_ csv: String) -> [GeoRelayDirectory.Entry] { - GeoRelayDirectory.validatedEntries( - from: Data(csv.utf8), - policy: .live, - minimumEntries: 1 - ) ?? [] - } - - func test_parseCSV_normalizesSecureRelaySchemesAndDeduplicatesEntries() { + func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() { let csv = """ relay url,lat,lon wss://one.example/,10,20 https://one.example,10,20 - wss://one.example:443/,10,20 - two.example,11,21 - wss://two.example:443,11,21 + http://two.example/,11,21 + invalid row + ws://three.example,not-a-lat,22 """ - let parsed = Set(parse(csv)) + let parsed = Set(GeoRelayDirectory.parseCSV(csv)) XCTAssertEqual( parsed, @@ -34,136 +26,6 @@ final class GeoRelayDirectoryTests: XCTestCase { ) } - func test_parseCSV_rejectsWholeDatasetWhenAnyRowOrHeaderIsUnsafe() { - let invalidCSVs = [ - "relay,lat,lon\nrelay.example,1,2\n", - "relay url,lat,lon\nrelay.example,1\n", - "relay url,lat,lon\nhttp://relay.example,1,2\n", - "relay url,lat,lon\nwss://user@relay.example,1,2\n", - "relay url,lat,lon\nwss://relay.example/path,1,2\n", - "relay url,lat,lon\nwss://relay.example?,1,2\n", - "relay url,lat,lon\nwss://relay.example#,1,2\n", - "relay url,lat,lon\nrelay.example:0,1,2\n", - "relay url,lat,lon\nrelay.example:99999,1,2\n", - "relay url,lat,lon\nlocalhost,1,2\n", - "relay url,lat,lon\nr\u{00e9}lay.example,1,2\n", - "relay url,lat,lon\nrelay\u{202e}.example,1,2\n", - "relay url,lat,lon\nrelay.example,NaN,2\n", - "relay url,lat,lon\nrelay.example,1_0,2\n", - "relay url,lat,lon\nrelay.example,\u{0661}\u{0660},2\n", - "relay url,lat,lon\nrelay.example,\u{ff11}\u{ff10},2\n", - "relay url,lat,lon\nrelay.example,91,2\n", - "relay url,lat,lon\nrelay.example,1,181\n", - "relay url,lat,lon\nrelay.example,1,2\nrelay.example,3,4\n" - ] - - for csv in invalidCSVs { - XCTAssertTrue(parse(csv).isEmpty, csv) - } - } - - func test_validatedEntries_enforcesByteRowEntryAndRetentionLimits() { - let restrictive = GeoRelayDirectoryValidationPolicy( - maximumBytes: 100, - maximumRows: 2, - maximumEntries: 2, - minimumRemoteEntries: 1, - minimumRetainedFraction: 0.5 - ) - let one = Data("relay url,lat,lon\none.example,1,2\n".utf8) - let three = Data("relay url,lat,lon\none.example,1,2\ntwo.example,3,4\nthree.example,5,6\n".utf8) - - XCTAssertNil(GeoRelayDirectory.validatedEntries( - from: one, - policy: restrictive, - minimumEntries: 2 - )) - XCTAssertNil(GeoRelayDirectory.validatedEntries( - from: Data(repeating: 0x41, count: 101), - policy: restrictive, - minimumEntries: 1 - )) - XCTAssertNil(GeoRelayDirectory.validatedEntries( - from: three, - policy: restrictive, - minimumEntries: 1 - )) - } - - func test_validatedEntries_requiresExactBaselineEntryOverlap() throws { - let policy = GeoRelayDirectoryValidationPolicy( - maximumBytes: 1_000, - maximumRows: 10, - maximumEntries: 10, - minimumRemoteEntries: 1, - minimumRetainedFraction: 0.5 - ) - let baseline = Set(try XCTUnwrap(GeoRelayDirectory.validatedEntries( - from: Data(""" - relay url,lat,lon - one.example,1,1 - two.example,2,2 - three.example,3,3 - """.utf8), - policy: policy, - minimumEntries: 1 - ))) - let disjoint = Data(""" - relay url,lat,lon - four.example,1,1 - five.example,2,2 - six.example,3,3 - """.utf8) - let rewrittenCoordinates = Data(""" - relay url,lat,lon - one.example,11,11 - two.example,12,12 - three.example,13,13 - """.utf8) - let halfRetained = Data(""" - relay url,lat,lon - wss://one.example:443/,1,1 - https://two.example/,2,2 - replacement.example,4,4 - """.utf8) - - XCTAssertNil(GeoRelayDirectory.validatedEntries( - from: disjoint, - policy: policy, - minimumEntries: 1, - baselineEntries: baseline - )) - XCTAssertNil(GeoRelayDirectory.validatedEntries( - from: rewrittenCoordinates, - policy: policy, - minimumEntries: 1, - baselineEntries: baseline - )) - XCTAssertNotNil(GeoRelayDirectory.validatedEntries( - from: halfRetained, - policy: policy, - minimumEntries: 1, - baselineEntries: baseline - )) - } - - func test_bundledReviewedCSV_passesStrictProductionValidation() throws { - let repositoryRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - let data = try Data( - contentsOf: repositoryRoot.appendingPathComponent("relays/online_relays_gps.csv") - ) - - let entries = try XCTUnwrap(GeoRelayDirectory.validatedEntries( - from: data, - policy: .live, - minimumEntries: GeoRelayDirectoryValidationPolicy.live.minimumRemoteEntries - )) - XCTAssertGreaterThan(entries.count, 250) - } - func test_closestRelays_sortsByDistanceForLatLonAndGeohash() { let harness = makeHarness( cacheCSV: """ @@ -191,47 +53,6 @@ final class GeoRelayDirectoryTests: XCTestCase { ) } - func test_closestRelays_breaksDistanceTiesDeterministicallyByHost() { - // Same coordinates for all entries: selection must still be stable so - // publishers and subscribers using the same directory agree on relays. - let harness = makeHarness( - cacheCSV: """ - relay url,lat,lon - zeta.example,10,10 - alpha.example,10,10 - mike.example,10,10 - """ - ) - let directory = GeoRelayDirectory(dependencies: harness.dependencies) - - XCTAssertEqual( - directory.closestRelays(toLat: 10, lon: 10, count: 2), - ["wss://alpha.example", "wss://mike.example"] - ) - } - - func test_fetchSuccess_postsDirectoryRefreshNotification() async { - let harness = makeHarness(fetchCSV: """ - relay url,lat,lon - notify.example,1,2 - """) - let directory = GeoRelayDirectory(dependencies: harness.dependencies) - - var notified = 0 - let observer = harness.notificationCenter.addObserver( - forName: .geoRelayDirectoryDidRefresh, - object: nil, - queue: .main - ) { _ in - notified += 1 - } - defer { harness.notificationCenter.removeObserver(observer) } - - directory.prefetchIfNeeded() - let refreshed = await waitUntil { notified == 1 } - XCTAssertTrue(refreshed) - } - func test_loadLocalEntries_prefersCacheThenBundleThenWorkingDirectory() { let cacheHarness = makeHarness( cacheCSV: """ @@ -290,15 +111,11 @@ final class GeoRelayDirectoryTests: XCTestCase { harness.userDefaults.set(harness.clock.now, forKey: "georelay.lastFetchAt") let directory = GeoRelayDirectory(dependencies: harness.dependencies) - let baselineRequestCount = await harness.fetcher.recordedRequestCount() directory.prefetchIfNeeded() - // Negative check: nothing should have been scheduled, so there is no - // condition to wait on. A modest delay gives a wrongly spawned fetch - // task a chance to run before we compare against the baseline. try? await Task.sleep(nanoseconds: 20_000_000) let requestCount = await harness.fetcher.recordedRequestCount() - XCTAssertEqual(requestCount, baselineRequestCount) + XCTAssertEqual(requestCount, 0) XCTAssertFalse(directory.debugHasRetryTask) } @@ -323,11 +140,9 @@ final class GeoRelayDirectoryTests: XCTestCase { XCTAssertFalse(directory.debugHasRetryTask) directory.prefetchIfNeeded(force: true) - // Negative check against the captured baseline: the forced refetch - // must be skipped, so there is no condition to wait on. try? await Task.sleep(nanoseconds: 20_000_000) let forcedRequestCount = await harness.fetcher.recordedRequestCount() - XCTAssertEqual(forcedRequestCount, requestCount) + XCTAssertEqual(forcedRequestCount, 1) } func test_prefetchIfNeeded_runsRemoteFetchOffMainThread() async { @@ -385,53 +200,6 @@ final class GeoRelayDirectoryTests: XCTestCase { XCTAssertFalse(directory.debugHasRetryTask) } - func test_prefetchIfNeeded_rejectsSharpValidLookingTruncationBeforeCaching() async { - let cached = """ - relay url,lat,lon - old-one.example,1,1 - old-two.example,2,2 - old-three.example,3,3 - """ - let truncated = """ - relay url,lat,lon - attacker.example,9,9 - """ - let recovered = """ - relay url,lat,lon - old-one.example,1,1 - old-two.example,2,2 - new-three.example,6,6 - """ - let harness = makeHarness( - cacheCSV: cached, - fetchResults: [ - .success(Data(truncated.utf8)), - .success(Data(recovered.utf8)) - ], - validationPolicy: GeoRelayDirectoryValidationPolicy( - maximumBytes: 64 * 1024, - maximumRows: 1_000, - maximumEntries: 1_000, - minimumRemoteEntries: 1, - minimumRetainedFraction: 0.5 - ) - ) - let directory = GeoRelayDirectory(dependencies: harness.dependencies) - - directory.prefetchIfNeeded() - - let refreshed = await waitUntil { - directory.entries.contains(where: { $0.host == "new-three.example" }) - } - XCTAssertTrue(refreshed) - XCTAssertFalse(directory.entries.contains(where: { $0.host == "attacker.example" })) - let requestCount = await harness.fetcher.recordedRequestCount() - let retryDelays = await harness.retryRecorder.recordedDelays() - XCTAssertEqual(requestCount, 2) - XCTAssertEqual(retryDelays, [5]) - XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], Data(recovered.utf8)) - } - func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async { let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive") let harness = makeHarness( @@ -442,43 +210,26 @@ final class GeoRelayDirectoryTests: XCTestCase { autoStart: true, activeNotificationName: activeNotification ) - // Wait on the refresh notification rather than the raw request count: - // the request count increments before the directory finishes handling - // the response on the main actor (resetting `isFetching`, recording - // `lastFetchAt`). Posting the next trigger inside that gap would get - // swallowed by the in-flight guard. The notification is posted at the - // end of the synchronous success handler, so once it fires the next - // trigger is guaranteed to be accepted. - var refreshCount = 0 - let refreshObserver = harness.notificationCenter.addObserver( - forName: .geoRelayDirectoryDidRefresh, - object: nil, - queue: .main - ) { _ in - refreshCount += 1 - } - defer { harness.notificationCenter.removeObserver(refreshObserver) } - var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies) - let initialFetch = await waitUntil { refreshCount == 1 } + let initialFetch = await waitUntil { + await harness.fetcher.recordedRequestCount() == 1 + } XCTAssertTrue(initialFetch) - var requestCount = await harness.fetcher.recordedRequestCount() - XCTAssertEqual(requestCount, 1) XCTAssertEqual(directory?.debugObserverCount, 2) harness.clock.now = harness.clock.now.addingTimeInterval(6) harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil) - let torTriggered = await waitUntil { refreshCount == 2 } + let torTriggered = await waitUntil { + await harness.fetcher.recordedRequestCount() == 2 + } XCTAssertTrue(torTriggered) - requestCount = await harness.fetcher.recordedRequestCount() - XCTAssertEqual(requestCount, 2) harness.clock.now = harness.clock.now.addingTimeInterval(61) harness.notificationCenter.post(name: activeNotification, object: nil) - let activeTriggered = await waitUntil { refreshCount == 3 } + let activeTriggered = await waitUntil { + await harness.fetcher.recordedRequestCount() == 3 + } XCTAssertTrue(activeTriggered) - requestCount = await harness.fetcher.recordedRequestCount() - XCTAssertEqual(requestCount, 3) weak var weakDirectory: GeoRelayDirectory? weakDirectory = directory @@ -495,14 +246,7 @@ final class GeoRelayDirectoryTests: XCTestCase { fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil, fetchObserver: (@Sendable () async -> Void)? = nil, autoStart: Bool = false, - activeNotificationName: Notification.Name? = nil, - validationPolicy: GeoRelayDirectoryValidationPolicy = GeoRelayDirectoryValidationPolicy( - maximumBytes: 64 * 1024, - maximumRows: 1_000, - maximumEntries: 1_000, - minimumRemoteEntries: 1, - minimumRetainedFraction: 0 - ) + activeNotificationName: Notification.Name? = nil ) -> GeoRelayHarness { let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)" let userDefaults = UserDefaults(suiteName: userDefaultsSuite)! @@ -560,8 +304,7 @@ final class GeoRelayDirectoryTests: XCTestCase { await retryRecorder.record(delay) }, activeNotificationName: activeNotificationName, - autoStart: autoStart, - validationPolicy: validationPolicy + autoStart: autoStart ) return GeoRelayHarness( @@ -576,20 +319,8 @@ final class GeoRelayDirectoryTests: XCTestCase { ) } - /// Polls until `condition` holds. The timeout is deliberately generous: - /// constrained CI runners (2-core, serialized testing) can starve the - /// detached utility-priority fetch task for seconds before it runs, and - /// a successful wait returns as soon as the condition becomes true. - /// Default deliberately far larger than the work being awaited. - /// - /// The directory performs its fetch in a `Task.detached(priority: .utility)`, - /// and utility priority competes with every other suite on a CI runner. At - /// ten seconds the retry-scheduling test timed out at exactly 10.06s with - /// the retry never scheduled — which reads like a missing retry rather than - /// a starved background task. Returning as soon as the condition holds means - /// a longer deadline only extends the genuine-failure case. private func waitUntil( - timeout: TimeInterval = TestConstants.settleTimeout, + timeout: TimeInterval = 1.0, condition: @escaping @MainActor () async -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Nostr/NostrPoWTests.swift b/bitchatTests/Nostr/NostrPoWTests.swift deleted file mode 100644 index 1905e0f0..00000000 --- a/bitchatTests/Nostr/NostrPoWTests.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// NostrPoWTests.swift -// bitchatTests -// -// Tests for NIP-13 proof-of-work: leading-zero-bit counting, commitment -// semantics, and nonce-tag mining for geohash (kind 20000) events. -// - -import CryptoKit -import Foundation -import Testing -import BitFoundation -@testable import bitchat - -struct NostrPoWTests { - - // MARK: - Leading zero bits - - @Test func leadingZeroBitsVectors() { - #expect(NostrPoW.leadingZeroBits(Data()) == 0) - #expect(NostrPoW.leadingZeroBits(Data([0x80])) == 0) - #expect(NostrPoW.leadingZeroBits(Data([0xFF, 0x00])) == 0) - #expect(NostrPoW.leadingZeroBits(Data([0x40])) == 1) - #expect(NostrPoW.leadingZeroBits(Data([0x01])) == 7) - #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0xF0])) == 16) - #expect(NostrPoW.leadingZeroBits(Data(repeating: 0x00, count: 32)) == 256) - } - - @Test func leadingZeroBitsExactByteBoundaries() { - // Zero byte contributes exactly 8, then the next byte decides. - #expect(NostrPoW.leadingZeroBits(Data([0x00, 0xFF])) == 8) - #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x80])) == 8) - #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x7F])) == 9) - #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x01])) == 15) - #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0x01])) == 23) - } - - @Test func leadingZeroBitsMatchesNIP13ExampleVector() throws { - // Worked example from the NIP-13 spec: this event ID has 36 leading - // zero bits. - let idHex = "000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d" - let idData = try #require(Data(hexString: idHex)) - #expect(NostrPoW.leadingZeroBits(idData) == 36) - } - - // MARK: - Commitment semantics - - /// An ID with exactly 16 leading zero bits. - private let id16 = "0000f000" + String(repeating: "ab", count: 28) - - @Test func committedTargetCountsNotActualDifficulty() { - // Claimed < actual: only the committed target is credited, so lucky - // extra zeroes earn nothing beyond the commitment. - let tags = [["g", "u4pruy"], ["nonce", "12345", "8"]] - #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 8) - } - - @Test func unmetCommitmentScoresZero() { - // Actual < claimed: the commitment is not met, so the claim is void. - let tags = [["nonce", "12345", "24"]] - #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 0) - } - - @Test func exactCommitmentIsCredited() { - let tags = [["nonce", "12345", "16"]] - #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 16) - } - - @Test func missingOrMalformedNonceTagScoresZero() { - // No nonce tag at all: leading zeroes without a commitment earn no - // credit (old clients simply keep the strict rate limits). - #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["g", "u4pruy"]]) == 0) - // Nonce tag without a committed target. - #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "12345"]]) == 0) - // Non-numeric or nonsensical targets. - #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "high"]]) == 0) - #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "0"]]) == 0) - #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "-4"]]) == 0) - #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "400"]]) == 0) - // Malformed event ID. - #expect(NostrPoW.validatedDifficulty(idHex: "not-hex", tags: [["nonce", "1", "8"]]) == 0) - } - - // MARK: - Mining - - @Test func minedNonceTagMeetsCommittedDifficulty() async throws { - let pubkey = String(repeating: "a", count: 64) - let createdAt = 1_700_000_000 - let baseTags = [["g", "u4pruydq"], ["n", "tester"]] - let content = "hello pow" - - let nonceTag = try #require(await NostrPoW.mineNonceTag( - pubkey: pubkey, - createdAt: createdAt, - kind: 20000, - tags: baseTags, - content: content, - targetBits: 8 - )) - - #expect(nonceTag.count == 3) - #expect(nonceTag.first == "nonce") - #expect(nonceTag[2] == "8") - - // Recompute the canonical NIP-01 event ID with the mined tag appended - // and verify the committed difficulty is genuinely met. - let idData = try Self.eventIDHash( - pubkey: pubkey, - createdAt: createdAt, - kind: 20000, - tags: baseTags + [nonceTag], - content: content - ) - #expect(NostrPoW.leadingZeroBits(idData) >= 8) - let idHex = idData.map { String(format: "%02x", $0) }.joined() - #expect(NostrPoW.validatedDifficulty(idHex: idHex, tags: baseTags + [nonceTag]) == 8) - } - - @Test func miningSurvivesContentThatNeedsEscaping() async throws { - // The in-place template mutation must stay correct when the content - // gets JSON-escaped — including content that contains hex runs that - // look exactly like the internal nonce placeholder. - let pubkey = String(repeating: "b", count: 64) - let createdAt = 1_700_000_123 - let content = "she said \"hi\"\n0000000000000000 / ffffffffffffffff 😀\\" - let baseTags = [["g", "9q8yy"]] - - let nonceTag = try #require(await NostrPoW.mineNonceTag( - pubkey: pubkey, - createdAt: createdAt, - kind: 20000, - tags: baseTags, - content: content, - targetBits: 4 - )) - - let idData = try Self.eventIDHash( - pubkey: pubkey, - createdAt: createdAt, - kind: 20000, - tags: baseTags + [nonceTag], - content: content - ) - #expect(NostrPoW.leadingZeroBits(idData) >= 4) - } - - @Test func minedGeohashEventValidatesEndToEnd() async throws { - let identity = try NostrIdentity.generate() - let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( - content: "hello from a mined event", - geohash: "u4pruydq", - senderIdentity: identity, - nickname: "miner", - teleported: false - ) - - // The signed event's own ID (recomputed by sign()) carries the work. - #expect(event.isValidSignature()) - let idData = try #require(Data(hexString: event.id)) - #expect(NostrPoW.leadingZeroBits(idData) >= NostrPoW.targetBits) - #expect(NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) == NostrPoW.targetBits) - - // Mining must not disturb the regular geohash tags. - #expect(event.tags.contains(["g", "u4pruydq"])) - #expect(event.tags.contains(["n", "miner"])) - #expect(event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue) - } - - @Test func cancelledMiningStillProducesHonestCommitment() async throws { - // Cancelling the surrounding task expedites mining: it steps the - // committed target down and still returns a tag whose commitment the - // hash actually meets — the message is never dropped or dishonest. - let pubkey = String(repeating: "c", count: 64) - let createdAt = 1_700_000_456 - let baseTags = [["g", "gbsuv"]] - let content = "expedited" - - let miningTask = Task { - await NostrPoW.mineNonceTag( - pubkey: pubkey, - createdAt: createdAt, - kind: 20000, - tags: baseTags, - content: content, - targetBits: 240 // unreachable: forces the cap/cancel path - ) - } - miningTask.cancel() - - let nonceTag = try #require(await miningTask.value) - let committed = try #require(Int(nonceTag[2])) - #expect(committed >= 0) - #expect(committed < 240) - - if committed > 0 { - let idData = try Self.eventIDHash( - pubkey: pubkey, - createdAt: createdAt, - kind: 20000, - tags: baseTags + [nonceTag], - content: content - ) - #expect(NostrPoW.leadingZeroBits(idData) >= committed) - } - } - - // MARK: - Helpers - - /// Canonical NIP-01 event ID hash, computed independently of the - /// production code path. - private static func eventIDHash( - pubkey: String, - createdAt: Int, - kind: Int, - tags: [[String]], - content: String - ) throws -> Data { - let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content] - let json = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) - return Data(SHA256.hash(data: json)) - } -} diff --git a/bitchatTests/Nostr/NostrRelaySettingsTests.swift b/bitchatTests/Nostr/NostrRelaySettingsTests.swift deleted file mode 100644 index 30c647ae..00000000 --- a/bitchatTests/Nostr/NostrRelaySettingsTests.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -/// The built-in relay set is four well-known hostnames, so a filter blocking -/// four names ends internet-delivered private messages. These cover the -/// hand-added relays that make that recoverable without shipping a build. -struct NostrRelaySettingsTests { - /// Each case gets its own suite so nothing touches the real preferences or - /// races another case. - private func makeDefaults() -> UserDefaults { - let suite = "bitchat.tests.relays.\(UUID().uuidString)" - return UserDefaults(suiteName: suite)! - } - - private let builtIn: Set = [ - "wss://relay.damus.io", - "wss://nos.lol" - ] - - @Test func addNormalizesABareHostname() { - let defaults = makeDefaults() - - // A bare hostname is how relays are usually quoted; wss is the only - // sensible assumption. - let result = NostrRelaySettings.add("relay.example.com", builtIn: builtIn, in: defaults) - - #expect(result == .success("wss://relay.example.com")) - #expect(NostrRelaySettings.customRelays(in: defaults) == ["wss://relay.example.com"]) - } - - @Test func addAcceptsAnOnionAddress() { - let defaults = makeDefaults() - - // The reason this feature exists: an onion relay is not blockable by - // hostname or SNI filtering. - let result = NostrRelaySettings.add( - "wss://exampleonionaddressxyz234567.onion", - builtIn: builtIn, - in: defaults - ) - - #expect(result == .success("wss://exampleonionaddressxyz234567.onion")) - } - - @Test func addRejectsMalformedInput() { - let defaults = makeDefaults() - - #expect(NostrRelaySettings.add("", builtIn: builtIn, in: defaults) == .failure(.malformed)) - #expect(NostrRelaySettings.add(" ", builtIn: builtIn, in: defaults) == .failure(.malformed)) - // A scheme the relay layer cannot dial must not be stored. - #expect(NostrRelaySettings.add("ftp://relay.example.com", builtIn: builtIn, in: defaults) == .failure(.malformed)) - #expect(NostrRelaySettings.customRelays(in: defaults).isEmpty) - } - - @Test func addRejectsDuplicatesAndBuiltIns() { - let defaults = makeDefaults() - #expect(NostrRelaySettings.add("wss://relay.example.com", builtIn: builtIn, in: defaults) == .success("wss://relay.example.com")) - - // Same relay written differently still normalizes to the same URL. - #expect(NostrRelaySettings.add("relay.example.com", builtIn: builtIn, in: defaults) == .failure(.alreadyPresent)) - #expect(NostrRelaySettings.add("WSS://Relay.Example.com", builtIn: builtIn, in: defaults) == .failure(.alreadyPresent)) - // Re-adding a built-in would double-count it in the target list. - #expect(NostrRelaySettings.add("wss://nos.lol", builtIn: builtIn, in: defaults) == .failure(.alreadyPresent)) - - #expect(NostrRelaySettings.customRelays(in: defaults) == ["wss://relay.example.com"]) - } - - @Test func addStopsAtTheLimit() { - let defaults = makeDefaults() - for index in 0.. -// - -import Foundation -import Testing -@testable import bitchat - -struct NostrInboundPipelineTimestampTests { - private let now = Date(timeIntervalSince1970: 1_700_000_000) - private let nowSeconds = 1_700_000_000 - private let skew = Int(TransportConfig.nostrDMMaxClockSkewSeconds) - private let lookback = Int(TransportConfig.nostrDMSubscribeLookbackSeconds) - - @Test("Rumor timestamps inside the lookback-plus-skew window are accepted") - func acceptsPlausibleTimestamps() { - #expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds, now: now)) - #expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback + 60, now: now)) - // A sender clock slightly ahead of the receiver is tolerated. - #expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew - 60, now: now)) - } - - @Test("Future-dated and stale rumor timestamps are rejected") - func rejectsImplausibleTimestamps() { - #expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew + 60, now: now)) - #expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback - skew - 60, now: now)) - } -} diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index 4a8d33e7..4ea8020f 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -2,10 +2,11 @@ // NostrProtocolTests.swift // bitchatTests // -// Tests for BitChat's proprietary private-envelope transport over Nostr. +// Tests for NIP-17 gift-wrapped private messages // import Testing +import CryptoKit import Foundation import BitFoundation @testable import bitchat @@ -101,239 +102,24 @@ struct NostrProtocolTests { senderIdentity: sender ) - // Try to decrypt with wrong recipient. The outer envelope is bound to - // the addressed recipient's `p` tag, so this now fails validation - // before any decryption is attempted. - expectInvalidEvent { - _ = try NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: wrongRecipient - ) - } - } - - @Test func decryptAcceptsCurrentAndroidInnerRecipientTag() throws { - let sender = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - - // Current Android's `createPrivateMessage` emits an unsigned kind-14 - // inner event with exactly [["p", recipient]], while released iOS - // uses no inner tags. This isolated generator reproduces the Android - // wire shape without making the production encoder depend on it. - let giftWrap = try NostrProtocol.createPrivateMessageWithInnerTagsForTesting( - content: "legacy message from Android", - recipientPubkey: recipient.publicKeyHex, - senderIdentity: sender, - innerMessageTags: [["p", recipient.publicKeyHex]] - ) - - let result = try NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: recipient - ) - #expect(result.content == "legacy message from Android") - #expect(result.senderPubkey == sender.publicKeyHex) - } - - @Test func decryptRejectsAlternateInnerTagShapes() throws { - let sender = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - let otherRecipient = try NostrIdentity.generate() - let invalidTagShapes = [ - [["p", otherRecipient.publicKeyHex]], - [["p", recipient.publicKeyHex], ["p", recipient.publicKeyHex]], - [["p", recipient.publicKeyHex, "unexpected"]], - [["x", recipient.publicKeyHex]] - ] - - for tags in invalidTagShapes { - let giftWrap = try NostrProtocol.createPrivateMessageWithInnerTagsForTesting( - content: "invalid inner tag shape", - recipientPubkey: recipient.publicKeyHex, - senderIdentity: sender, - innerMessageTags: tags - ) - expectInvalidEvent { - _ = try NostrProtocol.decryptPrivateMessage( + // Try to decrypt with wrong recipient + if #available(macOS 14.4, iOS 17.4, *) { + #expect(throws: CryptoKitError.authenticationFailure) { + try NostrProtocol.decryptPrivateMessage( giftWrap: giftWrap, - recipientIdentity: recipient + recipientIdentity: wrongRecipient + ) + } + } else { + #expect(throws: (any Error).self) { + try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: wrongRecipient ) } } } - @Test func decryptsFrozenLegacyEnvelopeProducedByAndroidB7f0b33d() throws { - let eventData = try Data(contentsOf: fixtureURL( - name: "AndroidLegacyPrivateEnvelopeB7f0b33d" - )) - let metadataData = try Data(contentsOf: fixtureURL( - name: "AndroidLegacyPrivateEnvelopeB7f0b33dMetadata" - )) - let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData) - let metadata = try JSONDecoder().decode(AndroidLegacyEnvelopeFixture.self, from: metadataData) - let recipientKey = try #require(Data(hexString: metadata.recipientPrivateKey)) - let recipient = try NostrIdentity(privateKeyData: recipientKey) - - #expect(metadata.androidCommit == "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db") - #expect(metadata.generator == "NostrProtocol.createPrivateMessage") - // The generator patch ships as .patch.txt so the Xcode synchronized - // test group reliably copies it as a text resource; its pinned SHA-256 - // covers the unchanged patch content. - #expect(metadata.generatorPatch == "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt") - #expect(metadata.fixtureSHA256 == eventData.sha256Fingerprint()) - #expect(metadata.gradleTest.contains("NostrProtocolTest.emitCrossPlatformFixtures")) - let generatorPatch = try String(contentsOf: fixtureURL( - name: "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch", - extension: "txt" - )) - #expect(metadata.generatorPatchSHA256 == Data(generatorPatch.utf8).sha256Fingerprint()) - #expect(generatorPatch.contains("fun emitCrossPlatformFixtures()")) - #expect(generatorPatch.contains(metadata.recipientPrivateKey)) - #expect(envelope.isValidSignature()) - #expect(envelope.kind == NostrProtocol.EventKind.giftWrap.rawValue) - #expect(envelope.tags == [["p", recipient.publicKeyHex]]) - - // The Android inner event carries exactly the recipient `p` tag, so a - // successful decrypt also proves the Android inner-tag acceptance. - let result = try NostrProtocol.decryptPrivateMessage( - giftWrap: envelope, - recipientIdentity: recipient - ) - #expect(result.content == "legacy fixture from Android b7f0b33d") - #expect(result.senderPubkey == metadata.senderPublicKey) - } - - @Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws { - let eventData = try Data(contentsOf: fixtureURL( - name: "LegacyPrivateEnvelope733098bb" - )) - let keyData = try Data(contentsOf: fixtureURL( - name: "LegacyPrivateEnvelope733098bbRecipientKey" - )) - let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData) - let keyFixture = try JSONDecoder().decode(LegacyRecipientKeyFixture.self, from: keyData) - let recipientKey = try #require(Data(hexString: keyFixture.recipientPrivateKey)) - let recipient = try NostrIdentity(privateKeyData: recipientKey) - - #expect(envelope.isValidSignature()) - let result = try NostrProtocol.decryptPrivateMessage( - giftWrap: envelope, - recipientIdentity: recipient - ) - #expect(result.content == "legacy fixture from 733098bb") - #expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6") - } - - @Test func decryptRejectsOversizedCiphertextBeforeDecoding() throws { - let recipient = try NostrIdentity.generate() - let wrapper = try NostrIdentity.generate() - let oversizedContent = "v2:" - + String( - repeating: "A", - count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes - ) - let event = NostrEvent( - pubkey: wrapper.publicKeyHex, - createdAt: Date(), - kind: .giftWrap, - tags: [["p", recipient.publicKeyHex]], - content: oversizedContent - ) - let signed = try event.sign(with: wrapper.schnorrSigningKey()) - - expectInvalidCiphertext { - _ = try NostrProtocol.decryptPrivateMessage( - giftWrap: signed, - recipientIdentity: recipient - ) - } - } - - @Test func decryptDoesNotMisinterpretStandardNIP44Payload() throws { - let recipient = try NostrIdentity.generate() - let wrapper = try NostrIdentity.generate() - // A valid NIP-44 v2 payload from the official test vectors. Its wire - // format starts with a version byte in standard Base64, not BitChat's - // historical `v2:` prefix. - let standardPayload = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb" - let event = NostrEvent( - pubkey: wrapper.publicKeyHex, - createdAt: Date(), - kind: .giftWrap, - tags: [["p", recipient.publicKeyHex]], - content: standardPayload - ) - let signed = try event.sign(with: wrapper.schnorrSigningKey()) - - expectInvalidCiphertext { - _ = try NostrProtocol.decryptPrivateMessage( - giftWrap: signed, - recipientIdentity: recipient - ) - } - } - - @Test func decryptRejectsWrongOuterKind() throws { - let sender = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - var giftWrap = try NostrProtocol.createPrivateMessage( - content: "wrong outer kind", - recipientPubkey: recipient.publicKeyHex, - senderIdentity: sender - ) - giftWrap = NostrEvent( - pubkey: giftWrap.pubkey, - createdAt: Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)), - kind: .textNote, - tags: giftWrap.tags, - content: giftWrap.content - ) - - expectInvalidEvent { - _ = try NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: recipient - ) - } - } - - @Test func decryptRejectsInvalidSealSignature() throws { - let sender = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting( - content: "forged signature", - recipientPubkey: recipient.publicKeyHex, - senderIdentity: sender - ) - - expectInvalidEvent { - _ = try NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: recipient - ) - } - } - - @Test func decryptRejectsSealRumorPubkeyMismatch() throws { - let claimedSender = try NostrIdentity.generate() - let sealSigner = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting( - content: "spoofed sender", - recipientPubkey: recipient.publicKeyHex, - rumorIdentity: claimedSender, - sealSignerIdentity: sealSigner - ) - - expectInvalidEvent { - _ = try NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: recipient - ) - } - } - - @Test func testAckRoundTripNIP44V2_Delivered() throws { // Identities let sender = try NostrIdentity.generate() @@ -467,133 +253,11 @@ struct NostrProtocolTests { #expect(object["limit"] as? Int == 42) } - - @Test func inboundNostrEventRejectsTooManyTags() throws { - var eventDict = Self.validInboundEventDict() - eventDict["tags"] = Array( - repeating: ["g", "u4pruyd"], - count: TransportConfig.nostrMaxEventTags + 1 - ) - - #expect(throws: NostrError.invalidEvent) { - _ = try NostrEvent(from: eventDict) - } - } - - @Test func inboundNostrEventRejectsTooManyTagValues() throws { - var eventDict = Self.validInboundEventDict() - eventDict["tags"] = [Array( - repeating: "value", - count: TransportConfig.nostrMaxEventTagValues + 1 - )] - - #expect(throws: NostrError.invalidEvent) { - _ = try NostrEvent(from: eventDict) - } - } - - @Test func inboundNostrEventRejectsOversizedTagValues() throws { - var eventDict = Self.validInboundEventDict() - eventDict["tags"] = [[ - "g", - String(repeating: "a", count: TransportConfig.nostrMaxEventTagValueBytes + 1) - ]] - - #expect(throws: NostrError.invalidEvent) { - _ = try NostrEvent(from: eventDict) - } - } - - @Test func inboundNostrEventAcceptsTagsWithinLimits() throws { - var eventDict = Self.validInboundEventDict() - eventDict["tags"] = [["g", "u4pruyd"], ["t", "teleport"]] - - let event = try NostrEvent(from: eventDict) - - #expect(event.tags.count == 2) - } - // MARK: - Helpers - private static func validInboundEventDict() -> [String: Any] { - [ - "id": String(repeating: "0", count: 64), - "pubkey": String(repeating: "1", count: 64), - "created_at": 1_234_567, - "kind": NostrProtocol.EventKind.ephemeralEvent.rawValue, - "tags": [["g", "u4pruyd"]], - "content": "hello", - "sig": String(repeating: "2", count: 128) - ] - } - - private struct LegacyRecipientKeyFixture: Decodable { - let recipientPrivateKey: String - - enum CodingKeys: String, CodingKey { - case recipientPrivateKey = "recipient_private_key" - } - } - - private struct AndroidLegacyEnvelopeFixture: Decodable { - let androidCommit: String - let generator: String - let generatorPatch: String - let generatorPatchSHA256: String - let gradleTest: String - let fixtureSHA256: String - let recipientPrivateKey: String - let senderPublicKey: String - - enum CodingKeys: String, CodingKey { - case androidCommit = "android_commit" - case generator - case generatorPatch = "generator_patch" - case generatorPatchSHA256 = "generator_patch_sha256" - case gradleTest = "gradle_test" - case fixtureSHA256 = "fixture_sha256" - case recipientPrivateKey = "recipient_private_key" - case senderPublicKey = "sender_public_key" - } - } - - private func fixtureURL(name: String, extension fileExtension: String = "json") throws -> URL { - // Bundle.module only exists under SwiftPM; the Xcode test targets - // resolve resources through the test bundle (same pattern as - // NoiseProtocolTests' NoiseTestVectors.json loader). - #if SWIFT_PACKAGE - let bundle = Bundle.module - #else - let bundle = Bundle(for: MockKeychain.self) - #endif - return try #require(bundle.url(forResource: name, withExtension: fileExtension)) - } - private static func base64URLDecode(_ s: String) -> Data? { var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") let rem = str.count % 4 if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) } return Data(base64Encoded: str) } - - private func expectInvalidEvent(_ operation: () throws -> Void) { - do { - try operation() - Issue.record("Expected NostrError.invalidEvent") - } catch NostrError.invalidEvent { - return - } catch { - Issue.record("Expected NostrError.invalidEvent, got \(error)") - } - } - - private func expectInvalidCiphertext(_ operation: () throws -> Void) { - do { - try operation() - Issue.record("Expected NostrError.invalidCiphertext") - } catch NostrError.invalidCiphertext { - return - } catch { - Issue.record("Expected NostrError.invalidCiphertext, got \(error)") - } - } } diff --git a/bitchatTests/NotificationStreamAssemblerTests.swift b/bitchatTests/NotificationStreamAssemblerTests.swift index 81bf8502..fdd37255 100644 --- a/bitchatTests/NotificationStreamAssemblerTests.swift +++ b/bitchatTests/NotificationStreamAssemblerTests.swift @@ -1,6 +1,5 @@ import Testing import Foundation -import BitFoundation @testable import bitchat struct NotificationStreamAssemblerTests { @@ -97,28 +96,6 @@ struct NotificationStreamAssemblerTests { #expect(decoded.timestamp == packet.timestamp) } - @Test func discardsPKCSPaddingBetweenNotificationFrames() throws { - var assembler = NotificationStreamAssembler() - let packet1 = makePacket(timestamp: 0x111) - let packet2 = makePacket(timestamp: 0x222) - let paddedFrame1 = try #require(packet1.toBinaryData(padding: true), "Failed to encode first padded packet") - let paddedFrame2 = try #require(packet2.toBinaryData(padding: true), "Failed to encode second padded packet") - - var result = assembler.append(paddedFrame1) - #expect(result.frames.count == 1) - #expect(result.droppedPrefixes.isEmpty) - #expect(result.reset == false) - - result = assembler.append(paddedFrame2) - #expect(result.frames.count == 1) - #expect(result.droppedPrefixes.isEmpty) - #expect(result.reset == false) - - let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode second frame") - #expect(decoded.timestamp == packet2.timestamp) - } - - @Test func testAssemblesCompressedLargeFrame() throws { var assembler = NotificationStreamAssembler() diff --git a/bitchatTests/PTTAudioTests.swift b/bitchatTests/PTTAudioTests.swift deleted file mode 100644 index d523cb30..00000000 --- a/bitchatTests/PTTAudioTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// PTTAudioTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import AVFoundation -import Foundation -@testable import bitchat - -struct PTTAudioTests { - // MARK: - ADTS framing - - @Test func adtsHeaderEncodesFrameLengthAndFormat() { - let payload = Data(repeating: 0xAB, count: 100) - let framed = ADTSFramer.frame(payload) - #expect(framed.count == 107) - - // Syncword + MPEG-4 + layer 00 + no CRC. - #expect(framed[0] == 0xFF) - #expect(framed[1] == 0xF1) - // AAC-LC (01), sampling index 8 (16 kHz), channel config 1. - #expect(framed[2] == 0x60) - #expect(framed[3] == 0x40 | UInt8((107 >> 11) & 0x3)) - #expect(framed[4] == UInt8((107 >> 3) & 0xFF)) - #expect(framed[5] == UInt8((107 & 0x7) << 5) | 0x1F) - #expect(framed[6] == 0xFC) - #expect(Data(framed.dropFirst(7)) == payload) - } - - @Test func adtsStreamIsReadableByCoreAudio() throws { - // A receiver persists bursts as ADTS .aac; the file must be openable - // by the same machinery the voice-note UI uses (AVAudioFile). - let frames = try encodeSineFrames(seconds: 0.5) - #expect(frames.count >= 4) - - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("ptt-test-\(UUID().uuidString).aac") - defer { try? FileManager.default.removeItem(at: url) } - var stream = Data() - for frame in frames { - stream.append(ADTSFramer.frame(frame)) - } - try stream.write(to: url) - - let file = try AVAudioFile(forReading: url) - #expect(file.length > 0) - } - - // MARK: - Codec round trip - - @Test func encoderProducesRealtimeSizedFrames() throws { - let frames = try encodeSineFrames(seconds: 1.0) - // 1 s of 64 ms frames ≈ 15 (allow encoder priming slack). - #expect(frames.count >= 10) - // ~16 kbps -> ~130 bytes/frame; all frames must fit the wire budget. - for frame in frames { - #expect(frame.count > 0) - #expect(frame.count < TransportConfig.pttMaxBurstContentBytes) - } - } - - @Test func decoderRoundTripsEncodedAudio() throws { - let frames = try encodeSineFrames(seconds: 0.5) - let decoder = try #require(PTTFrameDecoder()) - - var decodedSamples = 0 - var energy: Float = 0 - for frame in frames { - guard let pcm = decoder.decode(frame) else { continue } // priming - decodedSamples += Int(pcm.frameLength) - if let channel = pcm.floatChannelData?[0] { - for i in 0..= 4 * Int(PTTAudioFormat.samplesPerFrame)) - #expect(energy > 1) - } - - // MARK: - Helpers - - private func encodeSineFrames(seconds: Double) throws -> [Data] { - let encoder = try #require(PTTFrameEncoder()) - let format = try #require(PTTAudioFormat.pcmFormat) - let totalFrames = AVAudioFrameCount(seconds * PTTAudioFormat.sampleRate) - let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: totalFrames)) - buffer.frameLength = totalFrames - let channel = try #require(buffer.floatChannelData?[0]) - for i in 0.. -// - -import Testing -import AVFoundation -import Foundation -@testable import bitchat - -/// Thread-safe: the coordinator invokes it on its private serial queue. -private final class StubAudioSession: SessionApplying, @unchecked Sendable { - private let lock = NSLock() - private var _setCategoryError: Error? - - var setCategoryError: Error? { - get { lock.withLock { _setCategoryError } } - set { lock.withLock { _setCategoryError = newValue } } - } - - func setCategory(_ category: AudioSessionCoordinator.Category) throws { - try lock.withLock { - if let error = _setCategoryError { - _setCategoryError = nil - throw error - } - } - } - - func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {} -} - -private struct StubSessionError: Error {} - -private final class StubExclusivePlayback: ExclusivePlayback { - private(set) var pauseCount = 0 - - func pauseForExclusivity() { - pauseCount += 1 - } -} - -/// Blocks activation until released, so a test can land events inside the -/// window where the (off-main) session acquire is still in flight. -private final class GatedAudioSession: SessionApplying, @unchecked Sendable { - private let gate = DispatchSemaphore(value: 0) - private let lock = NSLock() - private var _categoryCallCount = 0 - private var _activationCalls: [Bool] = [] - - /// Non-zero once the acquire has reached the session queue (setCategory - /// runs just before the gated setActive). - var categoryCallCount: Int { lock.withLock { _categoryCallCount } } - var activationCalls: [Bool] { lock.withLock { _activationCalls } } - - func open() { gate.signal() } - - func setCategory(_ category: AudioSessionCoordinator.Category) throws { - lock.withLock { _categoryCallCount += 1 } - } - - func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { - if active { gate.wait() } - lock.withLock { _activationCalls.append(active) } - } -} - -@MainActor -private final class MockPlaybackEngine: PTTPlaybackEngine { - private(set) var startCount = 0 - private(set) var stopCount = 0 - private(set) var scheduledBuffers: [AVAudioPCMBuffer] = [] - private struct HeldCompletion { - let type: PTTPlaybackCompletionType - let callback: @Sendable (PTTPlaybackCompletionEvent) -> Void - } - private var heldCompletions: [HeldCompletion] = [] - private(set) var requestedCompletionTypes: [PTTPlaybackCompletionType] = [] - var startError: Error? - - // No object -> the player registers no configuration-change observer. - var configChangeObject: AnyObject? { nil } - - func start() throws { - if let error = startError { throw error } - startCount += 1 - } - - func play() {} - - func stop() { - stopCount += 1 - } - - func schedule( - _ buffer: AVAudioPCMBuffer, - completionType: PTTPlaybackCompletionType, - completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void - ) { - // Completions are held, not fired automatically: most tests exercise - // lifecycle, not drain-out. The mock models the important distinction - // between the node consuming bytes and audio actually playing out. - scheduledBuffers.append(buffer) - requestedCompletionTypes.append(completionType) - heldCompletions.append(HeldCompletion(type: completionType, callback: completionHandler)) - } - - /// Advances the node only to the point where it has consumed each - /// buffer. A `.dataPlayedBack` request must remain pending here. - func fireDataConsumedCallbacks() { - for completion in heldCompletions { - completion.callback(.dataConsumed) - } - } - - /// Advances all scheduled audio through audible playback. - func fireDataPlayedBackCallbacks() { - let completions = heldCompletions - heldCompletions = [] - for completion in completions { - completion.callback(.dataPlayedBack) - } - } - - /// Models AVAudioPlayerNode flushing its callbacks because an external - /// engine reconfiguration stopped the node before MainActor rebuilt it. - func firePlaybackStoppedCallbacks() { - let completions = heldCompletions - heldCompletions = [] - for completion in completions { - completion.callback(.playbackStopped) - } - } - - /// Plays only the oldest scheduled buffer, leaving the rest as an - /// audible tail that an engine rebuild must preserve. - func fireNextDataPlayedBackCallback() { - guard let index = heldCompletions.firstIndex(where: { $0.type == .dataPlayedBack }) else { return } - let completion = heldCompletions.remove(at: index) - completion.callback(.dataPlayedBack) - } -} - -@MainActor -struct PTTBurstPlayerTests { - private func makePlayer( - coordinator: AudioSessionCoordinator - ) throws -> (player: PTTBurstPlayer, engines: () -> [MockPlaybackEngine]) { - final class EngineBox { var engines: [MockPlaybackEngine] = [] } - let box = EngineBox() - // Fresh exclusivity slot: parallel tests must not steal this player's - // app-wide playback slot mid-test (the async session acquire opens - // suspension windows the old synchronous start never had). - let player = try #require(PTTBurstPlayer( - coordinator: coordinator, - exclusivity: VoiceNotePlaybackCoordinator(), - makeEngine: { - let engine = MockPlaybackEngine() - box.engines.append(engine) - return engine - } - )) - return (player, { box.engines }) - } - - /// Enough encoded audio to cross `TransportConfig.pttJitterBufferSeconds` - /// so playback starts without waiting for the deadline task. - private func encodeSineFrames(seconds: Double = 1.0) throws -> [Data] { - let encoder = try #require(PTTFrameEncoder()) - let format = try #require(PTTAudioFormat.pcmFormat) - let totalFrames = AVAudioFrameCount(seconds * PTTAudioFormat.sampleRate) - let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: totalFrames)) - buffer.frameLength = totalFrames - let channel = try #require(buffer.floatChannelData?[0]) - for i in 0.. Bool, - sourceLocation: SourceLocation = #_sourceLocation - ) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout)) - while !condition(), ContinuousClock.now < deadline { - await Task.yield() - try? await Task.sleep(nanoseconds: 1_000_000) - } - #expect(condition(), sourceLocation: sourceLocation) - } - - // MARK: - Talk-over (bidirectional) - - @Test func burstDrainWaitsForAudiblePlaybackNotDataConsumption() async throws { - let coordinator = AudioSessionCoordinator(session: StubAudioSession()) - let (player, engines) = try makePlayer(coordinator: coordinator) - - player.enqueue(try encodeSineFrames()) - await waitUntil { player.isPlaying } - let engine = try #require(engines().first) - #expect(engine.requestedCompletionTypes.allSatisfy { $0 == .dataPlayedBack }) - - player.finishAfterDrain() - engine.fireDataConsumedCallbacks() - await Task.yield() - #expect(!player.stopped) - #expect(player.isPlaying) - - engine.fireDataPlayedBackCallbacks() - await waitUntil { player.stopped } - #expect(!player.isPlaying) - } - - @Test func olderPTTSessionAcquireCannotStealNewerPlaybackReservation() async throws { - let session = GatedAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - let exclusivity = VoiceNotePlaybackCoordinator() - final class EngineBox { var engines: [MockPlaybackEngine] = [] } - let box = EngineBox() - let player = try #require(PTTBurstPlayer( - coordinator: coordinator, - exclusivity: exclusivity, - makeEngine: { - let engine = MockPlaybackEngine() - box.engines.append(engine) - return engine - } - )) - - player.enqueue(try encodeSineFrames()) - await waitUntil { session.categoryCallCount == 1 } - - // A user taps a voice note while the older inbound burst is blocked - // in audio-session activation. Its immediate play intent is newer. - let voiceNote = StubExclusivePlayback() - exclusivity.activate(voiceNote) - session.open() - - await waitUntil { player.stopped } - #expect(box.engines.count == 1) - #expect(box.engines[0].startCount == 0) - #expect(voiceNote.pauseCount == 0) - #expect(!player.isPlaying) - } - - @Test func categoryEscalationRestartsEngineAndKeepsStreaming() async throws { - let coordinator = AudioSessionCoordinator(session: StubAudioSession()) - let (player, engines) = try makePlayer(coordinator: coordinator) - - let frames = try encodeSineFrames() - player.enqueue(frames) - await waitUntil { player.isPlaying } - #expect(engines().count == 1) - #expect(engines()[0].startCount == 1) - #expect(!engines()[0].scheduledBuffers.isEmpty) - - // Push-to-talk pressed while the burst plays: capture escalates the - // session category. The playback engine must restart under the new - // configuration, not die. (Escalation fan-out is delivered before - // acquire returns, so no waiting is needed here.) - let capture = try await coordinator.acquire(.capture) {} - #expect(engines().count == 2) - #expect(engines()[0].stopCount == 1) - #expect(engines()[1].startCount == 1) - #expect(player.isPlaying) - - // Frames arriving after the restart keep playing on the new engine. - player.enqueue(frames) - #expect(!engines()[1].scheduledBuffers.isEmpty) - - coordinator.release(capture) - player.stop() - #expect(!player.isPlaying) - } - - @Test func categoryEscalationReplaysOnlyUnfinishedTailAfterBurstEnd() async throws { - let coordinator = AudioSessionCoordinator(session: StubAudioSession()) - let (player, engines) = try makePlayer(coordinator: coordinator) - - let frames = try encodeSineFrames() - player.enqueue(frames) - await waitUntil { player.isPlaying } - - let originalEngine = engines()[0] - let originallyScheduled = originalEngine.scheduledBuffers.count - try #require(originallyScheduled > 1) - - // One buffer has played, but deliberately do not yield for its - // MainActor completion task. The completion latch itself must keep - // the rebuild from replaying this already-completed prefix. - originalEngine.fireNextDataPlayedBackCallback() - player.finishAfterDrain() - - // A real AVAudioPlayerNode also invokes requested callbacks when its - // engine is stopped by a configuration change. Those callbacks must - // leave the unheard tail pending for the fresh engine. - originalEngine.firePlaybackStoppedCallbacks() - - // Capture joins after END while the remaining tail is still handed - // to the old engine. The fresh engine must replay that tail instead - // of looking empty and stopping immediately. - let capture = try await coordinator.acquire(.capture) {} - try #require(engines().count == 2) - let restartedEngine = engines()[1] - #expect(restartedEngine.scheduledBuffers.count == originallyScheduled - 1) - #expect(player.isPlaying) - #expect(!player.stopped) - - // Only the fresh engine's audible completions may stop this finished - // burst; the stop callbacks above did not drain it. - #expect(player.isPlaying) - #expect(!player.stopped) - - restartedEngine.fireDataPlayedBackCallbacks() - await waitUntil { player.stopped } - #expect(!player.isPlaying) - - coordinator.release(capture) - } - - @Test func realInterruptionStillStopsPlayback() async throws { - let coordinator = AudioSessionCoordinator(session: StubAudioSession()) - let (player, engines) = try makePlayer(coordinator: coordinator) - - let frames = try encodeSineFrames() - player.enqueue(frames) - await waitUntil { player.isPlaying } - - // A system interruption (phone call) is not an escalation: stop. - await coordinator.handleInterruptionBegan() - #expect(!player.isPlaying) - #expect(engines().count == 1) - #expect(engines()[0].stopCount == 1) - - // A stopped burst stays stopped. - let before = engines()[0].scheduledBuffers.count - player.enqueue(frames) - #expect(engines()[0].scheduledBuffers.count == before) - } - - // MARK: - Burst END racing the async session acquire - - /// Models production ownership (`ChatLiveVoiceCoordinator.finalize`): the - /// assembly — the player's sole strong owner — is discarded on burst END, - /// and only the parked draining reference keeps the player alive. The - /// audio must still play out and the session token must come back once - /// the drain finishes. - @Test func burstEndDuringSessionAcquireStillPlaysWithOnlyDrainOwner() async throws { - let session = GatedAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - final class EngineBox { var engines: [MockPlaybackEngine] = [] } - let box = EngineBox() - var owner: PTTBurstPlayer? = PTTBurstPlayer( - coordinator: coordinator, - exclusivity: VoiceNotePlaybackCoordinator(), - makeEngine: { - let engine = MockPlaybackEngine() - box.engines.append(engine) - return engine - } - ) - try #require(owner != nil) - let weakPlayer = { [weak owner] in owner } - - let frames = try encodeSineFrames() - owner?.enqueue(frames) - // END lands while activation is still blocked on the session queue. - // With nothing scheduled yet, the drain check must not mistake the - // not-yet-started burst for a played-out one and drop all its audio. - var draining: PTTBurstPlayer? = owner - owner?.onStopped = { draining = nil } - owner?.finishAfterDrain() - #expect(owner?.stopped == false) - owner = nil - - session.open() - await waitUntil { weakPlayer()?.isPlaying == true } - #expect(box.engines.count == 1) - #expect(box.engines[0].startCount == 1) - #expect(!box.engines[0].scheduledBuffers.isEmpty) - - // Play the tail out: the drain must stop the player, hand the token - // back (deactivation reaches the mock), and unpark the drain owner. - box.engines[0].fireDataPlayedBackCallbacks() - await waitUntil { session.activationCalls == [true, false] } - #expect(draining == nil) - #expect(weakPlayer() == nil) - } - - /// Backstop: if every owner drops the player before it stopped, `deinit` - /// must hand the registered session token back — the coordinator retains - /// tokens strongly, so a leaked one would keep the session active (and - /// pin any escalated category) for the app's lifetime. - @Test func ownerlessPlayerDeinitReleasesSessionToken() async throws { - let session = GatedAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - var player: PTTBurstPlayer? = PTTBurstPlayer( - coordinator: coordinator, - exclusivity: VoiceNotePlaybackCoordinator(), - makeEngine: { MockPlaybackEngine() } - ) - try #require(player != nil) - - let frames = try encodeSineFrames() - player?.enqueue(frames) - // Make sure the acquire is in flight (holding the player alive - // through its call frame) before the last external reference drops. - await waitUntil { session.categoryCallCount == 1 } - player?.finishAfterDrain() - player = nil - - session.open() - // The acquire task keeps the player alive just long enough to start; - // when it deallocates, deinit must release the freshly stored token. - await waitUntil { session.activationCalls == [true, false] } - } - - // MARK: - Session acquire failure - - @Test func sessionAcquireFailureDoesNotStartUnregisteredPlayback() async throws { - let session = StubAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - let (player, engines) = try makePlayer(coordinator: coordinator) - - // Playing without a registered holder would leave the engine exposed - // to another holder's last-release deactivating the session under it. - session.setCategoryError = StubSessionError() - let frames = try encodeSineFrames() - player.enqueue(frames) - await waitUntil { player.stopped } - - #expect(engines().count == 1) - #expect(engines()[0].startCount == 0) - #expect(!player.isPlaying) - - // The failed start latched the player off; later frames are ignored. - player.enqueue(frames) - #expect(engines()[0].scheduledBuffers.isEmpty) - #expect(!player.isPlaying) - } - - // MARK: - Engine start failure - - @Test func engineStartFailureRebuildsOnceBeforeGivingUp() async throws { - let coordinator = AudioSessionCoordinator(session: StubAudioSession()) - let (player, engines) = try makePlayer(coordinator: coordinator) - - // A capture racing the start can reconfigure the session while the - // engine spins up (its escalation fan-out no-ops on a never-started - // player): the player must rebuild once against the settled - // configuration instead of latching off. - engines()[0].startError = StubSessionError() - let frames = try encodeSineFrames() - player.enqueue(frames) - - await waitUntil { player.isPlaying } - #expect(engines().count == 2) - #expect(engines()[0].startCount == 0) - #expect(engines()[1].startCount == 1) - #expect(!engines()[1].scheduledBuffers.isEmpty) - player.stop() - } -} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift deleted file mode 100644 index 453a4a9a..00000000 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ /dev/null @@ -1,899 +0,0 @@ -// -// PerformanceBaselineTests.swift -// bitchatTests -// -// Performance baselines for hot paths so regressions are measured, not -// guessed. Uses XCTest `measure {}` (swift-testing has no equivalent) with -// NO recorded baselines — these tests record timing in CI logs but never -// fail on timing, so they cannot flake. -// -// Each benchmark builds its fixtures OUTSIDE the measure block with fixed -// seeds/content so every iteration does deterministic work: no network, -// no Tor, no sleeps. -// -// Skippable via the BITCHAT_SKIP_PERF_BASELINES=1 environment variable; -// runs by default. -// - -import XCTest -import SwiftUI -import BitFoundation -@testable import bitchat - -@MainActor -final class PerformanceBaselineTests: XCTestCase { - - override func setUpWithError() throws { - try XCTSkipIf( - ProcessInfo.processInfo.environment["BITCHAT_SKIP_PERF_BASELINES"] == "1", - "Performance baselines skipped via BITCHAT_SKIP_PERF_BASELINES" - ) - } - - override func tearDown() { - // Drain main-queue tasks spawned by coordinators (handlePublicMessage - // hops etc.) so they don't bleed into the next test's measure block. - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - super.tearDown() - } - - /// Reports one human-readable throughput line per benchmark so CI logs - /// are readable without parsing XCTest's measure output. The same line is - /// appended to the file named by `BITCHAT_PERF_LOG` (if set): under - /// `swift test --parallel` the runner swallows stdout of passing tests, - /// so the CI floor gate (scripts/check-perf-floors.sh) reads the file. - private func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) { - guard !samples.isEmpty else { return } - let avg = samples.reduce(0, +) / Double(samples.count) - let opsPerSec = avg > 0 ? Double(operations) / avg : .infinity - let line = String( - format: "PERF[%@]: %.0f %@/sec (avg %.3f ms per pass of %d, %d passes)", - name, opsPerSec, unit, avg * 1000, operations, samples.count - ) - print(line) - Self.appendToPerfLog(line) - } - - private static var perfLogPath: String? { - let path = ProcessInfo.processInfo.environment["BITCHAT_PERF_LOG"] - return (path?.isEmpty ?? true) ? nil : path - } - - /// Appends with `O_APPEND` because `swift test --parallel` may split this - /// class across worker processes that write concurrently. The file is - /// append-only (CI workspaces start fresh); delete it between local runs - /// if you reuse a path. - private static func appendToPerfLog(_ line: String) { - guard let path = perfLogPath else { return } - let fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0o644) - guard fd >= 0 else { return } - defer { close(fd) } - let bytes = Array((line + "\n").utf8) - bytes.withUnsafeBufferPointer { buffer in - _ = write(fd, buffer.baseAddress, buffer.count) - } - } - - // MARK: - 1a. Nostr inbound event handling (fresh events) - - /// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events - /// (kind 20000): dedup record, presence/nickname bookkeeping, and - /// public-message ingest scheduling. Schnorr signature verification is - /// NOT part of this path anymore — it runs exactly once, off the main - /// actor, in `NostrRelayManager` before delivery. - func testNostrInboundEventHandling_freshEvents() throws { - let events = try Self.makeSignedGeohashEvents(count: 500) - // A fresh context per measure pass so every event takes the - // first-seen path on every iteration. Kept alive so the weakly - // captured Task hops stay valid. - var keepAlive: [(PerfNostrContext, ChatNostrCoordinator)] = [] - var samples: [TimeInterval] = [] - - measure { - let context = PerfNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - keepAlive.append((context, coordinator)) - let start = Date() - for event in events { - coordinator.inbound.handleNostrEvent(event) - } - samples.append(Date().timeIntervalSince(start)) - XCTAssertEqual(context.processedEventCount, events.count) - } - - reportThroughput("nostrInbound.fresh", samples: samples, operations: events.count, unit: "events") - } - - // MARK: - 1b. Nostr inbound event handling (duplicate events) - - /// The dedup-hit path: identical events replayed. Duplicates dominate - /// real relay traffic (the same event arrives from several relays), so - /// this path runs hundreds of times a minute in busy geohashes. It is a - /// pure dedup lookup: no crypto (verification happens upstream in - /// `NostrRelayManager`, and only for the first-seen copy). - func testNostrInboundEventHandling_duplicateEvents() throws { - let events = try Self.makeSignedGeohashEvents(count: 500) - let context = PerfNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - // Pre-warm: every event is now recorded as processed. - for event in events { - coordinator.inbound.handleNostrEvent(event) - } - XCTAssertEqual(context.processedEventCount, events.count) - var samples: [TimeInterval] = [] - - measure { - let start = Date() - for event in events { - coordinator.inbound.handleNostrEvent(event) - } - samples.append(Date().timeIntervalSince(start)) - } - - // Dedup held: nothing was re-processed. - XCTAssertEqual(context.processedEventCount, events.count) - reportThroughput("nostrInbound.duplicate", samples: samples, operations: events.count, unit: "events") - } - - // MARK: - 2. BLE inbound packet pipeline (decode + dedup) - - /// Binary encode/decode round trip plus `MessageDeduplicator` at - /// realistic mesh sizes: 1000 packets with 100-300 byte payloads, each - /// dedup-checked twice (first-seen insert, then duplicate hit) the way - /// relayed packets arrive on multiple links. - func testBLEInboundPacketPipeline() throws { - var rng = SeededGenerator(seed: 0xB17C4A7) - let baseTimestamp = UInt64(1_700_000_000_000) - var packets: [BitchatPacket] = [] - var dedupIDs: [String] = [] - packets.reserveCapacity(1000) - dedupIDs.reserveCapacity(1000) - for index in 0..<1000 { - let senderID = Data((0..<8).map { _ in UInt8.random(in: 0...255, using: &rng) }) - let payloadSize = Int.random(in: 100...300, using: &rng) - let payload = Data((0.. delivered alternation would now hit - /// the store's no-downgrade guard on the delivered -> sent half. - func testDeliveryStatusIncrementalUpdates() { - let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40) - let coordinator = ChatDeliveryCoordinator(context: context) - let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250) - XCTAssertEqual(targetIDs.count, 500) - - let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) - let fixedDate2 = Date(timeIntervalSince1970: 1_700_000_001) - var toggle = false - var samples: [TimeInterval] = [] - - measure { - toggle.toggle() - let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .delivered(to: "peer", at: fixedDate2) - let start = Date() - var updated = 0 - for id in targetIDs where coordinator.updateMessageDeliveryStatus(id, status: status) { - updated += 1 - } - samples.append(Date().timeIntervalSince(start)) - XCTAssertEqual(updated, targetIDs.count) - } - - reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates") - } - - // MARK: - 4b. Delivery status updates against the store directly - - /// `ConversationStore.setDeliveryStatus(_:forMessageID:)` at the same - /// scale as 4a, without the coordinator/context wrapping — the store-side - /// cost of an ID-only delivery update (map lookup + per-conversation - /// ID-index apply + change emission). Replaces the deleted - /// `delivery.indexRebuild` benchmark: the positional location index and - /// its rebuild path no longer exist; the store's ID indexes are - /// maintained inside each mutation, so there is no rebuild to measure. - func testDeliveryStatusStoreUpdates() { - let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40) - let store = context.store - let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250) - XCTAssertEqual(targetIDs.count, 500) - - // Alternate two delivered timestamps so every update is real; a - // sent <-> delivered swing would hit the no-downgrade guard. - let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) - let fixedDate2 = Date(timeIntervalSince1970: 1_700_000_001) - var toggle = false - var samples: [TimeInterval] = [] - - measure { - toggle.toggle() - let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .delivered(to: "peer", at: fixedDate2) - let start = Date() - var updated = 0 - for id in targetIDs where store.setDeliveryStatus(status, forMessageID: id) { - updated += 1 - } - samples.append(Date().timeIntervalSince(start)) - XCTAssertEqual(updated, targetIDs.count) - } - - reportThroughput("delivery.storeUpdate", samples: samples, operations: targetIDs.count, unit: "updates") - } - - // MARK: - 5. Message formatting - - /// `MessageFormattingEngine.formatMessage` over 200 messages with - /// mentions, hashtags, and URLs. Formatting caches per message, so each - /// measure pass consumes a fresh pre-built batch (cache-miss path, which - /// is the cost paid when messages first render). - func testMessageFormatting() { - let context = PerfFormattingContext(nickname: "carol") - let batchCount = 16 // > XCTest's default 10 measure iterations - let batchSize = 200 - let contents: [(String, [String]?)] = [ - ("hello mesh, anyone around tonight?", nil), - ("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]), - ("checking in from the harbor #bitchat #mesh", nil), - ("@bob#0042 ping me when you get this", ["bob#0042"]), - ("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil) - ] - let batches: [[BitchatMessage]] = (0..= messageCount - && fixture.publicChat.messages.count >= messageCount - } - samples.append(Date().timeIntervalSince(start)) - XCTAssertTrue(consistent, "messages/PublicChatModel never converged") - let ingested = fixture.viewModel.messages.filter { expectedIDs.contains($0.id) } - XCTAssertEqual(ingested.count, messageCount) - } - - reportThroughput("pipeline.publicIngest", samples: samples, operations: messageCount, unit: "messages") - } - - // MARK: - 7. ConversationStore append (to-be architecture) - - /// Core op of the new single-source-of-truth `ConversationStore` - /// (docs/CONVERSATION-STORE-DESIGN.md): 1000 appends into one - /// conversation, every 10th arriving out of order so the binary-search - /// insert path (plus suffix reindex) is part of the measured work. - /// Includes per-message ID-index dedup and the per-conversation - /// `@Published` array write. - func testConversationStoreAppend() { - let messageCount = 1000 - let base = Date(timeIntervalSince1970: 1_700_000_000) - let messages: [BitchatMessage] = (0.. Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while !condition() { - guard Date() < deadline else { return false } - RunLoop.current.run(until: Date().addingTimeInterval(0.005)) - } - return true - } - - // MARK: - Fixtures - - /// Builds deterministic signed kind-20000 geohash events. Content cycles - /// through realistic chat lines; a handful of sender identities mimics a - /// busy channel. - private static func makeSignedGeohashEvents(count: Int) throws -> [NostrEvent] { - let senders = try (0..<8).map { _ in try NostrIdentity.generate() } - let lines = [ - "hello from the geohash", - "anyone near the station?", - "@bob#0042 are you on mesh too?", - "check this out https://example.com/p/123 #bitchat", - "teleport check, who's local?" - ] - return try (0.. UInt64 { - state &+= 0x9E37_79B9_7F4A_7C15 - var z = state - z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 - z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB - return z ^ (z >> 31) - } -} - -// MARK: - Mock ChatNostrContext - -/// Minimal `ChatNostrContext` for benchmarking the `ChatNostrCoordinator` -/// stack (`NostrInboundPipeline` in particular) without a `ChatViewModel` -/// (mirrors `ChatNostrCoordinatorContextTests`). -/// Callbacks are cheap dictionary/array operations so the measured cost is -/// the coordinator's own pipeline. -@MainActor -private final class PerfNostrContext: ChatNostrContext { - var activeChannel: ChannelID = .location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd")) - var currentGeohash: String? = "u4pruyd" - var geoSubscriptionID: String? - var geoDmSubscriptionID: String? - var geoSamplingSubs: [String: String] = [:] - var lastGeoNotificationAt: [String: Date] = [:] - var nostrRelayManager: NostrRelayManager? { nil } - - func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id } - func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id } - func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash } - func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) } - - func clearGeoSamplingSubs() -> [String] { - defer { geoSamplingSubs.removeAll() } - return Array(geoSamplingSubs.keys) - } - - var messages: [BitchatMessage] = [] - func flushPublicMessagePipeline() {} - func refreshVisibleMessages(from channel: ChannelID?) {} - func addPublicSystemMessage(_ content: String) {} - func drainPendingGeohashSystemMessages() -> [String] { [] } - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { true } - - private(set) var handledPublicMessageCount = 0 - func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessageCount += 1 } - func checkForMentions(_ message: BitchatMessage) {} - func sendHapticFeedback(for message: BitchatMessage) {} - func parseMentions(from content: String) -> [String] { - MessageFormattingEngine.extractMentions(from: content) - } - - var selectedPrivateChatPeer: PeerID? - var nostrKeyMapping: [PeerID: String] = [:] - func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } - func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {} - func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} - func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} - func startPrivateChat(with peerID: PeerID) {} - - private struct NoIdentity: Error {} - var geohashIdentities: [String: NostrIdentity] = [:] - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { - guard let identity = geohashIdentities[geohash] else { throw NoIdentity() } - return identity - } - func currentNostrIdentity() -> NostrIdentity? { nil } - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { false } - func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { "anon#\(pubkeyHex.prefix(4))" } - - private var processedNostrEventIDs: Set = [] - var processedEventCount: Int { processedNostrEventIDs.count } - func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) } - func recordProcessedNostrEvent(_ eventID: String) { processedNostrEventIDs.insert(eventID) } - func clearProcessedNostrEvents() { processedNostrEventIDs.removeAll() } - - var geoNicknames: [String: String] = [:] - private var teleportedKeys: Set = [] - var teleportedGeoCount: Int { teleportedKeys.count } - func startGeoParticipantRefreshTimer() {} - func stopGeoParticipantRefreshTimer() {} - func setActiveParticipantGeohash(_ geohash: String?) {} - private(set) var participantRecords = 0 - func recordGeoParticipant(pubkeyHex: String) { participantRecords += 1 } - func recordGeoParticipant(pubkeyHex: String, geohash: String) { participantRecords += 1 } - func geoParticipantCount(for geohash: String) -> Int { 0 } - func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname } - func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) } - func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) } - func clearTeleportedGeo() { teleportedKeys.removeAll() } - func clearGeoNicknames() { geoNicknames.removeAll() } - func visibleGeohashPeople() -> [GeoPerson] { [] } - - var isTeleported = false - func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { false } - - func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} - func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {} - func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {} - - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { nil } - func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { [] } - func notifyGeohashActivity(geohash: String, bodyPreview: String) {} -} - -// MARK: - Mock ChatDeliveryContext - -/// Minimal `ChatDeliveryContext` over a real `ConversationStore` (the -/// coordinator is a thin mapper onto store intents, so the measured cost is -/// the store's ID-map delivery path). -@MainActor -private final class PerfDeliveryContext: ChatDeliveryContext { - let store = ConversationStore() - var sentReadReceipts: Set = [] - var isStartupPhase: Bool { false } - private var publicIDs: [String] = [] - private var privateIDsByPeer: [(peerID: PeerID, messageIDs: [String])] = [] - - func notifyUIChanged() {} - func markMessageDelivered(_ messageID: String) {} - func markMessageDelivered(_ messageID: String, from peerIDs: Set) {} - func confirmPrivateMediaDelivery(_ messageID: String) {} - func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool { - true - } - - @discardableResult - func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { - store.setDeliveryStatus(status, forMessageID: messageID) - } - - @discardableResult - func setDeliveryStatus( - _ status: DeliveryStatus, - forMessageID messageID: String, - inDirectPeerAliases peerIDs: Set - ) -> Bool { - store.setDeliveryStatus( - status, - forMessageID: messageID, - inDirectPeerAliases: peerIDs - ) - } - - func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { - store.deliveryStatus(forMessageID: messageID) - } - - func privateMessageIDs() -> Set { - store.directMessageIDs() - } - - func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int { - let oldCount = sentReadReceipts.count - sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) - return oldCount - sentReadReceipts.count - } - - /// `publicCount` public messages (split evenly between mesh and one - /// geohash conversation so the corpus stays under the per-conversation - /// cap) + `peerCount` x `messagesPerPeer` private messages, seeded into - /// the store with deterministic IDs and timestamps. - static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext { - let context = PerfDeliveryContext() - let base = Date(timeIntervalSince1970: 1_700_000_000) - let geohashID = ConversationID.geohash("u4pruyd") - for i in 0.. [String] { - var targetIDs: [String] = [] - let publicStride = max(1, publicIDs.count / publicTargets) - for i in stride(from: 0, to: publicIDs.count, by: publicStride) where targetIDs.count < publicTargets { - targetIDs.append(publicIDs[i]) - } - var privateCount = 0 - outer: for (_, messageIDs) in privateIDsByPeer { - for i in stride(from: 0, to: messageIDs.count, by: 4) { - guard privateCount < privateTargets else { break outer } - targetIDs.append(messageIDs[i]) - privateCount += 1 - } - } - return targetIDs - } -} - -// MARK: - End-to-end pipeline fixture - -/// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style -/// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same -/// single-writer `ConversationStore`, so end-to-end ingest benchmarks cover -/// the full ingest-to-feature-model chain. Mirrors the construction used by -/// `ChatViewModelExtensionsTests`. -@MainActor -private final class PerfPipelineFixture { - let viewModel: ChatViewModel - let conversations: ConversationStore - let privateInbox: PrivateInboxModel - let publicChat: PublicChatModel - - init() { - let keychain = MockKeychain() - let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - let identityManager = MockIdentityManager(keychain) - let transport = MockTransport() - let conversations = ConversationStore() - let locationSuite = "PerformanceBaselineTests.\(UUID().uuidString)" - let locationStorage = UserDefaults(suiteName: locationSuite) ?? .standard - locationStorage.removePersistentDomain(forName: locationSuite) - let locationManager = LocationChannelManager(storage: locationStorage) - - self.conversations = conversations - self.viewModel = ChatViewModel( - keychain: keychain, - idBridge: idBridge, - identityManager: identityManager, - transport: transport, - conversations: conversations, - locationManager: locationManager - ) - self.privateInbox = PrivateInboxModel(conversations: conversations) - self.publicChat = PublicChatModel(conversations: conversations) - } -} - -// MARK: - Mock MessageFormattingContext - -@MainActor -private final class PerfFormattingContext: MessageFormattingContext { - let nickname: String - init(nickname: String) { self.nickname = nickname } - func isSelfMessage(_ message: BitchatMessage) -> Bool { false } - func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { .blue } - func peerURL(for peerID: PeerID) -> URL? { URL(string: "bitchat://peer/\(peerID.id)") } -} diff --git a/bitchatTests/Performance/perf-floors.json b/bitchatTests/Performance/perf-floors.json deleted file mode 100644 index 751caf74..00000000 --- a/bitchatTests/Performance/perf-floors.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "_philosophy": [ - "Floor throughputs for the PERF[...] lines printed by PerformanceBaselineTests.", - "Floors catch algorithmic regressions (O(n) -> O(n^2), accidental sync I/O,", - "quadratic re-scans), NOT tuning noise. Basis: ~50% of the SLOWEST observed", - "CI run (GitHub macos-latest), not local numbers - CI slowdown is", - "benchmark-dependent (sub-millisecond passes amplify runner overhead, e.g.", - "nostrInbound.duplicate runs at ~20% of local speed on CI while most", - "benchmarks run at ~40-60%). Every floor remains 10-200x above known", - "regression values (the pre-optimization duplicate path measured 2.2k/sec", - "against a 250k floor), so the gate still fails loudly on order-of-", - "magnitude regressions while never flaking on a slow runner.", - "Raise floors deliberately after intentional performance improvements;", - "lower them only with a written justification in the PR. If a benchmark is", - "renamed or removed, update this file in the same change - the gate fails", - "when a floored benchmark stops reporting.", - "Checked by scripts/check-perf-floors.sh against captured swift-test output." - ], - "_units": "operations per second, matching each benchmark's PERF line", - "_reference_local_numbers_2026_06": { - "nostrInbound.fresh": 2132, - "nostrInbound.duplicate": 2558907, - "bleInbound.roundTripAndDedup": 38063, - "gcs.buildAndDecode": 776, - "delivery.incrementalUpdate": 172615, - "delivery.storeUpdate": 158862, - "formatting.formatMessage": 12261, - "pipeline.privateIngest": 24848, - "pipeline.publicIngest": 13102, - "store.append": 213201, - "store.audit": 362 - }, - "_reference_local_numbers_2026_07": { - "store.steadyStateAppend_before": 2315, - "store.steadyStateAppend": 53976 - }, - "floors": { - "nostrInbound.fresh": 450, - "nostrInbound.duplicate": 250000, - "bleInbound.roundTripAndDedup": 9500, - "gcs.buildAndDecode": 190, - "delivery.incrementalUpdate": 43000, - "delivery.storeUpdate": 39000, - "formatting.formatMessage": 2200, - "pipeline.privateIngest": 3000, - "pipeline.publicIngest": 2400, - "store.append": 48000, - "store.steadyStateAppend": 10000, - "store.audit": 70 - }, - "_slowest_observed_ci_numbers_2026_06": { - "nostrInbound.fresh": 918, - "nostrInbound.duplicate": 524924, - "bleInbound.roundTripAndDedup": 20489, - "gcs.buildAndDecode": 504, - "delivery.incrementalUpdate": 110892, - "delivery.storeUpdate": 96531, - "formatting.formatMessage": 4575, - "pipeline.privateIngest": 6388, - "pipeline.publicIngest": 5006, - "store.append": 97423, - "store.audit": 140 - } -} diff --git a/bitchatTests/Prekeys/NoisePrekeyTests.swift b/bitchatTests/Prekeys/NoisePrekeyTests.swift deleted file mode 100644 index 44a39d11..00000000 --- a/bitchatTests/Prekeys/NoisePrekeyTests.swift +++ /dev/null @@ -1,283 +0,0 @@ -// -// NoisePrekeyTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -import CryptoKit -import BitFoundation -@testable import bitchat - -/// Forward-secret one-way Noise X envelopes sealed to one-time prekeys -/// instead of the recipient's identity static key. -struct NoisePrekeyTests { - - @Test func sealAndOpenRoundTrip() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let bundle = try #require(bob.currentPrekeyBundle()) - let prekey = try #require(bundle.prekeys.first) - - let payload = Data("meet at the north gate".utf8) - let sealed = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) - - let opened = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) - #expect(opened.payload == payload) - // The X pattern authenticates the sender: Bob learns Alice's real static key. - #expect(opened.senderStaticKey == alice.getStaticPublicKeyData()) - } - - @Test func wrongPrekeyIDCannotOpen() throws { - // The prologue binds the ciphertext to a specific prekey ID; opening - // with a different (existing) prekey must fail. - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let bundle = try #require(bob.currentPrekeyBundle()) - #expect(bundle.prekeys.count >= 2) - - let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: bundle.prekeys[0]) - #expect(throws: (any Error).self) { - _ = try bob.openPrekeyPayload(sealed, prekeyID: bundle.prekeys[1].id) - } - } - - @Test func unknownPrekeyIDThrows() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let bundle = try #require(bob.currentPrekeyBundle()) - let prekey = try #require(bundle.prekeys.first) - - let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) - #expect(throws: NoiseEncryptionError.unknownPrekey) { - _ = try bob.openPrekeyPayload(sealed, prekeyID: 0xDEAD_BEEF) - } - } - - @Test func wrongRecipientCannotOpen() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let carol = NoiseEncryptionService(keychain: MockKeychain()) - let bobBundle = try #require(bob.currentPrekeyBundle()) - // Ensure Carol holds a prekey under the same ID as Bob's. - _ = try #require(carol.currentPrekeyBundle()) - let prekey = try #require(bobBundle.prekeys.first) - - let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) - #expect(throws: (any Error).self) { - _ = try carol.openPrekeyPayload(sealed, prekeyID: prekey.id) - } - } - - @Test func tamperedCiphertextFailsToOpen() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let bundle = try #require(bob.currentPrekeyBundle()) - let prekey = try #require(bundle.prekeys.first) - - var sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) - sealed[sealed.count - 1] ^= 0x01 - #expect(throws: (any Error).self) { - _ = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) - } - } - - @Test func consumedPrekeyStillOpensRedeliveredCiphertext() throws { - // Spray-and-wait can deliver the same ciphertext via several couriers - // days apart; the consumed private survives a grace window for that. - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let bundle = try #require(bob.currentPrekeyBundle()) - let prekey = try #require(bundle.prekeys.first) - - let sealed = try alice.sealPrekeyPayload(Data("hello".utf8), recipientPrekey: prekey) - let first = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) - let second = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) - #expect(first.payload == second.payload) - } - - @Test func prekeyAndStaticSealsAreNotInterchangeable() throws { - // Domain-separated prologues: a static-sealed envelope must not open - // via the prekey path and vice versa, even with matching key material. - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let bundle = try #require(bob.currentPrekeyBundle()) - let prekey = try #require(bundle.prekeys.first) - - let staticSealed = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) - #expect(throws: (any Error).self) { - _ = try bob.openPrekeyPayload(staticSealed, prekeyID: prekey.id) - } - - let prekeySealed = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: prekey) - #expect(throws: (any Error).self) { - _ = try bob.openCourierPayload(prekeySealed) - } - } - - @Test func sealRejectsInvalidPrekeyPublicKey() { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - #expect(throws: (any Error).self) { - _ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 0, count: 32))) - } - #expect(throws: (any Error).self) { - _ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 1, count: 8))) - } - } - - @Test func sealsAreNotLinkableAcrossSends() throws { - // Fresh ephemeral per seal even to the same prekey. - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let bundle = try #require(bob.currentPrekeyBundle()) - let prekey = try #require(bundle.prekeys.first) - let payload = Data("same message".utf8) - - let a = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) - let b = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) - #expect(a != b) - #expect(a.prefix(32) != b.prefix(32)) - } -} - -/// Local one-time prekey lifecycle: batch generation, consumption, the 48h -/// redelivery grace window, replenishment, and the panic wipe. -struct LocalPrekeyStoreTests { - - private final class Clock { - var now: Date - init(_ now: Date = Date()) { self.now = now } - } - - private func makeStore(clock: Clock, keychain: MockKeychain = MockKeychain()) -> LocalPrekeyStore { - LocalPrekeyStore(keychain: keychain, now: { clock.now }) - } - - private func bundle(noiseKey: Data, prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) -> PrekeyBundle { - PrekeyBundle( - noiseStaticPublicKey: noiseKey, - prekeys: prekeys, - generatedAt: generatedAt, - signature: Data(count: PrekeyBundle.signatureLength) - ) - } - - @Test func mintsFullBatchOnFirstUse() { - let store = makeStore(clock: Clock()) - let (prekeys, generatedAt) = store.currentBundlePrekeys() - #expect(prekeys.count == LocalPrekeyStore.Policy.batchSize) - #expect(generatedAt > 0) - #expect(Set(prekeys.map(\.id)).count == prekeys.count) - } - - @Test func consumptionBelowThresholdTriggersReplenishAndBumpsGeneration() { - let clock = Clock() - let store = makeStore(clock: clock) - let (initial, firstGeneratedAt) = store.currentBundlePrekeys() - - // Consuming down to the threshold does not regenerate... - let keepUnconsumed = LocalPrekeyStore.Policy.replenishThreshold - for prekey in initial.dropLast(keepUnconsumed) { - store.markConsumed(prekey.id) - } - #expect(!store.replenishIfNeeded()) - #expect(store.unconsumedCount == keepUnconsumed) - - // ...one more consumption does, topping back up to a full batch with - // a newer generation stamp. - clock.now = clock.now.addingTimeInterval(60) - store.markConsumed(initial[initial.count - keepUnconsumed].id) - #expect(store.replenishIfNeeded()) - let (replenished, secondGeneratedAt) = store.currentBundlePrekeys() - #expect(replenished.count == LocalPrekeyStore.Policy.batchSize) - #expect(secondGeneratedAt > firstGeneratedAt) - // Surviving unconsumed prekeys stay in the fresh bundle. - let survivorIDs = Set(initial.suffix(keepUnconsumed - 1).map(\.id)) - #expect(survivorIDs.isSubset(of: Set(replenished.map(\.id)))) - } - - @Test func consumingAPrekeyRepublishesANewerBundlePeersAccept() { - // Codex P1: consuming a prekey (even above the replenish threshold) - // must republish a strictly newer bundle so a peer that cached the old - // one replaces it and stops assigning the consumed ID before its 48h - // grace lapses. - let clock = Clock() - let store = makeStore(clock: clock) - let noiseKey = Data(repeating: 0xC0, count: 32) - - // Owner publishes; a peer caches it and would assign the first prekey. - let (initial, firstGeneratedAt) = store.currentBundlePrekeys() - let peerCache = PrekeyBundleStore(persistsToDisk: false) - #expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: initial, generatedAt: firstGeneratedAt))) - let consumedID = initial[0].id - #expect(peerCache.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == consumedID) - - // The owner opens mail sealed to that prekey: it's retired and the - // republished bundle is strictly newer and no longer offers the ID. - #expect(store.markConsumed(consumedID)) - let (afterConsume, secondGeneratedAt) = store.currentBundlePrekeys() - #expect(secondGeneratedAt > firstGeneratedAt) - #expect(!afterConsume.contains { $0.id == consumedID }) - - // The peer accepts the replacement (a same-generatedAt copy would be - // rejected) and stops assigning the consumed ID for new mail. - #expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: afterConsume, generatedAt: secondGeneratedAt))) - #expect(peerCache.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id != consumedID) - - // 48h grace: the owner can still open a redelivery of the in-flight - // ciphertext sealed to the consumed ID until the window lapses. - clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60) - #expect(store.privateKey(for: consumedID) != nil) - clock.now = clock.now.addingTimeInterval(120) - #expect(store.privateKey(for: consumedID) == nil) - } - - @Test func consumedPrivateSurvivesGraceWindowThenDies() { - let clock = Clock() - let store = makeStore(clock: clock) - let (prekeys, _) = store.currentBundlePrekeys() - let id = prekeys[0].id - - store.markConsumed(id) - // Within the grace window: still retrievable for redeliveries. - clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60) - #expect(store.privateKey(for: id) != nil) - - // Past the grace window: gone (even before replenish prunes it). - clock.now = clock.now.addingTimeInterval(120) - #expect(store.privateKey(for: id) == nil) - store.replenishIfNeeded() - #expect(store.privateKey(for: id) == nil) - } - - @Test func persistsAcrossInstances() { - let keychain = MockKeychain() - let clock = Clock() - let first = LocalPrekeyStore(keychain: keychain, now: { clock.now }) - let (prekeys, generatedAt) = first.currentBundlePrekeys() - first.markConsumed(prekeys[0].id) - - let second = LocalPrekeyStore(keychain: keychain, now: { clock.now }) - let (reloaded, reloadedGeneratedAt) = second.currentBundlePrekeys() - // Consuming a prekey shrinks the published bundle, so its generation - // stamp advances strictly (even without the clock moving) — peers must - // see a newer bundle to replace the one that still offered the - // consumed ID. - #expect(reloadedGeneratedAt > generatedAt) - #expect(Set(reloaded.map(\.id)) == Set(prekeys.dropFirst().map(\.id))) - // The consumed key is still openable within grace after a relaunch. - #expect(second.privateKey(for: prekeys[0].id) != nil) - } - - @Test func wipeRemovesEverything() { - let keychain = MockKeychain() - let store = LocalPrekeyStore(keychain: keychain) - let (prekeys, _) = store.currentBundlePrekeys() - store.wipe() - #expect(store.privateKey(for: prekeys[0].id) == nil) - #expect(keychain.getIdentityKey(forKey: "prekeysV1") == nil) - } -} diff --git a/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift b/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift deleted file mode 100644 index 319757f0..00000000 --- a/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift +++ /dev/null @@ -1,197 +0,0 @@ -// -// PrekeyBundleStoreTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -import CryptoKit -import BitFoundation -@testable import bitchat - -/// Sender-side cache of peers' verified prekey bundles: latest-wins ingest, -/// per-message prekey assignment (never reused across messages), expiry, and -/// the peer cap. -struct PrekeyBundleStoreTests { - - private func makeBundle( - noiseKey: Data = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation, - ids: [UInt32] = [0, 1, 2], - generatedAt: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000) - ) -> PrekeyBundle { - PrekeyBundle( - noiseStaticPublicKey: noiseKey, - prekeys: ids.map { PrekeyBundle.Prekey(id: $0, publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation) }, - generatedAt: generatedAt, - signature: Data(count: PrekeyBundle.signatureLength) - ) - } - - @Test func ingestKeepsLatestByGeneratedAt() { - let store = PrekeyBundleStore(persistsToDisk: false) - let noiseKey = Data(repeating: 0xB0, count: 32) - let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - - let old = makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000) - let new = makeBundle(noiseKey: noiseKey, ids: [2, 3], generatedAt: nowMs) - - #expect(store.ingest(new)) - // Older (and equal) bundles never displace a newer one. - #expect(!store.ingest(old)) - #expect(!store.ingest(new)) - - let assigned = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) - #expect(assigned?.id == 2) - } - - @Test func assignmentsConsumeDistinctPrekeysPerMessage() { - let store = PrekeyBundleStore(persistsToDisk: false) - let noiseKey = Data(repeating: 0xB1, count: 32) - #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [10, 11]))) - - let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) - let second = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey) - #expect(first?.id == 10) - #expect(second?.id == 11) - // Exhausted: fall back to static sealing. - #expect(store.assignPrekey(messageID: "m3", recipientNoiseKey: noiseKey) == nil) - #expect(!store.hasUsableBundle(for: noiseKey)) - } - - @Test func redepositOfSameMessageReusesItsPrekey() { - let store = PrekeyBundleStore(persistsToDisk: false) - let noiseKey = Data(repeating: 0xB2, count: 32) - #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [5, 6, 7]))) - - let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) - let retry = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) - #expect(first?.id == retry?.id) - #expect(first?.publicKey == retry?.publicKey) - // Only one prekey was burned. - let next = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey) - #expect(next?.id == 6) - } - - @Test func topUpBundleKeepsConsumptionStateForSurvivingIDs() { - let store = PrekeyBundleStore(persistsToDisk: false) - let noiseKey = Data(repeating: 0xB3, count: 32) - let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000))) - #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 0) - - // The owner topped up: ID 1 survives (still unconsumed on their side), - // ID 0 rotated out, new IDs appear. - #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 8, 9], generatedAt: nowMs))) - // m1's assignment referenced a rotated-out ID; a re-deposit picks a - // fresh one rather than sealing to a dead key. - #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1) - #expect(store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 8) - } - - @Test func expiredBundleIsNeverUsed() { - var current = Date() - let store = PrekeyBundleStore(persistsToDisk: false, now: { current }) - let noiseKey = Data(repeating: 0xB4, count: 32) - #expect(store.ingest(makeBundle(noiseKey: noiseKey, generatedAt: UInt64(current.timeIntervalSince1970 * 1000)))) - #expect(store.hasUsableBundle(for: noiseKey)) - - current = current.addingTimeInterval(PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds + 60) - #expect(!store.hasUsableBundle(for: noiseKey)) - #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) == nil) - } - - @Test func peerCapEvictsLeastRecentlyUpdated() { - var current = Date() - let store = PrekeyBundleStore(persistsToDisk: false, maxPeers: 2, now: { current }) - let keys = (0..<3).map { Data(repeating: UInt8(0xC0 + $0), count: 32) } - - for key in keys { - #expect(store.ingest(makeBundle(noiseKey: key, generatedAt: UInt64(current.timeIntervalSince1970 * 1000)))) - current = current.addingTimeInterval(1) - } - // Oldest entry evicted; the two most recent survive. - #expect(!store.hasUsableBundle(for: keys[0])) - #expect(store.hasUsableBundle(for: keys[1])) - #expect(store.hasUsableBundle(for: keys[2])) - } - - @Test func persistsAcrossInstancesAndWipes() throws { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("prekey-bundle-store-tests-\(UUID().uuidString)", isDirectory: true) - let fileURL = dir.appendingPathComponent("bundles.json") - defer { try? FileManager.default.removeItem(at: dir) } - - let noiseKey = Data(repeating: 0xB5, count: 32) - let first = PrekeyBundleStore(fileURL: fileURL) - #expect(first.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 2]))) - #expect(first.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1) - - // Consumption state survives a relaunch, so a restart can't reuse a prekey. - let second = PrekeyBundleStore(fileURL: fileURL) - #expect(second.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 2) - - second.wipe() - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - let third = PrekeyBundleStore(fileURL: fileURL) - #expect(!third.hasUsableBundle(for: noiseKey)) - } -} - -/// Envelope v2 wire compatibility: the prekey ID rides an optional TLV that -/// v1 decoders skip as unknown. -struct CourierEnvelopeV2Tests { - - @Test func prekeyIDRoundTrips() throws { - let envelope = CourierEnvelope( - recipientTag: Data(repeating: 0x11, count: CourierEnvelope.tagLength), - expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000, - ciphertext: Data("ciphertext".utf8), - copies: 4, - prekeyID: 0xAABB_CCDD - ) - let encoded = try #require(envelope.encode()) - let decoded = try #require(CourierEnvelope.decode(encoded)) - #expect(decoded == envelope) - #expect(decoded.prekeyID == 0xAABB_CCDD) - } - - @Test func v1EnvelopeDecodesWithNilPrekeyID() throws { - let envelope = CourierEnvelope( - recipientTag: Data(repeating: 0x22, count: CourierEnvelope.tagLength), - expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000, - ciphertext: Data("legacy".utf8) - ) - let encoded = try #require(envelope.encode()) - let decoded = try #require(CourierEnvelope.decode(encoded)) - #expect(decoded.prekeyID == nil) - } - - @Test func v1EncodingIsByteIdenticalWithoutPrekeyID() throws { - // Static-sealed envelopes must stay on the pre-prekey wire format. - let tag = Data(repeating: 0x33, count: CourierEnvelope.tagLength) - let expiry: UInt64 = 1_800_000_000_000 - let ciphertext = Data("same".utf8) - let v1 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext).encode()) - let v1Explicit = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: nil).encode()) - #expect(v1 == v1Explicit) - // And a v2 envelope is the v1 bytes plus one trailing TLV a v1 - // decoder skips as unknown. - let v2 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: 7).encode()) - #expect(v2.prefix(v1.count) == v1) - #expect(v2.count == v1.count + 3 + 4) - } - - @Test func withCopiesPreservesPrekeyID() { - let envelope = CourierEnvelope( - recipientTag: Data(repeating: 0x44, count: CourierEnvelope.tagLength), - expiry: 1, - ciphertext: Data([0x01]), - copies: 4, - prekeyID: 9 - ) - #expect(envelope.withCopies(2).prekeyID == 9) - } -} diff --git a/bitchatTests/Prekeys/PrekeyBundleTests.swift b/bitchatTests/Prekeys/PrekeyBundleTests.swift deleted file mode 100644 index 8d9b212b..00000000 --- a/bitchatTests/Prekeys/PrekeyBundleTests.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// PrekeyBundleTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -import CryptoKit -import BitFoundation -@testable import bitchat - -/// Wire format and signature binding for gossiped one-time prekey bundles. -struct PrekeyBundleTests { - - private func makePrekeys(_ count: Int) -> [PrekeyBundle.Prekey] { - (0.. 0) - cleanupCanComplete = true - - // The first access retries cleanup synchronously. It must not return - // any surviving value from before the reinstall. - #expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil) - #expect(reconciliationAttempts == failedAttempts + 1) - #expect(manager.load( - key: "favorite", - service: "chat.bitchat.favorites" - ) == nil) - #expect(manager.load( - key: "outbox", - service: "chat.bitchat.outbox" - ) == nil) - #expect(manager.load( - key: "custom", - service: "chat.bitchat.future-custom" - ) == nil) - - let replacementIdentity = Data([21, 22, 23]) - let replacementCustom = Data([24, 25, 26]) - #expect(manager.saveIdentityKey( - replacementIdentity, - forKey: "noiseStaticKey" - )) - #expect(manager.getIdentityKey( - forKey: "noiseStaticKey" - ) == replacementIdentity) - manager.save( - key: "custom", - data: replacementCustom, - service: "chat.bitchat.future-custom", - accessible: nil - ) - #expect(manager.load( - key: "custom", - service: "chat.bitchat.future-custom" - ) == replacementCustom) - } } diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolPaddingTests.swift b/bitchatTests/Protocol/BinaryProtocolPaddingTests.swift similarity index 97% rename from localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolPaddingTests.swift rename to bitchatTests/Protocol/BinaryProtocolPaddingTests.swift index 7e87fae5..a5e3f44f 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolPaddingTests.swift +++ b/bitchatTests/Protocol/BinaryProtocolPaddingTests.swift @@ -6,7 +6,7 @@ // import Testing -@testable import BitFoundation +@testable import bitchat struct BinaryProtocolPaddingTests { @Test func padded_vs_unpadded_length() throws { diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift b/bitchatTests/Protocol/BinaryProtocolTests.swift similarity index 98% rename from localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift rename to bitchatTests/Protocol/BinaryProtocolTests.swift index ddf7c8fc..61a5c906 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift +++ b/bitchatTests/Protocol/BinaryProtocolTests.swift @@ -8,7 +8,8 @@ import Testing import Foundation -@testable import BitFoundation +import BitFoundation +@testable import bitchat struct BinaryProtocolTests { @@ -46,8 +47,7 @@ struct BinaryProtocolTests { // Verify recipient #expect(decodedPacket.recipientID != nil) let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes() - // Recipient IDs are a fixed 8-byte wire field: encode pads or truncates - // to BinaryProtocol.recipientIDSize, so only the first 8 bytes survive. + // TODO: Check if this is intended that the decoding only gets the first 8 #expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01") } @@ -295,7 +295,7 @@ struct BinaryProtocolTests { @Test("Create a large, compressible payload above current threshold (2048B)") func payloadCompression() throws { let repeatedString = String(repeating: "This is a test message. ", count: 200) - let largePayload = Data(repeatedString.utf8) + let largePayload = repeatedString.data(using: .utf8)! let packet = TestHelpers.createTestPacket(payload: largePayload) @@ -315,7 +315,7 @@ struct BinaryProtocolTests { @Test("Small payloads should not be compressed") func smallPayloadNoCompression() throws { - let smallPayload = Data("Hi".utf8) + let smallPayload = "Hi".data(using: .utf8)! let packet = TestHelpers.createTestPacket(payload: smallPayload) let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet") let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet") @@ -363,7 +363,7 @@ struct BinaryProtocolTests { var encodedSizes = Set() for payload in payloads { - let packet = TestHelpers.createTestPacket(payload: Data(payload.utf8)) + let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!) let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet") // Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently) @@ -806,13 +806,3 @@ struct BinaryProtocolTests { // We don't assert the result, just that no crash happens } } - -private extension Data { - func trimmingNullBytes() -> Data { - // Find the first null byte - if let nullIndex = self.firstIndex(of: 0) { - return self.prefix(nullIndex) - } - return self - } -} diff --git a/bitchatTests/ProtocolContractTests.swift b/bitchatTests/ProtocolContractTests.swift index 881dc3aa..df8a4b46 100644 --- a/bitchatTests/ProtocolContractTests.swift +++ b/bitchatTests/ProtocolContractTests.swift @@ -15,14 +15,18 @@ private final class DefaultDelegateProbe: BitchatDelegate { private final class DefaultTransportProbe: Transport { weak var delegate: BitchatDelegate? - weak var eventDelegate: TransportEventDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? let subject = CurrentValueSubject<[TransportPeerSnapshot], Never>([]) let myPeerID = PeerID(str: "0011223344556677") var myNickname = "Tester" + private let keychain = MockKeychain() private(set) var sentMessages: [(content: String, mentions: [String])] = [] + var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { + subject.eraseToAnyPublisher() + } + func currentPeerSnapshots() -> [TransportPeerSnapshot] { subject.value } func setNickname(_ nickname: String) { myNickname = nickname } func startServices() {} @@ -35,6 +39,7 @@ private final class DefaultTransportProbe: Transport { func getFingerprint(for peerID: PeerID) -> String? { nil } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func triggerHandshake(with peerID: PeerID) {} + func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService(keychain: keychain) } func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions)) } func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {} func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {} @@ -46,19 +51,14 @@ private final class DefaultTransportProbe: Transport { struct ProtocolContractTests { @Test func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() { - // Aliases must match what CommandProcessor actually accepts — - // the suggestion panel is the only command-discovery surface. - #expect(CommandInfo.message.id == "msg") - #expect(CommandInfo.message.alias == "/msg") + #expect(CommandInfo.message.id == "dm") + #expect(CommandInfo.message.alias == "/dm") #expect(CommandInfo.message.placeholder != nil) #expect(CommandInfo.clear.placeholder == nil) #expect(CommandInfo.favorite.description.isEmpty == false) - #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help)) - // Favorites are rejected by the processor in geohash contexts, so - // they are suggested only in mesh. - #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite)) - #expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite) == false) - #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite) == false) + #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false) + #expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite)) + #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite)) } @Test @@ -85,19 +85,24 @@ struct ProtocolContractTests { func transportDefaults_forwardOrNoOp() { let probe = DefaultTransportProbe() let peerID = PeerID(str: "0123456789abcdef") + let filePacket = BitchatFilePacket( + fileName: "voice.m4a", + fileSize: 4, + mimeType: "audio/mp4", + content: Data([1, 2, 3, 4]) + ) probe.sendMessage("hello", mentions: ["@alice"], messageID: "msg-1", timestamp: Date()) + probe.sendVerifyChallenge(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x01])) + probe.sendVerifyResponse(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x02])) + probe.sendFileBroadcast(filePacket, transferId: "tx-1") + probe.sendFilePrivate(filePacket, to: peerID, transferId: "tx-2") + probe.cancelTransfer("tx-3") + probe.declinePendingFile(id: "pending") #expect(probe.sentMessages.count == 1) #expect(probe.sentMessages.first?.content == "hello") - // Mesh-only features are capability protocols now, not inert - // defaults: a core-only transport simply doesn't have them. - #expect(!(probe as AnyObject is MeshFileTransferring)) - #expect(!(probe as AnyObject is MeshDiagnosing)) - #expect(probe.peerCapabilities(peerID).isEmpty) - // Secure delivery defaults to prompt delivery (itself defaulting to - // reachability) for transports without a forgeable link layer. - #expect(probe.canDeliverSecurely(to: peerID) == false) + #expect(probe.acceptPendingFile(id: "pending") == nil) } @Test diff --git a/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift b/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift index 09047b9a..0d5fa442 100644 --- a/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift +++ b/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift @@ -1,6 +1,5 @@ import Foundation import XCTest -@testable import BitFoundation // to avoid unnecessary public's @testable import bitchat final class BinaryEncodingUtilsTests: XCTestCase { diff --git a/bitchatTests/Protocols/BitchatFilePacketTests.swift b/bitchatTests/Protocols/BitchatFilePacketTests.swift index 22ea83f9..849eda47 100644 --- a/bitchatTests/Protocols/BitchatFilePacketTests.swift +++ b/bitchatTests/Protocols/BitchatFilePacketTests.swift @@ -1,4 +1,3 @@ -import BitFoundation import XCTest @testable import bitchat @@ -74,149 +73,4 @@ final class BitchatFilePacketTests: XCTestCase { XCTAssertEqual(decoded.fileSize, UInt64(content.count)) XCTAssertEqual(decoded.content, content) } - - /// The TLV tag list is a floor, not a ceiling: a decoder that bails on the - /// first tag it does not know makes the format unextendable, because a field - /// the sender considered optional costs the receiver the whole file. This - /// decoder skips them (`case nil: continue`) and that has to stay true — it - /// is load-bearing for any peer, version or third-party client that adds a - /// field we have not seen. `PrivateMediaMessageIdentity` exists precisely - /// because the Android decoder does *not* do this, so the asymmetry is real - /// and worth pinning on the side that gets it right. - func testDecodeSkipsUnknownTLVTypesInsteadOfDroppingTheFile() throws { - let content = Data((0..<64).map { UInt8($0) }) - let unknownValue = Data("some-message-id".utf8) - var data = Data() - - // fileName - data.append(0x01) - data.append(contentsOf: [0x00, 0x09]) - data.append(Data("photo.jpg".utf8)) - // fileSize - data.append(0x02) - data.append(contentsOf: [0x00, 0x04]) - data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)]) - // mimeType - data.append(0x03) - data.append(contentsOf: [0x00, 0x0A]) - data.append(Data("image/jpeg".utf8)) - // An unknown tag, where an encoder appending content last would put it - data.append(0x05) - data.append(contentsOf: [0x00, UInt8(unknownValue.count)]) - data.append(unknownValue) - // content - data.append(0x04) - data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)]) - data.append(content) - - let decoded = try XCTUnwrap(BitchatFilePacket.decode(data)) - XCTAssertEqual(decoded.fileName, "photo.jpg") - XCTAssertEqual(decoded.mimeType, "image/jpeg") - XCTAssertEqual(decoded.fileSize, UInt64(content.count)) - XCTAssertEqual(decoded.content, content) - } - - /// Same contract for an extension that trails the content, which a decoder - /// stopping at the first unknown tag would also lose. - func testDecodeSkipsAnUnknownTLVTrailingTheContent() throws { - let content = Data(repeating: 0x7F, count: 16) - var data = Data() - - data.append(0x01) - data.append(contentsOf: [0x00, 0x08]) - data.append(Data("note.m4a".utf8)) - data.append(0x04) - data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)]) - data.append(content) - data.append(0x7F) - data.append(contentsOf: [0x00, 0x04]) - data.append(Data([0x11, 0x11, 0x11, 0x11])) - - let decoded = try XCTUnwrap(BitchatFilePacket.decode(data)) - XCTAssertEqual(decoded.fileName, "note.m4a") - XCTAssertNil(decoded.mimeType) - XCTAssertEqual(decoded.fileSize, UInt64(content.count)) - XCTAssertEqual(decoded.content, content) - } - - func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws { - let senderKey = Data(repeating: 0x11, count: 32) - let recipientKey = Data(repeating: 0x22, count: 32) - let senderStable = PeerID(hexData: senderKey) - let recipientStable = PeerID(hexData: recipientKey) - let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" - - let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID( - senderPeerID: senderStable.toShort(), - recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"), - fileName: fileName - )) - let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID( - senderPeerID: senderStable, - recipientPeerID: recipientStable.toShort(), - fileName: fileName - )) - - XCTAssertEqual(senderID, receiverID) - XCTAssertTrue(senderID.hasPrefix("media-")) - XCTAssertEqual(senderID.count, 38) - XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID)) - XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))")) - XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))")) - XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString)) - } - - func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws { - let alice = PeerID(str: "0011223344556677") - let bob = PeerID(str: "8899aabbccddeeff") - let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a" - let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a" - let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID( - senderPeerID: alice, - recipientPeerID: bob, - fileName: firstName - )) - - XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID( - senderPeerID: bob, - recipientPeerID: alice, - fileName: firstName - )) - XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID( - senderPeerID: alice, - recipientPeerID: bob, - fileName: secondName - )) - XCTAssertNil(PrivateMediaMessageIdentity.stableID( - senderPeerID: alice, - recipientPeerID: bob, - fileName: nil - )) - XCTAssertNil(PrivateMediaMessageIdentity.stableID( - senderPeerID: alice, - recipientPeerID: bob, - fileName: "photo.jpg" - )) - XCTAssertNil(PrivateMediaMessageIdentity.stableID( - senderPeerID: alice, - recipientPeerID: bob, - fileName: "img_11111111-1111-1111-1111-111111111111.pdf" - )) - XCTAssertNotNil(PrivateMediaMessageIdentity.stableID( - senderPeerID: alice, - recipientPeerID: bob, - fileName: "voice_0011223344556677.m4a" - )) - } - - func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() { - XCTAssertEqual( - PrivateMediaMessageIdentity.stableID( - senderPeerID: PeerID(str: "0011223344556677"), - recipientPeerID: PeerID(str: "8899aabbccddeeff"), - fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" - ), - "media-910bd42c65060ab76bb6406f220c4516" - ) - } } diff --git a/bitchatTests/Protocols/BoardPacketsTests.swift b/bitchatTests/Protocols/BoardPacketsTests.swift deleted file mode 100644 index 9e1ff450..00000000 --- a/bitchatTests/Protocols/BoardPacketsTests.swift +++ /dev/null @@ -1,211 +0,0 @@ -// -// BoardPacketsTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import CryptoKit -import Foundation -import Testing -@testable import bitchat - -struct BoardPacketsTests { - - private let authorKey = Curve25519.Signing.PrivateKey() - - private func makeSignedPost( - geohash: String = "9q8yy", - content: String = "water point at the north gate", - nickname: String = "ranger", - createdAt: UInt64 = 1_700_000_000_000, - lifetimeMs: UInt64 = 24 * 60 * 60 * 1000, - flags: UInt8 = 0, - signWith key: Curve25519.Signing.PrivateKey? = nil, - claimKey: Data? = nil - ) throws -> BoardPostPacket { - let signer = key ?? authorKey - let publicKey = claimKey ?? signer.publicKey.rawRepresentation - let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) - let expiresAt = createdAt + lifetimeMs - let signingBytes = BoardPostPacket.signingBytes( - postID: postID, - geohash: geohash, - content: content, - authorSigningKey: publicKey, - authorNickname: nickname, - createdAt: createdAt, - expiresAt: expiresAt, - flags: flags - ) - let signature = try signer.signature(for: signingBytes) - return BoardPostPacket( - postID: postID, - geohash: geohash, - content: content, - authorSigningKey: publicKey, - authorNickname: nickname, - createdAt: createdAt, - expiresAt: expiresAt, - flags: flags, - signature: signature - ) - } - - private func makeSignedTombstone( - postID: Data, - deletedAt: UInt64 = 1_700_000_100_000, - signWith key: Curve25519.Signing.PrivateKey? = nil, - claimKey: Data? = nil - ) throws -> BoardTombstonePacket { - let signer = key ?? authorKey - let publicKey = claimKey ?? signer.publicKey.rawRepresentation - let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt)) - return BoardTombstonePacket( - postID: postID, - authorSigningKey: publicKey, - deletedAt: deletedAt, - signature: signature - ) - } - - // MARK: - Round trips - - @Test func postRoundTrip() throws { - let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag) - let encoded = BoardWire.post(post).encode() - let decoded = try #require(BoardWire.decode(from: encoded)) - #expect(decoded == .post(post)) - #expect(decoded.verifySignature()) - guard case .post(let roundTripped) = decoded else { - Issue.record("expected a post") - return - } - #expect(roundTripped.isUrgent) - #expect(roundTripped.geohash == "9q8yy") - } - - @Test func meshLocalPostRoundTrip() throws { - let post = try makeSignedPost(geohash: "") - let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode())) - #expect(decoded == .post(post)) - #expect(decoded.verifySignature()) - } - - @Test func tombstoneRoundTrip() throws { - let post = try makeSignedPost() - let tombstone = try makeSignedTombstone(postID: post.postID) - let encoded = BoardWire.tombstone(tombstone).encode() - let decoded = try #require(BoardWire.decode(from: encoded)) - #expect(decoded == .tombstone(tombstone)) - #expect(decoded.verifySignature()) - } - - // MARK: - Signature verification - - @Test func forgedPostSignatureFailsVerification() throws { - // Signed by an attacker's key but claiming the victim's key as author. - let attacker = Curve25519.Signing.PrivateKey() - let victim = Curve25519.Signing.PrivateKey() - let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation) - let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode())) - #expect(!decoded.verifySignature()) - } - - @Test func tamperedContentFailsVerification() throws { - let post = try makeSignedPost(content: "meet at noon") - let tampered = BoardPostPacket( - postID: post.postID, - geohash: post.geohash, - content: "meet at midnight", - authorSigningKey: post.authorSigningKey, - authorNickname: post.authorNickname, - createdAt: post.createdAt, - expiresAt: post.expiresAt, - flags: post.flags, - signature: post.signature - ) - let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode())) - #expect(!decoded.verifySignature()) - } - - @Test func forgedTombstoneSignatureFailsVerification() throws { - let post = try makeSignedPost() - let attacker = Curve25519.Signing.PrivateKey() - let forged = try makeSignedTombstone( - postID: post.postID, - signWith: attacker, - claimKey: post.authorSigningKey - ) - let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode())) - #expect(!decoded.verifySignature()) - } - - // MARK: - Decode validation - - @Test func rejectsExpiryBeyondSevenDays() throws { - let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1) - #expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil) - - let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs) - #expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil) - } - - @Test func rejectsExpiryBeforeCreation() throws { - let post = try makeSignedPost() - let inverted = BoardPostPacket( - postID: post.postID, - geohash: post.geohash, - content: post.content, - authorSigningKey: post.authorSigningKey, - authorNickname: post.authorNickname, - createdAt: post.expiresAt, - expiresAt: post.createdAt, - flags: post.flags, - signature: post.signature - ) - #expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil) - } - - @Test func rejectsOversizedContent() throws { - let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1)) - #expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil) - - let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes)) - #expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil) - } - - @Test func rejectsInvalidGeohashCharacters() throws { - let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32 - #expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil) - } - - @Test func toleratesUnknownTLVs() throws { - let post = try makeSignedPost() - var encoded = BoardWire.post(post).encode() - // Append an unknown TLV; decoders must skip it. - encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) - let decoded = try #require(BoardWire.decode(from: encoded)) - #expect(decoded == .post(post)) - #expect(decoded.verifySignature()) - } - - @Test func rejectsTruncatedPayload() throws { - let post = try makeSignedPost() - let encoded = BoardWire.post(post).encode() - #expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil) - } - - // MARK: - Urgent flag peek - - @Test func urgentFlagPeekMatchesFullDecode() throws { - let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag) - let calm = try makeSignedPost() - let tombstone = try makeSignedTombstone(postID: calm.postID) - #expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode())) - #expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode())) - #expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode())) - #expect(!BoardWire.urgentFlag(in: Data())) - } -} diff --git a/bitchatTests/Protocols/BridgeWireFormatTests.swift b/bitchatTests/Protocols/BridgeWireFormatTests.swift deleted file mode 100644 index 1dc42e9a..00000000 --- a/bitchatTests/Protocols/BridgeWireFormatTests.swift +++ /dev/null @@ -1,201 +0,0 @@ -// -// BridgeWireFormatTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("Bridge wire formats") -struct BridgeWireFormatTests { - // MARK: - Announce bridgeGeohash TLV - - @Test func announceRoundTripsBridgeGeohash() throws { - let packet = AnnouncementPacket( - nickname: "gw", - noisePublicKey: Data(repeating: 1, count: 32), - signingPublicKey: Data(repeating: 2, count: 32), - directNeighbors: nil, - capabilities: [.bridge, .gateway], - bridgeGeohash: "u4pruy" - ) - let encoded = try #require(packet.encode()) - let decoded = try #require(AnnouncementPacket.decode(from: encoded)) - #expect(decoded.bridgeGeohash == "u4pruy") - #expect(decoded.capabilities?.contains(.bridge) == true) - } - - @Test func announceWithoutBridgeCellDecodesNil() throws { - let packet = AnnouncementPacket( - nickname: "plain", - noisePublicKey: Data(repeating: 1, count: 32), - signingPublicKey: Data(repeating: 2, count: 32), - directNeighbors: nil - ) - let encoded = try #require(packet.encode()) - let decoded = try #require(AnnouncementPacket.decode(from: encoded)) - #expect(decoded.bridgeGeohash == nil) - } - - @Test func announceRejectsOversizedBridgeCellAtEncode() throws { - let packet = AnnouncementPacket( - nickname: "gw", - noisePublicKey: Data(repeating: 1, count: 32), - signingPublicKey: Data(repeating: 2, count: 32), - directNeighbors: nil, - bridgeGeohash: String(repeating: "u", count: 13) - ) - // Oversized cell is silently omitted, not a hard failure. - let encoded = try #require(packet.encode()) - let decoded = try #require(AnnouncementPacket.decode(from: encoded)) - #expect(decoded.bridgeGeohash == nil) - } - - // MARK: - Carrier directions - - @Test func bridgeCarrierDirectionsRoundTrip() throws { - for direction in [NostrCarrierPacket.Direction.toBridge, .fromBridge] { - let packet = try #require(NostrCarrierPacket( - direction: direction, - geohash: "u4pruy", - eventJSON: Data("{\"id\":\"x\"}".utf8) - )) - let encoded = try #require(packet.encode()) - let decoded = try #require(NostrCarrierPacket.decode(encoded)) - #expect(decoded.direction == direction) - #expect(decoded.geohash == "u4pruy") - } - } - - // MARK: - BitchatMessage bridged flag - // (Binary round-trip lives in BitFoundation's own tests — - // `toBinaryPayload` is internal to the package.) - - @Test func bridgedFlagSurvivesCodableRoundTrip() throws { - let message = BitchatMessage( - sender: "far-friend", - content: "hi", - timestamp: Date(), - isRelay: false, - isBridged: true - ) - let data = try JSONEncoder().encode(message) - let decoded = try JSONDecoder().decode(BitchatMessage.self, from: data) - #expect(decoded.isBridged) - } - - @Test func legacyJSONWithoutBridgedFlagDecodes() throws { - let plain = BitchatMessage( - sender: "old-client", - content: "hi", - timestamp: Date(), - isRelay: false - ) - let encoded = try JSONEncoder().encode(plain) - var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - json.removeValue(forKey: "isBridged") - let decoded = try JSONDecoder().decode(BitchatMessage.self, from: JSONSerialization.data(withJSONObject: json)) - #expect(!decoded.isBridged) - } - - @Test func bridgePeerIDParsesAndClassifies() { - let peerID = PeerID(str: "bridge:deadbeefcafe0123") - #expect(peerID.isBridge) - #expect(peerID.bare == "deadbeefcafe0123") - #expect(!peerID.isGeoChat) - } -} - -@Suite("Mesh message identity") -struct MeshMessageIdentityTests { - @Test func stableIDIsDeterministicHex() { - let id = MeshMessageIdentity.stableID( - senderIDHex: "0011223344556677", - timestampMs: 1_750_000_000_123, - content: "hello mesh" - ) - // Pinned vector: first 32 hex chars of - // SHA256("0011223344556677|1750000000123|hello mesh"). Any drift - // breaks cross-device (and cross-version) dedup. - #expect(id == "b83f94d81dcdd1b0c0048f6645995dd4") - #expect(id == MeshMessageIdentity.stableID( - senderIDHex: "0011223344556677", - timestampMs: 1_750_000_000_123, - content: "hello mesh" - )) - } - - @Test func senderIDIsCaseInsensitive() { - let lower = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 1, content: "x") - let upper = MeshMessageIdentity.stableID(senderIDHex: "AABBCCDD00112233", timestampMs: 1, content: "x") - #expect(lower == upper) - } - - @Test func contentWhitespaceIsNormalized() { - // Senders bridge the trimmed content while the radio carries the - // original; both must derive the same key. - let raw = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 1, content: " hello mesh \n") - let trimmed = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 1, content: "hello mesh") - #expect(raw == trimmed) - } - - @Test func anyCoordinateChangeChangesTheID() { - let base = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 5, content: "x") - #expect(base != MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112234", timestampMs: 5, content: "x")) - #expect(base != MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 6, content: "x")) - #expect(base != MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 5, content: "y")) - } - - @Test func millisecondTimestampTruncatesLikeTheWire() { - // Must match `BLEService.sendMessage`'s UInt64(seconds * 1000). - #expect(MeshMessageIdentity.millisecondTimestamp(Date(timeIntervalSince1970: 1_000.9996)) == 1_000_999) - #expect(MeshMessageIdentity.millisecondTimestamp(Date(timeIntervalSince1970: 1_000)) == 1_000_000) - } -} - -@Suite("Courier store bridge publish") -struct CourierStoreBridgePublishTests { - private func makeStore(now: @escaping () -> Date = Date.init) -> CourierStore { - CourierStore(persistsToDisk: false, now: now) - } - - private func makeEnvelope(now: Date = Date()) -> CourierEnvelope { - CourierEnvelope( - recipientTag: Data(repeating: 3, count: 16), - expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), - ciphertext: Data(repeating: 9, count: 64), - copies: 4 - ) - } - - @Test func bridgePublishIsNonDestructiveAndCooledDown() { - var currentDate = Date() - let store = makeStore(now: { currentDate }) - #expect(store.deposit(makeEnvelope(now: currentDate), from: Data(repeating: 5, count: 32))) - - let first = store.envelopesForBridgePublish(cooldown: 600) - #expect(first.count == 1) - // The relay copy is carry-only regardless of stored spray budget. - #expect(first.first?.copies == 1) - // (Non-destructiveness is proven below: the same envelope is - // eligible again after the cooldown. `carriedCount` publishes - // asynchronously, so it is not asserted here.) - - // Merely offering the envelope does not start the cooldown; a relay - // rejection/timeout must remain immediately retryable. - #expect(store.envelopesForBridgePublish(cooldown: 600).count == 1) - store.markBridgePublished(first[0]) - - // A confirmed publish starts the cooldown. - #expect(store.envelopesForBridgePublish(cooldown: 600).isEmpty) - - // After cooldown: eligible again. - currentDate = currentDate.addingTimeInterval(601) - #expect(store.envelopesForBridgePublish(cooldown: 600).count == 1) - } -} diff --git a/bitchatTests/Protocols/NostrCarrierPacketTests.swift b/bitchatTests/Protocols/NostrCarrierPacketTests.swift deleted file mode 100644 index 50ffb287..00000000 --- a/bitchatTests/Protocols/NostrCarrierPacketTests.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// NostrCarrierPacketTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -@testable import bitchat - -@Suite("Nostr carrier packet TLV") -struct NostrCarrierPacketTests { - private func makeEvent(geohash: String = "u4pruy", content: String = "hello mesh") throws -> NostrEvent { - let identity = try NostrIdentity.generate() - return try NostrProtocol.createEphemeralGeohashEvent( - content: content, - geohash: geohash, - senderIdentity: identity, - nickname: "tester" - ) - } - - @Test("round-trips both directions with the signed event intact") - func roundTrip() throws { - let event = try makeEvent() - for direction in [NostrCarrierPacket.Direction.toGateway, .fromGateway] { - let packet = try #require(NostrCarrierPacket(direction: direction, geohash: "u4pruy", event: event)) - let encoded = try #require(packet.encode()) - let decoded = try #require(NostrCarrierPacket.decode(encoded)) - - #expect(decoded == packet) - #expect(decoded.direction == direction) - #expect(decoded.geohash == "u4pruy") - - // The carried event survives byte-exact: same ID, and the - // signature still verifies after the mesh hop. - let carried = try #require(decoded.event()) - #expect(carried.id == event.id) - #expect(carried.sig == event.sig) - #expect(carried.isValidSignature()) - } - } - - @Test("rejects an oversized event at construction and at decode") - func oversizedRejected() throws { - let oversized = Data(repeating: 0x7B, count: NostrCarrierPacket.maxEventJSONBytes + 1) - #expect(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", eventJSON: oversized) == nil) - - // Hand-build the TLV bytes to bypass the initializer's cap. - var data = Data([0x01, 0x00, 0x01, NostrCarrierPacket.Direction.toGateway.rawValue]) - let geohash = Data("u4pruy".utf8) - data.append(contentsOf: [0x02, 0x00, UInt8(geohash.count)]) - data.append(geohash) - data.append(contentsOf: [0x03, UInt8((oversized.count >> 8) & 0xFF), UInt8(oversized.count & 0xFF)]) - data.append(oversized) - #expect(NostrCarrierPacket.decode(data) == nil) - } - - @Test("rejects an over-length or empty geohash") - func geohashBoundsEnforced() throws { - let event = try makeEvent() - #expect(NostrCarrierPacket(direction: .toGateway, geohash: "", event: event) == nil) - #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 13), event: event) == nil) - #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 12), event: event) != nil) - } - - @Test("skips unknown TLVs for forward compatibility") - func unknownTLVSkipped() throws { - let event = try makeEvent() - let packet = try #require(NostrCarrierPacket(direction: .fromGateway, geohash: "u4pruy", event: event)) - var encoded = try #require(packet.encode()) - // Append an unknown TLV (type 0x7F, 2-byte value). - encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) - let decoded = try #require(NostrCarrierPacket.decode(encoded)) - #expect(decoded == packet) - } - - @Test("rejects truncated and missing-field payloads") - func malformedRejected() throws { - let event = try makeEvent() - let packet = try #require(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", event: event)) - let encoded = try #require(packet.encode()) - - // Truncation anywhere inside the last TLV fails cleanly. - #expect(NostrCarrierPacket.decode(encoded.dropLast(1)) == nil) - #expect(NostrCarrierPacket.decode(encoded.prefix(4)) == nil) - #expect(NostrCarrierPacket.decode(Data()) == nil) - - // Direction TLV alone (missing geohash and event) fails. - #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x01])) == nil) - // Unknown direction value fails. - #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x77])) == nil) - } -} diff --git a/bitchatTests/Protocols/PacketsTests.swift b/bitchatTests/Protocols/PacketsTests.swift index c837ee1d..78c244de 100644 --- a/bitchatTests/Protocols/PacketsTests.swift +++ b/bitchatTests/Protocols/PacketsTests.swift @@ -1,4 +1,3 @@ -import BitFoundation import Foundation import Testing @@ -109,75 +108,6 @@ struct PacketsTests { #expect(decoded.directNeighbors == nil) } - @Test - func announcementPacketRoundTripsCapabilities() throws { - let capabilities: PeerCapabilities = [.prekeys, .board, .meshDiagnostics] - let packet = AnnouncementPacket( - nickname: "alice", - noisePublicKey: Data(repeating: 0x11, count: 32), - signingPublicKey: Data(repeating: 0x22, count: 32), - directNeighbors: nil, - capabilities: capabilities - ) - - let encoded = try #require(packet.encode()) - let decoded = try #require(AnnouncementPacket.decode(from: encoded)) - #expect(decoded.capabilities == capabilities) - } - - @Test - func announcementPacketWithoutCapabilitiesDecodesNilAndUnknownBitsSurvive() throws { - let legacy = try #require( - AnnouncementPacket( - nickname: "alice", - noisePublicKey: Data(repeating: 0x11, count: 32), - signingPublicKey: Data(repeating: 0x22, count: 32), - directNeighbors: nil - ).encode() - ) - // The TLV is emitted only when capabilities are set, so legacy peers - // (and this packet) decode as nil rather than empty. - #expect(try #require(AnnouncementPacket.decode(from: legacy)).capabilities == nil) - - var withFutureBits = legacy - withFutureBits.append(makeTLV(type: 0x05, value: Data([0x80, 0x01]))) - let decoded = try #require(AnnouncementPacket.decode(from: withFutureBits)) - #expect(decoded.capabilities?.rawValue == 0x0180) - } - - @Test - func authenticatedPeerStateUsesVersionedCanonicalTLVs() throws { - let signingKey = Data(repeating: 0xA5, count: 32) - let packet = AuthenticatedPeerStatePacket( - capabilities: [.privateMedia, .vouch], - signingPublicKey: signingKey - ) - - var encoded = try #require(packet.encode()) - #expect(encoded.prefix(5) == Data([0x01, 0x01, 0x02, 0x20, 0x01])) - // Unknown TLVs are forward-compatible and do not alter v1 state. - encoded.append(makeTLV(type: 0x7F, value: Data([0xCA, 0xFE]))) - - #expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet) - } - - @Test - func authenticatedPeerStateRejectsMalformedAmbiguousOrUnknownVersion() { - let key = Data(repeating: 0x44, count: 32) - let capabilities = makeTLV(type: 0x01, value: Data([0x00, 0x01])) - let signing = makeTLV(type: 0x02, value: key) - - #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x02]) + capabilities + signing) == nil) - #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + signing) == nil) - #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + capabilities + signing) == nil) - #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01, 0x01, 0x00]) + signing) == nil) - // 0x0001 is non-minimal little endian; the canonical form is [0x01]. - #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data([0x01, 0x00])) + signing) == nil) - #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + makeTLV(type: 0x02, value: Data(key.dropLast()))) == nil) - #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + Data(signing.dropLast())) == nil) - #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil) - } - @Test func privateMessagePacketRejectsUnknownTypeAndTruncation() { let unknownTLV = Data([0x7F, 0x01, 0x41]) diff --git a/bitchatTests/Protocols/VouchAttestationTests.swift b/bitchatTests/Protocols/VouchAttestationTests.swift deleted file mode 100644 index 064cf4d8..00000000 --- a/bitchatTests/Protocols/VouchAttestationTests.swift +++ /dev/null @@ -1,176 +0,0 @@ -import CryptoKit -import Foundation -import Testing - -@testable import bitchat - -struct VouchAttestationTests { - private let voucherKey = Curve25519.Signing.PrivateKey() - - private func makeAttestation( - fingerprint: Data = Data(repeating: 0xAA, count: 32), - signingKey: Data = Data(repeating: 0xBB, count: 32), - timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), - signedBy key: Curve25519.Signing.PrivateKey? = nil - ) throws -> VouchAttestation { - let signer = key ?? voucherKey - return try #require( - VouchAttestation.build( - voucheeFingerprint: fingerprint, - voucheeSigningKey: signingKey, - timestampMs: timestampMs, - sign: { try? signer.signature(for: $0) } - ) - ) - } - - @Test - func roundTripsAndVerifiesSignature() throws { - let attestation = try makeAttestation() - let encoded = try #require(attestation.encode()) - let decoded = try #require(VouchAttestation.decode(from: encoded)) - - #expect(decoded == attestation) - #expect(decoded.voucheeFingerprintHex == String(repeating: "aa", count: 32)) - #expect(decoded.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) - } - - @Test - func decodeSkipsUnknownTLVsAndRejectsMalformedInput() throws { - let attestation = try makeAttestation() - var encoded = try #require(attestation.encode()) - - // Unknown TLV appended: skipped for forward compatibility. - encoded.append(contentsOf: [0x7F, 0x02, 0x01, 0x02]) - #expect(VouchAttestation.decode(from: encoded) == attestation) - - // Truncation and missing fields are rejected. - #expect(VouchAttestation.decode(from: encoded.dropLast()) == nil) - #expect(VouchAttestation.decode(from: Data([0x01, 0x20])) == nil) - #expect(VouchAttestation.decode(from: Data()) == nil) - - // Wrong field sizes are rejected. - var wrongSize = Data([0x01, 0x10]) - wrongSize.append(Data(repeating: 0xAA, count: 16)) - #expect(VouchAttestation.decode(from: wrongSize) == nil) - } - - @Test - func buildRejectsWrongKeyAndFingerprintSizes() { - let sign: (Data) -> Data? = { try? self.voucherKey.signature(for: $0) } - #expect(VouchAttestation.build( - voucheeFingerprint: Data(repeating: 0xAA, count: 16), - voucheeSigningKey: Data(repeating: 0xBB, count: 32), - sign: sign - ) == nil) - #expect(VouchAttestation.build( - voucheeFingerprint: Data(repeating: 0xAA, count: 32), - voucheeSigningKey: Data(repeating: 0xBB, count: 16), - sign: sign - ) == nil) - } - - @Test - func forgedSignatureFailsVerification() throws { - let attestation = try makeAttestation() - let otherKey = Curve25519.Signing.PrivateKey() - - // Verifying against a key that didn't sign fails. - #expect(!attestation.verifySignature(voucherSigningKey: otherKey.publicKey.rawRepresentation)) - - // An attestation signed by an imposter fails against the real key. - let forged = try makeAttestation(signedBy: otherKey) - #expect(!forged.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) - #expect(!attestation.verifySignature(voucherSigningKey: Data(repeating: 0x01, count: 3))) - } - - @Test - func tamperedFieldsFailVerification() throws { - let attestation = try makeAttestation() - let publicKey = voucherKey.publicKey.rawRepresentation - - var tamperedFingerprint = attestation.voucheeFingerprint - tamperedFingerprint[0] ^= 0xFF - let tampered = VouchAttestation( - voucheeFingerprint: tamperedFingerprint, - voucheeSigningKey: attestation.voucheeSigningKey, - timestampMs: attestation.timestampMs, - signature: attestation.signature - ) - #expect(!tampered.verifySignature(voucherSigningKey: publicKey)) - - let backdated = VouchAttestation( - voucheeFingerprint: attestation.voucheeFingerprint, - voucheeSigningKey: attestation.voucheeSigningKey, - timestampMs: attestation.timestampMs - 1, - signature: attestation.signature - ) - #expect(!backdated.verifySignature(voucherSigningKey: publicKey)) - } - - @Test - func expiryWindowIsEnforced() throws { - let now = Date() - let fresh = try makeAttestation(timestampMs: UInt64(now.timeIntervalSince1970 * 1000)) - #expect(!fresh.isExpired(now: now)) - - let thirtyOneDaysAgo = now.addingTimeInterval(-31 * 24 * 60 * 60) - let expired = try makeAttestation(timestampMs: UInt64(thirtyOneDaysAgo.timeIntervalSince1970 * 1000)) - #expect(expired.isExpired(now: now)) - - let farFuture = now.addingTimeInterval(2 * 60 * 60) - let fromTheFuture = try makeAttestation(timestampMs: UInt64(farFuture.timeIntervalSince1970 * 1000)) - #expect(fromTheFuture.isExpired(now: now)) - - // A verified-but-expired attestation still has a valid signature; the - // two checks are independent gates. - #expect(expired.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) - } - - @Test - func batchRoundTripsAndEnforcesCap() throws { - let attestations = try (0..<3).map { index in - try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) - } - let payload = try #require(VouchAttestation.encodeList(attestations)) - #expect(VouchAttestation.decodeList(from: payload) == attestations) - - #expect(VouchAttestation.encodeList([]) == nil) - - let tooMany = try (0..<17).map { index in - try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) - } - #expect(VouchAttestation.encodeList(tooMany) == nil) - } - - @Test - func decodeListIgnoresEntriesBeyondCapAndMalformedEntries() throws { - let attestations = try (0..<17).map { index in - try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) - } - // Hand-build an oversized batch that lies about its count. - var payload = Data([UInt8(attestations.count)]) - for attestation in attestations { - let encoded = try #require(attestation.encode()) - payload.append(UInt8(encoded.count >> 8)) - payload.append(UInt8(encoded.count & 0xFF)) - payload.append(encoded) - } - let decoded = VouchAttestation.decodeList(from: payload) - #expect(decoded.count == VouchAttestation.maxBatchCount) - #expect(decoded == Array(attestations.prefix(VouchAttestation.maxBatchCount))) - - // A malformed middle entry is dropped without killing the batch. - let good = try makeAttestation() - let goodEncoded = try #require(good.encode()) - var mixed = Data([2]) - mixed.append(contentsOf: [0x00, 0x03, 0xDE, 0xAD, 0xBE]) - mixed.append(UInt8(goodEncoded.count >> 8)) - mixed.append(UInt8(goodEncoded.count & 0xFF)) - mixed.append(goodEncoded) - #expect(VouchAttestation.decodeList(from: mixed) == [good]) - - #expect(VouchAttestation.decodeList(from: Data()) == []) - #expect(VouchAttestation.decodeList(from: Data([5])) == []) - } -} diff --git a/bitchatTests/PublicMessagePipelineTests.swift b/bitchatTests/PublicMessagePipelineTests.swift index 4ab270a4..dd716cdc 100644 --- a/bitchatTests/PublicMessagePipelineTests.swift +++ b/bitchatTests/PublicMessagePipelineTests.swift @@ -2,72 +2,49 @@ // PublicMessagePipelineTests.swift // bitchatTests // -// Tests for PublicMessagePipeline batching, content dedup, and per-message -// conversation routing. Ordering and ID dedup live in the ConversationStore -// the flush commits into (the old late-insert threshold is gone; see -// ConversationStoreTests for ordered-insert coverage). +// Tests for PublicMessagePipeline ordering and deduplication. // import Testing import Foundation -import BitFoundation @testable import bitchat @MainActor private final class TestPipelineDelegate: PublicMessagePipelineDelegate { private let dedupService = MessageDeduplicationService() - /// Commits in arrival-at-commit order, per conversation. - private(set) var committed: [(message: BitchatMessage, conversationID: ConversationID)] = [] - /// Message IDs the commit rejects (simulates the store's ID dedup). - var rejectedMessageIDs: Set = [] - private(set) var recordedContentKeys: [String] = [] - private(set) var batchingStates: [Bool] = [] + var messages: [BitchatMessage] = [] - func messages(in conversationID: ConversationID) -> [BitchatMessage] { - committed.filter { $0.conversationID == conversationID }.map(\.message) + func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { + messages } - func pipeline(_: PublicMessagePipeline, normalizeContent content: String) -> String { + func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { + self.messages = messages + } + + func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { dedupService.normalizedContentKey(content) } - func pipeline(_: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { + func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { dedupService.contentTimestamp(forKey: key) } - func pipeline(_: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { + func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { dedupService.recordContentKey(key, timestamp: timestamp) - recordedContentKeys.append(key) } - func pipeline(_: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool { - guard !rejectedMessageIDs.contains(message.id) else { return false } - committed.append((message, conversationID)) - return true - } + func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {} - func pipelinePrewarmMessage(_: PublicMessagePipeline, message: BitchatMessage) {} + func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {} - func pipelineSetBatchingState(_: PublicMessagePipeline, isBatching: Bool) { - batchingStates.append(isBatching) - } -} - -@MainActor -private func makeMessage(id: String, content: String, timestamp: Date) -> BitchatMessage { - BitchatMessage( - id: id, - sender: "A", - content: content, - timestamp: timestamp, - isRelay: false - ) + func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {} } struct PublicMessagePipelineTests { @Test @MainActor - func flush_commitsInTimestampOrder() async { + func flush_sortsByTimestamp() async { let pipeline = PublicMessagePipeline() let delegate = TestPipelineDelegate() pipeline.delegate = delegate @@ -75,13 +52,26 @@ struct PublicMessagePipelineTests { let earlier = Date().addingTimeInterval(-10) let later = Date() - pipeline.enqueue(makeMessage(id: "a", content: "Later", timestamp: later), to: .mesh) - pipeline.enqueue(makeMessage(id: "b", content: "Earlier", timestamp: earlier), to: .mesh) + let messageA = BitchatMessage( + id: "a", + sender: "A", + content: "Later", + timestamp: later, + isRelay: false + ) + let messageB = BitchatMessage( + id: "b", + sender: "A", + content: "Earlier", + timestamp: earlier, + isRelay: false + ) + + pipeline.enqueue(messageA) + pipeline.enqueue(messageB) pipeline.flushIfNeeded() - #expect(delegate.messages(in: .mesh).map { $0.id } == ["b", "a"]) - // Batching state wrapped the flush. - #expect(delegate.batchingStates == [true, false]) + #expect(delegate.messages.map { $0.id } == ["b", "a"]) } @Test @MainActor @@ -91,55 +81,86 @@ struct PublicMessagePipelineTests { pipeline.delegate = delegate let now = Date() - pipeline.enqueue(makeMessage(id: "a", content: "Same", timestamp: now), to: .mesh) - pipeline.enqueue(makeMessage(id: "b", content: "Same", timestamp: now.addingTimeInterval(0.2)), to: .mesh) + let messageA = BitchatMessage( + id: "a", + sender: "A", + content: "Same", + timestamp: now, + isRelay: false + ) + let messageB = BitchatMessage( + id: "b", + sender: "A", + content: "Same", + timestamp: now.addingTimeInterval(0.2), + isRelay: false + ) + + pipeline.enqueue(messageA) + pipeline.enqueue(messageB) pipeline.flushIfNeeded() - #expect(delegate.messages(in: .mesh).count == 1) - #expect(delegate.messages(in: .mesh).first?.content == "Same") + #expect(delegate.messages.count == 1) + #expect(delegate.messages.first?.content == "Same") } @Test @MainActor - func flush_routesEachMessageToItsConversation() async { + func lateInsert_meshAppendsRecentOlderMessage() async { let pipeline = PublicMessagePipeline() let delegate = TestPipelineDelegate() pipeline.delegate = delegate + pipeline.updateActiveChannel(.mesh) let base = Date() - pipeline.enqueue(makeMessage(id: "mesh-1", content: "mesh hello", timestamp: base), to: .mesh) - // A channel switch mid-batch must not misroute already-buffered messages. - pipeline.enqueue(makeMessage(id: "geo-1", content: "geo hello", timestamp: base.addingTimeInterval(1)), to: .geohash("u4pruydq")) + let newer = BitchatMessage( + id: "new", + sender: "A", + content: "New", + timestamp: base, + isRelay: false + ) + let older = BitchatMessage( + id: "old", + sender: "A", + content: "Old", + timestamp: base.addingTimeInterval(-5), + isRelay: false + ) + + delegate.messages = [newer] + pipeline.enqueue(older) pipeline.flushIfNeeded() - #expect(delegate.messages(in: .mesh).map { $0.id } == ["mesh-1"]) - #expect(delegate.messages(in: .geohash("u4pruydq")).map { $0.id } == ["geo-1"]) + #expect(delegate.messages.map { $0.id } == ["new", "old"]) } @Test @MainActor - func flush_rejectedCommitDoesNotRecordContentKey() async { + func lateInsert_locationInsertsByTimestamp() async { let pipeline = PublicMessagePipeline() let delegate = TestPipelineDelegate() pipeline.delegate = delegate - delegate.rejectedMessageIDs = ["dup"] + pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq"))) - pipeline.enqueue(makeMessage(id: "dup", content: "already stored", timestamp: Date()), to: .mesh) + let base = Date() + let newer = BitchatMessage( + id: "new", + sender: "A", + content: "New", + timestamp: base, + isRelay: false + ) + let older = BitchatMessage( + id: "old", + sender: "A", + content: "Old", + timestamp: base.addingTimeInterval(-5), + isRelay: false + ) + + delegate.messages = [newer] + pipeline.enqueue(older) pipeline.flushIfNeeded() - #expect(delegate.messages(in: .mesh).isEmpty) - #expect(delegate.recordedContentKeys.isEmpty) - } - - @Test @MainActor - func removeMessage_discardsBridgeAliasBeforeBatchFlush() { - let pipeline = PublicMessagePipeline() - let delegate = TestPipelineDelegate() - pipeline.delegate = delegate - - pipeline.enqueue(makeMessage(id: "bridge-event", content: "same radio payload", timestamp: Date()), to: .mesh) - pipeline.removeMessage(withID: "bridge-event") - pipeline.flushIfNeeded() - - #expect(delegate.committed.isEmpty) - #expect(delegate.recordedContentKeys.isEmpty) + #expect(delegate.messages.map { $0.id } == ["old", "new"]) } } diff --git a/bitchatTests/PublicTimelineStoreTests.swift b/bitchatTests/PublicTimelineStoreTests.swift new file mode 100644 index 00000000..ac177117 --- /dev/null +++ b/bitchatTests/PublicTimelineStoreTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("PublicTimelineStore Tests") +struct PublicTimelineStoreTests { + + @Test("Mesh timeline deduplicates and trims to cap") + func meshTimelineDeduplicatesAndTrims() { + var store = PublicTimelineStore(meshCap: 2, geohashCap: 2) + let first = TestHelpers.createTestMessage(content: "one") + let second = TestHelpers.createTestMessage(content: "two") + let third = TestHelpers.createTestMessage(content: "three") + + store.append(first, to: .mesh) + store.append(second, to: .mesh) + store.append(first, to: .mesh) + store.append(third, to: .mesh) + + let messages = store.messages(for: .mesh) + #expect(messages.map(\.content) == ["two", "three"]) + } + + @Test("Geohash appendIfAbsent remove and clear work together") + func geohashStoreSupportsAppendRemoveAndClear() { + var store = PublicTimelineStore(meshCap: 2, geohashCap: 3) + let geohash = "u4pruydq" + let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) + let first = TestHelpers.createTestMessage(content: "geo one") + let second = TestHelpers.createTestMessage(content: "geo two") + + let didAppendFirst = store.appendIfAbsent(first, toGeohash: geohash) + let didAppendDuplicate = store.appendIfAbsent(first, toGeohash: geohash) + + #expect(didAppendFirst) + #expect(!didAppendDuplicate) + store.append(second, toGeohash: geohash) + let removed = store.removeMessage(withID: first.id) + + #expect(removed?.id == first.id) + #expect(store.messages(for: channel).map(\.content) == ["geo two"]) + + store.clear(channel: channel) + #expect(store.messages(for: channel).isEmpty) + } + + @Test("Mutate geohash updates stored messages in place") + func mutateGeohashAppliesTransformation() { + var store = PublicTimelineStore(meshCap: 2, geohashCap: 3) + let geohash = "u4pruydq" + let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) + let first = TestHelpers.createTestMessage(content: "geo one") + + store.append(first, toGeohash: geohash) + store.mutateGeohash(geohash) { timeline in + timeline.append(TestHelpers.createTestMessage(content: "geo two")) + } + + #expect(store.messages(for: channel).map(\.content) == ["geo one", "geo two"]) + } + + @Test("Queued geohash system messages drain once") + func pendingGeohashSystemMessagesDrainOnce() { + var store = PublicTimelineStore(meshCap: 1, geohashCap: 1) + + store.queueGeohashSystemMessage("first") + store.queueGeohashSystemMessage("second") + + #expect(store.drainPendingGeohashSystemMessages() == ["first", "second"]) + #expect(store.drainPendingGeohashSystemMessages().isEmpty) + } +} diff --git a/bitchatTests/SchnorrConcurrencyRepro.swift b/bitchatTests/SchnorrConcurrencyRepro.swift deleted file mode 100644 index 47ef9375..00000000 --- a/bitchatTests/SchnorrConcurrencyRepro.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Testing -import Foundation -@testable import bitchat - -struct SchnorrConcurrencyRepro { - @Test func concurrentVerificationOfValidEventAlwaysSucceeds() async throws { - let identity = try NostrIdentity.generate() - let event = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: Date(), - kind: .ephemeralEvent, - tags: [["g", "sn"]], - content: "concurrency probe" - ) - let signed = try event.sign(with: identity.schnorrSigningKey()) - #expect(signed.isValidSignature()) - - let failures = await withTaskGroup(of: Int.self) { group in - for _ in 0..<8 { - group.addTask { - var localFailures = 0 - for _ in 0..<250 { - if !signed.isValidSignature() { localFailures += 1 } - } - return localFailures - } - } - var total = 0 - for await f in group { total += f } - return total - } - #expect(failures == 0, "Schnorr verification returned false \(failures)/2000 times under concurrency") - } - - @Test func verificationSurvivesConcurrentSigningAndKeyGeneration() async throws { - let identity = try NostrIdentity.generate() - let event = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: Date(), - kind: .ephemeralEvent, - tags: [["g", "sn"]], - content: "concurrency probe" - ) - let signed = try event.sign(with: identity.schnorrSigningKey()) - #expect(signed.isValidSignature()) - - // Signing and key generation may mutate shared library state - // (secp256k1 context randomization); verification must stay correct - // while they run on other tasks. - let failures = await withTaskGroup(of: Int.self) { group in - for worker in 0..<8 { - group.addTask { - if worker < 4 { - var localFailures = 0 - for _ in 0..<250 { - if !signed.isValidSignature() { localFailures += 1 } - } - return localFailures - } else { - for i in 0..<100 { - if let id = try? NostrIdentity.generate() { - let e = NostrEvent( - pubkey: id.publicKeyHex, - createdAt: Date(), - kind: .ephemeralEvent, - tags: [], - content: "signer \(worker)-\(i)" - ) - _ = try? e.sign(with: id.schnorrSigningKey()) - } - } - return 0 - } - } - } - var total = 0 - for await f in group { total += f } - return total - } - #expect(failures == 0, "Schnorr verification returned false \(failures)/1000 times while signing ran concurrently") - } -} diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift deleted file mode 100644 index d1589303..00000000 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ /dev/null @@ -1,971 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEAnnounceHandlerTests { - private final class Recorder { - var existingNoisePublicKey: Data? - var existingSigningPublicKey: Data? - var persistedSigningPublicKey: Data? - var persistedSigningKeyQueries: [PeerID] = [] - var authenticatedSigningPublicKey: Data? - var signatureValid = true - var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) - var linkBoundToOtherPeer = false - var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) - var dedupSeenIDs: Set = [] - var shouldEmitReconnectLogResult = true - - var verifySignatureCalls: [(packet: BitchatPacket, signingPublicKey: Data)] = [] - var barrierCount = 0 - var upsertCalls: [(peerID: PeerID, announcement: AnnouncementPacket, isConnected: Bool, now: Date)] = [] - var reconnectLogQueries: [PeerID] = [] - var topologyUpdates: [(peerID: PeerID, neighbors: [Data])] = [] - var persistedIdentities: [AnnouncementPacket] = [] - var dedupContainsQueries: [String] = [] - var dedupMarkedIDs: [String] = [] - var uiEventDeliveries: [(peerID: PeerID, notifyPeerConnected: Bool, scheduleInitialSync: Bool)] = [] - var trackedPackets: [BitchatPacket] = [] - var announceBacks = 0 - var afterglowDelays: [TimeInterval] = [] - } - - private func makeHandler( - recorder: Recorder, - localPeerID: PeerID = PeerID(str: "0102030405060708"), - now: Date = Date(timeIntervalSince1970: 1_000) - ) -> BLEAnnounceHandler { - let environment = BLEAnnounceHandlerEnvironment( - localPeerID: { localPeerID }, - messageTTL: TransportConfig.messageTTLDefault, - now: { now }, - existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) }, - persistedSigningPublicKey: { peerID in - recorder.persistedSigningKeyQueries.append(peerID) - return recorder.persistedSigningPublicKey - }, - authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey }, - verifySignature: { packet, signingPublicKey in - recorder.verifySignatureCalls.append((packet, signingPublicKey)) - return recorder.signatureValid - }, - linkState: { _ in recorder.linkState }, - linkBoundToOtherPeer: { _, _ in recorder.linkBoundToOtherPeer }, - withRegistryBarrier: { body in - recorder.barrierCount += 1 - body() - }, - upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in - recorder.upsertCalls.append((peerID, announcement, isConnected, now)) - return recorder.upsertResult - }, - shouldEmitReconnectLog: { peerID, _ in - recorder.reconnectLogQueries.append(peerID) - return recorder.shouldEmitReconnectLogResult - }, - updateTopology: { peerID, neighbors in - recorder.topologyUpdates.append((peerID, neighbors)) - }, - persistIdentity: { announcement in - recorder.persistedIdentities.append(announcement) - }, - dedupContains: { id in - recorder.dedupContainsQueries.append(id) - return recorder.dedupSeenIDs.contains(id) - }, - dedupMarkProcessed: { id in - recorder.dedupMarkedIDs.append(id) - }, - deliverAnnounceUIEvents: { peerID, notifyPeerConnected, scheduleInitialSync in - recorder.uiEventDeliveries.append((peerID, notifyPeerConnected, scheduleInitialSync)) - }, - trackPacketSeen: { packet in - recorder.trackedPackets.append(packet) - }, - sendAnnounceBack: { - recorder.announceBacks += 1 - }, - scheduleAfterglow: { delay in - recorder.afterglowDelays.append(delay) - } - ) - return BLEAnnounceHandler(environment: environment) - } - - @Test - func verifiedNewPeerAnnounceUpsertsNotifiesSyncsAndAnnouncesBack() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x11, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) - let handler = makeHandler(recorder: recorder, now: now) - - let result = handler.handle(packet, from: peerID) - - #expect(result?.peerID == peerID) - #expect(result?.announcement.noisePublicKey == noiseKey) - #expect(result?.isDirectAnnounce == true) - #expect(result?.isVerified == true) - #expect(recorder.verifySignatureCalls.count == 1) - #expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32)) - #expect(recorder.barrierCount == 1) - #expect(recorder.upsertCalls.count == 1) - #expect(recorder.upsertCalls.first?.peerID == peerID) - #expect(recorder.upsertCalls.first?.announcement.nickname == "Alice") - #expect(recorder.upsertCalls.first?.isConnected == true) - #expect(recorder.upsertCalls.first?.now == now) - #expect(recorder.persistedIdentities.count == 1) - #expect(recorder.persistedIdentities.first?.noisePublicKey == noiseKey) - #expect(recorder.uiEventDeliveries.count == 1) - #expect(recorder.uiEventDeliveries.first?.peerID == peerID) - #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == true) - #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == true) - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.dedupMarkedIDs == ["announce-back-\(peerID)"]) - #expect(recorder.announceBacks == 1) - #expect(recorder.afterglowDelays.count == 1) - } - - @Test - func afterglowDelayStaysWithinConfiguredRange() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x12, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) - let handler = makeHandler(recorder: recorder, now: now) - - for _ in 0..<8 { - handler.handle(packet, from: peerID) - } - - #expect(recorder.afterglowDelays.count == 8) - for delay in recorder.afterglowDelays { - #expect(delay >= 0.3 && delay <= 0.6) - } - } - - @Test - func unverifiedAnnounceWithoutSignatureSkipsUpsertAndConnectNotify() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x22, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: nil - ) - - let recorder = Recorder() - let handler = makeHandler(recorder: recorder, now: now) - - let result = handler.handle(packet, from: peerID) - - #expect(result?.peerID == peerID) - #expect(result?.announcement.noisePublicKey == noiseKey) - #expect(result?.isVerified == false) - #expect(recorder.verifySignatureCalls.isEmpty) - #expect(recorder.barrierCount == 1) - #expect(recorder.upsertCalls.isEmpty) - #expect(recorder.topologyUpdates.isEmpty) - #expect(recorder.afterglowDelays.isEmpty) - // Original behavior: list refresh, identity persistence, sync tracking - // and announce-back still occur for unverified announces. - #expect(recorder.uiEventDeliveries.count == 1) - #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) - #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false) - // Identity persistence MUST NOT occur for unverified announces: - // persisting would let an attacker who replays a victim's noisePublicKey - // overwrite the victim's stored signing key/nickname (identity poisoning). - #expect(recorder.persistedIdentities.isEmpty) - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.announceBacks == 1) - } - - @Test - func invalidSignatureSkipsUpsertAndConnectNotify() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x23, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.signatureValid = false - let handler = makeHandler(recorder: recorder, now: now) - - let result = handler.handle(packet, from: peerID) - - #expect(result?.isVerified == false) - #expect(recorder.verifySignatureCalls.count == 1) - #expect(recorder.upsertCalls.isEmpty) - #expect(recorder.uiEventDeliveries.count == 1) - #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) - } - - @Test - func malformedAnnounceIsNoOp() { - let now = Date(timeIntervalSince1970: 1_000) - let peerID = PeerID(str: "1122334455667788") - let packet = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: peerID.id) ?? Data(), - recipientID: nil, - timestamp: timestamp(now), - payload: Data([0x01, 0x20]), - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - - let recorder = Recorder() - let handler = makeHandler(recorder: recorder, now: now) - - let result = handler.handle(packet, from: peerID) - - #expect(result == nil) - expectNoSideEffects(recorder) - } - - @Test - func selfAnnounceIsNoOp() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x33, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now) - - let result = handler.handle(packet, from: peerID) - - #expect(result == nil) - expectNoSideEffects(recorder) - } - - @Test - func staleAnnounceIsNoOp() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x44, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: staleTimestamp, - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - let handler = makeHandler(recorder: recorder, now: now) - - let result = handler.handle(packet, from: peerID) - - #expect(result == nil) - expectNoSideEffects(recorder) - } - - @Test - func reconnectedPeerNotifiesConnectionWithoutAfterglow() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x55, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: true, previousNickname: "Alice") - let handler = makeHandler(recorder: recorder, now: now) - - handler.handle(packet, from: peerID) - - #expect(recorder.uiEventDeliveries.count == 1) - #expect(recorder.uiEventDeliveries.first?.peerID == peerID) - #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == true) - #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == true) - #expect(recorder.reconnectLogQueries == [peerID]) - #expect(recorder.afterglowDelays.isEmpty) - } - - @Test - func relayedNewPeerSchedulesAfterglowWithoutConnectNotify() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x66, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64), - ttl: TransportConfig.messageTTLDefault - 1 - ) - - let recorder = Recorder() - recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) - let handler = makeHandler(recorder: recorder, now: now) - - let result = handler.handle(packet, from: peerID) - - #expect(result?.isDirectAnnounce == false) - #expect(result?.isVerified == true) - #expect(recorder.upsertCalls.count == 1) - #expect(recorder.upsertCalls.first?.isConnected == false) - #expect(recorder.uiEventDeliveries.count == 1) - #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) - #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false) - #expect(recorder.afterglowDelays.count == 1) - } - - /// TTL is unsigned, so a replayed announce with its TTL restored looks - /// "direct" — but it arrives on a link another peer already owns. That - /// link must not shortcut the (possibly absent) claimed peer into - /// "connected". - @Test - func directAnnounceOnLinkBoundToAnotherPeerDoesNotMarkConnected() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x88, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.linkBoundToOtherPeer = true - recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) - let handler = makeHandler(recorder: recorder, now: now) - - let result = handler.handle(packet, from: peerID) - - #expect(result?.isDirectAnnounce == true) - #expect(result?.isVerified == true) - #expect(recorder.upsertCalls.count == 1) - #expect(recorder.upsertCalls.first?.isConnected == false) - } - - /// A peer with its own live link stays connected even when a copy of its - /// announce arrives on someone else's link. - @Test - func directAnnounceOnForeignLinkKeepsPeerWithOwnLinkConnected() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x99, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.linkBoundToOtherPeer = true - recorder.linkState = (hasPeripheral: false, hasCentral: true) - let handler = makeHandler(recorder: recorder, now: now) - - handler.handle(packet, from: peerID) - - #expect(recorder.upsertCalls.count == 1) - #expect(recorder.upsertCalls.first?.isConnected == true) - } - - /// Documents the handler's contract around the rebind: the - /// linkBoundToOtherPeer read reflects the binding BEFORE the rebind runs, - /// so a replayed "direct" announce on someone else's link is denied the - /// connected shortcut — presence only flips via the rebind itself - /// (BLEService promotes the new owner after a successful, containment- - /// checked rebind) or via a later announce arriving on the now-rebound - /// link, as simulated here. Either way the residue is presence display - /// only — DMs remain gated on canDeliverSecurely, so without a Noise - /// session they take the retain + courier path (see MessageRouterTests - /// .sendPrivate_connectedWithoutSecureSessionRetainsAndDepositsWithCourier). - @Test - func secondReplayedDirectAnnounceAfterRebindMarksAbsentPeerConnected() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0xA1, count: 32) - let victim = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: victim, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - let handler = makeHandler(recorder: recorder, now: now) - - // First replay: the link still belongs to the replayer and the victim - // has no live link of its own — the connected shortcut is denied. - recorder.linkBoundToOtherPeer = true - recorder.linkState = (hasPeripheral: false, hasCentral: false) - handler.handle(packet, from: victim) - #expect(recorder.upsertCalls.count == 1) - #expect(recorder.upsertCalls.first?.isConnected == false) - - // The rebind then binds the replayer's link to the victim's ID (the - // rotation-heal path, containment-checked in BLEService). A second - // replay now finds the link owned by the claimed peer … - recorder.linkBoundToOtherPeer = false - recorder.linkState = (hasPeripheral: false, hasCentral: true) - handler.handle(packet, from: victim) - - // … and the absent victim reads as connected: the residual gap. - #expect(recorder.upsertCalls.count == 2) - #expect(recorder.upsertCalls.last?.isConnected == true) - } - - @Test - func announceBackIsSkippedWhenAlreadyMarked() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x77, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.dedupSeenIDs = ["announce-back-\(peerID)"] - let handler = makeHandler(recorder: recorder, now: now) - - handler.handle(packet, from: peerID) - - #expect(recorder.dedupContainsQueries == ["announce-back-\(peerID)"]) - #expect(recorder.dedupMarkedIDs.isEmpty) - #expect(recorder.announceBacks == 0) - } - - @Test - func verifiedAnnounceWithNeighborsUpdatesTopology() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x88, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let neighbors = [Data(repeating: 0xAB, count: 8), Data(repeating: 0xCD, count: 8)] - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64), - directNeighbors: neighbors - ) - - let recorder = Recorder() - let handler = makeHandler(recorder: recorder, now: now) - - handler.handle(packet, from: peerID) - - #expect(recorder.topologyUpdates.count == 1) - #expect(recorder.topologyUpdates.first?.peerID == peerID) - #expect(recorder.topologyUpdates.first?.neighbors == neighbors) - } - - @Test - func matchingPinnedSigningKeyIsAccepted() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x9A, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.existingNoisePublicKey = noiseKey - // Matches the signing key encoded by makeAnnouncePacket. - recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32) - let handler = makeHandler(recorder: recorder, now: now) - - handler.handle(packet, from: peerID) - - #expect(recorder.upsertCalls.count == 1) - #expect(recorder.persistedIdentities.count == 1) - } - - @Test - func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x9B, count: 32) - let peerID = PeerID(publicKey: noiseKey) - // Attacker announce: victim's noiseKey/peerID, attacker's signing key - // (0x99 from makeAnnouncePacket) with a "valid" self-signature. - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.existingNoisePublicKey = noiseKey - recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key - recorder.signatureValid = true - let handler = makeHandler(recorder: recorder, now: now) - - handler.handle(packet, from: peerID) - - #expect(recorder.upsertCalls.isEmpty) - #expect(recorder.persistedIdentities.isEmpty) - #expect(recorder.topologyUpdates.isEmpty) - #expect(recorder.uiEventDeliveries.count == 1) - #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) - } - - @Test - func persistedSigningKeyMismatchWithoutRegistryEntryIsRejected() throws { - // Registry has no entry (app restart or offline-peer eviction), but - // the persisted cryptographic identity still pins the victim's - // signing key. An attacker replaying the victim's noiseKey/peerID - // with their own signing key must not be treated as first contact. - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x9D, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.existingNoisePublicKey = nil - recorder.existingSigningPublicKey = nil - recorder.persistedSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's persisted pin - let handler = makeHandler(recorder: recorder, now: now) - - handler.handle(packet, from: peerID) - - #expect(recorder.persistedSigningKeyQueries == [peerID]) - #expect(recorder.upsertCalls.isEmpty) - #expect(recorder.persistedIdentities.isEmpty) - #expect(recorder.topologyUpdates.isEmpty) - #expect(recorder.uiEventDeliveries.count == 1) - #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) - } - - @Test - func persistedSigningKeyMatchWithoutRegistryEntryIsAccepted() throws { - // Legitimate returning peer: registry entry evicted, persisted pin - // matches the announced signing key — accepted like a normal announce. - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x9E, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - // Matches the signing key encoded by makeAnnouncePacket. - recorder.persistedSigningPublicKey = Data(repeating: 0x99, count: 32) - let handler = makeHandler(recorder: recorder, now: now) - - handler.handle(packet, from: peerID) - - #expect(recorder.upsertCalls.count == 1) - #expect(recorder.persistedIdentities.count == 1) - } - - @Test - func registryPinnedSigningKeySkipsPersistedLookup() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x9F, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.existingNoisePublicKey = noiseKey - recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32) - let handler = makeHandler(recorder: recorder, now: now) - - handler.handle(packet, from: peerID) - - #expect(recorder.persistedSigningKeyQueries.isEmpty) - #expect(recorder.upsertCalls.count == 1) - } - - @Test - func registryPinRejectionSkipsTopologyAndIdentityPersistence() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x9C, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64), - directNeighbors: [Data(repeating: 0xAB, count: 8)] - ) - - // Pre-barrier trust check sees no pinned key (e.g. concurrent race), - // but the registry itself refuses to replace its pinned signing key. - let recorder = Recorder() - recorder.upsertResult = nil - let handler = makeHandler(recorder: recorder, now: now) - - handler.handle(packet, from: peerID) - - #expect(recorder.upsertCalls.count == 1) - #expect(recorder.persistedIdentities.isEmpty) - #expect(recorder.topologyUpdates.isEmpty) - #expect(recorder.uiEventDeliveries.count == 1) - #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) - #expect(recorder.afterglowDelays.isEmpty) - } - - @Test - func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x99, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket( - noisePublicKey: noiseKey, - peerID: peerID, - timestamp: timestamp(now), - signature: Data(repeating: 0xEE, count: 64) - ) - - let recorder = Recorder() - recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32) - let handler = makeHandler(recorder: recorder, now: now) - - let result = handler.handle(packet, from: peerID) - - #expect(result?.isVerified == false) - #expect(recorder.upsertCalls.isEmpty) - #expect(recorder.uiEventDeliveries.count == 1) - #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) - } - - @Test - func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws { - // Real crypto: the attacker crafts a fully self-consistent announce - // (victim's noiseKey/peerID, attacker's signing key and nickname, - // valid packet signature made with the attacker's key). Without - // signing-key pinning this used to overwrite the victim's registry - // entry and persisted identity. - let victim = NoiseEncryptionService(keychain: MockKeychain()) - let attacker = NoiseEncryptionService(keychain: MockKeychain()) - let victimNoiseKey = victim.getStaticPublicKeyData() - let peerID = PeerID(publicKey: victimNoiseKey) - let now = Date() - - final class RegistryBox { - var registry = BLEPeerRegistry() - var persistedIdentities: [AnnouncementPacket] = [] - } - let box = RegistryBox() - - let environment = BLEAnnounceHandlerEnvironment( - localPeerID: { PeerID(str: "0102030405060708") }, - messageTTL: TransportConfig.messageTTLDefault, - now: { now }, - existingPeerKeys: { peerID in - let info = box.registry.info(for: peerID) - return (info?.noisePublicKey, info?.signingPublicKey) - }, - persistedSigningPublicKey: { _ in nil }, - authenticatedSigningPublicKey: { _ in nil }, - verifySignature: { packet, signingPublicKey in - victim.verifyPacketSignature(packet, publicKey: signingPublicKey) - }, - linkState: { _ in (hasPeripheral: true, hasCentral: false) }, - linkBoundToOtherPeer: { _, _ in false }, - withRegistryBarrier: { body in body() }, - upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in - box.registry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: announcement.nickname, - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - isConnected: isConnected, - now: now - ) - }, - shouldEmitReconnectLog: { _, _ in false }, - updateTopology: { _, _ in }, - persistIdentity: { announcement in - box.persistedIdentities.append(announcement) - }, - dedupContains: { _ in true }, - dedupMarkProcessed: { _ in }, - deliverAnnounceUIEvents: { _, _, _ in }, - trackPacketSeen: { _ in }, - sendAnnounceBack: {}, - scheduleAfterglow: { _ in } - ) - let handler = BLEAnnounceHandler(environment: environment) - - func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket { - let announcement = AnnouncementPacket( - nickname: nickname, - noisePublicKey: victimNoiseKey, - signingPublicKey: signer.getSigningPublicKeyData(), - directNeighbors: nil - ) - let payload = try #require(announcement.encode()) - let packet = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: peerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(now.timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - return try #require(signer.signPacket(packet)) - } - - // Legitimate announce from the victim is accepted and pinned. - let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim) - handler.handle(victimAnnounce, from: peerID) - - #expect(box.registry.info(for: peerID)?.nickname == "victim") - #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) - #expect(box.persistedIdentities.count == 1) - - // Attacker announce with a valid self-signature must be rejected. - let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker) - handler.handle(attackerAnnounce, from: peerID) - - #expect(box.registry.info(for: peerID)?.nickname == "victim") - #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) - #expect(box.persistedIdentities.count == 1) - - // The victim's subsequent announces (same pinned key) still work. - let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim) - handler.handle(victimRename, from: peerID) - - #expect(box.registry.info(for: peerID)?.nickname == "victim-renamed") - #expect(box.persistedIdentities.count == 2) - } - - @Test - func signingKeyPinSurvivesRegistryEvictionAndRestartEndToEnd() throws { - // Real crypto + real persistence: the victim announces and gets - // pinned, then the registry entry disappears (offline-peer eviction - // via reconcileConnectivity, or app restart which starts with an - // empty registry). The attacker replays the victim's - // noiseKey/peerID with their own signing key and a valid - // self-signature — the persisted identity must still block the - // takeover, and must not be overwritten. The victim (same signing - // key) must be re-accepted. - let victim = NoiseEncryptionService(keychain: MockKeychain()) - let attacker = NoiseEncryptionService(keychain: MockKeychain()) - let victimNoiseKey = victim.getStaticPublicKeyData() - let peerID = PeerID(publicKey: victimNoiseKey) - let now = Date() - - let identityKeychain = MockKeychain() - let identityManager = SecureIdentityStateManager(identityKeychain) - - final class RegistryBox { - var registry = BLEPeerRegistry() - } - let box = RegistryBox() - - func makeEnvironment(identityManager: SecureIdentityStateManager) -> BLEAnnounceHandlerEnvironment { - BLEAnnounceHandlerEnvironment( - localPeerID: { PeerID(str: "0102030405060708") }, - messageTTL: TransportConfig.messageTTLDefault, - now: { now }, - existingPeerKeys: { peerID in - let info = box.registry.info(for: peerID) - return (info?.noisePublicKey, info?.signingPublicKey) - }, - // Mirrors the BLEService wiring: fall back to the persisted - // cryptographic identity. - persistedSigningPublicKey: { peerID in - identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) - .compactMap { $0.signingPublicKey } - .first - }, - authenticatedSigningPublicKey: { _ in nil }, - verifySignature: { packet, signingPublicKey in - victim.verifyPacketSignature(packet, publicKey: signingPublicKey) - }, - linkState: { _ in (hasPeripheral: true, hasCentral: false) }, - linkBoundToOtherPeer: { _, _ in false }, - withRegistryBarrier: { body in body() }, - upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in - box.registry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: announcement.nickname, - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - isConnected: isConnected, - now: now - ) - }, - shouldEmitReconnectLog: { _, _ in false }, - updateTopology: { _, _ in }, - persistIdentity: { announcement in - identityManager.upsertCryptographicIdentity( - fingerprint: announcement.noisePublicKey.sha256Fingerprint(), - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - claimedNickname: announcement.nickname - ) - }, - dedupContains: { _ in true }, - dedupMarkProcessed: { _ in }, - deliverAnnounceUIEvents: { _, _, _ in }, - trackPacketSeen: { _ in }, - sendAnnounceBack: {}, - scheduleAfterglow: { _ in } - ) - } - let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager)) - - func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket { - let announcement = AnnouncementPacket( - nickname: nickname, - noisePublicKey: victimNoiseKey, - signingPublicKey: signer.getSigningPublicKeyData(), - directNeighbors: nil - ) - let payload = try #require(announcement.encode()) - let packet = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: peerID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(now.timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - return try #require(signer.signPacket(packet)) - } - - func persistedIdentity() -> CryptographicIdentity? { - // queue.sync read; fences the manager's pending barrier writes. - identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first - } - - // 1. Victim announces: pinned in the registry and persisted. - handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID) - #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) - #expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData()) - - // 2. Registry entry disappears (eviction / restart). - _ = box.registry.remove(peerID) - #expect(box.registry.info(for: peerID) == nil) - - // 3. Attacker replay with own signing key: rejected via the persisted - // pin, and neither the registry nor the persisted identity change. - handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID) - #expect(box.registry.info(for: peerID) == nil) - #expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData()) - #expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim") - - // 4. Victim re-announces with the same signing key: accepted again. - handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID) - #expect(box.registry.info(for: peerID)?.nickname == "victim") - #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) - - // 5. Simulated app restart: a fresh identity manager reloads the pin - // from the (mock) keychain, and a fresh registry starts empty. The - // attacker replay is still rejected. - identityManager.forceSave() - let reloadedManager = SecureIdentityStateManager(identityKeychain) - #expect( - reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey - == victim.getSigningPublicKeyData() - ) - box.registry = BLEPeerRegistry() - let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager)) - restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID) - #expect(box.registry.info(for: peerID) == nil) - #expect( - reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey - == victim.getSigningPublicKeyData() - ) - - // ...while the victim is accepted after the restart. - restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID) - #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) - } - - private func expectNoSideEffects(_ recorder: Recorder) { - #expect(recorder.barrierCount == 0) - #expect(recorder.upsertCalls.isEmpty) - #expect(recorder.topologyUpdates.isEmpty) - #expect(recorder.persistedIdentities.isEmpty) - #expect(recorder.dedupMarkedIDs.isEmpty) - #expect(recorder.uiEventDeliveries.isEmpty) - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.announceBacks == 0) - #expect(recorder.afterglowDelays.isEmpty) - } - - private func makeAnnouncePacket( - noisePublicKey: Data, - peerID: PeerID, - timestamp: UInt64, - signature: Data?, - ttl: UInt8 = TransportConfig.messageTTLDefault, - directNeighbors: [Data]? = nil - ) throws -> BitchatPacket { - let announcement = AnnouncementPacket( - nickname: "Alice", - noisePublicKey: noisePublicKey, - signingPublicKey: Data(repeating: 0x99, count: 32), - directNeighbors: directNeighbors - ) - let payload = try #require(announcement.encode()) - - return BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: peerID.id) ?? Data(), - recipientID: nil, - timestamp: timestamp, - payload: payload, - signature: signature, - ttl: ttl - ) - } - - private func timestamp(_ date: Date) -> UInt64 { - UInt64(date.timeIntervalSince1970 * 1000) - } -} diff --git a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift deleted file mode 100644 index 97fa211f..00000000 --- a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift +++ /dev/null @@ -1,294 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEAnnounceHandlingPolicyTests { - @Test - func preflightAcceptsMatchingFreshAnnounce() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x11, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: timestamp(now)) - - let decision = BLEAnnouncePreflightPolicy.evaluate( - packet: packet, - from: peerID, - localPeerID: PeerID(str: "0102030405060708"), - now: now - ) - - guard case .accept(let accepted) = decision else { - Issue.record("Expected announce preflight acceptance") - return - } - #expect(accepted.announcement.nickname == "Alice") - #expect(accepted.derivedPeerID == peerID) - } - - @Test - func preflightRejectsMalformedPayload() { - let now = Date(timeIntervalSince1970: 1_000) - let peerID = PeerID(str: "1122334455667788") - let packet = BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: peerID.id) ?? Data(), - recipientID: nil, - timestamp: timestamp(now), - payload: Data([0x01, 0x20]), - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - - let decision = BLEAnnouncePreflightPolicy.evaluate( - packet: packet, - from: peerID, - localPeerID: PeerID(str: "0102030405060708"), - now: now - ) - - guard case .reject(.malformed) = decision else { - Issue.record("Expected malformed announce rejection") - return - } - } - - @Test - func preflightRejectsSenderMismatchWithDerivedPeerID() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x22, count: 32) - let derivedPeerID = PeerID(publicKey: noiseKey) - let claimedPeerID = PeerID(str: "1122334455667788") - let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: claimedPeerID, timestamp: timestamp(now)) - - let decision = BLEAnnouncePreflightPolicy.evaluate( - packet: packet, - from: claimedPeerID, - localPeerID: PeerID(str: "0102030405060708"), - now: now - ) - - guard case .reject(.senderMismatch(let rejectedDerivedPeerID)) = decision else { - Issue.record("Expected sender mismatch rejection") - return - } - #expect(rejectedDerivedPeerID == derivedPeerID) - } - - @Test - func preflightRejectsSelfAnnounceAfterSenderMatches() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x33, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: timestamp(now)) - - let decision = BLEAnnouncePreflightPolicy.evaluate( - packet: packet, - from: peerID, - localPeerID: peerID, - now: now - ) - - guard case .reject(.selfAnnounce) = decision else { - Issue.record("Expected self announce rejection") - return - } - } - - @Test - func preflightRejectsStaleAnnounceWithAge() throws { - let now = Date(timeIntervalSince1970: 1_000) - let noiseKey = Data(repeating: 0x44, count: 32) - let peerID = PeerID(publicKey: noiseKey) - let oldTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000) - let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: oldTimestamp) - - let decision = BLEAnnouncePreflightPolicy.evaluate( - packet: packet, - from: peerID, - localPeerID: PeerID(str: "0102030405060708"), - now: now - ) - - guard case .reject(.stale(let ageSeconds)) = decision else { - Issue.record("Expected stale announce rejection") - return - } - #expect(abs(ageSeconds - 901) < 0.001) - } - - @Test - func trustPolicyRequiresSignature() { - let decision = BLEAnnounceTrustPolicy.evaluate( - hasSignature: false, - signatureValid: false, - existingNoisePublicKey: nil, - announcedNoisePublicKey: Data(repeating: 0x11, count: 32), - existingSigningPublicKey: nil, - announcedSigningPublicKey: Data(repeating: 0x99, count: 32) - ) - - #expect(decision == .reject(.missingSignature)) - #expect(!decision.isVerified) - } - - @Test - func trustPolicyRejectsInvalidSignature() { - let decision = BLEAnnounceTrustPolicy.evaluate( - hasSignature: true, - signatureValid: false, - existingNoisePublicKey: nil, - announcedNoisePublicKey: Data(repeating: 0x11, count: 32), - existingSigningPublicKey: nil, - announcedSigningPublicKey: Data(repeating: 0x99, count: 32) - ) - - #expect(decision == .reject(.invalidSignature)) - } - - @Test - func trustPolicyRejectsExistingKeyMismatch() { - let decision = BLEAnnounceTrustPolicy.evaluate( - hasSignature: true, - signatureValid: true, - existingNoisePublicKey: Data(repeating: 0xAA, count: 32), - announcedNoisePublicKey: Data(repeating: 0xBB, count: 32), - existingSigningPublicKey: nil, - announcedSigningPublicKey: Data(repeating: 0x99, count: 32) - ) - - #expect(decision == .reject(.keyMismatch)) - } - - @Test - func trustPolicyAcceptsValidSignatureWithMatchingExistingKey() { - let noiseKey = Data(repeating: 0xCC, count: 32) - - let decision = BLEAnnounceTrustPolicy.evaluate( - hasSignature: true, - signatureValid: true, - existingNoisePublicKey: noiseKey, - announcedNoisePublicKey: noiseKey, - existingSigningPublicKey: nil, - announcedSigningPublicKey: Data(repeating: 0x99, count: 32) - ) - - #expect(decision == .verified) - #expect(decision.isVerified) - } - - @Test - func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() { - let noiseKey = Data(repeating: 0xCC, count: 32) - - // Attacker replays the victim's noiseKey/peerID with their own signing - // key and a valid self-signature; the pinned key must win. - let decision = BLEAnnounceTrustPolicy.evaluate( - hasSignature: true, - signatureValid: true, - existingNoisePublicKey: noiseKey, - announcedNoisePublicKey: noiseKey, - existingSigningPublicKey: Data(repeating: 0x99, count: 32), - announcedSigningPublicKey: Data(repeating: 0x66, count: 32) - ) - - #expect(decision == .reject(.signingKeyMismatch)) - #expect(!decision.isVerified) - } - - @Test - func trustPolicyAcceptsMatchingPinnedSigningKey() { - let noiseKey = Data(repeating: 0xCC, count: 32) - let signingKey = Data(repeating: 0x99, count: 32) - - let decision = BLEAnnounceTrustPolicy.evaluate( - hasSignature: true, - signatureValid: true, - existingNoisePublicKey: noiseKey, - announcedNoisePublicKey: noiseKey, - existingSigningPublicKey: signingKey, - announcedSigningPublicKey: signingKey - ) - - #expect(decision == .verified) - } - - @Test - func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() { - let noiseKey = Data(repeating: 0xCC, count: 32) - let boundSigningKey = Data(repeating: 0x11, count: 32) - - let decision = BLEAnnounceTrustPolicy.evaluate( - hasSignature: true, - signatureValid: true, - existingNoisePublicKey: noiseKey, - announcedNoisePublicKey: noiseKey, - authenticatedSigningPublicKey: boundSigningKey, - announcedSigningPublicKey: Data(repeating: 0x22, count: 32) - ) - - #expect(decision == .reject(.authenticatedSigningKeyMismatch)) - } - - @Test - func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() { - let directNew = BLEAnnounceResponsePolicy.plan( - isDirectAnnounce: true, - isNewPeer: true, - isReconnectedPeer: false, - shouldSendAnnounceBack: false - ) - #expect(directNew.shouldNotifyPeerConnected) - #expect(directNew.shouldScheduleInitialSync) - #expect(directNew.shouldScheduleAfterglow) - - let relayedNew = BLEAnnounceResponsePolicy.plan( - isDirectAnnounce: false, - isNewPeer: true, - isReconnectedPeer: false, - shouldSendAnnounceBack: true - ) - #expect(!relayedNew.shouldNotifyPeerConnected) - #expect(!relayedNew.shouldScheduleInitialSync) - #expect(relayedNew.shouldSendAnnounceBack) - #expect(relayedNew.shouldScheduleAfterglow) - - let directExisting = BLEAnnounceResponsePolicy.plan( - isDirectAnnounce: true, - isNewPeer: false, - isReconnectedPeer: false, - shouldSendAnnounceBack: false - ) - #expect(!directExisting.shouldNotifyPeerConnected) - #expect(!directExisting.shouldScheduleInitialSync) - #expect(!directExisting.shouldScheduleAfterglow) - } - - private func makeAnnouncePacket( - noisePublicKey: Data, - peerID: PeerID, - timestamp: UInt64 - ) throws -> BitchatPacket { - let announcement = AnnouncementPacket( - nickname: "Alice", - noisePublicKey: noisePublicKey, - signingPublicKey: Data(repeating: 0x99, count: 32), - directNeighbors: nil - ) - let payload = try #require(announcement.encode()) - - return BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: peerID.id) ?? Data(), - recipientID: nil, - timestamp: timestamp, - payload: payload, - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - } - - private func timestamp(_ date: Date) -> UInt64 { - UInt64(date.timeIntervalSince1970 * 1000) - } -} diff --git a/bitchatTests/Services/BLEAnnounceThrottleTests.swift b/bitchatTests/Services/BLEAnnounceThrottleTests.swift deleted file mode 100644 index 0e135a3b..00000000 --- a/bitchatTests/Services/BLEAnnounceThrottleTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -struct BLEAnnounceThrottleTests { - @Test - func firstAnnounceIsAllowed() { - let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) - - let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100)) - - #expect(shouldSend) - } - - @Test - func regularAnnounceUsesNormalMinimumInterval() { - let now = Date(timeIntervalSince1970: 100) - let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) - - let first = throttle.shouldSend(force: false, now: now) - let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9)) - let afterInterval = throttle.shouldSend(force: false, now: now.addingTimeInterval(10)) - - #expect(first) - #expect(!suppressed) - #expect(afterInterval) - } - - @Test - func forcedAnnounceUsesShorterMinimumInterval() { - let now = Date(timeIntervalSince1970: 100) - let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) - - let first = throttle.shouldSend(force: false, now: now) - let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9)) - let afterInterval = throttle.shouldSend(force: true, now: now.addingTimeInterval(2)) - - #expect(first) - #expect(!suppressed) - #expect(afterInterval) - } - - @Test - func elapsedReportsTimeSinceAcceptedSend() { - let now = Date(timeIntervalSince1970: 100) - let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) - - _ = throttle.shouldSend(force: false, now: now) - - #expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3) - } - - @Test - func concurrentRequestsAdmitOnlyOneAnnounce() { - let now = Date(timeIntervalSince1970: 100) - let throttle = BLEAnnounceThrottle( - normalMinimumInterval: 10, - forcedMinimumInterval: 2 - ) - let accepted = LockedCounter() - - DispatchQueue.concurrentPerform(iterations: 1_000) { _ in - if throttle.shouldSend(force: false, now: now) { - accepted.increment() - } - } - - #expect(accepted.value == 1) - #expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3) - } - - @Test - func resetForgetsThrottleDebtSoARotationAnnounceIsNeverSwallowed() { - let throttle = BLEAnnounceThrottle( - normalMinimumInterval: 1, - forcedMinimumInterval: 1 - ) - let now = Date() - #expect(throttle.shouldSend(force: true, now: now)) - // A panic inside the forced window would be throttled... - #expect(!throttle.shouldSend(force: true, now: now.addingTimeInterval(0.2))) - // ...so the rotation resets the debt and announces immediately. - throttle.reset() - #expect(throttle.shouldSend(force: true, now: now.addingTimeInterval(0.3))) - } -} - -private final class LockedCounter: @unchecked Sendable { - private let lock = NSLock() - private var count = 0 - - var value: Int { lock.withLock { count } } - - func increment() { - lock.withLock { count += 1 } - } -} diff --git a/bitchatTests/Services/BLEConnectionSchedulerTests.swift b/bitchatTests/Services/BLEConnectionSchedulerTests.swift deleted file mode 100644 index 6746a461..00000000 --- a/bitchatTests/Services/BLEConnectionSchedulerTests.swift +++ /dev/null @@ -1,255 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -struct BLEConnectionSchedulerTests { - @Test - func discoveryQueuesWeakSignalCandidates() { - let scheduler = BLEConnectionScheduler(dynamicRSSIThreshold: -80) - let now = Date() - let candidate = makeCandidate(id: "p1", rssi: -85, now: now) - - let decision = scheduler.handleDiscovery( - candidate, - connectedOrConnectingCount: 0, - existingState: nil, - peripheralState: .disconnected, - now: now - ) - - #expect(decision == .queued) - #expect(scheduler.candidateCount == 1) - } - - @Test - func discoveryQueuesAndSchedulesRetryWhenRateLimited() { - let scheduler = BLEConnectionScheduler(connectRateLimitInterval: 1.0) - let now = Date() - scheduler.recordConnectionAttempt(at: now) - - let decision = scheduler.handleDiscovery( - makeCandidate(id: "p1", rssi: -50, now: now), - connectedOrConnectingCount: 0, - existingState: nil, - peripheralState: .disconnected, - now: now.addingTimeInterval(0.25) - ) - - guard case .scheduleRetry(let delay) = decision else { - Issue.record("Expected scheduleRetry, got \(decision)") - return - } - #expect(delay > 0) - #expect(scheduler.candidateCount == 1) - } - - @Test - func nextCandidateSelectsBestScoredCandidate() { - let scheduler = BLEConnectionScheduler() - let now = Date() - scheduler.enqueue(makeCandidate(id: "weak", rssi: -88, now: now)) - scheduler.enqueue(makeCandidate(id: "strong", rssi: -60, now: now.addingTimeInterval(1))) - - let decision = scheduler.nextCandidate( - connectedOrConnectingCount: 0, - isAlreadyConnectingOrConnected: { _ in false }, - now: now.addingTimeInterval(2) - ) - - guard case .connect(let candidate) = decision else { - Issue.record("Expected connect decision") - return - } - #expect(candidate.peripheralID == "strong") - } - - @Test - func enqueueReplacesExistingPeripheralCandidate() { - let scheduler = BLEConnectionScheduler() - let now = Date() - scheduler.enqueue(makeCandidate(id: "same", rssi: -90, now: now)) - scheduler.enqueue(makeCandidate(id: "same", rssi: -55, now: now.addingTimeInterval(1))) - - let decision = scheduler.nextCandidate( - connectedOrConnectingCount: 0, - isAlreadyConnectingOrConnected: { _ in false }, - now: now.addingTimeInterval(2) - ) - - guard case .connect(let candidate) = decision else { - Issue.record("Expected connect decision") - return - } - #expect(scheduler.candidateCount == 0) - #expect(candidate.peripheralID == "same") - #expect(candidate.rssi == -55) - } - - @Test - func weakTimedOutCandidateIsRequeuedWithRetryDelay() { - let scheduler = BLEConnectionScheduler( - weakLinkCooldownSeconds: 30, - weakLinkRSSICutoff: -90 - ) - let now = Date() - scheduler.recordConnectionTimeout(peripheralID: "weak", at: now) - scheduler.enqueue(makeCandidate(id: "weak", rssi: -95, now: now)) - - let decision = scheduler.nextCandidate( - connectedOrConnectingCount: 0, - isAlreadyConnectingOrConnected: { _ in false }, - now: now.addingTimeInterval(5) - ) - - guard case .retryAfter(let delay) = decision else { - Issue.record("Expected retryAfter decision") - return - } - #expect(delay == 15) - #expect(scheduler.candidateCount == 1) - } - - @Test - func disconnectErrorOnlyBrieflyBlocksRediscovery() { - // A dropped established connection (walked out of range) gets a short - // settle window, not the full connect-timeout backoff. - let scheduler = BLEConnectionScheduler() - let now = Date() - scheduler.recordDisconnectError(peripheralID: "p1", at: now) - - let during = scheduler.handleDiscovery( - makeCandidate(id: "p1", rssi: -80, now: now.addingTimeInterval(1)), - connectedOrConnectingCount: 0, - existingState: nil, - peripheralState: .disconnected, - now: now.addingTimeInterval(1) - ) - #expect(during == .ignore) - - let afterWindow = now.addingTimeInterval(TransportConfig.bleDisconnectDiscoveryIgnoreSeconds + 1) - let after = scheduler.handleDiscovery( - makeCandidate(id: "p1", rssi: -80, now: afterWindow), - connectedOrConnectingCount: 0, - existingState: nil, - peripheralState: .disconnected, - now: afterWindow - ) - #expect(after == .connectNow) - } - - @Test - func disconnectSettleWindowAppliesToQueuedCandidates() { - // A candidate can already be queued when its peripheral drops (weak - // adverts are enqueued even while connected). The post-disconnect - // queue drain must honor the settle window, not reconnect instantly. - let scheduler = BLEConnectionScheduler() - let now = Date() - scheduler.enqueue(makeCandidate(id: "p1", rssi: -85, now: now)) - scheduler.recordDisconnectError(peripheralID: "p1", at: now) - - let during = scheduler.nextCandidate( - connectedOrConnectingCount: 0, - isAlreadyConnectingOrConnected: { _ in false }, - now: now.addingTimeInterval(0.1) - ) - guard case .retryAfter(let delay) = during else { - Issue.record("Expected retryAfter during settle window, got \(during)") - return - } - #expect(delay > 0) - #expect(scheduler.candidateCount == 1) - - let after = scheduler.nextCandidate( - connectedOrConnectingCount: 0, - isAlreadyConnectingOrConnected: { _ in false }, - now: now.addingTimeInterval(TransportConfig.bleDisconnectDiscoveryIgnoreSeconds + 1) - ) - guard case .connect(let candidate) = after else { - Issue.record("Expected connect after settle window, got \(after)") - return - } - #expect(candidate.peripheralID == "p1") - } - - @Test - func connectTimeoutBlocksRediscoveryForFullWindow() { - let scheduler = BLEConnectionScheduler() - let now = Date() - scheduler.recordConnectionTimeout(peripheralID: "p1", at: now) - - let midWindow = scheduler.handleDiscovery( - makeCandidate(id: "p1", rssi: -80, now: now.addingTimeInterval(10)), - connectedOrConnectingCount: 0, - existingState: nil, - peripheralState: .disconnected, - now: now.addingTimeInterval(10) - ) - #expect(midWindow == .ignore) - } - - @Test - func repeatedTimeoutsDoNotTightenGlobalRSSIThreshold() { - // Flaky links are penalized per-peripheral only; timeouts from a few - // distant peers must not blind us to every other edge-of-range peer. - let scheduler = BLEConnectionScheduler() - let now = Date() - scheduler.recordConnectionTimeout(peripheralID: "p1", at: now) - scheduler.recordConnectionTimeout(peripheralID: "p2", at: now) - scheduler.recordConnectionTimeout(peripheralID: "p3", at: now) - - let threshold = scheduler.updateRSSIThreshold( - connectedCount: 1, - connectedOrConnectingLinkCount: 1, - now: now.addingTimeInterval(1) - ) - - #expect(threshold == TransportConfig.bleDynamicRSSIThresholdDefault) - } - - @Test - func isolationRelaxesRSSIThresholdOverTime() { - let scheduler = BLEConnectionScheduler() - let now = Date() - - let initial = scheduler.updateRSSIThreshold( - connectedCount: 0, - connectedOrConnectingLinkCount: 0, - now: now - ) - #expect(initial == TransportConfig.bleRSSIIsolatedBase) - - let relaxed = scheduler.updateRSSIThreshold( - connectedCount: 0, - connectedOrConnectingLinkCount: 0, - now: now.addingTimeInterval(TransportConfig.bleIsolationRelaxThresholdSeconds + 1) - ) - #expect(relaxed == TransportConfig.bleRSSIIsolatedRelaxed) - } - - @Test - func rssiThresholdTightensWhenCandidateQueueIsFull() { - let scheduler = BLEConnectionScheduler(candidateCap: 2) - let now = Date() - scheduler.enqueue(makeCandidate(id: "p1", rssi: -65, now: now)) - scheduler.enqueue(makeCandidate(id: "p2", rssi: -66, now: now)) - - let threshold = scheduler.updateRSSIThreshold( - connectedCount: 1, - connectedOrConnectingLinkCount: 1, - now: now - ) - - #expect(threshold == TransportConfig.bleRSSIConnectedThreshold) - } -} - -private func makeCandidate(id: String, rssi: Int, now: Date) -> BLEConnectionCandidate { - BLEConnectionCandidate( - peripheral: id, - peripheralID: id, - rssi: rssi, - name: id, - isConnectable: true, - discoveredAt: now - ) -} diff --git a/bitchatTests/Services/BLEDirectedRelaySpoolTests.swift b/bitchatTests/Services/BLEDirectedRelaySpoolTests.swift deleted file mode 100644 index a8fa6fb5..00000000 --- a/bitchatTests/Services/BLEDirectedRelaySpoolTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEDirectedRelaySpoolTests { - @Test - func enqueueDeduplicatesByRecipientAndMessageID() { - let recipient = PeerID(str: "1122334455667788") - let now = Date() - var spool = BLEDirectedRelaySpool() - - let inserted = spool.enqueue( - packet: makePacket(payload: [0x01]), - recipient: recipient, - messageID: "message-1", - enqueuedAt: now - ) - let duplicate = spool.enqueue( - packet: makePacket(payload: [0x02]), - recipient: recipient, - messageID: "message-1", - enqueuedAt: now - ) - - #expect(inserted) - #expect(!duplicate) - #expect(spool.count == 1) - } - - @Test - func drainUnexpiredReturnsFreshPacketsAndClearsSpool() { - let recipient = PeerID(str: "1122334455667788") - let now = Date() - var spool = BLEDirectedRelaySpool() - let freshPacket = makePacket(payload: [0x01]) - let expiredPacket = makePacket(payload: [0x02]) - - spool.enqueue(packet: freshPacket, recipient: recipient, messageID: "fresh", enqueuedAt: now.addingTimeInterval(-1)) - spool.enqueue(packet: expiredPacket, recipient: recipient, messageID: "old", enqueuedAt: now.addingTimeInterval(-20)) - - let drained = spool.drainUnexpired(now: now, window: 5) - - #expect(drained.count == 1) - #expect(drained.first?.recipient == recipient) - #expect(drained.first?.packet.payload == freshPacket.payload) - #expect(spool.isEmpty) - } - - @Test - func pruneExpiredKeepsFreshPacketsAcrossRecipients() { - let firstRecipient = PeerID(str: "1122334455667788") - let secondRecipient = PeerID(str: "8877665544332211") - let now = Date() - var spool = BLEDirectedRelaySpool() - - spool.enqueue(packet: makePacket(payload: [0x01]), recipient: firstRecipient, messageID: "fresh-1", enqueuedAt: now) - spool.enqueue(packet: makePacket(payload: [0x02]), recipient: secondRecipient, messageID: "fresh-2", enqueuedAt: now.addingTimeInterval(-2)) - spool.enqueue(packet: makePacket(payload: [0x03]), recipient: secondRecipient, messageID: "old", enqueuedAt: now.addingTimeInterval(-10)) - - spool.pruneExpired(now: now, window: 5) - - #expect(spool.count == 2) - let drained = spool.drainUnexpired(now: now, window: 5) - #expect(Set(drained.map(\.recipient)) == Set([firstRecipient, secondRecipient])) - } - - @Test - func removeAllClearsStoredPackets() { - var spool = BLEDirectedRelaySpool() - - spool.enqueue( - packet: makePacket(payload: [0x01]), - recipient: PeerID(str: "1122334455667788"), - messageID: "message-1", - enqueuedAt: Date() - ) - spool.removeAll() - - #expect(spool.isEmpty) - } - - private func makePacket(payload: [UInt8]) -> BitchatPacket { - BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: "8877665544332211") ?? Data(), - recipientID: Data(hexString: "1122334455667788"), - timestamp: 1234, - payload: Data(payload), - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - } -} diff --git a/bitchatTests/Services/BLEFanoutSelectorTests.swift b/bitchatTests/Services/BLEFanoutSelectorTests.swift deleted file mode 100644 index 5832b676..00000000 --- a/bitchatTests/Services/BLEFanoutSelectorTests.swift +++ /dev/null @@ -1,278 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEFanoutSelectorTests { - @Test - func directedSendUsesAllNonIngressLinks() { - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["p1", "p2"], - centralIDs: ["c1", "c2"], - ingressLink: .central("c1"), - directedPeerHint: PeerID(str: "1122334455667788"), - packetType: MessageType.noiseEncrypted.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs == Set(["p1", "p2"])) - #expect(selection.centralIDs == Set(["c2"])) - } - - @Test - func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() { - let target = PeerID(str: "1122334455667788") - let bystander = PeerID(str: "8877665544332211") - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["target-p", "bystander-p"], - centralIDs: ["target-c", "bystander-c"], - ingressLink: nil, - peripheralPeerBindings: [ - "target-p": target, - "bystander-p": bystander - ], - centralPeerBindings: [ - "target-c": target, - "bystander-c": bystander - ], - directedPeerHint: target, - packetType: MessageType.courierEnvelope.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs == Set(["target-p"])) - #expect(selection.centralIDs.isEmpty) - } - - @Test - func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() { - let target = PeerID(str: "1122334455667788") - let bystander = PeerID(str: "8877665544332211") - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["bystander-p"], - centralIDs: ["target-c", "bystander-c"], - ingressLink: nil, - peripheralPeerBindings: [ - "bystander-p": bystander - ], - centralPeerBindings: [ - "target-c": target, - "bystander-c": bystander - ], - directedPeerHint: target, - packetType: MessageType.courierEnvelope.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs.isEmpty) - #expect(selection.centralIDs == Set(["target-c"])) - } - - @Test - func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() { - let target = PeerID(str: "1122334455667788") - let bystander = PeerID(str: "8877665544332211") - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["bystander-p"], - centralIDs: ["target-c", "bystander-c"], - ingressLink: .central("target-c"), - peripheralPeerBindings: [ - "bystander-p": bystander - ], - centralPeerBindings: [ - "target-c": target, - "bystander-c": bystander - ], - directedPeerHint: target, - packetType: MessageType.courierEnvelope.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs.isEmpty) - #expect(selection.centralIDs.isEmpty) - } - - @Test - func directedSendExcludesAllLinksToIngressPeer() { - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["p1", "p2"], - centralIDs: ["c1", "c2"], - ingressLink: .central("c1"), - excludedLinks: [.peripheral("p2"), .central("c2")], - directedPeerHint: PeerID(str: "1122334455667788"), - packetType: MessageType.noiseEncrypted.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs == Set(["p1"])) - #expect(selection.centralIDs.isEmpty) - } - - @Test - func controlPacketsUseAllNonIngressLinks() { - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["p1", "p2", "p3"], - centralIDs: ["c1", "c2", "c3"], - ingressLink: .peripheral("p2"), - directedPeerHint: nil, - packetType: MessageType.requestSync.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs == Set(["p1", "p3"])) - #expect(selection.centralIDs == Set(["c1", "c2", "c3"])) - } - - @Test - func broadcastPacketsUseDeterministicSubsetAfterIngressExclusion() { - let peripherals = (1...8).map { "p\($0)" } - let centrals = (1...8).map { "c\($0)" } - - let first = BLEFanoutSelector.selectLinks( - peripheralIDs: peripherals, - centralIDs: centrals, - ingressLink: .peripheral("p4"), - directedPeerHint: nil, - packetType: MessageType.message.rawValue, - messageID: "message-1" - ) - let second = BLEFanoutSelector.selectLinks( - peripheralIDs: peripherals, - centralIDs: centrals, - ingressLink: .peripheral("p4"), - directedPeerHint: nil, - packetType: MessageType.message.rawValue, - messageID: "message-1" - ) - - #expect(first == second) - #expect(!first.peripheralIDs.contains("p4")) - #expect(first.peripheralIDs.count == 4) - #expect(first.centralIDs.count == 4) - } - - @Test - func dualLinkPeerRelaysOnSingleLinkPreferringPeripheral() { - // A dual-role pair holds two live links to the same peer; relays must - // not transmit the same packet down both. The peripheral (write) link - // wins because it has per-link flow control. - let peer = PeerID(str: "1122334455667788") - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["p1"], - centralIDs: ["c1"], - ingressLink: nil, - peripheralPeerBindings: ["p1": peer], - centralPeerBindings: ["c1": peer], - directedPeerHint: nil, - packetType: MessageType.fragment.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs == Set(["p1"])) - #expect(selection.centralIDs.isEmpty) - } - - @Test - func unboundLinksSurviveDuplicatePeerCollapse() { - // Links whose peer is not yet known (pre-announce) must keep - // receiving broadcasts alongside a deduplicated bound pair. - let peer = PeerID(str: "1122334455667788") - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["p1"], - centralIDs: ["c-bound", "c-unbound"], - ingressLink: nil, - peripheralPeerBindings: ["p1": peer], - centralPeerBindings: ["c-bound": peer], - directedPeerHint: nil, - packetType: MessageType.fragment.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs == Set(["p1"])) - #expect(selection.centralIDs == Set(["c-unbound"])) - } - - @Test - func duplicateBoundLinksToOnePeerCollapseToItsPreferredLink() { - // After a restore the same phone can hold several live links bound to - // one peer; broadcasts must go down exactly one — the most recently - // bound (preferred) one, not dictionary order. - let peer = PeerID(str: "1122334455667788") - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["p-stale", "p-preferred", "p-stale-2"], - centralIDs: ["c-bound"], - ingressLink: nil, - peripheralPeerBindings: [ - "p-stale": peer, - "p-preferred": peer, - "p-stale-2": peer - ], - centralPeerBindings: ["c-bound": peer], - preferredPeripheralPerPeer: [peer: "p-preferred"], - directedPeerHint: nil, - packetType: MessageType.fragment.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs == Set(["p-preferred"])) - #expect(selection.centralIDs.isEmpty) - } - - @Test - func directedSendCollapsesDuplicateBoundLinksToPreferred() { - let peer = PeerID(str: "1122334455667788") - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["p-stale", "p-preferred"], - centralIDs: [], - ingressLink: nil, - peripheralPeerBindings: [ - "p-stale": peer, - "p-preferred": peer - ], - preferredPeripheralPerPeer: [peer: "p-preferred"], - directedPeerHint: peer, - packetType: MessageType.noiseEncrypted.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs == Set(["p-preferred"])) - #expect(selection.centralIDs.isEmpty) - } - - @Test - func uncollapsedSelectionReachesEveryLinkOfADuplicatelyLinkedPeer() { - // Announce fanout (collapse bypassed by the planner): the announce is - // the packet that binds links, so every live link must receive it. - let peer = PeerID(str: "1122334455667788") - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["p1", "p2"], - centralIDs: ["c1"], - ingressLink: nil, - peripheralPeerBindings: ["p1": peer, "p2": peer], - centralPeerBindings: ["c1": peer], - preferredPeripheralPerPeer: [peer: "p1"], - collapseDuplicatePeerLinks: false, - directedPeerHint: nil, - packetType: MessageType.announce.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs == Set(["p1", "p2"])) - #expect(selection.centralIDs == Set(["c1"])) - } - - @Test - func broadcastWithTwoLinksKeepsBothAfterIngressExclusion() { - let selection = BLEFanoutSelector.selectLinks( - peripheralIDs: ["p1", "p2"], - centralIDs: ["c1", "c2"], - ingressLink: nil, - directedPeerHint: nil, - packetType: MessageType.message.rawValue, - messageID: "message-1" - ) - - #expect(selection.peripheralIDs == Set(["p1", "p2"])) - #expect(selection.centralIDs == Set(["c1", "c2"])) - } -} diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift deleted file mode 100644 index 835bcdba..00000000 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ /dev/null @@ -1,1431 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEFileTransferHandlerTests { - private final class Recorder { - var localNickname = "Me" - var peers: [PeerID: BLEPeerInfo] = [:] - var signedName: String? - var signatureVerifies = false - var saveResult: URL? = URL(fileURLWithPath: "/tmp/files/incoming/sample.pdf") - - var signatureVerifyCount = 0 - var signedNameQueries: [PeerID] = [] - var blockedPeers: Set = [] - var trackedPackets: [BitchatPacket] = [] - var quotaReservations: [Int] = [] - var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = [] - var receiptStates: [String: BLEPrivateMediaReceiptState] = [:] - var receiptCommits: [(messageID: String, storedURL: URL)] = [] - var receiptCommitSucceeds = true - var removedIncomingFiles: [URL] = [] - var finishedIncomingFileDeliveries: [URL] = [] - var lastSeenUpdates: [PeerID] = [] - var deliveryAcks: [(messageID: String, peerID: PeerID)] = [] - var deliveredMessages: [BitchatMessage] = [] - var shouldAcceptDelivery = true - var deliveryOutcome = TransportEventDeliveryOutcome.accepted - var saveOverride: (( - _ data: Data, - _ preferredName: String?, - _ subdirectory: String, - _ fallbackExtension: String?, - _ defaultPrefix: String - ) -> URL?)? - var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)? - var receiptCommitOverride: ((String, URL) -> Bool)? - var removeIncomingFileOverride: ((URL) -> Void)? - var finishIncomingFileDeliveryOverride: ((URL) -> Void)? - } - - private let localPeerID = PeerID(str: "0102030405060708") - private let remotePeerID = PeerID(str: "1122334455667788") - private let sampleSigningKey = Data(repeating: 0xAB, count: 32) - - @Test @MainActor - func deliveryGateFinalizesInitialRejection() { - var completions = 0 - var finalizations = 0 - - TransportEventDeliveryGate.attempt( - shouldDeliver: { false }, - deliver: { - Issue.record("delivery sink must not run") - return .accepted - }, - completion: { completions += 1 }, - finalization: { _ in finalizations += 1 } - ) - - #expect(completions == 0) - #expect(finalizations == 1) - } - - @Test @MainActor - func deliveryGateFinalizesMissingOrRejectingSink() { - var completions = 0 - var finalizations = 0 - - TransportEventDeliveryGate.attempt( - shouldDeliver: { true }, - deliver: { .rejected }, - completion: { completions += 1 }, - finalization: { _ in finalizations += 1 } - ) - - #expect(completions == 0) - #expect(finalizations == 1) - } - - @Test @MainActor - func deliveryGateFinalizesPostInsertionRejection() { - var deliveryChecks = 0 - var completions = 0 - var finalizations = 0 - - TransportEventDeliveryGate.attempt( - shouldDeliver: { - deliveryChecks += 1 - return deliveryChecks == 1 - }, - deliver: { .accepted }, - completion: { completions += 1 }, - finalization: { _ in finalizations += 1 } - ) - - #expect(deliveryChecks == 2) - #expect(completions == 0) - #expect(finalizations == 1) - } - - @Test @MainActor - func deliveryGatePreservesInvokedUnconfirmedOutcomeWithoutAck() { - var completions = 0 - var outcomes: [TransportEventDeliveryOutcome] = [] - - TransportEventDeliveryGate.attempt( - shouldDeliver: { true }, - deliver: { .invokedUnconfirmed }, - completion: { completions += 1 }, - finalization: { outcomes.append($0) } - ) - - #expect(completions == 0) - #expect(outcomes == [.invokedUnconfirmed]) - } - - private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler { - let environment = BLEFileTransferHandlerEnvironment( - localPeerID: { [localPeerID] in localPeerID }, - localNickname: { recorder.localNickname }, - peersSnapshot: { recorder.peers }, - verifyPacketSignature: { _, _ in - recorder.signatureVerifyCount += 1 - return recorder.signatureVerifies - }, - localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey }, - signedSenderDisplayName: { _, peerID in - recorder.signedNameQueries.append(peerID) - return recorder.signedName - }, - trackPacketSeen: { packet in - recorder.trackedPackets.append(packet) - }, - enforceStorageQuota: { reservingBytes in - recorder.quotaReservations.append(reservingBytes) - }, - saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in - recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix)) - if let saveOverride = recorder.saveOverride { - return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix) - } - return recorder.saveResult - }, - privateMediaReceiptState: { messageID in - if let receiptStateOverride = recorder.receiptStateOverride { - return receiptStateOverride(messageID) - } - return recorder.receiptStates[messageID] ?? .absent - }, - commitPrivateMediaFile: { messageID, storedURL in - recorder.receiptCommits.append((messageID, storedURL)) - if let receiptCommitOverride = recorder.receiptCommitOverride { - return receiptCommitOverride(messageID, storedURL) - } - guard recorder.receiptCommitSucceeds else { return false } - recorder.receiptStates[messageID] = .accepted(storedURL) - return true - }, - removeIncomingFile: { storedURL in - recorder.removedIncomingFiles.append(storedURL) - recorder.removeIncomingFileOverride?(storedURL) - }, - finishIncomingFileDelivery: { storedURL in - recorder.finishedIncomingFileDeliveries.append(storedURL) - recorder.finishIncomingFileDeliveryOverride?(storedURL) - }, - isPrivateMediaSenderBlocked: { peerID in - recorder.blockedPeers.contains(peerID) - }, - updatePeerLastSeen: { peerID in - recorder.lastSeenUpdates.append(peerID) - }, - acknowledgePrivateMedia: { messageID, peerID in - recorder.deliveryAcks.append((messageID, peerID)) - }, - deliverMessage: { message, shouldDeliver, completion, finalization in - var outcome = TransportEventDeliveryOutcome.rejected - defer { finalization(outcome) } - guard recorder.shouldAcceptDelivery else { return } - guard shouldDeliver() else { return } - recorder.deliveredMessages.append(message) - guard shouldDeliver() else { return } - if recorder.deliveryOutcome == .invokedUnconfirmed { - outcome = .invokedUnconfirmed - return - } - outcome = .accepted - completion() - } - ) - return BLEFileTransferHandler(environment: environment) - } - - @Test - func broadcastFileFromVerifiedPeerIsSavedAndDelivered() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] - recorder.signatureVerifies = true - let handler = makeHandler(recorder: recorder) - let content = Data("%PDF-1.7".utf8) - let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: content) - - #expect(handler.handle(packet, from: remotePeerID)) - - #expect(recorder.signatureVerifyCount == 1) - #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.quotaReservations == [content.count]) - #expect(recorder.saveCalls.count == 1) - #expect(recorder.saveCalls.first?.data == content) - #expect(recorder.saveCalls.first?.preferredName == "sample") - #expect(recorder.saveCalls.first?.subdirectory == "files/incoming") - #expect(recorder.saveCalls.first?.fallbackExtension == "pdf") - #expect(recorder.saveCalls.first?.defaultPrefix == "file") - #expect(recorder.lastSeenUpdates.isEmpty) - #expect(recorder.deliveredMessages.count == 1) - let message = recorder.deliveredMessages.first - #expect(message?.sender == "Alice") - #expect(message?.content == "[file] sample.pdf") - #expect(message?.isPrivate == false) - #expect(message?.senderPeerID == remotePeerID) - #expect(message?.timestamp == Date(timeIntervalSince1970: 900)) - #expect(message?.deliveryStatus == .notSentYet) - } - - @Test - func selfEchoIsDropped() throws { - let recorder = Recorder() - recorder.signatureVerifies = true - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3) - - #expect(!handler.handle(packet, from: localPeerID)) - - expectNoSideEffects(recorder) - } - - @Test - func unknownPeerWithoutValidSignatureIsDropped() throws { - let recorder = Recorder() - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) - - #expect(!handler.handle(packet, from: remotePeerID)) - - #expect(recorder.signedNameQueries == [remotePeerID]) - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - } - - @Test - func broadcastFromConnectedUnverifiedPeerWithoutSignatureIsDropped() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket( - sender: remotePeerID, - mimeType: "application/pdf", - content: Data("%PDF-1.7".utf8), - hasSignature: false - ) - - // Failed sender authentication must also stop the packet from being - // relayed to downstream nodes. - #expect(!handler.handle(packet, from: remotePeerID)) - - // Broadcast files carry an attacker-controllable senderID, so — like - // public messages — a connected-but-unverified peer must present a valid - // packet signature. No signing key + no signed identity means dropped. - #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - } - - @Test - func broadcastFromConnectedUnverifiedPeerWithSignedIdentityIsAccepted() throws { - let recorder = Recorder() - // Connected but nickname not yet verified and no registry signing key — - // the persisted-identity signature lookup still authenticates the - // sender, so the transfer is accepted under that verified name. - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] - recorder.signedName = "Bob" - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) - - #expect(handler.handle(packet, from: remotePeerID)) - - #expect(recorder.signedNameQueries == [remotePeerID]) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveredMessages.first?.sender == "Bob") - } - - @Test - func signedSelfBroadcastReplayIsDelivered() throws { - // Our own broadcast file replayed via gossip sync arrives with ttl==0; - // it is verified against our local signing key before delivery. - let recorder = Recorder() - recorder.signatureVerifies = true - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket( - sender: localPeerID, - mimeType: "application/pdf", - content: Data("%PDF-1.7".utf8), - ttl: 0 - ) - - #expect(handler.handle(packet, from: localPeerID)) - - #expect(recorder.signatureVerifyCount == 1) - #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveredMessages.first?.sender == "Me") - } - - @Test - func broadcastFromPeerNotInRegistryAcceptedViaSignedIdentity() throws { - let recorder = Recorder() - recorder.signedName = "Carol" - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) - - #expect(handler.handle(packet, from: remotePeerID)) - - // Peer absent from the registry: fall back to the persisted-identity - // signature lookup (mirrors BLEPublicMessageHandler). - #expect(recorder.signedNameQueries == [remotePeerID]) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveredMessages.first?.sender == "Carol") - } - - @Test - func spoofedBroadcastVoiceNoteWithoutSignatureIsDropped() throws { - // Regression for the PR #1406 finding: an in-range peer that observed a - // public voice burst tries to overwrite the live bubble by broadcasting - // a `voice_.m4a` note under the talker's senderID. Without a - // valid signature the note never reaches the coordinator's absorption. - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)] - let handler = makeHandler(recorder: recorder) - let m4a = Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A ".utf8) - let packet = try makeFileTransferPacket( - sender: remotePeerID, - mimeType: "audio/mp4", - content: m4a, - fileName: "voice_1122334455667788", - hasSignature: false - ) - - // The spoofed note must be dropped locally AND not relayed onward. - #expect(!handler.handle(packet, from: remotePeerID)) - - #expect(recorder.deliveredMessages.isEmpty) - } - - @Test - func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket( - sender: remotePeerID, - mimeType: "application/pdf", - content: Data("%PDF-1.7".utf8), - recipientID: Data(hexString: localPeerID.id), - hasSignature: false - ) - - #expect(!handler.handle(packet, from: remotePeerID)) - - #expect(recorder.signatureVerifyCount == 0) - #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.quotaReservations.isEmpty) - #expect(recorder.saveCalls.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - } - - @Test - func fileDirectedToAnotherPeerIsIgnored() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] - recorder.signatureVerifies = true - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket( - sender: remotePeerID, - mimeType: "application/pdf", - content: Data("%PDF-1.7".utf8), - recipientID: Data(hexString: "AABBCCDDEEFF0011") - ) - - // Not for us, but it must keep relaying toward the real recipient. - #expect(handler.handle(packet, from: remotePeerID)) - - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.quotaReservations.isEmpty) - #expect(recorder.saveCalls.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - } - - @Test - func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] - recorder.signatureVerifies = true - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket( - sender: remotePeerID, - mimeType: "application/pdf", - content: Data("%PDF-1.7".utf8), - recipientID: Data(hexString: localPeerID.id) - ) - - #expect(handler.handle(packet, from: remotePeerID)) - - // Directed transfers are not tracked for gossip sync. - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.lastSeenUpdates == [remotePeerID]) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveredMessages.first?.isPrivate == true) - #expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false) - // Must be explicit: BitchatMessage defaults private messages to - // .sending, which the media views render as an in-flight send - // (empty reveal mask, disabled reveal tap). - #expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900))) - } - - @Test - func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] - let handler = makeHandler(recorder: recorder) - let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) - let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" - let file = BitchatFilePacket( - fileName: fileName, - fileSize: UInt64(content.count), - mimeType: "image/jpeg", - content: content - ) - let payload = try #require(file.encode()) - let timestamp = Date(timeIntervalSince1970: 1_234) - - #expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp)) - - #expect(recorder.signatureVerifyCount == 0) - #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.quotaReservations == [content.count]) - #expect(recorder.saveCalls.first?.data == content) - #expect(recorder.lastSeenUpdates == [remotePeerID]) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveredMessages.first?.isPrivate == true) - #expect(recorder.deliveredMessages.first?.timestamp == timestamp) - #expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID( - senderPeerID: remotePeerID, - recipientPeerID: localPeerID, - fileName: fileName - )) - #expect(recorder.receiptCommits.count == 1) - #expect(recorder.deliveryAcks.count == 1) - #expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id) - } - - @Test - func rejectedStableDeliveryReleasesPendingPayloadOwnership() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "private-media-handler-rejected-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: root) } - let store = BLEIncomingFileStore(baseDirectory: root) - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo( - remotePeerID, - nickname: "Alice", - isVerified: true - )] - recorder.shouldAcceptDelivery = false - recorder.saveOverride = { - store.save( - data: $0, - preferredName: $1, - subdirectory: $2, - fallbackExtension: $3, - defaultPrefix: $4 - ) - } - recorder.receiptStateOverride = { - store.privateMediaReceiptState(messageID: $0) - } - recorder.receiptCommitOverride = { - store.commitPrivateMediaFile(messageID: $0, storedURL: $1) - } - recorder.removeIncomingFileOverride = { - store.removeIncomingFile(at: $0) - } - recorder.finishIncomingFileDeliveryOverride = { - store.finishIncomingFileDelivery(at: $0) - } - - let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) - let fileName = - "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" - let file = BitchatFilePacket( - fileName: fileName, - fileSize: UInt64(content.count), - mimeType: "image/jpeg", - content: content - ) - let payload = try #require(file.encode()) - let stableID = try #require(PrivateMediaMessageIdentity.stableID( - senderPeerID: remotePeerID, - recipientPeerID: localPeerID, - fileName: fileName - )) - - #expect(makeHandler(recorder: recorder).handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_234) - )) - #expect(recorder.deliveredMessages.isEmpty) - #expect(recorder.deliveryAcks.isEmpty) - #expect(recorder.finishedIncomingFileDeliveries.count == 1) - #expect(store.reservePrivateMediaDeletion( - messageIDs: [stableID], - payloadRelativePaths: [:] - ) != nil) - } - - @Test - func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo( - remotePeerID, - nickname: "Alice", - isVerified: true, - signingPublicKey: sampleSigningKey - )] - recorder.signatureVerifies = true - let handler = makeHandler(recorder: recorder) - let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) - let packet = try makeFileTransferPacket( - sender: remotePeerID, - mimeType: "image/jpeg", - content: content, - recipientID: Data(hexString: localPeerID.id), - fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" - ) - - #expect(handler.handle(packet, from: remotePeerID)) - #expect(recorder.receiptCommits.isEmpty) - #expect(recorder.deliveryAcks.isEmpty) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false) - } - - @Test - func rejectedRawDeliveryRemovesUIUnownedPayload() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo( - remotePeerID, - nickname: "Alice", - isVerified: true, - signingPublicKey: sampleSigningKey - )] - recorder.signatureVerifies = true - recorder.shouldAcceptDelivery = false - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket( - sender: remotePeerID, - mimeType: "image/jpeg", - content: Data([0xFF, 0xD8, 0xFF, 0xD9]), - recipientID: Data(hexString: localPeerID.id), - fileName: "raw-rejected.jpg" - ) - - #expect(handler.handle(packet, from: remotePeerID)) - #expect(recorder.deliveredMessages.isEmpty) - #expect(recorder.removedIncomingFiles.count == 1) - #expect(recorder.removedIncomingFiles.first == recorder.saveResult) - #expect(recorder.finishedIncomingFileDeliveries.isEmpty) - } - - @Test - func plainDelegateRawDeliveryPreservesPayloadWithoutSynchronousAck() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo( - remotePeerID, - nickname: "Alice", - isVerified: true, - signingPublicKey: sampleSigningKey - )] - recorder.signatureVerifies = true - recorder.deliveryOutcome = .invokedUnconfirmed - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket( - sender: remotePeerID, - mimeType: "image/jpeg", - content: Data([0xFF, 0xD8, 0xFF, 0xD9]), - recipientID: Data(hexString: localPeerID.id), - fileName: "plain-delegate.jpg" - ) - - #expect(handler.handle(packet, from: remotePeerID)) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveryAcks.isEmpty) - #expect(recorder.removedIncomingFiles.isEmpty) - #expect(recorder.finishedIncomingFileDeliveries.count == 1) - #expect( - recorder.finishedIncomingFileDeliveries.first - == recorder.saveResult - ) - } - - @Test - func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] - let handler = makeHandler(recorder: recorder) - let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) - let file = BitchatFilePacket( - fileName: "photo.jpg", - fileSize: UInt64(content.count), - mimeType: "image/jpeg", - content: content - ) - let payload = try #require(file.encode()) - - #expect(handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_234) - )) - #expect(handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_235) - )) - - #expect(recorder.deliveredMessages.count == 2) - #expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id) - #expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") }) - } - - @Test - func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] - let handler = makeHandler(recorder: recorder) - let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) - let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" - let file = BitchatFilePacket( - fileName: fileName, - fileSize: UInt64(content.count), - mimeType: "image/jpeg", - content: content - ) - let payload = try #require(file.encode()) - let expectedID = try #require(PrivateMediaMessageIdentity.stableID( - senderPeerID: remotePeerID, - recipientPeerID: localPeerID, - fileName: fileName - )) - - // First encrypted arrival may precede the sender's authenticated bit-9 - // proof. It still uses the bit-8 stable ID/ACK contract. - #expect(handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_234) - )) - // A later automatic retry after proof must resolve the same durable ID - // rather than create a legacy random-ID bubble. - #expect(handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_235) - )) - - #expect(recorder.quotaReservations == [content.count]) - #expect(recorder.saveCalls.count == 1) - // The handler re-offers a durable duplicate so a relaunched UI can - // restore its bubble; the synchronous conversation sink deduplicates. - #expect(recorder.deliveredMessages.count == 2) - #expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID]) - #expect(recorder.deliveryAcks.count == 2) - #expect(recorder.deliveryAcks.allSatisfy { - $0.messageID == expectedID && $0.peerID == remotePeerID - }) - } - - @Test - func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "private-media-handler-relaunch-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: root) } - let store = BLEIncomingFileStore(baseDirectory: root) - let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) - let file = BitchatFilePacket( - fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", - fileSize: UInt64(content.count), - mimeType: "image/jpeg", - content: content - ) - let payload = try #require(file.encode()) - - func configure(_ recorder: Recorder) { - recorder.peers = [remotePeerID: makePeerInfo( - remotePeerID, - nickname: "Alice", - isVerified: true - )] - recorder.saveOverride = { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in - store.save( - data: data, - preferredName: preferredName, - subdirectory: subdirectory, - fallbackExtension: fallbackExtension, - defaultPrefix: defaultPrefix - ) - } - recorder.receiptStateOverride = { - store.privateMediaReceiptState(messageID: $0) - } - recorder.receiptCommitOverride = { - store.commitPrivateMediaFile(messageID: $0, storedURL: $1) - } - recorder.removeIncomingFileOverride = { - store.removeIncomingFile(at: $0) - } - recorder.finishIncomingFileDeliveryOverride = { - store.finishIncomingFileDelivery(at: $0) - } - } - - let first = Recorder() - configure(first) - #expect(makeHandler(recorder: first).handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_234) - )) - let originalMessage = try #require(first.deliveredMessages.first) - #expect(first.deliveryAcks.count == 1) - - // A fresh handler models process relaunch: its in-memory reservation - // cache is empty, so only the durable receipt can suppress disk work. - let relaunched = Recorder() - configure(relaunched) - #expect(makeHandler(recorder: relaunched).handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_235) - )) - - #expect(relaunched.quotaReservations.isEmpty) - #expect(relaunched.saveCalls.isEmpty) - #expect(relaunched.receiptCommits.isEmpty) - #expect(relaunched.deliveredMessages.count == 1) - #expect(relaunched.deliveredMessages.first?.id == originalMessage.id) - #expect(relaunched.deliveredMessages.first?.content == originalMessage.content) - #expect(relaunched.deliveryAcks.count == 1) - #expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id) - } - - @Test - func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] - let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) - let file = BitchatFilePacket( - fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", - fileSize: UInt64(content.count), - mimeType: "image/jpeg", - content: content - ) - let payload = try #require(file.encode()) - var handler: BLEFileTransferHandler! - var nestedResult: Bool? - var failFirstSave = true - recorder.saveOverride = { _, _, _, _, _ in - if failFirstSave { - failFirstSave = false - nestedResult = handler.handlePrivatePayload( - payload, - from: self.remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_235) - ) - return nil - } - return recorder.saveResult - } - handler = makeHandler(recorder: recorder) - - // The nested arrival sees the first reservation as pending. It is - // coalesced without an ACK; then the first durable save fails. - #expect(!handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_234) - )) - #expect(nestedResult == true) - #expect(recorder.saveCalls.count == 1) - #expect(recorder.deliveryAcks.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - - // Failure released the reservation, so the sender's later retry can - // persist and deliver normally. - #expect(handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_236) - )) - #expect(recorder.saveCalls.count == 2) - #expect(recorder.deliveryAcks.count == 1) - #expect(recorder.deliveredMessages.count == 1) - } - - @Test - func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo( - remotePeerID, - nickname: "Alice", - isVerified: true - )] - let fileName = - "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" - let messageID = try #require(PrivateMediaMessageIdentity.stableID( - senderPeerID: remotePeerID, - recipientPeerID: localPeerID, - fileName: fileName - )) - recorder.receiptStates[messageID] = .unavailable - let handler = makeHandler(recorder: recorder) - let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) - let payload = try #require(BitchatFilePacket( - fileName: fileName, - fileSize: UInt64(content.count), - mimeType: "image/jpeg", - content: content - ).encode()) - - #expect(handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_234) - )) - #expect(recorder.quotaReservations.isEmpty) - #expect(recorder.saveCalls.isEmpty) - #expect(recorder.receiptCommits.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - #expect(recorder.deliveryAcks.isEmpty) - } - - @Test - func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo( - remotePeerID, - nickname: "Alice", - isVerified: true - )] - recorder.receiptCommitSucceeds = false - let handler = makeHandler(recorder: recorder) - let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) - let payload = try #require(BitchatFilePacket( - fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", - fileSize: UInt64(content.count), - mimeType: "image/jpeg", - content: content - ).encode()) - - #expect(!handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_234) - )) - #expect(recorder.saveCalls.count == 1) - #expect(recorder.receiptCommits.count == 1) - #expect(recorder.removedIncomingFiles.count == 1) - #expect(recorder.removedIncomingFiles.first == recorder.saveResult) - #expect(recorder.deliveredMessages.isEmpty) - #expect(recorder.deliveryAcks.isEmpty) - } - - @Test - func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws { - let recorder = Recorder() - recorder.blockedPeers = [remotePeerID] - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] - let handler = makeHandler(recorder: recorder) - let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) - let file = BitchatFilePacket( - fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", - fileSize: UInt64(content.count), - mimeType: "image/jpeg", - content: content - ) - let payload = try #require(file.encode()) - - #expect(handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_234) - )) - - #expect(recorder.quotaReservations.isEmpty) - #expect(recorder.saveCalls.isEmpty) - #expect(recorder.lastSeenUpdates.isEmpty) - #expect(recorder.deliveryAcks.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - - // Unblocking must allow a retry through; the blocked attempt cannot - // poison the stable-ID dedup reservation. - recorder.blockedPeers = [] - #expect(handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_235) - )) - #expect(recorder.saveCalls.count == 1) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveryAcks.count == 1) - } - - @Test - func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() { - let recorder = Recorder() - let handler = makeHandler(recorder: recorder) - let oversizedCount = FileTransferLimits.maxPayloadBytes + 1 - var length = UInt32(oversizedCount).bigEndian - var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV - withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) } - payload.append(Data(repeating: 0x41, count: oversizedCount)) - - #expect(!handler.handlePrivatePayload( - payload, - from: remotePeerID, - timestamp: Date(timeIntervalSince1970: 1_234) - )) - - #expect(recorder.quotaReservations.isEmpty) - #expect(recorder.saveCalls.isEmpty) - #expect(recorder.lastSeenUpdates.isEmpty) - #expect(recorder.deliveryAcks.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - } - - @Test - func malformedPayloadIsTrackedForSyncButDropped() { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] - recorder.signatureVerifies = true - let handler = makeHandler(recorder: recorder) - let packet = BitchatPacket( - type: MessageType.fileTransfer.rawValue, - senderID: Data(hexString: remotePeerID.id) ?? Data(), - recipientID: nil, - timestamp: 900_000, - payload: Data([0x01, 0x02, 0x03]), - signature: Data(repeating: 0x5A, count: 64), - ttl: TransportConfig.messageTTLDefault - ) - - // Local decode failures are not proof of forgery; the packet stays relayable. - #expect(handler.handle(packet, from: remotePeerID)) - - // Sync tracking happens before payload validation, matching the original order. - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.quotaReservations.isEmpty) - #expect(recorder.saveCalls.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - } - - @Test - func unsupportedMimeIsDroppedBeforeQuotaAndSave() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] - recorder.signatureVerifies = true - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: nil, content: Data([0x4D, 0x5A, 0x00, 0x00])) - - #expect(handler.handle(packet, from: remotePeerID)) - - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.quotaReservations.isEmpty) - #expect(recorder.saveCalls.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - } - - @Test - func saveFailureSkipsDelivery() throws { - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] - recorder.signatureVerifies = true - recorder.saveResult = nil - let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) - - // A local save failure must not stop the mesh relay. - #expect(handler.handle(packet, from: remotePeerID)) - - #expect(recorder.quotaReservations.count == 1) - #expect(recorder.saveCalls.count == 1) - #expect(recorder.lastSeenUpdates.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - } - - @Test - func quotaEvictionForFinalizedArrivalSkipsInFlightLiveCaptures() throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("quota-live-capture-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } - let store = BLEIncomingFileStore(baseDirectory: base) - let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") - - // The in-flight partial is the LRU-oldest eviction candidate; without - // the voice_live_ pattern guard it would be deleted first, unlinking - // the inode under the coordinator's open FileHandle. - let inFlight = incoming.appendingPathComponent("voice_live_00112233445566ff_1122334455667788_dm.aac") - let evictable = incoming.appendingPathComponent("voice_old.m4a") - try Data(count: 51 * 1024 * 1024).write(to: inFlight) - try Data(count: 51 * 1024 * 1024).write(to: evictable) - try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -7200)], ofItemAtPath: inFlight.path) - try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -60)], ofItemAtPath: evictable.path) - - // 102 MB used against the 100 MB quota forces one eviction. This is - // the finalized-file arrival path (BLEFileTransferHandler via - // BLEService), which knows nothing about in-flight captures — the - // store itself must protect them. - store.enforceQuota(reservingBytes: 0) - - #expect(FileManager.default.fileExists(atPath: inFlight.path)) - #expect(!FileManager.default.fileExists(atPath: evictable.path)) - } - - @Test - func legacyIncomingDeleteUnlinksUnreferencedPayload() throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent( - "legacy-unlink-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: base) } - let store = BLEIncomingFileStore(baseDirectory: base) - let incoming = try store.incomingDirectory( - subdirectory: "images/incoming" - ) - let payload = incoming.appendingPathComponent("legacy.jpg") - try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) - - #expect(store.removeLegacyIncomingFile( - relativePath: "images/incoming/legacy.jpg" - )) - #expect(!FileManager.default.fileExists(atPath: payload.path)) - - // Only paths directly inside an incoming media directory qualify. - let outgoing = base.appendingPathComponent( - "files/images/outgoing", - isDirectory: true - ) - try FileManager.default.createDirectory( - at: outgoing, - withIntermediateDirectories: true - ) - let victim = outgoing.appendingPathComponent("victim.jpg") - try Data([0x01]).write(to: victim) - #expect(!store.removeLegacyIncomingFile( - relativePath: "images/outgoing/victim.jpg" - )) - #expect(!store.removeLegacyIncomingFile( - relativePath: "images/incoming/../outgoing/victim.jpg" - )) - #expect(FileManager.default.fileExists(atPath: victim.path)) - } - - @Test - func legacyIncomingDeleteLeavesPendingDeliveryPayload() throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent( - "legacy-pending-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: base) } - let store = BLEIncomingFileStore(baseDirectory: base) - - // save() marks the stored path pending until delivery finishes; a - // legacy delete naming the same basename must not unlink it. - let stored = try #require(store.save( - data: Data([0xFF, 0xD8, 0xFF, 0xD9]), - preferredName: "pending.jpg", - subdirectory: "images/incoming", - fallbackExtension: "jpg", - defaultPrefix: "img" - )) - let relativePath = "images/incoming/\(stored.lastPathComponent)" - - #expect(!store.removeLegacyIncomingFile(relativePath: relativePath)) - #expect(FileManager.default.fileExists(atPath: stored.path)) - - // Once the delivery window closes the same unlink is allowed. - store.finishIncomingFileDelivery(at: stored) - #expect(store.removeLegacyIncomingFile(relativePath: relativePath)) - #expect(!FileManager.default.fileExists(atPath: stored.path)) - } - - @Test - func legacyIncomingDeleteLeavesReceiptProtectedPayload() throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent( - "legacy-protected-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: base) } - let store = BLEIncomingFileStore(baseDirectory: base) - let incoming = try store.incomingDirectory( - subdirectory: "images/incoming" - ) - let payload = incoming.appendingPathComponent("owned.jpg") - try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) - - // The basename belongs to a stable receipt (another record). The - // legacy delete must leave it for that owner. - #expect(store.commitPrivateMediaFile( - messageID: "media-00112233445566778899aabbccddeeff", - storedURL: payload - )) - #expect(!store.removeLegacyIncomingFile( - relativePath: "images/incoming/owned.jpg" - )) - #expect(FileManager.default.fileExists(atPath: payload.path)) - } - - @Test - func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } - let store = BLEIncomingFileStore(baseDirectory: base) - let subdirectories = [ - "voicenotes/incoming", - "voicenotes/outgoing", - "images/incoming", - "images/outgoing", - "files/incoming", - "files/outgoing" - ] - - for subdirectory in subdirectories { - let directory = base - .appendingPathComponent("files", isDirectory: true) - .appendingPathComponent(subdirectory, isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin")) - } - let unmanaged = base.appendingPathComponent("files/legacy/secret.bin") - try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data("legacy".utf8).write(to: unmanaged) - - try store.panicWipe() - - #expect(!FileManager.default.fileExists(atPath: unmanaged.path)) - for subdirectory in subdirectories { - let directory = base - .appendingPathComponent("files", isDirectory: true) - .appendingPathComponent(subdirectory, isDirectory: true) - var isDirectory: ObjCBool = false - #expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory)) - #expect(isDirectory.boolValue) - #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) - } - } - - @Test - func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent( - "panic-receipt-cache-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: base) } - let messageID = "media-00112233445566778899aabbccddeeff" - - let seed = BLEPrivateMediaReceiptStore(baseDirectory: base) - let payload = base - .appendingPathComponent( - "files/images/incoming", - isDirectory: true - ) - .appendingPathComponent("panic-receipt.jpg") - try FileManager.default.createDirectory( - at: payload.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try Data("secret".utf8).write(to: payload, options: .atomic) - #expect(seed.commitAccepted( - messageID: messageID, - storedURL: payload - )) - #expect(seed.recordDeleted(messageID: messageID)) - - // Production wiring: receipt lookups run against the service's OWN - // incoming-file store while the panic wipe runs on the separate store - // `PanicRecoveryOperations.live()` constructs. The test must reset - // the instance BLEService uses, not a same-instance shortcut. - let keychain = MockKeychain() - let identityManager = MockIdentityManager(keychain) - let service = BLEService( - keychain: keychain, - idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), - identityManager: identityManager, - initializeBluetoothManagers: false, - incomingFileStore: BLEIncomingFileStore(baseDirectory: base) - ) - #expect( - service._test_privateMediaReceiptState(messageID: messageID) - == .tombstoned - ) - - service.suspendForPanicReset() - // A receive callback drained during suspension can still consult the - // ledger and re-cache the pre-wipe decision before media deletion. - #expect( - service._test_privateMediaReceiptState(messageID: messageID) - == .tombstoned - ) - - // The wipe itself runs on the recovery operations' distinct store, - // exactly like ChatViewModel's panic transaction. - let recoveryStore = BLEIncomingFileStore(baseDirectory: base) - try recoveryStore.panicWipe() - service.completePanicReset(restartServices: false) - - #expect( - service._test_privateMediaReceiptState(messageID: messageID) - == .absent - ) - } - - @Test - func panicWipeInvalidatesPayloadCoordinationReservations() throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent( - "panic-payload-coordination-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: base) } - let store = BLEIncomingFileStore(baseDirectory: base) - let pendingName = "pending-before-panic.jpg" - let pendingURL = try #require(store.save( - data: Data("old".utf8), - preferredName: pendingName, - subdirectory: "images/incoming", - fallbackExtension: "jpg", - defaultPrefix: "image" - )) - let messageID = "media-aabbccddeeff00112233445566778899" - let reservation = try #require(store.reservePrivateMediaDeletion( - messageIDs: [messageID], - payloadRelativePaths: [ - messageID: "images/incoming/delete-before-panic.jpg" - ] - )) - - try store.panicWipe() - - #expect(!store.commitPrivateMediaDeletion( - reservation: reservation, - messageIDs: [messageID], - payloadRelativePaths: [ - messageID: "images/incoming/delete-before-panic.jpg" - ], - protectedPayloadRelativePaths: [] - )) - let postPanicURL = try #require(store.save( - data: Data("new".utf8), - preferredName: pendingName, - subdirectory: "images/incoming", - fallbackExtension: "jpg", - defaultPrefix: "image" - )) - #expect(pendingURL.lastPathComponent == pendingName) - #expect(postPanicURL.lastPathComponent == pendingName) - } - - @Test - func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws { - enum MarkerFailure: Error { case unavailable } - - let base = FileManager.default.temporaryDirectory - .appendingPathComponent( - "panic-marker-failure-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: base) } - let secret = base - .appendingPathComponent("files/images/outgoing", isDirectory: true) - .appendingPathComponent("secret.jpg") - try FileManager.default.createDirectory( - at: secret.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try Data("secret".utf8).write(to: secret) - let store = BLEIncomingFileStore( - baseDirectory: base, - panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable } - ) - - do { - try store.panicWipe(hasDurablePendingMarker: false) - Issue.record("Expected the missing durable marker to fail closed") - } catch { - // The marker error is reported only after the deletion attempt. - } - - #expect(!FileManager.default.fileExists(atPath: secret.path)) - #expect( - FileManager.default.fileExists( - atPath: secret.deletingLastPathComponent().path - ) - ) - } - - @Test - func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws { - enum MarkerFailure: Error { case unavailable } - - let base = FileManager.default.temporaryDirectory - .appendingPathComponent( - "panic-external-marker-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: base) } - let secret = base - .appendingPathComponent("files/voicenotes/incoming", isDirectory: true) - .appendingPathComponent("secret.m4a") - try FileManager.default.createDirectory( - at: secret.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try Data("secret".utf8).write(to: secret) - let store = BLEIncomingFileStore( - baseDirectory: base, - panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable } - ) - - try store.panicWipe(hasDurablePendingMarker: true) - - #expect(!FileManager.default.fileExists(atPath: secret.path)) - } - - @Test - func panicRecoveryMarkerPersistsUntilExplicitCommit() throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent( - "panic-recovery-marker-\(UUID().uuidString)", - isDirectory: true - ) - defer { try? FileManager.default.removeItem(at: base) } - let store = BLEIncomingFileStore(baseDirectory: base) - - try store.markPanicRecoveryPending() - #expect(try store.isPanicRecoveryPending()) - try store.panicWipe(hasDurablePendingMarker: true) - #expect(try store.isPanicRecoveryPending()) - - try store.completePanicRecovery() - - #expect(try !store.isPanicRecoveryPending()) - } - - private func expectNoSideEffects(_ recorder: Recorder) { - #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.quotaReservations.isEmpty) - #expect(recorder.saveCalls.isEmpty) - #expect(recorder.lastSeenUpdates.isEmpty) - #expect(recorder.deliveredMessages.isEmpty) - } - - private func makePeerInfo( - _ peerID: PeerID, - nickname: String, - isVerified: Bool, - isConnected: Bool = true, - signingPublicKey: Data? = nil - ) -> BLEPeerInfo { - BLEPeerInfo( - peerID: peerID, - nickname: nickname, - isConnected: isConnected, - noisePublicKey: nil, - signingPublicKey: signingPublicKey, - isVerifiedNickname: isVerified, - lastSeen: Date(timeIntervalSince1970: 999) - ) - } - - private func makeFileTransferPacket( - sender: PeerID, - mimeType: String?, - content: Data, - ttl: UInt8 = TransportConfig.messageTTLDefault, - recipientID: Data? = nil, - fileName: String = "sample", - hasSignature: Bool = true - ) throws -> BitchatPacket { - let filePacket = BitchatFilePacket( - fileName: fileName, - fileSize: UInt64(content.count), - mimeType: mimeType, - content: content - ) - let payload = try #require(filePacket.encode()) - return BitchatPacket( - type: MessageType.fileTransfer.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: recipientID, - timestamp: 900_000, - payload: payload, - signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil, - ttl: ttl - ) - } -} diff --git a/bitchatTests/Services/BLEFileTransferPolicyTests.swift b/bitchatTests/Services/BLEFileTransferPolicyTests.swift deleted file mode 100644 index 2010f0e8..00000000 --- a/bitchatTests/Services/BLEFileTransferPolicyTests.swift +++ /dev/null @@ -1,143 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEFileTransferPolicyTests { - @Test - func selfEchoRejectsOnlyNonSyncReplayFromLocalPeer() { - let localPeerID = PeerID(str: "1122334455667788") - let localPacket = makePacket(sender: localPeerID, ttl: 3) - let replayPacket = makePacket(sender: localPeerID, ttl: 0) - let remotePacket = makePacket(sender: PeerID(str: "8877665544332211"), ttl: 3) - - #expect(BLEFileTransferPolicy.isSelfEcho(packet: localPacket, from: localPeerID, localPeerID: localPeerID)) - #expect(!BLEFileTransferPolicy.isSelfEcho(packet: replayPacket, from: localPeerID, localPeerID: localPeerID)) - #expect(!BLEFileTransferPolicy.isSelfEcho(packet: remotePacket, from: PeerID(str: "8877665544332211"), localPeerID: localPeerID)) - } - - @Test - func deliveryPlanTracksPublicBroadcastsForSync() { - let localPeerID = PeerID(str: "1122334455667788") - - #expect(BLEFileTransferPolicy.deliveryPlan( - packet: makePacket(sender: PeerID(str: "8877665544332211"), recipientID: nil), - localPeerID: localPeerID - ) == BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)) - - #expect(BLEFileTransferPolicy.deliveryPlan( - packet: makePacket( - sender: PeerID(str: "8877665544332211"), - recipientID: Data(repeating: 0xFF, count: 8) - ), - localPeerID: localPeerID - ) == BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)) - } - - @Test - func deliveryPlanAcceptsOnlyDirectedPacketsForLocalPeer() { - let localPeerID = PeerID(str: "1122334455667788") - let otherPeerID = PeerID(str: "8877665544332211") - - #expect(BLEFileTransferPolicy.deliveryPlan( - packet: makePacket( - sender: otherPeerID, - recipientID: Data(hexString: localPeerID.id) - ), - localPeerID: localPeerID - ) == BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false)) - - #expect(BLEFileTransferPolicy.deliveryPlan( - packet: makePacket( - sender: otherPeerID, - recipientID: Data(hexString: otherPeerID.id) - ), - localPeerID: localPeerID - ) == nil) - } - - @Test - func validatorAcceptsMatchingMimeAndMagicBytes() throws { - let payload = try makeFilePayload( - mimeType: "application/pdf", - content: Data("%PDF-1.7".utf8) - ) - - let result = BLEIncomingFileValidator.validate(payload: payload) - - guard case .success(let acceptance) = result else { - Issue.record("Expected file validation success") - return - } - #expect(acceptance.mime == .pdf) - #expect(acceptance.filePacket.content == Data("%PDF-1.7".utf8)) - } - - @Test - func validatorRejectsMalformedPayload() { - let result = BLEIncomingFileValidator.validate(payload: Data([0x01, 0x02, 0x03])) - - #expect(result.rejection == .malformedPayload) - } - - @Test - func validatorRejectsUnsupportedMime() throws { - let payload = try makeFilePayload( - mimeType: nil, - content: Data([0x4D, 0x5A, 0x00, 0x00]) - ) - - let result = BLEIncomingFileValidator.validate(payload: payload) - - #expect(result.rejection == .unsupportedMime(mimeType: nil, bytes: 4)) - } - - @Test - func validatorRejectsMimeMagicMismatch() throws { - let payload = try makeFilePayload( - mimeType: "image/png", - content: Data([0x00, 0x01, 0x02, 0x03]) - ) - - let result = BLEIncomingFileValidator.validate(payload: payload) - - #expect(result.rejection == .magicMismatch( - mime: .png, - bytes: 4, - prefixHex: "00 01 02 03" - )) - } - - private func makePacket( - sender: PeerID, - ttl: UInt8 = 3, - recipientID: Data? = nil - ) -> BitchatPacket { - BitchatPacket( - type: MessageType.fileTransfer.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: recipientID, - timestamp: 900_000, - payload: Data([0x01, 0x02]), - signature: nil, - ttl: ttl - ) - } - - private func makeFilePayload(mimeType: String?, content: Data) throws -> Data { - let packet = BitchatFilePacket( - fileName: "sample", - fileSize: UInt64(content.count), - mimeType: mimeType, - content: content - ) - return try #require(packet.encode()) - } -} - -private extension Result where Failure == BLEIncomingFileRejection { - var rejection: BLEIncomingFileRejection? { - guard case .failure(let rejection) = self else { return nil } - return rejection - } -} diff --git a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift deleted file mode 100644 index ed4da0a1..00000000 --- a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift +++ /dev/null @@ -1,368 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEFragmentAssemblyBufferTests { - @Test - func appendCompletesOutOfOrderFragments() throws { - var buffer = BLEFragmentAssemblyBuffer() - let original = makePacket(payload: makePayload(count: 512)) - let fragmentPackets = try makeFragments(for: original, chunkSize: 128, fragmentID: Data(repeating: 0x01, count: 8)) - let headers = try fragmentPackets.reversed().map { try #require(BLEFragmentHeader(packet: $0)) } - var result: BLEFragmentAssemblyBuffer.AppendResult? - - for header in headers { - result = buffer.append(header, maxInFlightAssemblies: 8) - } - - if case let .complete(header, reassembledData, started) = result { - #expect(header.total == fragmentPackets.count) - #expect(!started) - #expect(BinaryProtocol.decode(reassembledData)?.payload == original.payload) - } else { - Issue.record("Expected final fragment to complete reassembly") - } - } - - @Test - func appendDuplicateFragmentDoesNotCompleteEarly() throws { - var buffer = BLEFragmentAssemblyBuffer() - let original = makePacket(payload: makePayload(count: 384)) - let fragmentPackets = try makeFragments(for: original, chunkSize: 128, fragmentID: Data(repeating: 0x02, count: 8)) - let first = try #require(BLEFragmentHeader(packet: fragmentPackets[0])) - let second = try #require(BLEFragmentHeader(packet: fragmentPackets[1])) - - if case let .stored(_, started) = buffer.append(first, maxInFlightAssemblies: 8) { - #expect(started) - } else { - Issue.record("Expected first fragment to be stored") - } - - if case .stored = buffer.append(first, maxInFlightAssemblies: 8) { - // Duplicate should replace the same index, not increase completion count. - } else { - Issue.record("Expected duplicate fragment to remain incomplete") - } - - if case .stored = buffer.append(second, maxInFlightAssemblies: 8) { - // Still missing at least one fragment. - } else { - Issue.record("Expected assembly to remain incomplete") - } - } - - @Test - func appendEvictsOldestAssemblyWhenCapIsReached() throws { - var buffer = BLEFragmentAssemblyBuffer() - let oldPacket = makePacket(payload: makePayload(count: 256, seed: 1), timestamp: 1) - let newPacket = makePacket(payload: makePayload(count: 256, seed: 2), timestamp: 2) - let oldFragments = try makeFragments(for: oldPacket, chunkSize: 128, fragmentID: Data(repeating: 0x03, count: 8)) - let newFragments = try makeFragments(for: newPacket, chunkSize: 128, fragmentID: Data(repeating: 0x04, count: 8)) - let oldFirst = try #require(BLEFragmentHeader(packet: oldFragments[0])) - let oldSecond = try #require(BLEFragmentHeader(packet: oldFragments[1])) - let newFirst = try #require(BLEFragmentHeader(packet: newFragments[0])) - let newSecond = try #require(BLEFragmentHeader(packet: newFragments[1])) - - _ = buffer.append(oldFirst, maxInFlightAssemblies: 1, now: Date(timeIntervalSince1970: 1)) - _ = buffer.append(newFirst, maxInFlightAssemblies: 1, now: Date(timeIntervalSince1970: 2)) - - if case let .stored(_, started) = buffer.append(oldSecond, maxInFlightAssemblies: 1) { - #expect(started) - } else { - Issue.record("Expected evicted assembly to restart when old fragment arrives") - } - - if case let .stored(_, started) = buffer.append(newSecond, maxInFlightAssemblies: 1) { - #expect(started) - } else { - Issue.record("Expected new assembly to restart after old one consumed the only slot") - } - } - - @Test - func appendOversizedAssemblyDropsPartialState() throws { - var buffer = BLEFragmentAssemblyBuffer() - let fragmentID = Data(repeating: 0x05, count: 8) - let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket( - fragmentID: fragmentID, - index: 0, - total: 2, - originalType: MessageType.message.rawValue, - fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes) - ))) - let oversized = try #require(BLEFragmentHeader(packet: makeFragmentPacket( - fragmentID: fragmentID, - index: 1, - total: 2, - originalType: MessageType.message.rawValue, - fragmentData: Data([0x02]) - ))) - - _ = buffer.append(first, maxInFlightAssemblies: 8) - let result = buffer.append(oversized, maxInFlightAssemblies: 8) - - if case let .oversized(_, projectedSize, limit, started) = result { - #expect(projectedSize == FileTransferLimits.maxPayloadBytes + 1) - #expect(limit == FileTransferLimits.maxPayloadBytes) - #expect(!started) - } else { - Issue.record("Expected oversized fragment assembly to be evicted") - } - - if case let .stored(_, started) = buffer.append(oversized, maxInFlightAssemblies: 8) { - #expect(started) - } else { - Issue.record("Expected later fragment to start a clean assembly") - } - } - - @Test - func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws { - var buffer = BLEFragmentAssemblyBuffer() - let fragmentID = Data(repeating: 0x15, count: 8) - let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket( - fragmentID: fragmentID, - index: 0, - total: 2, - originalType: MessageType.noiseEncrypted.rawValue, - fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes) - ))) - let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket( - fragmentID: fragmentID, - index: 1, - total: 2, - originalType: MessageType.noiseEncrypted.rawValue, - fragmentData: Data([0x02]) - ))) - - _ = buffer.append(first, maxInFlightAssemblies: 8) - let result = buffer.append(second, maxInFlightAssemblies: 8) - - if case let .complete(_, data, _) = result { - #expect(data.count == FileTransferLimits.maxPayloadBytes + 1) - } else { - Issue.record("Expected encrypted private-file assembly to use framed-file limit") - } - } - - @Test - func removeExpiredDropsOldAssemblies() throws { - var buffer = BLEFragmentAssemblyBuffer() - let packet = makePacket(payload: makePayload(count: 256)) - let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: Data(repeating: 0x06, count: 8)) - let first = try #require(BLEFragmentHeader(packet: fragments[0])) - let second = try #require(BLEFragmentHeader(packet: fragments[1])) - - _ = buffer.append(first, maxInFlightAssemblies: 8, now: Date(timeIntervalSince1970: 1)) - #expect(buffer.removeExpired(before: Date(timeIntervalSince1970: 2)) == 1) - - if case let .stored(_, started) = buffer.append(second, maxInFlightAssemblies: 8) { - #expect(started) - } else { - Issue.record("Expected expired assembly to be gone") - } - } - - @Test - func stalledBroadcastAssemblyReportsFragmentIDOnceUntilRetryLapses() throws { - var buffer = BLEFragmentAssemblyBuffer() - let fragmentID = Data((1...8).map { UInt8($0) }) - let packet = makePacket(payload: makePayload(count: 256)) - let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) - let first = try #require(BLEFragmentHeader(packet: fragments[0])) - - let t0 = Date(timeIntervalSince1970: 100) - _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0) - - // Not yet stalled. - let early = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(4)) - #expect(early.isEmpty) - - // Stalled: reported once, big-endian stream ID. - let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) - #expect(stalled == [fragmentID]) - - // Within the retry window: not re-reported. - let repeated = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(8)) - #expect(repeated.isEmpty) - - // After the retry window it is requested again. - let retried = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(17)) - #expect(retried == [fragmentID]) - } - - @Test - func newFragmentResetsStallClockAndCompletionStopsRequests() throws { - var buffer = BLEFragmentAssemblyBuffer() - let fragmentID = Data((10...17).map { UInt8($0) }) - let packet = makePacket(payload: makePayload(count: 384)) - let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) - let headers = try fragments.map { try #require(BLEFragmentHeader(packet: $0)) } - #expect(headers.count >= 3) - - let t0 = Date(timeIntervalSince1970: 100) - _ = buffer.append(headers[0], maxInFlightAssemblies: 8, now: t0) - // A fragment arriving at t0+4 resets the stall clock. - _ = buffer.append(headers[1], maxInFlightAssemblies: 8, now: t0.addingTimeInterval(4)) - let afterProgress = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) - #expect(afterProgress.isEmpty) - - // Completion removes the assembly entirely. - var result: BLEFragmentAssemblyBuffer.AppendResult? - for header in headers.dropFirst(2) { - result = buffer.append(header, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5)) - } - guard case .complete = result else { - Issue.record("Expected assembly to complete") - return - } - let afterCompletion = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60)) - #expect(afterCompletion.isEmpty) - } - - @Test - func duplicateFragmentsDoNotResetStallClock() throws { - var buffer = BLEFragmentAssemblyBuffer() - let fragmentID = Data((20...27).map { UInt8($0) }) - let packet = makePacket(payload: makePayload(count: 256)) - let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) - let first = try #require(BLEFragmentHeader(packet: fragments[0])) - - let t0 = Date(timeIntervalSince1970: 100) - _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0) - - // Relay duplicates of the same index arrive every few seconds; they - // bring no new data, so they must not keep the stream "fresh". - _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(3)) - _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5)) - - let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) - #expect(stalled == [fragmentID]) - } - - @Test - func overflowStalledStreamsRotateAcrossPasses() throws { - var buffer = BLEFragmentAssemblyBuffer() - let cap = RequestSyncPacket.maxFragmentIdFilterCount - let streamCount = cap + 10 - let t0 = Date(timeIntervalSince1970: 100) - - // Incomplete broadcast assemblies with staggered last-fragment times - // (stream 0 is the oldest stall). - var ids: [Data] = [] - for i in 0..> 8), UInt8(i & 0xFF)]) - ids.append(fragmentID) - let header = try #require(BLEFragmentHeader(packet: makeFragmentPacket( - fragmentID: fragmentID, - index: 0, - total: 2, - originalType: MessageType.message.rawValue, - fragmentData: Data([0x01]) - ))) - _ = buffer.append(header, maxInFlightAssemblies: streamCount, now: t0.addingTimeInterval(Double(i))) - } - - // All streams are stalled; only the cap's worth (oldest first) is - // requested and rate-limited, the overflow stays eligible. - let firstPassAt = t0.addingTimeInterval(Double(streamCount) + 5) - let firstPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt) - #expect(firstPass == Array(ids.prefix(cap))) - - // Next pass picks up exactly the overflow streams. - let secondPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(1)) - #expect(secondPass == Array(ids.suffix(streamCount - cap))) - - // Nothing left until a retry window lapses. - let thirdPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(2)) - #expect(thirdPass.isEmpty) - } - - @Test - func directedAssembliesAreNeverReportedAsStalled() throws { - var buffer = BLEFragmentAssemblyBuffer() - let fragment = makeFragmentPacket( - fragmentID: Data(repeating: 0x0A, count: 8), - index: 0, - total: 2, - originalType: MessageType.message.rawValue, - fragmentData: Data([0x01]), - recipientID: Data(hexString: "0102030405060708") - ) - let header = try #require(BLEFragmentHeader(packet: fragment)) - - let t0 = Date(timeIntervalSince1970: 100) - _ = buffer.append(header, maxInFlightAssemblies: 8, now: t0) - let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60)) - #expect(stalled.isEmpty) - } - - private func makePacket(payload: Data, timestamp: UInt64 = 0x0102030405) -> BitchatPacket { - BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]), - recipientID: nil, - timestamp: timestamp, - payload: payload, - signature: nil, - ttl: 3 - ) - } - - private func makePayload(count: Int, seed: UInt64 = 0x1234ABCD) -> Data { - var state = seed - return Data((0..> 32) - }) - } - - private func makeFragments(for packet: BitchatPacket, chunkSize: Int, fragmentID: Data) throws -> [BitchatPacket] { - let fullData = try #require(packet.toBinaryData(padding: false)) - let chunks = stride(from: 0, to: fullData.count, by: chunkSize).map { offset in - Data(fullData[offset.. BitchatPacket { - var payload = Data() - payload.append(fragmentID) - payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) }) - payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) }) - payload.append(originalType) - payload.append(fragmentData) - - return BitchatPacket( - type: MessageType.fragment.rawValue, - senderID: senderID, - recipientID: recipientID, - timestamp: timestamp, - payload: payload, - signature: nil, - ttl: 3 - ) - } -} diff --git a/bitchatTests/Services/BLEFragmentHandlerTests.swift b/bitchatTests/Services/BLEFragmentHandlerTests.swift deleted file mode 100644 index e48a74ed..00000000 --- a/bitchatTests/Services/BLEFragmentHandlerTests.swift +++ /dev/null @@ -1,230 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEFragmentHandlerTests { - private final class Recorder { - /// Optional override for the assembly result; defaults to `.stored`. - var appendResult: ((BLEFragmentHeader) -> BLEFragmentAssemblyBuffer.AppendResult)? - var accepted = true - - var trackedPackets: [BitchatPacket] = [] - var appendedHeaders: [BLEFragmentHeader] = [] - var ingressChecks: [(packet: BitchatPacket, innerSender: PeerID)] = [] - var reinjectedPackets: [(packet: BitchatPacket, from: PeerID)] = [] - } - - private let localPeerID = PeerID(str: "0102030405060708") - private let remotePeerID = PeerID(str: "1122334455667788") - - private func makeHandler(recorder: Recorder) -> BLEFragmentHandler { - let environment = BLEFragmentHandlerEnvironment( - localPeerID: { [localPeerID] in localPeerID }, - trackPacketSeen: { packet in - recorder.trackedPackets.append(packet) - }, - appendFragment: { header in - recorder.appendedHeaders.append(header) - return recorder.appendResult?(header) ?? .stored(header: header, started: true) - }, - isAcceptedIngressPayload: { packet, innerSender in - recorder.ingressChecks.append((packet, innerSender)) - return recorder.accepted - }, - processReassembledPacket: { packet, from in - recorder.reinjectedPackets.append((packet, from)) - } - ) - return BLEFragmentHandler(environment: environment) - } - - @Test - func ownFragmentIsTrackedForSyncButNotAssembled() { - let recorder = Recorder() - let handler = makeHandler(recorder: recorder) - let packet = makeFragmentPacket(sender: localPeerID, index: 0, total: 2) - - handler.handle(packet, from: localPeerID) - - // Sync replay hands own fragments back after a relaunch; they must - // re-enter the sync store (so the next round's filter covers them - // and redelivery stops) without being reassembled. - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.appendedHeaders.isEmpty) - #expect(recorder.reinjectedPackets.isEmpty) - } - - @Test - func malformedFragmentPayloadIsIgnored() { - let recorder = Recorder() - let handler = makeHandler(recorder: recorder) - let packet = BitchatPacket( - type: MessageType.fragment.rawValue, - senderID: Data(hexString: remotePeerID.id) ?? Data(), - recipientID: nil, - timestamp: 900_000, - payload: Data([0x01, 0x02, 0x03]), - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.appendedHeaders.isEmpty) - } - - @Test - func broadcastFragmentIsTrackedAndAppended() { - let recorder = Recorder() - let handler = makeHandler(recorder: recorder) - let chunk = Data([0xDE, 0xAD, 0xBE, 0xEF]) - let packet = makeFragmentPacket(sender: remotePeerID, index: 0, total: 3, chunk: chunk) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.appendedHeaders.count == 1) - #expect(recorder.appendedHeaders.first?.index == 0) - #expect(recorder.appendedHeaders.first?.total == 3) - #expect(recorder.appendedHeaders.first?.originalType == MessageType.message.rawValue) - #expect(recorder.appendedHeaders.first?.fragmentData == chunk) - #expect(recorder.ingressChecks.isEmpty) - #expect(recorder.reinjectedPackets.isEmpty) - } - - @Test - func directedFragmentIsNotTrackedForSync() { - let recorder = Recorder() - let handler = makeHandler(recorder: recorder) - let packet = makeFragmentPacket( - sender: remotePeerID, - index: 1, - total: 3, - recipientID: Data(hexString: localPeerID.id) - ) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.appendedHeaders.count == 1) - } - - @Test - func completedReassemblyReinjectsAcceptedPacketWithZeroTTL() throws { - let innerSender = PeerID(str: "99AABBCCDDEEFF00") - let innerPacket = BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data(hexString: innerSender.id) ?? Data(), - recipientID: nil, - timestamp: 900_000, - payload: Data("hello".utf8), - signature: nil, - ttl: 5 - ) - let reassembled = try #require(innerPacket.toBinaryData()) - - let recorder = Recorder() - recorder.appendResult = { header in - .complete(header: header, reassembledData: reassembled, started: false) - } - let handler = makeHandler(recorder: recorder) - let packet = makeFragmentPacket(sender: remotePeerID, index: 1, total: 2) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.ingressChecks.count == 1) - #expect(recorder.ingressChecks.first?.innerSender == innerSender) - #expect(recorder.reinjectedPackets.count == 1) - let reinjected = recorder.reinjectedPackets.first - #expect(reinjected?.from == remotePeerID) - #expect(reinjected?.packet.type == MessageType.message.rawValue) - #expect(reinjected?.packet.payload == Data("hello".utf8)) - // Reassembled packets must re-enter the pipeline with TTL zeroed. - #expect(reinjected?.packet.ttl == 0) - } - - @Test - func rejectedReassembledPacketIsNotReinjected() throws { - let innerPacket = BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data(hexString: "99AABBCCDDEEFF00") ?? Data(), - recipientID: nil, - timestamp: 900_000, - payload: Data("hello".utf8), - signature: nil, - ttl: 5 - ) - let reassembled = try #require(innerPacket.toBinaryData()) - - let recorder = Recorder() - recorder.accepted = false - recorder.appendResult = { header in - .complete(header: header, reassembledData: reassembled, started: false) - } - let handler = makeHandler(recorder: recorder) - let packet = makeFragmentPacket(sender: remotePeerID, index: 1, total: 2) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.ingressChecks.count == 1) - #expect(recorder.reinjectedPackets.isEmpty) - } - - @Test - func undecodableReassembledDataIsDropped() { - let recorder = Recorder() - recorder.appendResult = { header in - .complete(header: header, reassembledData: Data([0x00, 0x01]), started: false) - } - let handler = makeHandler(recorder: recorder) - let packet = makeFragmentPacket(sender: remotePeerID, index: 1, total: 2) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.ingressChecks.isEmpty) - #expect(recorder.reinjectedPackets.isEmpty) - } - - @Test - func incompleteAssemblyDoesNotReinject() { - let recorder = Recorder() - let handler = makeHandler(recorder: recorder) - let packet = makeFragmentPacket(sender: remotePeerID, index: 0, total: 2) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.appendedHeaders.count == 1) - #expect(recorder.ingressChecks.isEmpty) - #expect(recorder.reinjectedPackets.isEmpty) - } - - private func makeFragmentPacket( - sender: PeerID, - index: UInt16, - total: UInt16, - originalType: UInt8 = MessageType.message.rawValue, - chunk: Data = Data([0xAA, 0xBB]), - recipientID: Data? = nil - ) -> BitchatPacket { - var payload = Data() - payload.append(contentsOf: [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]) // fragment ID - payload.append(UInt8(index >> 8)) - payload.append(UInt8(index & 0xFF)) - payload.append(UInt8(total >> 8)) - payload.append(UInt8(total & 0xFF)) - payload.append(originalType) - payload.append(chunk) - - return BitchatPacket( - type: MessageType.fragment.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: recipientID, - timestamp: 900_000, - payload: payload, - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - } -} diff --git a/bitchatTests/Services/BLEInboundWriteBufferTests.swift b/bitchatTests/Services/BLEInboundWriteBufferTests.swift deleted file mode 100644 index 636e2abf..00000000 --- a/bitchatTests/Services/BLEInboundWriteBufferTests.swift +++ /dev/null @@ -1,140 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEInboundWriteBufferTests { - @Test - func appendDecodesPacketAcrossChunkedWrites() throws { - var buffer = BLEInboundWriteBuffer() - let packet = makePacket() - let frame = try #require(packet.toBinaryData(padding: false)) - let splitIndex = max(1, frame.count / 2) - - let firstResult = buffer.append( - chunks: [BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex))], - for: "central-1", - capBytes: 1024 - ) - - if case let .waiting(metadata) = firstResult { - #expect(metadata.accumulatedBytes == splitIndex) - #expect(metadata.appendedBytes == splitIndex) - #expect(metadata.offsets == [0]) - #expect(metadata.packetType == MessageType.message.rawValue) - } else { - Issue.record("Expected first chunk to wait for more data") - } - - let secondResult = buffer.append( - chunks: [BLEInboundWriteChunk(offset: splitIndex, data: frame.suffix(from: splitIndex))], - for: "central-1", - capBytes: 1024 - ) - - if case let .decoded(decoded, metadata) = secondResult { - #expect(decoded.type == packet.type) - #expect(decoded.senderID == packet.senderID) - #expect(decoded.payload == packet.payload) - #expect(metadata.accumulatedBytes == frame.count) - #expect(metadata.offsets == [splitIndex]) - } else { - Issue.record("Expected complete frame to decode") - } - } - - @Test - func appendMergesMultipleOffsetChunksInOneCall() throws { - var buffer = BLEInboundWriteBuffer() - let packet = makePacket(timestamp: 0xABC) - let frame = try #require(packet.toBinaryData(padding: false)) - let splitIndex = max(1, frame.count / 3) - - let result = buffer.append( - chunks: [ - BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex)), - BLEInboundWriteChunk(offset: splitIndex, data: frame.suffix(from: splitIndex)) - ], - for: "central-1", - capBytes: 1024 - ) - - if case let .decoded(decoded, metadata) = result { - #expect(decoded.timestamp == packet.timestamp) - #expect(metadata.appendedBytes == frame.count) - #expect(metadata.offsets == [0, splitIndex]) - } else { - Issue.record("Expected merged chunks to decode") - } - } - - @Test - func appendDropsOversizedBufferAndAllowsLaterDecode() throws { - var buffer = BLEInboundWriteBuffer() - let oversized = buffer.append( - chunks: [BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0xAA, count: 8))], - for: "central-1", - capBytes: 4 - ) - - if case let .oversized(metadata) = oversized { - #expect(metadata.accumulatedBytes == 8) - #expect(metadata.appendedBytes == 8) - } else { - Issue.record("Expected oversized buffer to be dropped") - } - - let packet = makePacket(timestamp: 0xDEF) - let frame = try #require(packet.toBinaryData(padding: false)) - let decoded = buffer.append( - chunks: [BLEInboundWriteChunk(offset: 0, data: frame)], - for: "central-1", - capBytes: 1024 - ) - - if case let .decoded(decodedPacket, _) = decoded { - #expect(decodedPacket.timestamp == packet.timestamp) - } else { - Issue.record("Expected later clean frame to decode") - } - } - - @Test - func removeAllClearsPartialWrites() throws { - var buffer = BLEInboundWriteBuffer() - let packet = makePacket() - let frame = try #require(packet.toBinaryData(padding: false)) - let splitIndex = max(1, frame.count / 2) - - _ = buffer.append( - chunks: [BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex))], - for: "central-1", - capBytes: 1024 - ) - buffer.removeAll() - - let result = buffer.append( - chunks: [BLEInboundWriteChunk(offset: splitIndex, data: frame.suffix(from: splitIndex))], - for: "central-1", - capBytes: 1024 - ) - - if case .waiting = result { - // Expected: the first half was cleared, so the second half alone cannot decode. - } else { - Issue.record("Expected buffer reset to discard the earlier partial write") - } - } - - private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket { - BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]), - recipientID: nil, - timestamp: timestamp, - payload: Data([0xDE, 0xAD, 0xBE, 0xEF]), - signature: nil, - ttl: 3 - ) - } -} diff --git a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift deleted file mode 100644 index 40532d19..00000000 --- a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift +++ /dev/null @@ -1,250 +0,0 @@ -import Foundation -import Testing -import BitFoundation -@testable import bitchat - -struct BLEIngressLinkRegistryTests { - @Test - func recordIfNewSuppressesDuplicateWithinLifetime() { - var registry = BLEIngressLinkRegistry() - let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1) - let peer = PeerID(str: "1122334455667788") - let now = Date() - - let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0) - let duplicateRecord = registry.recordIfNew(packet, link: .peripheral("peripheral-b"), peerID: peer, now: now.addingTimeInterval(1.0), lifetime: 3.0) - - #expect(firstRecord) - #expect(!duplicateRecord) - #expect(registry.link(for: packet) == .central("central-a")) - #expect(registry.peerID(for: packet) == peer) - } - - @Test - func recordIfNewAllowsExpiredDuplicateAndUpdatesLink() { - var registry = BLEIngressLinkRegistry() - let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1) - let peer = PeerID(str: "1122334455667788") - let now = Date() - - let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0) - let expiredRecord = registry.recordIfNew(packet, link: .peripheral("peripheral-b"), peerID: peer, now: now.addingTimeInterval(4.0), lifetime: 3.0) - - #expect(firstRecord) - #expect(expiredRecord) - #expect(registry.link(for: packet) == .peripheral("peripheral-b")) - #expect(registry.peerID(for: packet) == peer) - } - - @Test - func pruneRemovesExpiredIngressLinks() { - var registry = BLEIngressLinkRegistry() - let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1) - let peer = PeerID(str: "1122334455667788") - let now = Date() - - let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0) - #expect(firstRecord) - registry.prune(before: now.addingTimeInterval(4.0)) - - #expect(registry.isEmpty) - #expect(registry.link(for: packet) == nil) - #expect(registry.peerID(for: packet) == nil) - } - - @Test - func packetContextRejectsSelfLoopback() { - let localPeer = PeerID(str: "1122334455667788") - let packet = makePacket(sender: localPeer, timestamp: 1) - - let result = BLEIngressLinkRegistry.packetContext( - for: packet, - claimedSenderID: localPeer, - boundPeerID: nil, - localPeerID: localPeer, - directAnnounceTTL: 7 - ) - - #expect(result == .failure(.selfLoopback(packetType: MessageType.message.rawValue))) - } - - @Test - func packetContextAllowsRelayedSenderOnBoundLink() throws { - let localPeer = PeerID(str: "0011223344556677") - let boundPeer = PeerID(str: "1122334455667788") - let relayedSender = PeerID(str: "8899aabbccddeeff") - let packet = makePacket(sender: relayedSender, timestamp: 1) - - let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( - for: packet, - claimedSenderID: relayedSender, - boundPeerID: boundPeer, - localPeerID: localPeer, - directAnnounceTTL: 7 - ))) - - #expect(context.receivedFromPeerID == boundPeer) - #expect(context.validationPeerID == relayedSender) - } - - @Test - func packetContextAttributesDirectAnnounceMismatchToClaimedSender() throws { - // A rotated peer re-announces its new ID on a link still bound to the - // old one. The announce must flow through (attributed to the claimed - // sender) so signature verification can decide whether to rebind. - let localPeer = PeerID(str: "0011223344556677") - let boundPeer = PeerID(str: "1122334455667788") - let claimedPeer = PeerID(str: "8899aabbccddeeff") - let packet = makeAnnouncePacket(sender: claimedPeer, ttl: 7) - - let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( - for: packet, - claimedSenderID: claimedPeer, - boundPeerID: boundPeer, - localPeerID: localPeer, - directAnnounceTTL: 7 - ))) - - #expect(context.receivedFromPeerID == claimedPeer) - #expect(context.validationPeerID == claimedPeer) - } - - @Test - func packetContextAttributesRelayedAnnounceMismatchToBoundPeer() throws { - // Relayed announces (ttl below direct) keep relayed attribution: the - // link peer forwarded someone else's announce. - let localPeer = PeerID(str: "0011223344556677") - let boundPeer = PeerID(str: "1122334455667788") - let claimedPeer = PeerID(str: "8899aabbccddeeff") - let packet = makeAnnouncePacket(sender: claimedPeer, ttl: 6) - - let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( - for: packet, - claimedSenderID: claimedPeer, - boundPeerID: boundPeer, - localPeerID: localPeer, - directAnnounceTTL: 7 - ))) - - #expect(context.receivedFromPeerID == boundPeer) - #expect(context.validationPeerID == claimedPeer) - } - - @Test - func packetContextRejectsRequestSyncSenderMismatchOnBoundLink() { - let localPeer = PeerID(str: "0011223344556677") - let boundPeer = PeerID(str: "1122334455667788") - let claimedPeer = PeerID(str: "8899aabbccddeeff") - let packet = makeRequestSyncPacket(sender: claimedPeer) - - let result = BLEIngressLinkRegistry.packetContext( - for: packet, - claimedSenderID: claimedPeer, - boundPeerID: boundPeer, - localPeerID: localPeer, - directAnnounceTTL: 7 - ) - - #expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer))) - } - - @Test - func packetContextAllowsRequestSyncFromBoundPeer() throws { - let localPeer = PeerID(str: "0011223344556677") - let boundPeer = PeerID(str: "1122334455667788") - let packet = makeRequestSyncPacket(sender: boundPeer) - - let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( - for: packet, - claimedSenderID: boundPeer, - boundPeerID: boundPeer, - localPeerID: localPeer, - directAnnounceTTL: 7 - ))) - - #expect(context.receivedFromPeerID == boundPeer) - } - - @Test - func packetContextUsesBoundPeerForRSRValidation() throws { - let localPeer = PeerID(str: "0011223344556677") - let boundPeer = PeerID(str: "1122334455667788") - let relayedSender = PeerID(str: "8899aabbccddeeff") - var packet = makePacket(sender: relayedSender, timestamp: 1) - packet.isRSR = true - - let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( - for: packet, - claimedSenderID: relayedSender, - boundPeerID: boundPeer, - localPeerID: localPeer, - directAnnounceTTL: 7 - ))) - - #expect(context.receivedFromPeerID == boundPeer) - #expect(context.validationPeerID == boundPeer) - } - - @Test - func packetContextAllowsSelfAuthoredRSRWithTTLZeroFromBoundPeer() throws { - let localPeer = PeerID(str: "0011223344556677") - let boundPeer = PeerID(str: "1122334455667788") - var packet = makePacket(sender: localPeer, timestamp: 1) - packet.isRSR = true - packet.ttl = 0 - - let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( - for: packet, - claimedSenderID: localPeer, - boundPeerID: boundPeer, - localPeerID: localPeer, - directAnnounceTTL: 7 - ))) - - #expect(context.receivedFromPeerID == boundPeer) - #expect(context.validationPeerID == boundPeer) - } -} - -private func makePacket(sender: PeerID, timestamp: UInt64) -> BitchatPacket { - BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: nil, - timestamp: timestamp, - payload: Data("hello".utf8), - signature: nil, - ttl: 3 - ) -} - -private func makeRequestSyncPacket(sender: PeerID) -> BitchatPacket { - BitchatPacket( - type: MessageType.requestSync.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: nil, - timestamp: 1, - payload: Data(), - signature: nil, - ttl: 0 - ) -} - -private func makeAnnouncePacket(sender: PeerID, ttl: UInt8) -> BitchatPacket { - BitchatPacket( - type: MessageType.announce.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: nil, - timestamp: 1, - payload: Data(), - signature: nil, - ttl: ttl - ) -} - -private func trySuccess(_ result: Result) -> BLEIngressPacketContext? { - guard case .success(let context) = result else { - return nil - } - return context -} diff --git a/bitchatTests/Services/BLEIngressPacketGuardTests.swift b/bitchatTests/Services/BLEIngressPacketGuardTests.swift deleted file mode 100644 index 0d07ec1b..00000000 --- a/bitchatTests/Services/BLEIngressPacketGuardTests.swift +++ /dev/null @@ -1,186 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE ingress packet guard tests") -struct BLEIngressPacketGuardTests { - @Test("valid packets return the received and validation peer context") - func validPacketsReturnContext() throws { - let local = PeerID(str: "0011223344556677") - let bound = PeerID(str: "1122334455667788") - let sender = PeerID(str: "8899aabbccddeeff") - let packet = makePacket(sender: sender, timestamp: 1_000) - - let context = try #require(success(BLEIngressPacketGuard.evaluate( - packet: packet, - claimedSenderID: sender, - boundPeerID: bound, - localPeerID: local, - directAnnounceTTL: 7, - nowMs: 1_000, - isValidSyncResponse: { _ in false } - ))) - - #expect(context.receivedFromPeerID == bound) - #expect(context.validationPeerID == sender) - } - - @Test("self loopback and request-sync spoofing are rejected before timestamp checks") - func linkBindingRejectionsWinBeforeTimestampChecks() { - let local = PeerID(str: "0011223344556677") - let bound = PeerID(str: "1122334455667788") - let claimed = PeerID(str: "8899aabbccddeeff") - let selfPacket = makePacket(sender: local, timestamp: 0) - let spoofedRequestSync = makePacket(type: .requestSync, sender: claimed, timestamp: 0, ttl: 0) - - let selfResult = BLEIngressPacketGuard.evaluate( - packet: selfPacket, - claimedSenderID: local, - boundPeerID: bound, - localPeerID: local, - directAnnounceTTL: 7, - nowMs: 1_000_000, - isValidSyncResponse: { _ in false } - ) - let spoofResult = BLEIngressPacketGuard.evaluate( - packet: spoofedRequestSync, - claimedSenderID: claimed, - boundPeerID: bound, - localPeerID: local, - directAnnounceTTL: 7, - nowMs: 1_000_000, - isValidSyncResponse: { _ in false } - ) - - #expect(selfResult == .failure(.selfLoopback(packetType: MessageType.message.rawValue))) - #expect(spoofResult == .failure(.directSenderMismatch(boundPeerID: bound, claimedSenderID: claimed))) - } - - @Test("direct announce with a mismatched binding flows through to normal validation") - func directAnnounceMismatchStillValidatesTimestamp() throws { - // Rotation heal path: the announce passes the binding check attributed - // to the claimed sender, but stays subject to timestamp validation. - let local = PeerID(str: "0011223344556677") - let bound = PeerID(str: "1122334455667788") - let claimed = PeerID(str: "8899aabbccddeeff") - let freshAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 1_000_000, ttl: 7) - let staleAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 0, ttl: 7) - - let freshContext = try #require(success(BLEIngressPacketGuard.evaluate( - packet: freshAnnounce, - claimedSenderID: claimed, - boundPeerID: bound, - localPeerID: local, - directAnnounceTTL: 7, - nowMs: 1_000_000, - isValidSyncResponse: { _ in false } - ))) - let staleResult = BLEIngressPacketGuard.evaluate( - packet: staleAnnounce, - claimedSenderID: claimed, - boundPeerID: bound, - localPeerID: local, - directAnnounceTTL: 7, - nowMs: 1_000_000, - isValidSyncResponse: { _ in false } - ) - - #expect(freshContext.receivedFromPeerID == claimed) - #expect(freshContext.validationPeerID == claimed) - #expect(staleResult == .failure(.timestampSkew( - peerID: claimed, - skewMs: 1_000_000, - maxSkewMs: 120_000 - ))) - } - - @Test("timestamp skew outside the window is rejected") - func timestampSkewIsRejected() { - let local = PeerID(str: "0011223344556677") - let sender = PeerID(str: "1122334455667788") - let packet = makePacket(sender: sender, timestamp: 1_000) - - let result = BLEIngressPacketGuard.evaluate( - packet: packet, - claimedSenderID: sender, - boundPeerID: nil, - localPeerID: local, - directAnnounceTTL: 7, - nowMs: 200_000, - maxTimestampSkewMs: 120_000, - isValidSyncResponse: { _ in false } - ) - - #expect(result == .failure(.timestampSkew( - peerID: sender, - skewMs: 199_000, - maxSkewMs: 120_000 - ))) - } - - @Test("valid RSR packets use bound peer validation and bypass timestamp skew") - func validRSRUsesBoundPeerAndBypassesTimestamp() throws { - let local = PeerID(str: "0011223344556677") - let bound = PeerID(str: "1122334455667788") - let sender = PeerID(str: "8899aabbccddeeff") - var packet = makePacket(sender: sender, timestamp: 1) - packet.isRSR = true - - let context = try #require(success(BLEIngressPacketGuard.evaluate( - packet: packet, - claimedSenderID: sender, - boundPeerID: bound, - localPeerID: local, - directAnnounceTTL: 7, - nowMs: 1_000_000, - isValidSyncResponse: { $0 == bound } - ))) - - #expect(context.receivedFromPeerID == bound) - #expect(context.validationPeerID == bound) - } - - @Test("invalid RSR packets are rejected") - func invalidRSRIsRejected() { - let local = PeerID(str: "0011223344556677") - let bound = PeerID(str: "1122334455667788") - let sender = PeerID(str: "8899aabbccddeeff") - var packet = makePacket(sender: sender, timestamp: 1) - packet.isRSR = true - - let result = BLEIngressPacketGuard.evaluate( - packet: packet, - claimedSenderID: sender, - boundPeerID: bound, - localPeerID: local, - directAnnounceTTL: 7, - nowMs: 1_000_000, - isValidSyncResponse: { _ in false } - ) - - #expect(result == .failure(.invalidRSR(peerID: bound))) - } - - private func makePacket( - type: MessageType = .message, - sender: PeerID, - timestamp: UInt64, - ttl: UInt8 = 3 - ) -> BitchatPacket { - BitchatPacket( - type: type.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: nil, - timestamp: timestamp, - payload: Data([0x01, 0x02, 0x03]), - signature: nil, - ttl: ttl - ) - } - - private func success(_ result: Result) -> BLEIngressPacketContext? { - guard case .success(let context) = result else { return nil } - return context - } -} diff --git a/bitchatTests/Services/BLELinkAuthStateTests.swift b/bitchatTests/Services/BLELinkAuthStateTests.swift deleted file mode 100644 index c16d41a0..00000000 --- a/bitchatTests/Services/BLELinkAuthStateTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLELinkAuthStateTests { - private let peerID = PeerID(str: "1122334455667788") - private let link = BLEIngressLinkID.peripheral("periph-a") - - @Test - func authenticationBindsToTheExactLinkAndOwner() { - var auth = BLELinkAuthState() - auth.markAuthenticated(link, owner: peerID) - - #expect(auth.isAuthenticated(link, for: peerID)) - #expect(!auth.isAuthenticated(link, for: PeerID(str: "8899aabbccddeeff"))) - #expect(!auth.isAuthenticated(.peripheral("periph-b"), for: peerID)) - - auth.retireLink(link) - #expect(!auth.isAuthenticated(link, for: peerID)) - } - - @Test - func retireLinksOwnedByPeerReturnsAndRetiresThemAll() { - var auth = BLELinkAuthState() - auth.markAuthenticated(.peripheral("periph-a"), owner: peerID) - auth.markAuthenticated(.central("central-a"), owner: peerID) - auth.markAuthenticated(.central("central-b"), owner: PeerID(str: "8899aabbccddeeff")) - - let departed = Set(auth.retireLinks(ownedBy: peerID)) - - #expect(departed == [.peripheral("periph-a"), .central("central-a")]) - #expect(auth.links(ownedBy: peerID).isEmpty) - #expect(auth.isAuthenticated(.central("central-b"), for: PeerID(str: "8899aabbccddeeff"))) - } - - @Test - func rebindCooldownPermitsOncePerWindowAndAgesOut() { - var auth = BLELinkAuthState() - let start = Date(timeIntervalSince1970: 1_000) - - let first = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30) - #expect(first) - let withinWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(10), cooldown: 30) - #expect(!withinWindow) - // A different link has its own allowance. - let otherLink = auth.permitRebind(linkUUID: "periph-b", now: start.addingTimeInterval(10), cooldown: 30) - #expect(otherLink) - // The window ages out. - let afterWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(31), cooldown: 30) - #expect(afterWindow) - } - - @Test - func containmentCooldownsSurviveASessionReset() { - var auth = BLELinkAuthState() - let start = Date(timeIntervalSince1970: 2_000) - auth.markAuthenticated(link, owner: peerID) - let rebindBefore = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30) - let retirementBefore = auth.permitRedundantRetirement(peerID: peerID, now: start, cooldown: 30) - #expect(rebindBefore) - #expect(retirementBefore) - - // Panic/emergency resets wipe proofs and epochs — but a stable - // CoreBluetooth UUID must not earn a fresh rebind or retirement - // allowance just because the session state around it was wiped. - auth.removeAll() - - #expect(!auth.isAuthenticated(link, for: peerID)) - let rebindAfterReset = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(5), cooldown: 30) - let retirementAfterReset = auth.permitRedundantRetirement(peerID: peerID, now: start.addingTimeInterval(5), cooldown: 30) - #expect(!rebindAfterReset) - #expect(!retirementAfterReset) - } -} diff --git a/bitchatTests/Services/BLELinkBindingsTests.swift b/bitchatTests/Services/BLELinkBindingsTests.swift deleted file mode 100644 index 3730cb2a..00000000 --- a/bitchatTests/Services/BLELinkBindingsTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -import BitFoundation -import Testing -@testable import bitchat - -struct BLELinkBindingsTests { - private let peerID = PeerID(str: "1122334455667788") - private let otherPeerID = PeerID(str: "8899aabbccddeeff") - - @Test - func centralBindingExposesBoundPeerAndLinks() { - var bindings = BLELinkBindings() - - bindings.bindCentral("central-a", to: peerID) - - #expect(bindings.peer(forCentralUUID: "central-a") == peerID) - #expect(bindings.hasCentral(boundTo: peerID)) - #expect(bindings.boundPeer(for: .central("central-a")) == peerID) - #expect(bindings.links(to: peerID) == [.central("central-a")]) - } - - @Test - func linksReturnsAllBindingsForPeerAcrossRoles() { - var bindings = BLELinkBindings() - - bindings.bindCentral("central-a", to: peerID) - bindings.bindCentral("central-b", to: peerID) - bindings.bindCentral("central-c", to: otherPeerID) - bindings.bindPeripheral("periph-a", to: peerID) - - #expect(bindings.links(to: peerID) == [.central("central-a"), .central("central-b"), .peripheral("periph-a")]) - } - - @Test - func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() { - var bindings = BLELinkBindings() - - bindings.bindCentral("central-a", to: peerID) - bindings.bindCentral("central-b", to: otherPeerID) - - let removedPeerIDs = Set(bindings.clearCentrals()) - - #expect(removedPeerIDs == Set([peerID, otherPeerID])) - #expect(bindings.peer(forCentralUUID: "central-a") == nil) - #expect(bindings.links(to: peerID).isEmpty) - } - - @Test - func rotationRebindDropsTheRetiredIdentitysReverseMapping() { - var bindings = BLELinkBindings() - bindings.bindPeripheral("periph-a", to: peerID) - #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a") - - // The link's owner rotates: the old identity must no longer claim - // this link as its preferred peripheral. - bindings.bindPeripheral("periph-a", to: otherPeerID) - - #expect(bindings.preferredPeripheralUUID(for: peerID) == nil) - #expect(bindings.preferredPeripheralUUID(for: otherPeerID) == "periph-a") - #expect(bindings.peer(forPeripheralID: "periph-a") == otherPeerID) - } - - @Test - func removingThePreferredLinkRepairsOntoTheChosenSurvivor() { - var bindings = BLELinkBindings() - bindings.bindPeripheral("periph-a", to: peerID) - bindings.bindPeripheral("periph-b", to: peerID) - // periph-b bound last: it is the preferred link. - #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b") - - let removed = bindings.peripheralRemoved("periph-b") { remaining in - #expect(remaining == ["periph-a"]) - return remaining.first - } - - #expect(removed == peerID) - #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a") - #expect(bindings.links(to: peerID) == [.peripheral("periph-a")]) - } - - @Test - func removingADuplicateLinkDoesNotStrandThePreferredOne() { - var bindings = BLELinkBindings() - bindings.bindPeripheral("periph-a", to: peerID) - bindings.bindPeripheral("periph-b", to: peerID) - - // Removing the non-preferred duplicate must leave the reverse map - // untouched (no repair callback consulted for a non-preferred link). - let removed = bindings.peripheralRemoved("periph-a") { _ in - Issue.record("survivor choice must not run for a non-preferred link") - return nil - } - - #expect(removed == peerID) - #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b") - } - - @Test - func removingTheLastLinkClearsThePreferredMapping() { - var bindings = BLELinkBindings() - bindings.bindPeripheral("periph-a", to: peerID) - - let removed = bindings.peripheralRemoved("periph-a") { remaining in - #expect(remaining.isEmpty) - return nil - } - - #expect(removed == peerID) - #expect(bindings.preferredPeripheralUUID(for: peerID) == nil) - #expect(bindings.links(to: peerID).isEmpty) - } -} diff --git a/bitchatTests/Services/BLELocalIdentityStateStoreTests.swift b/bitchatTests/Services/BLELocalIdentityStateStoreTests.swift deleted file mode 100644 index ed10ab2f..00000000 --- a/bitchatTests/Services/BLELocalIdentityStateStoreTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLELocalIdentityStateStoreTests { - @Test - func identityReplacementUpdatesWireBytesAtomically() throws { - let initial = PeerID(str: "0011223344556677") - let replacement = PeerID(str: "8899aabbccddeeff") - let store = BLELocalIdentityStateStore(peerID: initial, nickname: "alice") - - store.replacePeerIdentity(with: replacement) - - let snapshot = store.snapshot() - #expect(snapshot.peerID == replacement) - #expect(snapshot.peerIDData == Data(hexString: replacement.id)) - #expect(snapshot.nickname == "alice") - } - - @Test - func concurrentReadsNeverObserveSplitIdentityState() { - let peerIDs = [ - PeerID(str: "0011223344556677"), - PeerID(str: "8899aabbccddeeff") - ] - let store = BLELocalIdentityStateStore(peerID: peerIDs[0], nickname: "alice") - let failures = LockedFailureRecorder() - - DispatchQueue.concurrentPerform(iterations: 2_000) { index in - if index.isMultiple(of: 2) { - store.replacePeerIdentity(with: peerIDs[index % peerIDs.count]) - } else { - store.setNickname(index.isMultiple(of: 3) ? "alice" : "bob") - } - - let snapshot = store.snapshot() - let expectedWireID = Data(hexString: snapshot.peerID.id) ?? Data() - if snapshot.peerIDData != expectedWireID { - failures.record() - } - } - - #expect(!failures.hasFailure) - } -} - -private final class LockedFailureRecorder: @unchecked Sendable { - private let lock = NSLock() - private var failed = false - - var hasFailure: Bool { lock.withLock { failed } } - - func record() { - lock.withLock { failed = true } - } -} diff --git a/bitchatTests/Services/BLELogRateLimiterTests.swift b/bitchatTests/Services/BLELogRateLimiterTests.swift deleted file mode 100644 index 9185b7d0..00000000 --- a/bitchatTests/Services/BLELogRateLimiterTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE log rate limiter tests") -struct BLELogRateLimiterTests { - @Test("repeated log keys are suppressed until the default interval elapses") - func suppressesRepeatedKeysUntilIntervalElapses() { - let limiter = BLELogRateLimiter(defaultMinimumInterval: 5) - let now = Date(timeIntervalSince1970: 100) - - #expect(limiter.shouldLog(key: "ready:peer-a", now: now)) - #expect(!limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(4.9))) - #expect(limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(5))) - } - - @Test("different log keys are tracked independently") - func tracksKeysIndependently() { - let limiter = BLELogRateLimiter(defaultMinimumInterval: 5) - let now = Date(timeIntervalSince1970: 100) - - #expect(limiter.shouldLog(key: "ready:peer-a", now: now)) - #expect(limiter.shouldLog(key: "ready:peer-b", now: now)) - #expect(!limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(1))) - } - - @Test("call sites can override the default interval") - func supportsPerCallIntervals() { - let limiter = BLELogRateLimiter(defaultMinimumInterval: 5) - let now = Date(timeIntervalSince1970: 100) - - #expect(limiter.shouldLog(key: "self-loopback", now: now, minimumInterval: 30)) - #expect(!limiter.shouldLog(key: "self-loopback", now: now.addingTimeInterval(29), minimumInterval: 30)) - #expect(limiter.shouldLog(key: "self-loopback", now: now.addingTimeInterval(30), minimumInterval: 30)) - } -} diff --git a/bitchatTests/Services/BLEMaintenancePolicyTests.swift b/bitchatTests/Services/BLEMaintenancePolicyTests.swift deleted file mode 100644 index 68157f6c..00000000 --- a/bitchatTests/Services/BLEMaintenancePolicyTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE maintenance policy tests") -struct BLEMaintenancePolicyTests { - @Test("discovery mode keeps announces frequent and advertising on") - func discoveryModeAnnouncesAndEnsuresAdvertising() { - let plan = BLEMaintenancePolicy.plan( - cycle: 1, - connectedCount: 0, - peerRegistryIsEmpty: true, - elapsedSinceLastAnnounce: TransportConfig.bleAnnounceIntervalSeconds, - hasRecentTraffic: false - ) - - #expect(plan.shouldSendAnnounce) - #expect(plan.shouldEnsureAdvertising) - #expect(plan.shouldFlushDirectedSpool) - #expect(!plan.shouldRunCleanup) - #expect(!plan.shouldResetCounter) - } - - @Test("connected sparse meshes use sparse announce cadence") - func connectedSparseMeshUsesSparseAnnounceCadence() { - let beforeTarget = BLEMaintenancePolicy.plan( - cycle: 2, - connectedCount: 2, - peerRegistryIsEmpty: false, - elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsSparse - 0.1, - hasRecentTraffic: false, - connectedAnnounceJitterOffset: 0 - ) - let atTarget = BLEMaintenancePolicy.plan( - cycle: 2, - connectedCount: 2, - peerRegistryIsEmpty: false, - elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsSparse, - hasRecentTraffic: false, - connectedAnnounceJitterOffset: 0 - ) - - #expect(!beforeTarget.shouldSendAnnounce) - #expect(atTarget.shouldSendAnnounce) - #expect(!beforeTarget.shouldEnsureAdvertising) - } - - @Test("dense meshes use dense announce cadence") - func denseMeshUsesDenseAnnounceCadence() { - let beforeTarget = BLEMaintenancePolicy.plan( - cycle: 3, - connectedCount: TransportConfig.bleHighDegreeThreshold, - peerRegistryIsEmpty: false, - elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsDense - 0.1, - hasRecentTraffic: false, - connectedAnnounceJitterOffset: 0 - ) - let atTarget = BLEMaintenancePolicy.plan( - cycle: 3, - connectedCount: TransportConfig.bleHighDegreeThreshold, - peerRegistryIsEmpty: false, - elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsDense, - hasRecentTraffic: false, - connectedAnnounceJitterOffset: 0 - ) - - #expect(!beforeTarget.shouldSendAnnounce) - #expect(atTarget.shouldSendAnnounce) - #expect(atTarget.shouldRunCleanup) - } - - @Test("recent traffic can request a quick announce") - func recentTrafficRequestsQuickAnnounce() { - let plan = BLEMaintenancePolicy.plan( - cycle: 4, - connectedCount: 2, - peerRegistryIsEmpty: false, - elapsedSinceLastAnnounce: 10.0, - hasRecentTraffic: true, - connectedAnnounceJitterOffset: TransportConfig.bleConnectedAnnounceJitterSparse - ) - - #expect(plan.shouldSendAnnounce) - #expect(!plan.shouldFlushDirectedSpool) - } - - @Test("maintenance cadence controls cleanup spool flushing and counter reset") - func maintenanceCadenceControlsPeriodicWork() { - let cleanupAndFlush = BLEMaintenancePolicy.plan( - cycle: 3, - connectedCount: 1, - peerRegistryIsEmpty: false, - elapsedSinceLastAnnounce: 0, - hasRecentTraffic: false, - connectedAnnounceJitterOffset: 0 - ) - let reset = BLEMaintenancePolicy.plan( - cycle: 6, - connectedCount: 1, - peerRegistryIsEmpty: false, - elapsedSinceLastAnnounce: 0, - hasRecentTraffic: false, - connectedAnnounceJitterOffset: 0 - ) - - #expect(cleanupAndFlush.shouldRunCleanup) - #expect(cleanupAndFlush.shouldFlushDirectedSpool) - #expect(!cleanupAndFlush.shouldResetCounter) - #expect(reset.shouldRunCleanup) - #expect(!reset.shouldFlushDirectedSpool) - #expect(reset.shouldResetCounter) - } -} diff --git a/bitchatTests/Services/BLEMeshPingTrackerTests.swift b/bitchatTests/Services/BLEMeshPingTrackerTests.swift deleted file mode 100644 index a3a81008..00000000 --- a/bitchatTests/Services/BLEMeshPingTrackerTests.swift +++ /dev/null @@ -1,84 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEMeshPingTrackerTests { - private func makeProbe(peerID: PeerID) -> BLEMeshPingProbe { - BLEMeshPingProbe( - peerID: peerID, - sentAt: Date(timeIntervalSince1970: 1_000), - lifecycleGeneration: 1, - completion: { _ in }, - timeout: DispatchWorkItem {} - ) - } - - @Test func resolveReturnsProbeOnlyForTheProbedPeer() { - var tracker = BLEMeshPingTracker() - let nonce = Data([1, 2, 3, 4, 5, 6, 7, 8]) - let probed = PeerID(str: "aaaaaaaaaaaaaaaa") - tracker.register(makeProbe(peerID: probed), nonce: nonce) - - // A pong claiming the right nonce from the wrong peer must not - // consume the probe. - let wrongPeer = tracker.resolve(nonce: nonce, from: PeerID(str: "bbbbbbbbbbbbbbbb")) - #expect(wrongPeer == nil) - let rightPeer = tracker.resolve(nonce: nonce, from: probed) - #expect(rightPeer != nil) - // Consumed exactly once. - let secondResolve = tracker.resolve(nonce: nonce, from: probed) - #expect(secondResolve == nil) - } - - @Test func expireConsumesTheProbeSoResolveCannotFireTwice() { - var tracker = BLEMeshPingTracker() - let nonce = Data([9, 9, 9, 9, 9, 9, 9, 9]) - let probed = PeerID(str: "aaaaaaaaaaaaaaaa") - tracker.register(makeProbe(peerID: probed), nonce: nonce) - - let firstExpire = tracker.expire(nonce: nonce) - #expect(firstExpire != nil) - let secondExpire = tracker.expire(nonce: nonce) - #expect(secondExpire == nil) - let resolveAfterExpire = tracker.resolve(nonce: nonce, from: probed) - #expect(resolveAfterExpire == nil) - } - - @Test func inboundBudgetIsPerLinkAndBounded() { - var tracker = BLEMeshPingTracker() - let now = Date(timeIntervalSince1970: 2_000) - let linkA = PeerID(str: "aaaaaaaaaaaaaaaa") - let linkB = PeerID(str: "bbbbbbbbbbbbbbbb") - - var allowedOnA = 0 - for _ in 0..<(TransportConfig.meshPingInboundMaxPerLink + 5) { - if tracker.shouldRespond(toLink: linkA, now: now) { allowedOnA += 1 } - } - #expect(allowedOnA == TransportConfig.meshPingInboundMaxPerLink) - // One saturated link must not consume another link's budget. - let allowedOnB = tracker.shouldRespond(toLink: linkB, now: now) - #expect(allowedOnB) - } - - @Test func resetDropsProbesRestoresBudgetAndHandsBackTimeouts() { - var tracker = BLEMeshPingTracker() - let now = Date(timeIntervalSince1970: 3_000) - let link = PeerID(str: "aaaaaaaaaaaaaaaa") - let nonce = Data([4, 4, 4, 4, 4, 4, 4, 4]) - tracker.register(makeProbe(peerID: link), nonce: nonce) - for _ in 0.. = .success(nil) - var handshakeAuthenticated = false - var hasSession = false - let sessionGeneration = UUID() - var awaitingResponderHandshake = false - var decryptResult: Result = .success(Data()) - var currentDate = Date(timeIntervalSince1970: 1_000) - var transportGenerationReady = false - var forcedServiceDecryptError: Error? - - var processedHandshakes: [(peerID: PeerID, message: Data)] = [] - var hasSessionQueries: [PeerID] = [] - var initiatedHandshakes: [PeerID] = [] - var broadcastPackets: [BitchatPacket] = [] - var lastSeenUpdates: [PeerID] = [] - var decryptCalls: [(payload: Data, peerID: PeerID)] = [] - var clearedSessions: [PeerID] = [] - var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = [] - var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = [] - /// Ordered side-effect log to assert recovery sequencing. - var events: [String] = [] - } - - private let localPeerID = PeerID(str: "0102030405060708") - private let remotePeerID = PeerID(str: "1122334455667788") - private let localPeerIDData = Data(hexString: "0102030405060708") ?? Data() - - private func makeHandler( - recorder: Recorder, - now: Date = Date(timeIntervalSince1970: 1_000) - ) -> BLENoisePacketHandler { - recorder.currentDate = now - let environment = BLENoisePacketHandlerEnvironment( - localPeerID: { [localPeerID] in localPeerID }, - localPeerIDData: { [localPeerIDData] in localPeerIDData }, - messageTTL: TransportConfig.messageTTLDefault, - now: { recorder.currentDate }, - processHandshakeMessage: { peerID, message in - recorder.processedHandshakes.append((peerID, message)) - return NoiseHandshakeProcessingResult( - response: try recorder.handshakeResult.get(), - didEstablishAuthenticatedSession: - recorder.handshakeAuthenticated - ) - }, - hasNoiseSession: { peerID in - recorder.hasSessionQueries.append(peerID) - return recorder.hasSession - }, - isAwaitingResponderHandshakeCompletion: { _ in - recorder.awaitingResponderHandshake - }, - initiateHandshake: { peerID in - recorder.initiatedHandshakes.append(peerID) - recorder.events.append("initiateHandshake") - }, - broadcastPacket: { packet in - recorder.broadcastPackets.append(packet) - }, - updatePeerLastSeen: { peerID in - recorder.lastSeenUpdates.append(peerID) - }, - decrypt: { payload, peerID in - recorder.decryptCalls.append((payload, peerID)) - return BLENoiseDecryptionResult( - plaintext: try recorder.decryptResult.get(), - sessionGeneration: recorder.sessionGeneration - ) - }, - clearSession: { peerID in - recorder.clearedSessions.append(peerID) - recorder.events.append("clearSession") - }, - handleAuthenticatedPeerState: { peerID, payload, generation in - recorder.authenticatedPeerStates.append((peerID, payload, generation)) - }, - deliverNoisePayload: { peerID, type, payload, timestamp in - recorder.deliveries.append((peerID, type, payload, timestamp)) - } - ) - return BLENoisePacketHandler(environment: environment) - } - - private func makeServiceBackedHandler( - service: NoiseEncryptionService, - localPeerID: PeerID, - recorder: Recorder, - transportGenerationIsReady: - @escaping (UUID) -> Bool - ) -> BLENoisePacketHandler { - BLENoisePacketHandler( - environment: BLENoisePacketHandlerEnvironment( - localPeerID: { localPeerID }, - localPeerIDData: { - Data(hexString: localPeerID.id) ?? Data() - }, - messageTTL: TransportConfig.messageTTLDefault, - now: { recorder.currentDate }, - processHandshakeMessage: { peerID, message in - try service.processHandshakeMessageWithResult( - from: peerID, - message: message - ) - }, - hasNoiseSession: { peerID in - service.hasSession(with: peerID) - }, - isAwaitingResponderHandshakeCompletion: { peerID in - service.isAwaitingResponderHandshakeCompletion( - with: peerID - ) - }, - initiateHandshake: { peerID in - recorder.initiatedHandshakes.append(peerID) - }, - broadcastPacket: { packet in - recorder.broadcastPackets.append(packet) - }, - updatePeerLastSeen: { peerID in - recorder.lastSeenUpdates.append(peerID) - }, - decrypt: { payload, peerID in - recorder.decryptCalls.append((payload, peerID)) - if let error = recorder.forcedServiceDecryptError { - throw error - } - let result = - try service.decryptWithSessionGeneration( - payload, - from: peerID, - establishedGenerationIsReady: - transportGenerationIsReady - ) - return BLENoiseDecryptionResult( - plaintext: result.plaintext, - sessionGeneration: result.sessionGeneration - ) - }, - clearSession: { peerID in - recorder.clearedSessions.append(peerID) - service.clearSession(for: peerID) - }, - handleAuthenticatedPeerState: { peerID, payload, generation in - recorder.authenticatedPeerStates.append( - (peerID, payload, generation) - ) - }, - deliverNoisePayload: { peerID, type, payload, timestamp in - recorder.deliveries.append( - (peerID, type, payload, timestamp) - ) - } - ) - ) - } - - private func establishedServices() throws -> ( - sender: NoiseEncryptionService, - receiver: NoiseEncryptionService, - senderPeerID: PeerID, - receiverPeerID: PeerID - ) { - let sender = NoiseEncryptionService(keychain: MockKeychain()) - let receiver = NoiseEncryptionService(keychain: MockKeychain()) - let senderPeerID = PeerID( - publicKey: sender.getStaticPublicKeyData() - ) - let receiverPeerID = PeerID( - publicKey: receiver.getStaticPublicKeyData() - ) - let message1 = try sender.initiateHandshake(with: receiverPeerID) - let message2 = try #require( - try receiver.processHandshakeMessage( - from: senderPeerID, - message: message1 - ) - ) - let message3 = try #require( - try sender.processHandshakeMessage( - from: receiverPeerID, - message: message2 - ) - ) - _ = try receiver.processHandshakeMessage( - from: senderPeerID, - message: message3 - ) - return ( - sender, - receiver, - senderPeerID, - receiverPeerID - ) - } - - // MARK: Handshake - - @Test - func handshakeForUsBroadcastsResponsePacket() { - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - recorder.handshakeResult = .success(Data([0xAA, 0xBB])) - let handler = makeHandler(recorder: recorder, now: now) - let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) - - handler.handleHandshake(packet, from: remotePeerID) - - #expect(recorder.processedHandshakes.count == 1) - #expect(recorder.processedHandshakes.first?.peerID == remotePeerID) - #expect(recorder.processedHandshakes.first?.message == packet.payload) - #expect(recorder.broadcastPackets.count == 1) - let response = recorder.broadcastPackets.first - #expect(response?.type == MessageType.noiseHandshake.rawValue) - #expect(response?.senderID == localPeerIDData) - #expect(response?.recipientID == Data(hexString: remotePeerID.id)) - #expect(response?.payload == Data([0xAA, 0xBB])) - #expect(response?.signature == nil) - #expect(response?.ttl == TransportConfig.messageTTLDefault) - #expect(response?.timestamp == UInt64(now.timeIntervalSince1970 * 1000)) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func handshakeWithoutResponseDoesNotBroadcast() { - let recorder = Recorder() - recorder.handshakeResult = .success(nil) - let handler = makeHandler(recorder: recorder) - let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) - - handler.handleHandshake(packet, from: remotePeerID) - - #expect(recorder.processedHandshakes.count == 1) - #expect(recorder.broadcastPackets.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func handshakeResultPreservesExactCandidateAuthentication() { - let recorder = Recorder() - recorder.handshakeAuthenticated = true - let handler = makeHandler(recorder: recorder) - let packet = makeHandshakePacket( - recipientID: Data(hexString: localPeerID.id) - ) - - let result = handler.handleHandshakeWithResult( - packet, - from: remotePeerID - ) - - #expect(result.processed) - #expect(result.didEstablishAuthenticatedSession) - } - - @Test - func handshakeForAnotherPeerIsIgnored() { - let recorder = Recorder() - let handler = makeHandler(recorder: recorder) - let packet = makeHandshakePacket(recipientID: Data(hexString: remotePeerID.id)) - - handler.handleHandshake(packet, from: remotePeerID) - - #expect(recorder.processedHandshakes.isEmpty) - #expect(recorder.broadcastPackets.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func handshakeFailureInitiatesNewHandshakeWhenNoSession() { - let recorder = Recorder() - recorder.handshakeResult = .failure(TestError()) - recorder.hasSession = false - let handler = makeHandler(recorder: recorder) - let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) - - handler.handleHandshake(packet, from: remotePeerID) - - #expect(recorder.hasSessionQueries == [remotePeerID]) - #expect(recorder.initiatedHandshakes == [remotePeerID]) - #expect(recorder.broadcastPackets.isEmpty) - } - - @Test - func handshakeFailureSkipsInitiateWhenSessionExists() { - let recorder = Recorder() - recorder.handshakeResult = .failure(TestError()) - recorder.hasSession = true - let handler = makeHandler(recorder: recorder) - let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) - - handler.handleHandshake(packet, from: remotePeerID) - - #expect(recorder.hasSessionQueries == [remotePeerID]) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func peerIdentityMismatchDoesNotRecreateHandshakeState() { - let recorder = Recorder() - recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch) - recorder.hasSession = false - let handler = makeHandler(recorder: recorder) - let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) - - #expect(!handler.handleHandshake(packet, from: remotePeerID)) - - #expect(recorder.hasSessionQueries.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - #expect(recorder.broadcastPackets.isEmpty) - } - - @Test - func managedHandshakeFailureDoesNotStartASecondRecovery() { - let recorder = Recorder() - recorder.handshakeResult = .failure( - NoiseManagedHandshakeFailure(underlying: TestError()) - ) - recorder.hasSession = false - let handler = makeHandler(recorder: recorder) - let packet = makeHandshakePacket( - recipientID: Data(hexString: localPeerID.id) - ) - - #expect(!handler.handleHandshake(packet, from: remotePeerID)) - #expect(recorder.hasSessionQueries.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - #expect(recorder.broadcastPackets.isEmpty) - } - - // MARK: Encrypted - - @Test - func encryptedWithoutRecipientIsDropped() { - let recorder = Recorder() - let handler = makeHandler(recorder: recorder) - let packet = makeEncryptedPacket(recipientID: nil) - - handler.handleEncrypted(packet, from: remotePeerID) - - #expect(recorder.lastSeenUpdates.isEmpty) - #expect(recorder.decryptCalls.isEmpty) - #expect(recorder.deliveries.isEmpty) - } - - @Test - func encryptedForAnotherPeerIsDropped() { - let recorder = Recorder() - let handler = makeHandler(recorder: recorder) - let packet = makeEncryptedPacket(recipientID: Data(hexString: remotePeerID.id)) - - handler.handleEncrypted(packet, from: remotePeerID) - - #expect(recorder.lastSeenUpdates.isEmpty) - #expect(recorder.decryptCalls.isEmpty) - #expect(recorder.deliveries.isEmpty) - } - - @Test - func decryptedPayloadIsDeliveredWithTypeAndTimestamp() { - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - recorder.decryptResult = .success(Data([NoisePayloadType.privateMessage.rawValue, 0x01, 0x02, 0x03])) - let handler = makeHandler(recorder: recorder, now: now) - let sentAt = Date(timeIntervalSince1970: 900) - let packet = makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id), - timestamp: UInt64(sentAt.timeIntervalSince1970 * 1000) - ) - - handler.handleEncrypted(packet, from: remotePeerID) - - #expect(recorder.lastSeenUpdates == [remotePeerID]) - #expect(recorder.decryptCalls.count == 1) - #expect(recorder.decryptCalls.first?.payload == packet.payload) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.peerID == remotePeerID) - #expect(recorder.deliveries.first?.type == .privateMessage) - #expect(recorder.deliveries.first?.payload == Data([0x01, 0x02, 0x03])) - #expect(recorder.deliveries.first?.timestamp == sentAt) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() { - let recorder = Recorder() - recorder.decryptResult = .success(Data([ - NoisePayloadType.authenticatedPeerState.rawValue, - 0x01, 0x02, 0x03 - ])) - let handler = makeHandler(recorder: recorder) - let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) - - handler.handleEncrypted(packet, from: remotePeerID) - - #expect(recorder.authenticatedPeerStates.count == 1) - #expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID) - #expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03])) - #expect(recorder.authenticatedPeerStates.first?.generation == recorder.sessionGeneration) - #expect(recorder.deliveries.isEmpty) - } - - @Test - func emptyDecryptedPayloadIsIgnored() { - let recorder = Recorder() - recorder.decryptResult = .success(Data()) - let handler = makeHandler(recorder: recorder) - let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) - - handler.handleEncrypted(packet, from: remotePeerID) - - #expect(recorder.decryptCalls.count == 1) - #expect(recorder.deliveries.isEmpty) - #expect(recorder.clearedSessions.isEmpty) - } - - @Test - func unknownNoisePayloadTypeIsIgnored() { - let recorder = Recorder() - recorder.decryptResult = .success(Data([0xEE, 0x01])) - let handler = makeHandler(recorder: recorder) - let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) - - handler.handleEncrypted(packet, from: remotePeerID) - - #expect(recorder.decryptCalls.count == 1) - #expect(recorder.deliveries.isEmpty) - } - - @Test - func missingSessionInitiatesHandshakeWithoutClearing() { - let recorder = Recorder() - recorder.decryptResult = .failure(NoiseEncryptionError.sessionNotEstablished) - recorder.hasSession = false - let handler = makeHandler(recorder: recorder) - let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) - - handler.handleEncrypted(packet, from: remotePeerID) - - #expect(recorder.hasSessionQueries == [remotePeerID]) - #expect(recorder.initiatedHandshakes == [remotePeerID]) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.deliveries.isEmpty) - } - - @Test - func missingSessionSkipsInitiateWhenSessionAppeared() { - let recorder = Recorder() - recorder.decryptResult = .failure(NoiseEncryptionError.sessionNotEstablished) - recorder.hasSession = true - let handler = makeHandler(recorder: recorder) - let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) - - handler.handleEncrypted(packet, from: remotePeerID) - - #expect(recorder.initiatedHandshakes.isEmpty) - #expect(recorder.clearedSessions.isEmpty) - } - - @Test - func decryptFailureClearsSessionThenReinitiatesHandshake() { - let recorder = Recorder() - recorder.decryptResult = .failure(TestError()) - // Even with a live session, recovery clears it and re-initiates unconditionally. - recorder.hasSession = true - let handler = makeHandler(recorder: recorder) - let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) - - handler.handleEncrypted(packet, from: remotePeerID) - - #expect(recorder.clearedSessions == [remotePeerID]) - #expect(recorder.initiatedHandshakes == [remotePeerID]) - // Session-recovery order must stay clear → re-initiate. - #expect(recorder.events == ["clearSession", "initiateHandshake"]) - #expect(recorder.deliveries.isEmpty) - } - - @Test - func earlyCiphertextIsRetriedAfterResponderHandshakeCompletes() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - CryptoKitError.authenticationFailure - ) - let handler = makeHandler(recorder: recorder) - let encrypted = makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id) - ) - - handler.handleEncrypted(encrypted, from: remotePeerID) - - #expect(recorder.decryptCalls.count == 1) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE]) - ) - handler.handleSessionAuthenticated(remotePeerID) - handler.handleSessionAuthenticated(remotePeerID) - - #expect(recorder.decryptCalls.count == 2) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.type == .privateMessage) - #expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE])) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func panicResetDiscardsDeferredCiphertextBeforeFutureAuthentication() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - CryptoKitError.authenticationFailure - ) - let handler = makeHandler(recorder: recorder) - let prePanicCiphertext = makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id), - payload: Data( - count: NoiseSecurityConstants.maxMessageSize - + NoiseSecurityConstants.transportCiphertextOverhead - ) - ) - - handler.handleEncrypted(prePanicCiphertext, from: remotePeerID) - #expect(recorder.decryptCalls.count == 1) - - handler.resetForPanic() - - // Three maximum-sized packets fit only when reset also zeroed the - // global byte accounting. They model ciphertext received under the - // replacement identity before that responder handshake completes. - for index in 0..<3 { - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id), - timestamp: UInt64(901_000 + index), - payload: Data( - count: NoiseSecurityConstants.maxMessageSize - + NoiseSecurityConstants.transportCiphertextOverhead - ) - ), - from: remotePeerID - ) - } - #expect(recorder.decryptCalls.count == 4) - - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE]) - ) - handler.handleSessionAuthenticated(remotePeerID) - - // Only the three post-reset packets replay; the pre-panic packet does - // not survive into the replacement session. - #expect(recorder.decryptCalls.count == 7) - #expect(recorder.deliveries.count == 3) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce() - throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID( - publicKey: alice.getStaticPublicKeyData() - ) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - - let message1 = try alice.initiateHandshake(with: bobPeerID) - let message2 = try #require( - try bob.processHandshakeMessage( - from: alicePeerID, - message: message1 - ) - ) - let message3 = try #require( - try alice.processHandshakeMessage( - from: bobPeerID, - message: message2 - ) - ) - let typedPayload = Data([ - NoisePayloadType.privateMessage.rawValue, - 0xCA, 0xFE - ]) - let ciphertext = try alice.encrypt( - typedPayload, - for: bobPeerID - ) - - // Manager promotion has completed, but the serialized BLE callback is - // deliberately still behind this ciphertext. - _ = try bob.processHandshakeMessage( - from: alicePeerID, - message: message3 - ) - let recorder = Recorder() - recorder.transportGenerationReady = false - let handler = makeServiceBackedHandler( - service: bob, - localPeerID: bobPeerID, - recorder: recorder, - transportGenerationIsReady: { _ in - recorder.transportGenerationReady - } - ) - let packet = makeEncryptedPacket( - recipientID: Data(hexString: bobPeerID.id), - payload: ciphertext - ) - - handler.handleEncrypted(packet, from: alicePeerID) - #expect(recorder.deliveries.isEmpty) - #expect(recorder.clearedSessions.isEmpty) - - // The exact ciphertext must still authenticate, proving the readiness - // rejection happened before the receive nonce was consumed. - recorder.transportGenerationReady = true - handler.handleSessionAuthenticated(alicePeerID) - - #expect(recorder.decryptCalls.count == 2) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.type == .privateMessage) - #expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE])) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func ciphertextQueuedAheadOfRestoreCallbackDoesNotConsumeNonce() - throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let mallory = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID( - publicKey: alice.getStaticPublicKeyData() - ) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - - let initial1 = try alice.initiateHandshake(with: bobPeerID) - let initial2 = try #require( - try bob.processHandshakeMessage( - from: alicePeerID, - message: initial1 - ) - ) - let initial3 = try #require( - try alice.processHandshakeMessage( - from: bobPeerID, - message: initial2 - ) - ) - _ = try bob.processHandshakeMessage( - from: alicePeerID, - message: initial3 - ) - let typedPayload = Data([ - NoisePayloadType.privateMessage.rawValue, - 0xBE, 0xEF - ]) - let delayedCiphertext = try alice.encrypt( - typedPayload, - for: bobPeerID - ) - - let forged1 = try mallory.initiateHandshake(with: bobPeerID) - let forged2 = try #require( - try bob.processHandshakeMessage( - from: alicePeerID, - message: forged1 - ) - ) - let forged3 = try #require( - try mallory.processHandshakeMessage( - from: bobPeerID, - message: forged2 - ) - ) - #expect(throws: NoiseSessionError.peerIdentityMismatch) { - try bob.processHandshakeMessage( - from: alicePeerID, - message: forged3 - ) - } - #expect(bob.hasEstablishedSession(with: alicePeerID)) - - let recorder = Recorder() - recorder.transportGenerationReady = false - let handler = makeServiceBackedHandler( - service: bob, - localPeerID: bobPeerID, - recorder: recorder, - transportGenerationIsReady: { _ in - recorder.transportGenerationReady - } - ) - let packet = makeEncryptedPacket( - recipientID: Data(hexString: bobPeerID.id), - payload: delayedCiphertext - ) - - // Manager rollback is visible, while the BLE restore callback is - // deliberately still queued behind this ciphertext. - handler.handleEncrypted(packet, from: alicePeerID) - #expect(recorder.deliveries.isEmpty) - #expect(recorder.clearedSessions.isEmpty) - - recorder.transportGenerationReady = true - handler.handleSessionAuthenticated(alicePeerID) - - #expect(recorder.decryptCalls.count == 2) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.type == .privateMessage) - #expect(recorder.deliveries.first?.payload == Data([0xBE, 0xEF])) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func oversizedCiphertextCannotEvictEstablishedTransport() throws { - let pair = try establishedServices() - let recorder = Recorder() - recorder.transportGenerationReady = true - let handler = makeServiceBackedHandler( - service: pair.receiver, - localPeerID: pair.receiverPeerID, - recorder: recorder, - transportGenerationIsReady: { _ in - recorder.transportGenerationReady - } - ) - - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: pair.receiverPeerID.id), - payload: Data( - count: - NoiseSecurityConstants - .maxPrivateFileCiphertextSize + 1 - ) - ), - from: pair.senderPeerID - ) - #expect( - pair.receiver.hasEstablishedSession(with: pair.senderPeerID) - ) - #expect(recorder.clearedSessions.isEmpty) - - let valid = try pair.sender.encrypt( - Data([NoisePayloadType.privateMessage.rawValue, 0x01]), - for: pair.receiverPeerID - ) - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: pair.receiverPeerID.id), - payload: valid - ), - from: pair.senderPeerID - ) - - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.payload == Data([0x01])) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func forgedAuthenticationFailureCannotEvictEstablishedTransport() - throws { - let pair = try establishedServices() - let recorder = Recorder() - recorder.transportGenerationReady = true - let handler = makeServiceBackedHandler( - service: pair.receiver, - localPeerID: pair.receiverPeerID, - recorder: recorder, - transportGenerationIsReady: { _ in - recorder.transportGenerationReady - } - ) - let valid = try pair.sender.encrypt( - Data([NoisePayloadType.privateMessage.rawValue, 0x02]), - for: pair.receiverPeerID - ) - var forged = valid - forged[forged.index(before: forged.endIndex)] ^= 0xFF - - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: pair.receiverPeerID.id), - payload: forged - ), - from: pair.senderPeerID - ) - #expect( - pair.receiver.hasEstablishedSession(with: pair.senderPeerID) - ) - #expect(recorder.clearedSessions.isEmpty) - - // Authentication failure leaves nonce state untouched, so the exact - // original ciphertext remains valid. - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: pair.receiverPeerID.id), - payload: valid - ), - from: pair.senderPeerID - ) - - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.payload == Data([0x02])) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func replayCannotEvictEstablishedTransportOrBlockNextNonce() throws { - let pair = try establishedServices() - let recorder = Recorder() - recorder.transportGenerationReady = true - let handler = makeServiceBackedHandler( - service: pair.receiver, - localPeerID: pair.receiverPeerID, - recorder: recorder, - transportGenerationIsReady: { _ in - recorder.transportGenerationReady - } - ) - let first = try pair.sender.encrypt( - Data([NoisePayloadType.privateMessage.rawValue, 0x03]), - for: pair.receiverPeerID - ) - let firstPacket = makeEncryptedPacket( - recipientID: Data(hexString: pair.receiverPeerID.id), - payload: first - ) - - handler.handleEncrypted(firstPacket, from: pair.senderPeerID) - handler.handleEncrypted(firstPacket, from: pair.senderPeerID) - #expect( - pair.receiver.hasEstablishedSession(with: pair.senderPeerID) - ) - #expect(recorder.clearedSessions.isEmpty) - - let next = try pair.sender.encrypt( - Data([NoisePayloadType.privateMessage.rawValue, 0x04]), - for: pair.receiverPeerID - ) - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: pair.receiverPeerID.id), - payload: next - ), - from: pair.senderPeerID - ) - - #expect(recorder.deliveries.count == 2) - #expect(recorder.deliveries.map { $0.payload } == [ - Data([0x03]), Data([0x04]) - ]) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func rateLimitFailureCannotEvictEstablishedTransportOrConsumeNonce() - throws { - let pair = try establishedServices() - let recorder = Recorder() - recorder.transportGenerationReady = true - recorder.forcedServiceDecryptError = - NoiseSecurityError.rateLimitExceeded - let handler = makeServiceBackedHandler( - service: pair.receiver, - localPeerID: pair.receiverPeerID, - recorder: recorder, - transportGenerationIsReady: { _ in - recorder.transportGenerationReady - } - ) - let valid = try pair.sender.encrypt( - Data([NoisePayloadType.privateMessage.rawValue, 0x05]), - for: pair.receiverPeerID - ) - - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: pair.receiverPeerID.id), - payload: Data(repeating: 0xA5, count: 20) - ), - from: pair.senderPeerID - ) - #expect( - pair.receiver.hasEstablishedSession(with: pair.senderPeerID) - ) - #expect(recorder.clearedSessions.isEmpty) - - recorder.forcedServiceDecryptError = nil - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: pair.receiverPeerID.id), - payload: valid - ), - from: pair.senderPeerID - ) - - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.payload == Data([0x05])) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func maximumPrivateFileCiphertextIsEligibleForDeferredRetry() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - CryptoKitError.authenticationFailure - ) - let handler = makeHandler(recorder: recorder) - let encrypted = makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id), - payload: Data( - count: NoiseSecurityConstants.maxPrivateFileCiphertextSize - ) - ) - - handler.handleEncrypted(encrypted, from: remotePeerID) - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.privateFile.rawValue, 0x01]) - ) - handler.handleSessionAuthenticated(remotePeerID) - - #expect(recorder.decryptCalls.count == 2) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.type == .privateFile) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func oversizedEarlyCiphertextIsNotDeferred() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - CryptoKitError.authenticationFailure - ) - let handler = makeHandler(recorder: recorder) - let encrypted = makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id), - payload: Data( - count: - NoiseSecurityConstants.maxPrivateFileCiphertextSize + 1 - ) - ) - - handler.handleEncrypted(encrypted, from: remotePeerID) - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.delivered.rawValue, 0x01]) - ) - handler.handleSessionAuthenticated(remotePeerID) - - #expect(recorder.decryptCalls.count == 1) - #expect(recorder.deliveries.isEmpty) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func missingSessionCiphertextIsRetriedAfterResponderHandshakeCompletes() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - NoiseEncryptionError.sessionNotEstablished - ) - let handler = makeHandler(recorder: recorder) - let encrypted = makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id) - ) - - handler.handleEncrypted(encrypted, from: remotePeerID) - #expect(recorder.decryptCalls.count == 1) - #expect(recorder.initiatedHandshakes.isEmpty) - - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.delivered.rawValue, 0x01]) - ) - handler.handleSessionAuthenticated(remotePeerID) - - #expect(recorder.decryptCalls.count == 2) - #expect(recorder.deliveries.count == 1) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func lowNonceCiphertextIsRetriedAfterResponderHandshakeCompletes() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure(NoiseError.replayDetected) - let handler = makeHandler(recorder: recorder) - let encrypted = makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id) - ) - - handler.handleEncrypted(encrypted, from: remotePeerID) - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.readReceipt.rawValue, 0x02]) - ) - handler.handleSessionAuthenticated(remotePeerID) - - #expect(recorder.decryptCalls.count == 2) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.type == .readReceipt) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func invalidDeferredCiphertextDoesNotClearAuthenticatedSession() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - CryptoKitError.authenticationFailure - ) - let handler = makeHandler(recorder: recorder) - let encrypted = makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id) - ) - - handler.handleEncrypted(encrypted, from: remotePeerID) - recorder.awaitingResponderHandshake = false - handler.handleSessionAuthenticated(remotePeerID) - - #expect(recorder.decryptCalls.count == 2) - #expect(recorder.deliveries.isEmpty) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func nonCipherFailureDuringResponderHandshakeIsDroppedNotDeferred() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure(TestError()) - let handler = makeHandler(recorder: recorder) - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id) - ), - from: remotePeerID - ) - - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.delivered.rawValue, 0x01]) - ) - handler.handleSessionAuthenticated(remotePeerID) - - #expect(recorder.decryptCalls.count == 1) - #expect(recorder.deliveries.isEmpty) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func earlyCiphertextBufferIsBoundedPerPeer() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - CryptoKitError.authenticationFailure - ) - let handler = makeHandler(recorder: recorder) - - for index in 0..<5 { - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id), - timestamp: UInt64(900_000 + index) - ), - from: remotePeerID - ) - } - #expect(recorder.decryptCalls.count == 5) - - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.delivered.rawValue, 0x01]) - ) - handler.handleSessionAuthenticated(remotePeerID) - - #expect(recorder.decryptCalls.count == 9) - #expect(recorder.deliveries.count == 4) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func earlyCiphertextBufferIsBoundedGlobally() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - CryptoKitError.authenticationFailure - ) - let handler = makeHandler(recorder: recorder) - let peers = (1...33).map { - PeerID(str: String(format: "%016llx", UInt64($0))) - } - let packet = makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id) - ) - - for peerID in peers { - handler.handleEncrypted(packet, from: peerID) - } - #expect(recorder.decryptCalls.count == 33) - - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.delivered.rawValue, 0x01]) - ) - for peerID in peers { - handler.handleSessionAuthenticated(peerID) - } - - #expect(recorder.decryptCalls.count == 65) - #expect(recorder.deliveries.count == 32) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func earlyCiphertextBufferKeepsPrivateFileRoomAndByteBound() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - CryptoKitError.authenticationFailure - ) - let handler = makeHandler(recorder: recorder) - let peers = [ - PeerID(str: "0000000000000001"), - PeerID(str: "0000000000000002"), - PeerID(str: "0000000000000003") - ] - - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id), - payload: Data( - count: - NoiseSecurityConstants.maxPrivateFileCiphertextSize - ) - ), - from: peers[0] - ) - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id), - payload: Data(count: 256 * 1024) - ), - from: peers[1] - ) - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id), - payload: Data([0x01]) - ), - from: peers[2] - ) - - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.delivered.rawValue, 0x01]) - ) - for peerID in peers { - handler.handleSessionAuthenticated(peerID) - } - - #expect(recorder.decryptCalls.count == 5) - #expect(recorder.deliveries.count == 2) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func expiredEarlyCiphertextIsNotRetried() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - CryptoKitError.authenticationFailure - ) - let handler = makeHandler(recorder: recorder) - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id) - ), - from: remotePeerID - ) - - recorder.currentDate = - recorder.currentDate.addingTimeInterval( - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout - + 0.001 - ) - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.delivered.rawValue, 0x01]) - ) - handler.handleSessionAuthenticated(remotePeerID) - - #expect(recorder.decryptCalls.count == 1) - #expect(recorder.deliveries.isEmpty) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - @Test - func earlyCiphertextSurvivesResponderHandshakeWindow() { - let recorder = Recorder() - recorder.hasSession = true - recorder.awaitingResponderHandshake = true - recorder.decryptResult = .failure( - CryptoKitError.authenticationFailure - ) - let handler = makeHandler(recorder: recorder) - handler.handleEncrypted( - makeEncryptedPacket( - recipientID: Data(hexString: localPeerID.id) - ), - from: remotePeerID - ) - - recorder.currentDate = - recorder.currentDate.addingTimeInterval( - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout - - 0.001 - ) - recorder.awaitingResponderHandshake = false - recorder.decryptResult = .success( - Data([NoisePayloadType.delivered.rawValue, 0x01]) - ) - handler.handleSessionAuthenticated(remotePeerID) - - #expect(recorder.decryptCalls.count == 2) - #expect(recorder.deliveries.count == 1) - #expect(recorder.clearedSessions.isEmpty) - #expect(recorder.initiatedHandshakes.isEmpty) - } - - private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket { - BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: Data(hexString: remotePeerID.id) ?? Data(), - recipientID: recipientID, - timestamp: 900_000, - payload: Data([0x01, 0x02, 0x03]), - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - } - - private func makeEncryptedPacket( - recipientID: Data?, - timestamp: UInt64 = 900_000, - payload: Data = Data([0xC0, 0xFF, 0xEE]) - ) -> BitchatPacket { - BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: remotePeerID.id) ?? Data(), - recipientID: recipientID, - timestamp: timestamp, - payload: payload, - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - } -} diff --git a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift b/bitchatTests/Services/BLENoisePayloadFactoryTests.swift deleted file mode 100644 index c1722f8a..00000000 --- a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import Testing -import BitFoundation -@testable import bitchat - -struct BLENoisePayloadFactoryTests { - @Test - func privateMessagePayloadPrefixesTLVWithNoiseType() throws { - let payload = try #require(BLENoisePayloadFactory.privateMessage(content: "secret", messageID: "pm-1")) - - #expect(payload.first == NoisePayloadType.privateMessage.rawValue) - - let packet = try #require(PrivateMessagePacket.decode(from: Data(payload.dropFirst()))) - #expect(packet.messageID == "pm-1") - #expect(packet.content == "secret") - } - - @Test - func receiptPayloadsUseMessageIDBytes() { - let read = BLENoisePayloadFactory.readReceipt(originalMessageID: "read-id") - let delivered = BLENoisePayloadFactory.delivered(messageID: "delivered-id") - - #expect(read.first == NoisePayloadType.readReceipt.rawValue) - #expect(String(data: Data(read.dropFirst()), encoding: .utf8) == "read-id") - #expect(delivered.first == NoisePayloadType.delivered.rawValue) - #expect(String(data: Data(delivered.dropFirst()), encoding: .utf8) == "delivered-id") - } - - @Test - func typedPayloadKeepsOpaqueDataUnchanged() { - let payload = BLENoisePayloadFactory.typedPayload(.verifyChallenge, payload: Data([0xCA, 0xFE])) - - #expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE])) - } - - @Test - func privateFilePayloadPrefixesCanonicalFilePacket() throws { - let content = Data("%PDF-secret".utf8) - let file = BitchatFilePacket( - fileName: "secret.pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ) - - let payload = try #require(BLENoisePayloadFactory.privateFile(file)) - - #expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value") - let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst()))) - #expect(decoded.fileName == "secret.pdf") - #expect(decoded.mimeType == "application/pdf") - #expect(decoded.content == content) - } - - @Test - func androidB7f0b33PrivateFilePlaintextFixtureIsByteCompatible() throws { - // Runtime-emitted by Android commit b7f0b33d from - // BitchatFilePacket("a.txt", 3, "text/plain", [01, 02, 03]) and - // NoisePayload(type = FILE_TRANSFER, data = file.encode()).encode(). - let fixtureHex = "20010005612e7478740200040000000303000a746578742f706c61696e0400000003010203" - let fixture = try #require(Data(hexString: fixtureHex)) - - let typed = try #require(NoisePayload.decode(fixture)) - #expect(typed.type == .privateFile) - let file = try #require(BitchatFilePacket.decode(typed.data)) - #expect(file.fileName == "a.txt") - #expect(file.fileSize == 3) - #expect(file.mimeType == "text/plain") - #expect(file.content == Data([0x01, 0x02, 0x03])) - #expect(BLENoisePayloadFactory.privateFile(file) == fixture) - } - - @Test - func prereleasePrivateFileTypeCanonicalizesOnDecode() throws { - let encoded = Data([NoisePayloadType.prereleasePrivateFileRawValue, 0xCA, 0xFE]) - let decoded = try #require(NoisePayload.decode(encoded)) - - #expect(decoded.type == .privateFile) - #expect(decoded.data == Data([0xCA, 0xFE])) - #expect(decoded.encode().first == 0x20) - } - - @Test - func authenticatedPeerStateUsesPermanent0x21Type() throws { - let state = AuthenticatedPeerStatePacket( - capabilities: .privateMedia, - signingPublicKey: Data(repeating: 0x77, count: 32) - ) - let encoded = try #require(BLENoisePayloadFactory.authenticatedPeerState(state)) - - #expect(encoded.first == 0x21) - #expect(AuthenticatedPeerStatePacket.decode(from: Data(encoded.dropFirst())) == state) - } -} diff --git a/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift deleted file mode 100644 index 8e8f025b..00000000 --- a/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift +++ /dev/null @@ -1,118 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE Noise reconnect policy") -struct BLENoiseReconnectPolicyTests { - @Test("Revalidation requires a cached session and no authenticated link") - func revalidationPreconditions() { - var policy = BLENoiseReconnectPolicy() - let link = BLEIngressLinkID.peripheral("peripheral-a") - let now = Date(timeIntervalSince1970: 1_000) - - let withoutSession = policy.shouldRevalidate( - on: link, - hasEstablishedSession: false, - isNoiseAuthenticatedLink: false, - hasAuthenticatedPeerLink: false, - now: now - ) - #expect(!withoutSession) - let authenticated = policy.shouldRevalidate( - on: link, - hasEstablishedSession: true, - isNoiseAuthenticatedLink: true, - hasAuthenticatedPeerLink: true, - now: now - ) - #expect(!authenticated) - let eligible = policy.shouldRevalidate( - on: link, - hasEstablishedSession: true, - isNoiseAuthenticatedLink: false, - hasAuthenticatedPeerLink: false, - now: now - ) - #expect(eligible) - } - - @Test("Revalidation is once per link epoch or after sixty seconds") - func revalidationIsBoundPerLinkEpoch() { - var policy = BLENoiseReconnectPolicy() - let link = BLEIngressLinkID.central("central-a") - let start = Date(timeIntervalSince1970: 2_000) - - let initial = policy.shouldRevalidate( - on: link, - hasEstablishedSession: true, - isNoiseAuthenticatedLink: false, - hasAuthenticatedPeerLink: false, - now: start - ) - #expect(initial) - let duringCooldown = policy.shouldRevalidate( - on: link, - hasEstablishedSession: true, - isNoiseAuthenticatedLink: false, - hasAuthenticatedPeerLink: false, - now: start.addingTimeInterval(59.999) - ) - #expect(!duringCooldown) - let afterCooldown = policy.shouldRevalidate( - on: link, - hasEstablishedSession: true, - isNoiseAuthenticatedLink: false, - hasAuthenticatedPeerLink: false, - now: start.addingTimeInterval(60) - ) - #expect(afterCooldown) - - policy.endLinkEpoch(link) - let nextEpoch = policy.shouldRevalidate( - on: link, - hasEstablishedSession: true, - isNoiseAuthenticatedLink: false, - hasAuthenticatedPeerLink: false, - now: start.addingTimeInterval(60.001) - ) - #expect(nextEpoch) - } - - @Test("An authenticated sibling suppresses redundant reconnect") - func authenticatedSiblingSuppressesReconnect() { - var policy = BLENoiseReconnectPolicy() - let link = BLEIngressLinkID.peripheral("unproven-sibling") - let start = Date(timeIntervalSince1970: 3_000) - - let suppressed = policy.shouldRevalidate( - on: link, - hasEstablishedSession: true, - isNoiseAuthenticatedLink: false, - hasAuthenticatedPeerLink: true, - now: start - ) - #expect(!suppressed) - let eligible = policy.shouldRevalidate( - on: link, - hasEstablishedSession: true, - isNoiseAuthenticatedLink: false, - hasAuthenticatedPeerLink: false, - now: start - ) - #expect(eligible) - } - - @Test("Reserved replacement bit is not advertised") - func reservedReplacementBitIsNotAdvertised() { - #expect( - !PeerCapabilities.localSupported.contains( - .nonDestructiveNoiseReplacement - ) - ) - #expect(PeerCapabilities.localSupported.contains(.privateMedia)) - #expect( - PeerCapabilities.localSupported.contains(.privateMediaReceipts) - ) - } -} diff --git a/bitchatTests/Services/BLENoiseSessionQueuesTests.swift b/bitchatTests/Services/BLENoiseSessionQueuesTests.swift deleted file mode 100644 index a933118e..00000000 --- a/bitchatTests/Services/BLENoiseSessionQueuesTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLENoiseSessionQueuesTests { - @Test - func privateMessagesDrainInPeerOrderAndClearOnlyThatPeer() { - let firstPeer = PeerID(str: "aaaaaaaaaaaaaaaa") - let secondPeer = PeerID(str: "bbbbbbbbbbbbbbbb") - var queues = BLENoiseSessionQueues() - - queues.appendPrivateMessage(content: "first", messageID: "m1", for: firstPeer) - queues.appendPrivateMessage(content: "second", messageID: "m2", for: firstPeer) - queues.appendPrivateMessage(content: "other", messageID: "m3", for: secondPeer) - - let drained = queues.takePrivateMessages(for: firstPeer) - - #expect(drained == [ - BLEPendingPrivateMessage(content: "first", messageID: "m1"), - BLEPendingPrivateMessage(content: "second", messageID: "m2") - ]) - #expect(queues.takePrivateMessages(for: firstPeer).isEmpty) - #expect(queues.takePrivateMessages(for: secondPeer) == [ - BLEPendingPrivateMessage(content: "other", messageID: "m3") - ]) - } - - @Test - func prependPrivateMessagesRestoresFailedMessagesAheadOfNewerOnes() { - let peerID = PeerID(str: "aaaaaaaaaaaaaaaa") - var queues = BLENoiseSessionQueues() - - queues.appendPrivateMessage(content: "new", messageID: "m2", for: peerID) - queues.prependPrivateMessages([ - BLEPendingPrivateMessage(content: "retry", messageID: "m1") - ], for: peerID) - - #expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1", "m2"]) - } - - @Test - func typedPayloadsDrainIndependentlyFromPrivateMessages() { - let peerID = PeerID(str: "aaaaaaaaaaaaaaaa") - var queues = BLENoiseSessionQueues() - - queues.appendPrivateMessage(content: "queued", messageID: "m1", for: peerID) - queues.appendTypedPayload(Data([0x01]), for: peerID) - queues.appendTypedPayload(Data([0x02]), for: peerID) - - #expect(queues.takeTypedPayloads(for: peerID) == [ - BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil), - BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil) - ]) - #expect(queues.takeTypedPayloads(for: peerID).isEmpty) - #expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"]) - } - - @Test - func removeAllClearsBothQueueTypes() { - let peerID = PeerID(str: "aaaaaaaaaaaaaaaa") - var queues = BLENoiseSessionQueues() - - queues.appendPrivateMessage(content: "queued", messageID: "m1", for: peerID) - queues.appendTypedPayload(Data([0x01]), for: peerID) - queues.removeAll() - - #expect(queues.isEmpty) - } - - @Test - func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() { - let peerID = PeerID(str: "aaaaaaaaaaaaaaaa") - var queues = BLENoiseSessionQueues() - - queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID) - queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID) - - let removed = queues.removeTypedPayload(transferId: "media-1") - let removedAgain = queues.removeTypedPayload(transferId: "media-1") - #expect(removed) - #expect(!removedAgain) - #expect(queues.takeTypedPayloads(for: peerID) == [ - BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil) - ]) - } -} diff --git a/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift b/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift deleted file mode 100644 index c58d5bf5..00000000 --- a/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift +++ /dev/null @@ -1,188 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE outbound fragment planner tests") -struct BLEOutboundFragmentPlannerTests { - @Test("planner splits packets and preserves reassembled payload") - func plannerSplitsAndReassemblesPacket() throws { - let packet = makePacket(payload: makePayload(count: 384)) - let request = BLEOutboundFragmentTransferRequest( - packet: packet, - pad: false, - maxChunk: 128, - directedPeer: nil, - transferId: nil - ) - - let plan = try #require(BLEOutboundFragmentPlanner.makePlan( - for: request, - defaultChunkSize: 256, - bleMaxMTU: 512, - fragmentID: Data(repeating: 0xA1, count: 8) - )) - let headers = try plan.fragmentPackets.map { try #require(BLEFragmentHeader(packet: $0)) } - let reassembled = headers.reduce(into: Data()) { data, header in - data.append(header.fragmentData) - } - let decoded = try #require(BinaryProtocol.decode(reassembled)) - - #expect(plan.fragmentVersion == 1) - #expect(plan.chunkSize == 128) - #expect(plan.spacingMs == TransportConfig.bleFragmentSpacingMs) - #expect(headers.map(\.index) == Array(0.. BLEOutboundFragmentPlan { - let packet = BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: "0011223344556677") ?? Data(), - recipientID: Data(hexString: "8877665544332211"), - timestamp: 0x0102030405, - payload: Data(maxPayload.prefix(payloadCount)), - signature: nil, - ttl: 3, - version: 2 - ) - return try #require(BLEOutboundFragmentPlanner.makePlan( - for: BLEOutboundFragmentTransferRequest( - packet: packet, - pad: false, - maxChunk: nil, - directedPeer: PeerID(str: "8877665544332211"), - transferId: "boundary" - ), - defaultChunkSize: TransportConfig.bleDefaultFragmentSize, - bleMaxMTU: 512, - fragmentID: Data(repeating: 0xD4, count: 8) - )) - } - - func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan { - var low = 1 - var high = maxPayload.count - while low < high { - let mid = low + (high - low) / 2 - if try plan(payloadCount: mid).totalFragments >= target { - high = mid - } else { - low = mid + 1 - } - } - return try plan(payloadCount: low) - } - - let at256 = try firstPlan(withAtLeast: 256) - let at257 = try firstPlan(withAtLeast: 257) - - #expect(at256.totalFragments == 256) - #expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256)) - #expect(at257.totalFragments == 257) - #expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257)) - } - - private func makePacket( - payload: Data, - route: [Data]? = nil, - isRSR: Bool = false - ) -> BitchatPacket { - BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]), - recipientID: nil, - timestamp: 0x0102030405, - payload: payload, - signature: nil, - ttl: 3, - route: route, - isRSR: isRSR - ) - } - - private func makePayload(count: Int, seed: UInt64 = 0xABCD_1234) -> Data { - var state = seed - return Data((0..> 32) - }) - } -} diff --git a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift deleted file mode 100644 index 27670408..00000000 --- a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift +++ /dev/null @@ -1,350 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEOutboundFragmentTransferSchedulerTests { - @Test - func submitStartsPublicMessageWithoutTransferReservation() { - var scheduler = BLEOutboundFragmentTransferScheduler() - let request = makeRequest(type: MessageType.message.rawValue, transferId: nil) - - let result = scheduler.submit(request, maxConcurrentTransfers: 1) - - if case let .start(_, reservedTransferId) = result { - #expect(reservedTransferId == nil) - #expect(scheduler.activeCount == 0) - #expect(scheduler.pendingCount == 0) - } else { - Issue.record("Expected non-file fragments to start without reserving a transfer slot") - } - } - - @Test - func explicitTransferIDReservesEncryptedPrivateFileFragments() { - var scheduler = BLEOutboundFragmentTransferScheduler() - let request = makeRequest( - type: MessageType.noiseEncrypted.rawValue, - transferId: "private-media" - ) - - let result = scheduler.submit(request, maxConcurrentTransfers: 1) - - if case let .start(_, reservedTransferId) = result { - #expect(reservedTransferId == "private-media") - #expect(scheduler.activeCount == 1) - } else { - Issue.record("Expected encrypted private media to reserve its progress slot") - } - } - - @Test - func submitQueuesFileTransferWhenSlotsAreFull() { - var scheduler = BLEOutboundFragmentTransferScheduler() - let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "first") - let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "second") - - guard case let .start(_, firstReservation?) = scheduler.submit(first, maxConcurrentTransfers: 1) else { - Issue.record("Expected first file transfer to reserve a slot") - return - } - #expect(firstReservation == "first") - - let result = scheduler.submit(second, maxConcurrentTransfers: 1) - - if case let .queued(_, transferId, position) = result { - #expect(transferId == "second") - #expect(position == .back) - #expect(scheduler.activeCount == 1) - #expect(scheduler.pendingCount == 1) - } else { - Issue.record("Expected second file transfer to queue while slots are full") - } - } - - @Test - func strictDirectTransferIsRejectedWithoutBeingQueuedWhenSlotsAreFull() { - var scheduler = BLEOutboundFragmentTransferScheduler() - let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active") - let strict = makeRequest( - type: MessageType.fileTransfer.rawValue, - transferId: "strict", - requireDirectPeerLink: true - ) - - guard case .start = scheduler.submit(active, maxConcurrentTransfers: 1) else { - Issue.record("Expected active transfer to reserve the only slot") - return - } - - let result = scheduler.submit(strict, maxConcurrentTransfers: 1) - - if case let .rejectedStrict(request, transferId) = result { - #expect(request.requireDirectPeerLink) - #expect(transferId == "strict") - #expect(scheduler.activeCount == 1) - #expect(scheduler.pendingCount == 0) - } else { - Issue.record("Expected strict transfer to reject instead of entering the pending queue") - } - } - - @Test - func submitQueuesDuplicateActiveTransferAtFront() { - var scheduler = BLEOutboundFragmentTransferScheduler() - let request = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "same") - - _ = scheduler.submit(request, maxConcurrentTransfers: 2) - let result = scheduler.submit(request, maxConcurrentTransfers: 2) - - if case let .queued(_, transferId, position) = result { - #expect(transferId == "same") - #expect(position == .front) - #expect(scheduler.activeCount == 1) - #expect(scheduler.pendingCount == 1) - } else { - Issue.record("Expected duplicate active transfer to queue at the front") - } - } - - @Test - func resendWithoutTransferIdOfActiveBroadcastContentIsDropped() { - // Field bug: a gossip-sync replay re-fragmented a 41KB voice file - // that was still being broadcast, sending two complete fragment - // streams. The resend path has no explicit transferId; drop it while - // a covering transfer of the same bytes is in flight. - var scheduler = BLEOutboundFragmentTransferScheduler() - let original = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "app-id", payload: "voice-file") - let resend = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: nil, payload: "voice-file") - - _ = scheduler.submit(original, maxConcurrentTransfers: 2) - let result = scheduler.submit(resend, maxConcurrentTransfers: 2) - - if case let .droppedDuplicate(_, activeTransferId) = result { - #expect(activeTransferId == "app-id") - #expect(scheduler.activeCount == 1) - #expect(scheduler.pendingCount == 0) - } else { - Issue.record("Expected the transferId-less resend of in-flight broadcast content to be dropped") - } - } - - @Test - func directedResendToAnUncoveredAudienceStillRuns() { - // The in-flight copy is directed to one peer; a resend of the same - // bytes to a different peer is not redundant. - var scheduler = BLEOutboundFragmentTransferScheduler() - let toFirstPeer = makeRequest( - type: MessageType.fileTransfer.rawValue, - transferId: "app-id", - payload: "shared-file", - directedPeer: PeerID(str: "1122334455667788") - ) - let toSecondPeer = makeRequest( - type: MessageType.fileTransfer.rawValue, - transferId: nil, - payload: "shared-file", - directedPeer: PeerID(str: "8877665544332211") - ) - - _ = scheduler.submit(toFirstPeer, maxConcurrentTransfers: 2) - let result = scheduler.submit(toSecondPeer, maxConcurrentTransfers: 2) - - if case .start = result { - #expect(scheduler.activeCount == 2) - } else { - Issue.record("Expected a resend directed at an uncovered peer to start") - } - } - - @Test - func explicitTransferIdSendIsNeverDroppedAsDuplicate() { - // App-initiated sends carry a transferId the progress UI tracks; - // only transferId-less resend paths are deduplicated. - var scheduler = BLEOutboundFragmentTransferScheduler() - let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "send-1", payload: "same-bytes") - let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "send-2", payload: "same-bytes") - - _ = scheduler.submit(first, maxConcurrentTransfers: 2) - let result = scheduler.submit(second, maxConcurrentTransfers: 2) - - if case let .start(_, reservedTransferId?) = result { - #expect(reservedTransferId == "send-2") - } else { - Issue.record("Expected an explicit-transferId send to run despite identical content") - } - } - - @Test - func duplicateOfPendingContentIsDroppedAtSubmit() { - // A duplicate must not queue behind a pending copy of the same - // content and resend the whole file when the slot frees. - var scheduler = BLEOutboundFragmentTransferScheduler() - let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active", payload: "file-a") - let queuedContent = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "waiting", payload: "file-b") - let queuedDuplicate = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: nil, payload: "file-b") - - _ = scheduler.submit(active, maxConcurrentTransfers: 1) - _ = scheduler.submit(queuedContent, maxConcurrentTransfers: 1) - - if case .droppedDuplicate = scheduler.submit(queuedDuplicate, maxConcurrentTransfers: 1) { - // Dropped immediately: the pending "waiting" transfer covers it. - } else { - Issue.record("Expected the duplicate of pending content to be dropped at submit") - } - #expect(scheduler.pendingCount == 1) - } - - @Test - func resendAfterCompletionIsAllowed() { - // Duplicate suppression only covers in-flight transfers: a peer that - // requests the file after the stream completed must get a resend. - var scheduler = BLEOutboundFragmentTransferScheduler() - let original = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "app-id", payload: "voice-file") - let resend = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: nil, payload: "voice-file") - - _ = scheduler.submit(original, maxConcurrentTransfers: 1) - let didActivate = scheduler.activateReservedTransfer(id: "app-id", totalFragments: 1, workItems: []) - #expect(didActivate) - #expect(scheduler.markFragmentSent(transferId: "app-id") == .complete(sentFragments: 1, totalFragments: 1)) - - if case .start = scheduler.submit(resend, maxConcurrentTransfers: 1) { - #expect(scheduler.activeCount == 1) - } else { - Issue.record("Expected a resend after completion to start") - } - } - - @Test - func cancelActiveTransferReturnsScheduledWorkItems() { - var scheduler = BLEOutboundFragmentTransferScheduler() - let request = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active") - _ = scheduler.submit(request, maxConcurrentTransfers: 1) - let workItem = DispatchWorkItem {} - - let didActivate = scheduler.activateReservedTransfer(id: "active", totalFragments: 2, workItems: [workItem]) - #expect(didActivate) - - if case let .active(transferId, workItems) = scheduler.cancelTransfer("active") { - #expect(transferId == "active") - #expect(workItems.count == 1) - #expect(scheduler.activeCount == 0) - } else { - Issue.record("Expected active transfer cancellation to return its work items") - } - } - - @Test - func completedTransferFreesSlotForPendingTransfer() { - var scheduler = BLEOutboundFragmentTransferScheduler() - let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "first") - let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "second") - - _ = scheduler.submit(first, maxConcurrentTransfers: 1) - let didActivate = scheduler.activateReservedTransfer(id: "first", totalFragments: 2, workItems: []) - #expect(didActivate) - _ = scheduler.submit(second, maxConcurrentTransfers: 1) - - #expect(scheduler.markFragmentSent(transferId: "first") == .progress(sentFragments: 1, totalFragments: 2)) - #expect(scheduler.markFragmentSent(transferId: "first") == .complete(sentFragments: 2, totalFragments: 2)) - - let starts = scheduler.reservePendingStarts(maxConcurrentTransfers: 1) - #expect(starts.count == 1) - - if case let .start(_, reservedTransferId?) = starts.first { - #expect(reservedTransferId == "second") - #expect(scheduler.activeCount == 1) - #expect(scheduler.pendingCount == 0) - } else { - Issue.record("Expected pending transfer to reserve the freed slot") - } - } - - @Test - func blockedDuplicateAtFrontOfQueueDoesNotStarveALaterUnrelatedPendingTransfer() { - // Bug: reservePendingStarts spent the slot budget on a pending - // request the moment it was dequeued, before checking whether that - // request would actually be admitted. A resend of still-active - // content sitting at the front of the queue therefore consumed a - // slot even though it was deferred back to the queue rather than - // started -- starving an unrelated, genuinely startable transfer - // right behind it until some other transfer happened to complete. - var scheduler = BLEOutboundFragmentTransferScheduler() - let t1 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t1", payload: "file-a") - let t2 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t2", payload: "file-b") - let dupT1 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t1", payload: "file-a") - let unrelated = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t3", payload: "file-c") - - _ = scheduler.submit(t1, maxConcurrentTransfers: 2) - _ = scheduler.submit(t2, maxConcurrentTransfers: 2) - #expect(scheduler.activeCount == 2) - - // Both slots are full, so a resend of "t1" (still active) and an - // unrelated transfer both land in the pending queue, in that order. - _ = scheduler.submit(dupT1, maxConcurrentTransfers: 2) - _ = scheduler.submit(unrelated, maxConcurrentTransfers: 2) - #expect(scheduler.pendingCount == 2) - - // "t2" finishes; "t1" stays active, so the queued "t1" resend at the - // front of the queue is still blocked when we reserve pending starts. - let didActivate = scheduler.activateReservedTransfer(id: "t2", totalFragments: 1, workItems: []) - #expect(didActivate) - #expect(scheduler.markFragmentSent(transferId: "t2") == .complete(sentFragments: 1, totalFragments: 1)) - - let starts = scheduler.reservePendingStarts(maxConcurrentTransfers: 2) - - let startedTransferIds: [String] = starts.compactMap { - if case let .start(_, reservedTransferId) = $0 { return reservedTransferId } - return nil - } - #expect(startedTransferIds == ["t3"], "the unrelated pending transfer must start in the same pass despite the blocked front item") - #expect(scheduler.activeCount == 2, "t1 (still running) and the newly-started t3") - #expect(scheduler.pendingCount == 1, "only the blocked t1 resend remains queued") - } - - @Test - func removeAllReturnsActiveWorkItemsAndDropsPendingTransfers() { - var scheduler = BLEOutboundFragmentTransferScheduler() - let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active") - let pending = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "pending") - let workItem = DispatchWorkItem {} - - _ = scheduler.submit(active, maxConcurrentTransfers: 1) - let didActivate = scheduler.activateReservedTransfer(id: "active", totalFragments: 1, workItems: [workItem]) - #expect(didActivate) - _ = scheduler.submit(pending, maxConcurrentTransfers: 1) - - let removed = scheduler.removeAll() - - #expect(removed.count == 1) - #expect(removed.first?.id == "active") - #expect(removed.first?.workItems.count == 1) - #expect(scheduler.activeCount == 0) - #expect(scheduler.pendingCount == 0) - } - - private func makeRequest( - type: UInt8, - transferId: String?, - payload: String? = nil, - directedPeer: PeerID? = nil, - requireDirectPeerLink: Bool = false - ) -> BLEOutboundFragmentTransferRequest { - BLEOutboundFragmentTransferRequest( - packet: BitchatPacket( - type: type, - senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]), - recipientID: nil, - timestamp: 0x0102030405, - payload: Data((payload ?? transferId ?? "payload").utf8), - signature: nil, - ttl: 3 - ), - pad: false, - maxChunk: nil, - directedPeer: directedPeer, - transferId: transferId, - requireDirectPeerLink: requireDirectPeerLink - ) - } -} diff --git a/bitchatTests/Services/BLEOutboundLinkPlannerTests.swift b/bitchatTests/Services/BLEOutboundLinkPlannerTests.swift deleted file mode 100644 index e57d0140..00000000 --- a/bitchatTests/Services/BLEOutboundLinkPlannerTests.swift +++ /dev/null @@ -1,227 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEOutboundLinkPlannerTests { - @Test - func recipientIDSuppliesDirectedHintAndUsesAllAvailableLinks() throws { - let recipient = PeerID(str: "1122334455667788") - let packet = makePacket(type: .noiseEncrypted, recipient: recipient) - - let plan = BLEOutboundLinkPlanner.plan( - packet: packet, - dataCount: 32, - peripheralIDs: ["p1", "p2"], - peripheralWriteLimits: [128, 128], - centralIDs: ["c1"], - centralNotifyLimits: [128], - ingressRecord: nil, - excludedLinks: [], - directedOnlyPeer: nil - ) - - #expect(plan.directedPeerHint == recipient) - #expect(plan.fragmentChunkSize == nil) - #expect(plan.selectedLinks.peripheralIDs == Set(["p1", "p2"])) - #expect(plan.selectedLinks.centralIDs == Set(["c1"])) - #expect(!plan.shouldSpoolDirectedPacket) - } - - @Test - func oversizedNonFragmentPacketPlansFragmentationFromSmallestLinkLimit() { - let packet = makePacket(type: .message) - let smallestLimit = 96 - - let plan = BLEOutboundLinkPlanner.plan( - packet: packet, - dataCount: 160, - peripheralIDs: ["p1"], - peripheralWriteLimits: [128], - centralIDs: ["c1"], - centralNotifyLimits: [smallestLimit], - ingressRecord: nil, - excludedLinks: [], - directedOnlyPeer: nil - ) - - #expect(plan.fragmentChunkSize == BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: smallestLimit)) - #expect(plan.selectedLinks.peripheralIDs == Set(["p1"])) - #expect(plan.selectedLinks.centralIDs == Set(["c1"])) - #expect(!plan.shouldSpoolDirectedPacket) - } - - @Test - func oversizedDirectedCourierDoesNotUseUnrelatedPeersMTUAsHandoffSuccess() { - let recipient = PeerID(str: "1122334455667788") - let unrelated = PeerID(str: "8877665544332211") - let packet = makePacket(type: .courierEnvelope, recipient: recipient) - - let plan = BLEOutboundLinkPlanner.plan( - packet: packet, - dataCount: 512, - peripheralIDs: ["unrelated-link"], - peripheralWriteLimits: [64], - centralIDs: [], - centralNotifyLimits: [], - ingressRecord: nil, - excludedLinks: [], - peripheralPeerBindings: ["unrelated-link": unrelated], - directedOnlyPeer: recipient, - requireDirectPeerLink: true - ) - - #expect(plan.directedPeerHint == recipient) - #expect(plan.fragmentChunkSize == nil) - #expect(plan.selectedLinks.peripheralIDs.isEmpty) - #expect(plan.selectedLinks.centralIDs.isEmpty) - #expect(!plan.shouldSpoolDirectedPacket) - } - - @Test - func fragmentPacketsBypassFragmentationPlanning() { - let packet = makePacket(type: .fragment) - - let plan = BLEOutboundLinkPlanner.plan( - packet: packet, - dataCount: 512, - peripheralIDs: ["p1"], - peripheralWriteLimits: [64], - centralIDs: ["c1"], - centralNotifyLimits: [64], - ingressRecord: nil, - excludedLinks: [], - directedOnlyPeer: nil - ) - - #expect(plan.fragmentChunkSize == nil) - #expect(plan.selectedLinks.peripheralIDs == Set(["p1"])) - #expect(plan.selectedLinks.centralIDs == Set(["c1"])) - } - - @Test - func directedEncryptedPacketSpoolsWhenNoLinksAreAvailable() { - let recipient = PeerID(str: "1122334455667788") - let packet = makePacket(type: .noiseEncrypted, recipient: recipient) - - let plan = BLEOutboundLinkPlanner.plan( - packet: packet, - dataCount: 32, - peripheralIDs: [], - peripheralWriteLimits: [], - centralIDs: [], - centralNotifyLimits: [], - ingressRecord: nil, - excludedLinks: [], - directedOnlyPeer: recipient - ) - - #expect(plan.directedPeerHint == recipient) - #expect(plan.shouldSpoolDirectedPacket) - } - - @Test - func bridgeCourierPacketDoesNotTurnProcessLocalSpoolIntoHandoffSuccess() { - let recipient = PeerID(str: "1122334455667788") - let packet = makePacket(type: .courierEnvelope, recipient: recipient) - - let plan = BLEOutboundLinkPlanner.plan( - packet: packet, - dataCount: 32, - peripheralIDs: [], - peripheralWriteLimits: [], - centralIDs: [], - centralNotifyLimits: [], - ingressRecord: nil, - excludedLinks: [], - directedOnlyPeer: recipient - ) - - #expect(plan.selectedLinks.peripheralIDs.isEmpty) - #expect(plan.selectedLinks.centralIDs.isEmpty) - #expect(!plan.shouldSpoolDirectedPacket) - } - - @Test - func publicBroadcastDoesNotSpoolWhenNoLinksAreAvailable() { - let packet = makePacket(type: .message) - - let plan = BLEOutboundLinkPlanner.plan( - packet: packet, - dataCount: 32, - peripheralIDs: [], - peripheralWriteLimits: [], - centralIDs: [], - centralNotifyLimits: [], - ingressRecord: nil, - excludedLinks: [], - directedOnlyPeer: nil - ) - - #expect(plan.directedPeerHint == nil) - #expect(!plan.shouldSpoolDirectedPacket) - } - - @Test - func directAnnounceBypassesDuplicateLinkCollapseButRelayedAnnounceDoesNot() { - let peer = PeerID(str: "1122334455667788") - let bindings: [String: PeerID] = ["p1": peer, "p2": peer] - - let direct = BLEOutboundLinkPlanner.plan( - packet: makePacket(type: .announce), - dataCount: 32, - peripheralIDs: ["p1", "p2"], - peripheralWriteLimits: [128, 128], - centralIDs: [], - centralNotifyLimits: [], - ingressRecord: nil, - excludedLinks: [], - peripheralPeerBindings: bindings, - preferredPeripheralPerPeer: [peer: "p1"], - directedOnlyPeer: nil - ) - // A direct announce is the link-binding packet: it must reach every - // live link, including a peer's duplicate connections. - #expect(direct.selectedLinks.peripheralIDs == Set(["p1", "p2"])) - - let relayed = BLEOutboundLinkPlanner.plan( - packet: makePacket(type: .announce, ttl: TransportConfig.messageTTLDefault - 1), - dataCount: 32, - peripheralIDs: ["p1", "p2"], - peripheralWriteLimits: [128, 128], - centralIDs: [], - centralNotifyLimits: [], - ingressRecord: nil, - excludedLinks: [], - peripheralPeerBindings: bindings, - preferredPeripheralPerPeer: [peer: "p1"], - directedOnlyPeer: nil - ) - // Relayed announces keep the per-peer collapse (relay hygiene). - #expect(relayed.selectedLinks.peripheralIDs == Set(["p1"])) - } - - @Test - func minimumLinkLimitUsesTheSmallestPresentRoleLimit() { - #expect(BLEOutboundLinkPlanner.minimumLinkLimit(peripheralWriteLimits: [80, 120], centralNotifyLimits: []) == 80) - #expect(BLEOutboundLinkPlanner.minimumLinkLimit(peripheralWriteLimits: [], centralNotifyLimits: [60, 90]) == 60) - #expect(BLEOutboundLinkPlanner.minimumLinkLimit(peripheralWriteLimits: [], centralNotifyLimits: []) == nil) - } - - private func makePacket( - type: MessageType, - sender: PeerID = PeerID(str: "8877665544332211"), - recipient: PeerID? = nil, - ttl: UInt8 = TransportConfig.messageTTLDefault - ) -> BitchatPacket { - BitchatPacket( - type: type.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: recipient.flatMap { Data(hexString: $0.id) }, - timestamp: 1234, - payload: Data([0x01, 0x02]), - signature: nil, - ttl: ttl - ) - } -} diff --git a/bitchatTests/Services/BLEOutboundNotificationBufferTests.swift b/bitchatTests/Services/BLEOutboundNotificationBufferTests.swift deleted file mode 100644 index f440ba3c..00000000 --- a/bitchatTests/Services/BLEOutboundNotificationBufferTests.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -struct BLEOutboundNotificationBufferTests { - @Test - func enqueueStoresNotificationsUntilTaken() { - var buffer = BLEOutboundNotificationBuffer() - - let result = buffer.enqueue( - data: Data([0x01]), - targets: ["central-1"], - capCount: 2 - ) - - if case let .enqueued(count) = result { - #expect(count == 1) - } else { - Issue.record("Expected notification to be enqueued") - } - - let pending = buffer.takeAll() - - #expect(pending.count == 1) - #expect(pending.first?.data == Data([0x01])) - #expect(pending.first?.targets == ["central-1"]) - #expect(buffer.isEmpty) - } - - @Test - func enqueueRejectsWhenCapIsFull() { - var buffer = BLEOutboundNotificationBuffer() - - _ = buffer.enqueue(data: Data([0x01]), targets: nil, capCount: 1) - let result = buffer.enqueue(data: Data([0x02]), targets: nil, capCount: 1) - - if case let .full(count) = result { - #expect(count == 1) - } else { - Issue.record("Expected full notification buffer") - } - - #expect(buffer.count == 1) - } - - @Test - func prependRestoresUnsentNotificationsAheadOfNewerItems() { - var buffer = BLEOutboundNotificationBuffer() - let unsent = [ - BLEPendingNotification(data: Data([0x01]), targets: ["old"]) - ] - - _ = buffer.enqueue(data: Data([0x02]), targets: ["new"], capCount: 4) - buffer.prepend(unsent) - - let pending = buffer.takeAll() - - #expect(pending.map { Int($0.data.first ?? 0) } == [0x01, 0x02]) - } - - @Test - func removeAllClearsBufferedNotifications() { - var buffer = BLEOutboundNotificationBuffer() - - _ = buffer.enqueue(data: Data([0x01]), targets: nil, capCount: 4) - buffer.removeAll() - - #expect(buffer.isEmpty) - } - - @Test - func unsubscribeRemovesTargetSpecificCiphertextOnlyForThatCentral() { - var buffer = BLEOutboundNotificationBuffer() - _ = buffer.enqueue(data: Data([1]), targets: ["gone"], capCount: 4) - _ = buffer.enqueue(data: Data([2]), targets: ["gone", "live"], capCount: 4) - _ = buffer.enqueue(data: Data([3]), targets: nil, capCount: 4) - - buffer.removeTarget { $0 == "gone" } - let remaining = buffer.takeAll() - - #expect(remaining.map(\.data) == [Data([2]), Data([3])]) - #expect(remaining[0].targets == ["live"]) - #expect(remaining[1].targets == nil) - } -} diff --git a/bitchatTests/Services/BLEOutboundWriteBufferTests.swift b/bitchatTests/Services/BLEOutboundWriteBufferTests.swift deleted file mode 100644 index 801de777..00000000 --- a/bitchatTests/Services/BLEOutboundWriteBufferTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -struct BLEOutboundWriteBufferTests { - @Test - func enqueueOrdersWritesByPriority() { - var buffer = BLEOutboundWriteBuffer() - let peerID = "peer-1" - - _ = buffer.enqueue( - data: Data(repeating: 0x01, count: 4), - for: peerID, - priority: .fileTransfer, - capBytes: 64 - ) - _ = buffer.enqueue( - data: Data(repeating: 0x02, count: 4), - for: peerID, - priority: .high, - capBytes: 64 - ) - _ = buffer.enqueue( - data: Data(repeating: 0x03, count: 4), - for: peerID, - priority: .fragment(totalFragments: 2), - capBytes: 64 - ) - - let writes = buffer.takeAll(for: peerID) - - #expect(writes.map { Int($0.data.first ?? 0) } == [0x02, 0x03, 0x01]) - } - - @Test - func enqueueTrimsLowestPriorityItemsToCap() { - var buffer = BLEOutboundWriteBuffer() - let peerID = "peer-1" - - _ = buffer.enqueue(data: Data(repeating: 0x01, count: 8), for: peerID, priority: .low, capBytes: 16) - _ = buffer.enqueue(data: Data(repeating: 0x02, count: 8), for: peerID, priority: .fileTransfer, capBytes: 16) - let result = buffer.enqueue(data: Data(repeating: 0x03, count: 8), for: peerID, priority: .high, capBytes: 16) - - if case let .enqueued(trimmedBytes, remainingBytes) = result { - #expect(trimmedBytes == 8) - #expect(remainingBytes == 16) - } else { - Issue.record("Expected buffered write to trim, not drop as oversized") - } - - let writes = buffer.takeAll(for: peerID) - - #expect(writes.map { Int($0.data.first ?? 0) } == [0x03, 0x02]) - } - - @Test - func enqueueRejectsOversizedSingleChunk() { - var buffer = BLEOutboundWriteBuffer() - - let result = buffer.enqueue( - data: Data(repeating: 0x01, count: 32), - for: "peer-1", - priority: .high, - capBytes: 16 - ) - - if case let .oversized(bytes) = result { - #expect(bytes == 32) - } else { - Issue.record("Expected oversized write to be rejected") - } - - #expect(buffer.peripheralIDs.isEmpty) - } - - @Test - func acceptanceReportsWhenNewLowPriorityWriteIsTrimmed() { - var buffer = BLEOutboundWriteBuffer() - let peerID = "peer-1" - _ = buffer.enqueue( - data: Data(repeating: 0x01, count: 8), - for: peerID, - priority: .high, - capBytes: 8 - ) - - let attempt = buffer.enqueueReportingAcceptance( - data: Data(repeating: 0x02, count: 8), - for: peerID, - priority: .low, - capBytes: 8 - ) - - #expect(!attempt.accepted) - #expect(buffer.takeAll(for: peerID).compactMap(\.data.first) == [0x01]) - } - - @Test - func disconnectDiscardRemovesOnlyThatPeripheralQueue() { - var buffer = BLEOutboundWriteBuffer() - _ = buffer.enqueue(data: Data([1]), for: "gone", priority: .high, capBytes: 100) - _ = buffer.enqueue(data: Data([2]), for: "live", priority: .high, capBytes: 100) - - buffer.discardAll(for: "gone") - - #expect(buffer.takeAll(for: "gone").isEmpty) - #expect(buffer.takeAll(for: "live").map(\.data) == [Data([2])]) - } -} diff --git a/bitchatTests/Services/BLEPacketFreshnessPolicyTests.swift b/bitchatTests/Services/BLEPacketFreshnessPolicyTests.swift deleted file mode 100644 index 78a84b58..00000000 --- a/bitchatTests/Services/BLEPacketFreshnessPolicyTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -struct BLEPacketFreshnessPolicyTests { - @Test - func nilRecipientCountsAsBroadcast() { - #expect(BLEPacketFreshnessPolicy.isBroadcastRecipient(nil)) - } - - @Test - func allOnesEightByteRecipientCountsAsBroadcast() { - #expect(BLEPacketFreshnessPolicy.isBroadcastRecipient(Data(repeating: 0xFF, count: 8))) - } - - @Test - func directedRecipientIsNotBroadcast() { - #expect(!BLEPacketFreshnessPolicy.isBroadcastRecipient(Data([0, 1, 2, 3, 4, 5, 6, 7]))) - } - - @Test - func recentPacketIsNotStale() { - let now = Date(timeIntervalSince1970: 1000) - let timestamp = UInt64((now.timeIntervalSince1970 - 100) * 1000) - - #expect(!BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: timestamp, now: now)) - } - - @Test - func oldPacketIsStale() { - let now = Date(timeIntervalSince1970: 1000) - let timestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000) - - #expect(BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: timestamp, now: now)) - } - - @Test - func futurePacketAgeIsZero() { - let now = Date(timeIntervalSince1970: 1000) - let timestamp = UInt64((now.timeIntervalSince1970 + 10) * 1000) - - #expect(BLEPacketFreshnessPolicy.ageSeconds(timestampMilliseconds: timestamp, now: now) == 0) - } -} diff --git a/bitchatTests/Services/BLEPeerEventDebouncerTests.swift b/bitchatTests/Services/BLEPeerEventDebouncerTests.swift deleted file mode 100644 index 9a259b57..00000000 --- a/bitchatTests/Services/BLEPeerEventDebouncerTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEPeerEventDebouncerTests { - @Test - func suppressesRepeatedPeerEventsInsideInterval() { - let peerID = PeerID(str: "1122334455667788") - let now = Date(timeIntervalSince1970: 100) - var debouncer = BLEPeerEventDebouncer() - - let first = debouncer.shouldEmit(peerID: peerID, now: now, minimumInterval: 5) - let suppressed = debouncer.shouldEmit(peerID: peerID, now: now.addingTimeInterval(4.9), minimumInterval: 5) - let afterInterval = debouncer.shouldEmit(peerID: peerID, now: now.addingTimeInterval(5), minimumInterval: 5) - - #expect(first) - #expect(!suppressed) - #expect(afterInterval) - } - - @Test - func tracksPeersIndependently() { - let first = PeerID(str: "1122334455667788") - let second = PeerID(str: "8877665544332211") - let now = Date(timeIntervalSince1970: 100) - var debouncer = BLEPeerEventDebouncer() - - let firstEmit = debouncer.shouldEmit(peerID: first, now: now, minimumInterval: 5) - let secondEmit = debouncer.shouldEmit(peerID: second, now: now.addingTimeInterval(1), minimumInterval: 5) - let firstRepeat = debouncer.shouldEmit(peerID: first, now: now.addingTimeInterval(1), minimumInterval: 5) - - #expect(firstEmit) - #expect(secondEmit) - #expect(!firstRepeat) - #expect(debouncer.count == 2) - } - - @Test - func removeAllClearsDebounceState() { - let peerID = PeerID(str: "1122334455667788") - let now = Date(timeIntervalSince1970: 100) - var debouncer = BLEPeerEventDebouncer() - - debouncer.shouldEmit(peerID: peerID, now: now, minimumInterval: 5) - debouncer.removeAll() - - #expect(debouncer.count == 0) - let afterClear = debouncer.shouldEmit(peerID: peerID, now: now.addingTimeInterval(1), minimumInterval: 5) - #expect(afterClear) - } -} diff --git a/bitchatTests/Services/BLEPeerPublishCoalescerTests.swift b/bitchatTests/Services/BLEPeerPublishCoalescerTests.swift deleted file mode 100644 index a633adb4..00000000 --- a/bitchatTests/Services/BLEPeerPublishCoalescerTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -struct BLEPeerPublishCoalescerTests { - @Test - func firstRequestPublishesImmediately() { - var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1) - - #expect(coalescer.requestPublish(now: Date(timeIntervalSince1970: 100)) == .publishNow) - } - - @Test - func rapidRequestSchedulesAfterRemainingInterval() { - let now = Date(timeIntervalSince1970: 100) - var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1) - - _ = coalescer.requestPublish(now: now) - - if case .schedule(let delay) = coalescer.requestPublish(now: now.addingTimeInterval(0.04)) { - #expect(abs(delay - 0.06) < 0.001) - } else { - Issue.record("Expected a scheduled publish") - } - } - - @Test - func requestSkipsWhilePublishIsAlreadyPending() { - let now = Date(timeIntervalSince1970: 100) - var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1) - - _ = coalescer.requestPublish(now: now) - _ = coalescer.requestPublish(now: now.addingTimeInterval(0.04)) - - #expect(coalescer.requestPublish(now: now.addingTimeInterval(0.05)) == .skip) - } - - @Test - func elapsedRequestPublishesImmediatelyEvenWithPendingPublish() { - let now = Date(timeIntervalSince1970: 100) - var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1) - - _ = coalescer.requestPublish(now: now) - _ = coalescer.requestPublish(now: now.addingTimeInterval(0.04)) - - #expect(coalescer.requestPublish(now: now.addingTimeInterval(0.11)) == .publishNow) - } - - @Test - func scheduledPublishFiredClearsPendingState() { - let now = Date(timeIntervalSince1970: 100) - var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1) - - _ = coalescer.requestPublish(now: now) - _ = coalescer.requestPublish(now: now.addingTimeInterval(0.04)) - coalescer.scheduledPublishFired(now: now.addingTimeInterval(0.1)) - - if case .schedule(let delay) = coalescer.requestPublish(now: now.addingTimeInterval(0.15)) { - #expect(abs(delay - 0.05) < 0.001) - } else { - Issue.record("Expected a scheduled publish") - } - } -} diff --git a/bitchatTests/Services/BLEPeerRegistryTests.swift b/bitchatTests/Services/BLEPeerRegistryTests.swift deleted file mode 100644 index 204c8a3a..00000000 --- a/bitchatTests/Services/BLEPeerRegistryTests.swift +++ /dev/null @@ -1,223 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE peer registry tests") -struct BLEPeerRegistryTests { - @Test("upserted announces track new, reconnect, and rename transitions") - func upsertVerifiedAnnounceTracksTransitions() throws { - var registry = BLEPeerRegistry() - let peerID = PeerID(str: "1122334455667788") - let firstSeen = Date(timeIntervalSince1970: 100) - - let firstResult = registry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: "alice", - noisePublicKey: Data([1, 2, 3]), - signingPublicKey: Data([4, 5, 6]), - isConnected: true, - now: firstSeen - ) - let first = try #require(firstResult) - - #expect(first.isNewPeer) - #expect(!first.wasDisconnected) - #expect(first.previousNickname == nil) - #expect(registry.connectedPeerIDs == [peerID]) - #expect(registry.nickname(for: peerID, connectedOnly: true) == "alice") - - registry.markDisconnected(peerID) - let reconnectResult = registry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: "alice-renamed", - noisePublicKey: Data([1, 2, 3]), - signingPublicKey: Data([4, 5, 6]), - isConnected: true, - now: firstSeen.addingTimeInterval(1) - ) - let reconnect = try #require(reconnectResult) - - #expect(!reconnect.isNewPeer) - #expect(reconnect.wasDisconnected) - #expect(reconnect.previousNickname == "alice") - #expect(registry.info(for: peerID)?.nickname == "alice-renamed") - } - - @Test("pinned signing key cannot be silently replaced by a later announce") - func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws { - var registry = BLEPeerRegistry() - let peerID = PeerID(str: "1122334455667788") - let noiseKey = Data(repeating: 0x11, count: 32) - let victimSigningKey = Data(repeating: 0x42, count: 32) - let attackerSigningKey = Data(repeating: 0x66, count: 32) - let firstSeen = Date(timeIntervalSince1970: 100) - - let pinResult = registry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: "victim", - noisePublicKey: noiseKey, - signingPublicKey: victimSigningKey, - isConnected: true, - now: firstSeen - ) - #expect(pinResult != nil) - - // Attacker replays the victim's noiseKey/peerID with their own - // signing key and nickname; the upsert must be refused wholesale. - let attack = registry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: "attacker", - noisePublicKey: noiseKey, - signingPublicKey: attackerSigningKey, - isConnected: true, - now: firstSeen.addingTimeInterval(1) - ) - - #expect(attack == nil) - let info = try #require(registry.info(for: peerID)) - #expect(info.nickname == "victim") - #expect(info.signingPublicKey == victimSigningKey) - - // A legitimate re-announce with the pinned key is still accepted. - let legit = registry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: "victim-renamed", - noisePublicKey: noiseKey, - signingPublicKey: victimSigningKey, - isConnected: true, - now: firstSeen.addingTimeInterval(2) - ) - #expect(legit != nil) - #expect(registry.info(for: peerID)?.nickname == "victim-renamed") - } - - @Test("announce without a signing key keeps the pinned key") - func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws { - var registry = BLEPeerRegistry() - let peerID = PeerID(str: "1122334455667788") - let noiseKey = Data(repeating: 0x11, count: 32) - let signingKey = Data(repeating: 0x42, count: 32) - let firstSeen = Date(timeIntervalSince1970: 100) - - let initialResult = registry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: "alice", - noisePublicKey: noiseKey, - signingPublicKey: signingKey, - isConnected: true, - now: firstSeen - ) - #expect(initialResult != nil) - - let update = registry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: "alice", - noisePublicKey: noiseKey, - signingPublicKey: nil, - isConnected: true, - now: firstSeen.addingTimeInterval(1) - ) - - #expect(update != nil) - #expect(registry.info(for: peerID)?.signingPublicKey == signingKey) - } - - @Test("registry preserves absent versus explicit empty capabilities") - func capabilitiesPresenceIsPreserved() { - var registry = BLEPeerRegistry() - let oldPeer = PeerID(str: "1122334455667788") - let modernPeer = PeerID(str: "8877665544332211") - - _ = registry.upsertVerifiedAnnounce( - peerID: oldPeer, - nickname: "old", - noisePublicKey: Data(repeating: 0x11, count: 32), - signingPublicKey: Data(repeating: 0x12, count: 32), - isConnected: true, - now: Date(), - capabilities: nil - ) - _ = registry.upsertVerifiedAnnounce( - peerID: modernPeer, - nickname: "modern", - noisePublicKey: Data(repeating: 0x21, count: 32), - signingPublicKey: Data(repeating: 0x22, count: 32), - isConnected: true, - now: Date(), - capabilities: [] - ) - - #expect(registry.capabilities(for: oldPeer).isEmpty) - #expect(!registry.capabilitiesWereExplicitlyAdvertised(for: oldPeer)) - #expect(registry.capabilities(for: modernPeer).isEmpty) - #expect(registry.capabilitiesWereExplicitlyAdvertised(for: modernPeer)) - } - - @Test("reachability keeps recent verified offline peers only when mesh is attached") - func reachabilityRequiresMeshAttachmentForOfflinePeers() { - let offlinePeer = PeerID(str: "1122334455667788") - let connectedPeer = PeerID(str: "8877665544332211") - let now = Date() - - var isolatedRegistry = BLEPeerRegistry() - isolatedRegistry.upsert(BLEPeerInfo( - peerID: offlinePeer, - nickname: "offline", - isConnected: false, - noisePublicKey: nil, - signingPublicKey: nil, - isVerifiedNickname: true, - lastSeen: now - )) - - #expect(!isolatedRegistry.isReachable(offlinePeer, now: now)) - - var attachedRegistry = isolatedRegistry - attachedRegistry.upsert(BLEPeerInfo( - peerID: connectedPeer, - nickname: "connected", - isConnected: true, - noisePublicKey: nil, - signingPublicKey: nil, - isVerifiedNickname: true, - lastSeen: now - )) - - #expect(attachedRegistry.isReachable(offlinePeer, now: now)) - } - - @Test("connectivity reconciliation disconnects inactive peers and prunes expired offline peers") - func reconcileConnectivityUpdatesAndPrunesPeerState() { - var registry = BLEPeerRegistry() - let inactiveConnectedPeer = PeerID(str: "1122334455667788") - let expiredOfflinePeer = PeerID(str: "8877665544332211") - let now = Date() - - registry.upsert(BLEPeerInfo( - peerID: inactiveConnectedPeer, - nickname: "inactive", - isConnected: true, - noisePublicKey: nil, - signingPublicKey: nil, - isVerifiedNickname: true, - lastSeen: now.addingTimeInterval(-TransportConfig.blePeerInactivityTimeoutSeconds - 1) - )) - registry.upsert(BLEPeerInfo( - peerID: expiredOfflinePeer, - nickname: "expired", - isConnected: false, - noisePublicKey: nil, - signingPublicKey: nil, - isVerifiedNickname: true, - lastSeen: .distantPast - )) - - let changes = registry.reconcileConnectivity(now: now, linkStates: [:]) - - #expect(changes.disconnectedPeerIDs == [inactiveConnectedPeer]) - #expect(changes.removedPeers.map(\.peerID) == [expiredOfflinePeer]) - #expect(registry.info(for: inactiveConnectedPeer)?.isConnected == false) - #expect(registry.info(for: expiredOfflinePeer) == nil) - } -} diff --git a/bitchatTests/Services/BLEPeerSenderDisplayNameTests.swift b/bitchatTests/Services/BLEPeerSenderDisplayNameTests.swift deleted file mode 100644 index 9bbf0c51..00000000 --- a/bitchatTests/Services/BLEPeerSenderDisplayNameTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE peer sender display name tests") -struct BLEPeerSenderDisplayNameTests { - @Test("local peer resolves to local nickname") - func localPeerUsesLocalNickname() { - let local = PeerID(str: "1122334455667788") - - #expect(BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: local, - localPeerID: local, - localNickname: "me", - peers: [:], - allowConnectedUnverified: false - ) == "me") - } - - @Test("verified nickname collisions add peer suffix") - func verifiedCollisionAddsSuffix() { - let local = PeerID(str: "1122334455667788") - let peer = PeerID(str: "8877665544332211") - let peers = [ - peer: makeInfo(peerID: peer, nickname: "sam", isConnected: false, isVerifiedNickname: true) - ] - - #expect(BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peer, - localPeerID: local, - localNickname: "sam", - peers: peers, - allowConnectedUnverified: false - ) == "sam#8877") - } - - @Test("connected unverified fallback is opt in") - func connectedUnverifiedFallbackIsOptIn() { - let local = PeerID(str: "1122334455667788") - let peer = PeerID(str: "8877665544332211") - let peers = [ - peer: makeInfo(peerID: peer, nickname: "", isConnected: true, isVerifiedNickname: false) - ] - - #expect(BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peer, - localPeerID: local, - localNickname: "me", - peers: peers, - allowConnectedUnverified: false - ) == nil) - #expect(BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peer, - localPeerID: local, - localNickname: "me", - peers: peers, - allowConnectedUnverified: true - ) == "anon8877") - } - - private func makeInfo( - peerID: PeerID, - nickname: String, - isConnected: Bool, - isVerifiedNickname: Bool - ) -> BLEPeerInfo { - BLEPeerInfo( - peerID: peerID, - nickname: nickname, - isConnected: isConnected, - noisePublicKey: nil, - signingPublicKey: nil, - isVerifiedNickname: isVerifiedNickname, - lastSeen: Date(timeIntervalSince1970: 0) - ) - } -} diff --git a/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift b/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift deleted file mode 100644 index a443b856..00000000 --- a/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift +++ /dev/null @@ -1,951 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -struct BLEPrivateMediaReceiptStoreTests { - private struct TestError: Error {} - private struct ReceiptFixture: Codable { - let kind: String - let relativePath: String? - let recordedAt: Date - } - private struct JournalEntryFixture: Codable { - let relativePaths: [String] - let recordedAt: Date - } - private struct JournalFixture: Codable { - let version: Int - let entries: [String: JournalEntryFixture] - } - - private let messageID = "media-00112233445566778899aabbccddeeff" - private let secondMessageID = "media-ffeeddccbbaa99887766554433221100" - - @Test - func acceptedReceiptPersistsAcrossStoreInstances() throws { - let root = makeRoot("persist") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - - let first = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(first.commitAccepted(messageID: messageID, storedURL: payload)) - #expect(first.state(for: messageID) == .accepted(payload)) - - let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(relaunched.state(for: messageID) == .accepted(payload)) - } - - @Test - func acceptedReceiptRejectsOutgoingPayloadAndCrossIDPathReuse() throws { - let root = makeRoot("accepted-ownership") - defer { try? FileManager.default.removeItem(at: root) } - let incoming = try makePayload(in: root) - let outgoingDirectory = root.appendingPathComponent( - "files/images/outgoing", - isDirectory: true - ) - try FileManager.default.createDirectory( - at: outgoingDirectory, - withIntermediateDirectories: true - ) - let outgoing = outgoingDirectory.appendingPathComponent("image.jpg") - try Data([0x01]).write(to: outgoing) - let store = BLEPrivateMediaReceiptStore(baseDirectory: root) - - #expect(!store.commitAccepted( - messageID: messageID, - storedURL: outgoing - )) - #expect(store.commitAccepted( - messageID: messageID, - storedURL: incoming - )) - #expect(!store.commitAccepted( - messageID: secondMessageID, - storedURL: incoming - )) - #expect(FileManager.default.fileExists(atPath: incoming.path)) - #expect(FileManager.default.fileExists(atPath: outgoing.path)) - } - - @Test - func liveAcceptedPathSurvivesTTLAndCapacityPressure() throws { - let root = makeRoot("accepted-retention") - defer { try? FileManager.default.removeItem(at: root) } - let firstPayload = try makePayload(in: root, name: "first.jpg") - let secondPayload = try makePayload(in: root, name: "second.jpg") - let recordedAt = Date(timeIntervalSince1970: 2_000) - let seed = BLEPrivateMediaReceiptStore( - baseDirectory: root, - capacity: 1, - ttl: 1, - now: { recordedAt } - ) - #expect(seed.commitAccepted( - messageID: messageID, - storedURL: firstPayload - )) - - let relaunched = BLEPrivateMediaReceiptStore( - baseDirectory: root, - capacity: 1, - ttl: 1, - now: { recordedAt.addingTimeInterval(2) } - ) - #expect(relaunched.state(for: messageID) == .accepted(firstPayload)) - #expect(!relaunched.commitAccepted( - messageID: secondMessageID, - storedURL: secondPayload - )) - #expect(relaunched.state(for: messageID) == .accepted(firstPayload)) - } - - @Test - func incomingAllocationDoesNotReuseReceiptOwnedMissingPath() throws { - let root = makeRoot("accepted-reservation") - defer { try? FileManager.default.removeItem(at: root) } - let original = try makePayload(in: root) - #expect(BLEPrivateMediaReceiptStore( - baseDirectory: root - ).commitAccepted( - messageID: messageID, - storedURL: original - )) - try FileManager.default.removeItem(at: original) - - let stored = BLEIncomingFileStore(baseDirectory: root).save( - data: Data([0x03]), - preferredName: "image.jpg", - subdirectory: "images/incoming", - fallbackExtension: "jpg", - defaultPrefix: "image" - ) - - #expect(stored?.lastPathComponent != "image.jpg") - #expect(stored.map { - FileManager.default.fileExists(atPath: $0.path) - } == true) - } - - @Test - func pendingRawArrivalBlocksDeletionFallbackPathReuse() throws { - let root = makeRoot("pending-raw-arrival") - defer { try? FileManager.default.removeItem(at: root) } - let incoming = BLEIncomingFileStore(baseDirectory: root) - - // The old stable bubble names image.jpg, but its receipt and payload - // have already been pruned. A raw arrival saves that basename before - // its main-actor bubble is inserted. - let rawArrival = incoming.save( - data: Data([0x03]), - preferredName: "image.jpg", - subdirectory: "images/incoming", - fallbackExtension: "jpg", - defaultPrefix: "image" - ) - #expect(rawArrival?.lastPathComponent == "image.jpg") - - let reservation = incoming.reservePrivateMediaDeletion( - messageIDs: [messageID], - payloadRelativePaths: [ - messageID: "images/incoming/image.jpg" - ] - ) - #expect(reservation == nil) - #expect(rawArrival.map { - FileManager.default.fileExists(atPath: $0.path) - } == true) - } - - @Test - func quotaDoesNotEvictOrReusePendingDeliveryPath() throws { - let root = makeRoot("pending-quota") - defer { try? FileManager.default.removeItem(at: root) } - let incoming = BLEIncomingFileStore( - baseDirectory: root, - quotaBytes: 1 - ) - let first = try #require(incoming.save( - data: Data([0x01, 0x02]), - preferredName: "image.jpg", - subdirectory: "images/incoming", - fallbackExtension: "jpg", - defaultPrefix: "image" - )) - - incoming.enforceQuota(reservingBytes: 1) - #expect(FileManager.default.fileExists(atPath: first.path)) - - let second = try #require(incoming.save( - data: Data([0x03]), - preferredName: "image.jpg", - subdirectory: "images/incoming", - fallbackExtension: "jpg", - defaultPrefix: "image" - )) - #expect(second != first) - #expect(second.lastPathComponent == "image (1).jpg") - #expect(FileManager.default.fileExists(atPath: first.path)) - #expect(FileManager.default.fileExists(atPath: second.path)) - } - - @Test - func invalidReceiptAndJournalCannotTargetOutgoingPayload() throws { - let root = makeRoot("invalid-owned-path") - defer { try? FileManager.default.removeItem(at: root) } - let outgoingDirectory = root.appendingPathComponent( - "files/images/outgoing", - isDirectory: true - ) - try FileManager.default.createDirectory( - at: outgoingDirectory, - withIntermediateDirectories: true - ) - let victim = outgoingDirectory.appendingPathComponent("victim.jpg") - try Data([0x02]).write(to: victim) - let receiptURL = receiptRecord(in: root) - try FileManager.default.createDirectory( - at: receiptURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let fixture = ReceiptFixture( - kind: "tombstone", - relativePath: "images/outgoing/victim.jpg", - recordedAt: Date() - ) - try JSONEncoder().encode(fixture).write( - to: receiptURL, - options: .atomic - ) - - #expect( - BLEPrivateMediaReceiptStore(baseDirectory: root) - .state(for: messageID) == .unavailable - ) - #expect(FileManager.default.fileExists(atPath: victim.path)) - - // The invalid record was quarantined aside, never executed. Clear it - // so the second phase exercises the journal validation on its own. - #expect(!FileManager.default.fileExists(atPath: receiptURL.path)) - try FileManager.default.removeItem(at: quarantinedRecord(in: root)) - let journal = JournalFixture( - version: 1, - entries: [messageID: JournalEntryFixture( - relativePaths: ["images/outgoing/victim.jpg"], - recordedAt: fixture.recordedAt - )] - ) - try JSONEncoder().encode(journal).write( - to: deletionJournal(in: root), - options: .atomic - ) - - #expect( - BLEPrivateMediaReceiptStore(baseDirectory: root) - .state(for: messageID) == .unavailable - ) - #expect(FileManager.default.fileExists(atPath: victim.path)) - } - - @Test - func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws { - let root = makeRoot("list-failure") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( - messageID: messageID, - storedURL: payload - )) - let record = receiptRecord(in: root) - #expect(FileManager.default.fileExists(atPath: record.path)) - - var shouldFail = true - let store = BLEPrivateMediaReceiptStore( - baseDirectory: root, - directoryReader: { directory in - if shouldFail { - shouldFail = false - throw TestError() - } - return try FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: nil - ) - } - ) - - #expect(store.state(for: messageID) == .unavailable) - #expect(FileManager.default.fileExists(atPath: record.path)) - #expect(store.state(for: messageID) == .accepted(payload)) - } - - @Test - func recordReadFailureQuarantinesOnlyThatRecord() throws { - let root = makeRoot("read-failure") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( - messageID: messageID, - storedURL: payload - )) - let record = receiptRecord(in: root) - let durableBytes = try Data(contentsOf: record) - - let store = BLEPrivateMediaReceiptStore( - baseDirectory: root, - dataReader: { url in - if url.lastPathComponent.hasPrefix(self.messageID) { - throw TestError() - } - return try Data(contentsOf: url) - } - ) - - #expect(store.state(for: messageID) == .unavailable) - // The record was moved aside, bytes intact, not deleted. - #expect(!FileManager.default.fileExists(atPath: record.path)) - let quarantined = quarantinedRecord(in: root) - #expect(FileManager.default.fileExists(atPath: quarantined.path)) - #expect(try Data(contentsOf: quarantined) == durableBytes) - // The quarantine is sticky for this ID; no absent/accepted flapping. - #expect(store.state(for: messageID) == .unavailable) - } - - @Test - func decodeFailureQuarantinesRecordWithoutRetryOrDeletion() throws { - let root = makeRoot("decode-failure") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( - messageID: messageID, - storedURL: payload - )) - let record = receiptRecord(in: root) - let corruptBytes = Data("{not-json".utf8) - try corruptBytes.write(to: record, options: .atomic) - - let store = BLEPrivateMediaReceiptStore(baseDirectory: root) - // Only this ID fails closed; it cannot be re-recorded past the - // quarantine either. - #expect(store.state(for: messageID) == .unavailable) - #expect(!store.commitAccepted(messageID: messageID, storedURL: payload)) - #expect(!store.recordDeleted(messageID: messageID)) - #expect(store.state(for: messageID) == .unavailable) - - // The unreadable bytes were preserved at the quarantine name. - let quarantined = quarantinedRecord(in: root) - #expect(!FileManager.default.fileExists(atPath: record.path)) - #expect(FileManager.default.fileExists(atPath: quarantined.path)) - #expect(try Data(contentsOf: quarantined) == corruptBytes) - - // A relaunch stays fail-closed for this ID without ever re-reading - // the quarantined file. - let readURLs = ReadTracker() - let relaunched = BLEPrivateMediaReceiptStore( - baseDirectory: root, - dataReader: { url in - readURLs.append(url) - return try Data(contentsOf: url) - } - ) - #expect(relaunched.state(for: messageID) == .unavailable) - #expect(!readURLs.urls.contains { - $0.lastPathComponent == quarantined.lastPathComponent - }) - } - - @Test - func corruptRecordDoesNotBlockAnotherSendersMedia() throws { - let root = makeRoot("quarantine-isolation") - defer { try? FileManager.default.removeItem(at: root) } - let otherMessageID = "media-ffeeddccbbaa99887766554433221100" - let corruptPayload = try makePayload(in: root, name: "corrupt.jpg") - let healthyPayload = try makePayload(in: root, name: "healthy.jpg") - - let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(seed.commitAccepted( - messageID: messageID, - storedURL: corruptPayload - )) - #expect(seed.commitAccepted( - messageID: otherMessageID, - storedURL: healthyPayload - )) - try Data("{not-json".utf8).write( - to: receiptRecord(in: root), - options: .atomic - ) - - let store = BLEPrivateMediaReceiptStore(baseDirectory: root) - // Only the damaged record fails closed; the other sender's ledger - // entry keeps serving and new decisions still commit durably. - #expect(store.state(for: messageID) == .unavailable) - #expect(store.state(for: otherMessageID) == .accepted(healthyPayload)) - - let freshMessageID = "media-0102030405060708090a0b0c0d0e0f10" - let freshPayload = try makePayload(in: root, name: "fresh.jpg") - #expect(store.commitAccepted( - messageID: freshMessageID, - storedURL: freshPayload - )) - #expect(store.state(for: freshMessageID) == .accepted(freshPayload)) - } - - @Test - func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws { - let root = makeRoot("tombstone-decode") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(seed.commitAccepted(messageID: messageID, storedURL: payload)) - #expect(seed.recordDeleted(messageID: messageID)) - #expect(!FileManager.default.fileExists(atPath: payload.path)) - - let record = receiptRecord(in: root) - let corruptBytes = Data([0xFF, 0x00, 0x7B]) - try corruptBytes.write(to: record, options: .atomic) - - let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(relaunched.state(for: messageID) == .unavailable) - // The quarantined tombstone can never flip to absent — a sender retry - // must not resurrect explicitly deleted media even when the payload - // bytes arrive again. - try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) - #expect(!relaunched.commitAccepted( - messageID: messageID, - storedURL: payload - )) - let quarantined = quarantinedRecord(in: root) - #expect(FileManager.default.fileExists(atPath: quarantined.path)) - #expect(try Data(contentsOf: quarantined) == corruptBytes) - #expect( - BLEPrivateMediaReceiptStore(baseDirectory: root) - .state(for: messageID) == .unavailable - ) - } - - @Test - func deletionBatchCommitsEveryIDBeforeRemovingPayloads() throws { - let root = makeRoot("batch") - defer { try? FileManager.default.removeItem(at: root) } - let firstPayload = try makePayload(in: root, name: "first.jpg") - let secondPayload = try makePayload(in: root, name: "second.jpg") - let store = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(store.commitAccepted( - messageID: messageID, - storedURL: firstPayload - )) - #expect(store.commitAccepted( - messageID: secondMessageID, - storedURL: secondPayload - )) - - #expect(store.recordDeleted( - messageIDs: [secondMessageID, messageID] - )) - - #expect(store.state(for: messageID) == .tombstoned) - #expect(store.state(for: secondMessageID) == .tombstoned) - #expect(!FileManager.default.fileExists(atPath: firstPayload.path)) - #expect(!FileManager.default.fileExists(atPath: secondPayload.path)) - #expect(!FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - } - - @Test - func failedJournalCommitPreservesAcceptedStateAndPayloads() throws { - let root = makeRoot("journal-failure") - defer { try? FileManager.default.removeItem(at: root) } - let firstPayload = try makePayload(in: root, name: "first.jpg") - let secondPayload = try makePayload(in: root, name: "second.jpg") - let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(seed.commitAccepted( - messageID: messageID, - storedURL: firstPayload - )) - #expect(seed.commitAccepted( - messageID: secondMessageID, - storedURL: secondPayload - )) - - let failing = BLEPrivateMediaReceiptStore( - baseDirectory: root, - dataWriter: { _, _, _ in throw TestError() } - ) - #expect(!failing.recordDeleted( - messageIDs: [messageID, secondMessageID] - )) - - // A failed commit must not install a volatile tombstone. Otherwise a - // sender retry could be ACKed although the caller kept both bubbles. - #expect(failing.state(for: messageID) == .accepted(firstPayload)) - #expect( - failing.state(for: secondMessageID) == .accepted(secondPayload) - ) - #expect(FileManager.default.fileExists(atPath: firstPayload.path)) - #expect(FileManager.default.fileExists(atPath: secondPayload.path)) - #expect(!FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - } - - @Test - func journalRecoversBatchAfterCrashDuringMaterialization() throws { - let root = makeRoot("crash-recovery") - defer { try? FileManager.default.removeItem(at: root) } - let firstPayload = try makePayload(in: root, name: "first.jpg") - let secondPayload = try makePayload(in: root, name: "second.jpg") - let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(seed.commitAccepted( - messageID: messageID, - storedURL: firstPayload - )) - #expect(seed.commitAccepted( - messageID: secondMessageID, - storedURL: secondPayload - )) - - let interrupted = BLEPrivateMediaReceiptStore( - baseDirectory: root, - dataWriter: { data, url, options in - let isJournal = - url.lastPathComponent == ".deletion-journal.json" - let isFirstRecord = - url.deletingPathExtension().lastPathComponent == messageID - guard isJournal || isFirstRecord else { - throw TestError() - } - try data.write(to: url, options: options) - } - ) - #expect(interrupted.recordDeleted( - messageIDs: [messageID, secondMessageID] - )) - #expect(FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - #expect(!FileManager.default.fileExists(atPath: firstPayload.path)) - #expect(FileManager.default.fileExists(atPath: secondPayload.path)) - #expect(interrupted.state(for: messageID) == .tombstoned) - #expect(interrupted.state(for: secondMessageID) == .tombstoned) - - let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(relaunched.state(for: messageID) == .tombstoned) - #expect(relaunched.state(for: secondMessageID) == .tombstoned) - #expect(!FileManager.default.fileExists(atPath: firstPayload.path)) - #expect(!FileManager.default.fileExists(atPath: secondPayload.path)) - #expect(!FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - } - - @Test - func journalRetriesPayloadUnlinkAfterRelaunch() throws { - let root = makeRoot("unlink-recovery") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(seed.commitAccepted( - messageID: messageID, - storedURL: payload - )) - - let unlinkFailure = BLEPrivateMediaReceiptStore( - baseDirectory: root, - payloadRemover: { _ in throw TestError() } - ) - #expect(unlinkFailure.recordDeleted(messageID: messageID)) - #expect(unlinkFailure.state(for: messageID) == .tombstoned) - #expect(FileManager.default.fileExists(atPath: payload.path)) - #expect(FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - - let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(relaunched.state(for: messageID) == .tombstoned) - #expect(!FileManager.default.fileExists(atPath: payload.path)) - #expect(!FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - } - - @Test - func expiredReceiptUsesFallbackPathForCrashSafeCleanup() throws { - let root = makeRoot("expired-fallback") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - let recordedAt = Date(timeIntervalSince1970: 1_000) - let seed = BLEPrivateMediaReceiptStore( - baseDirectory: root, - ttl: 10, - now: { recordedAt } - ) - #expect(seed.commitAccepted( - messageID: messageID, - storedURL: payload - )) - // Simulate a receipt pruned by an older app version while its bubble - // and payload remain. Current code retains live accepted-path owners. - try FileManager.default.removeItem(at: receiptRecord(in: root)) - - let afterExpiry = recordedAt.addingTimeInterval(11) - let interrupted = BLEPrivateMediaReceiptStore( - baseDirectory: root, - ttl: 10, - now: { afterExpiry }, - payloadRemover: { _ in throw TestError() } - ) - #expect(interrupted.recordDeleted( - messageIDs: [messageID], - payloadRelativePaths: [ - messageID: "images/incoming/image.jpg" - ] - )) - #expect(interrupted.state(for: messageID) == .tombstoned) - #expect(FileManager.default.fileExists(atPath: payload.path)) - #expect(FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - - let relaunched = BLEPrivateMediaReceiptStore( - baseDirectory: root, - ttl: 10, - now: { afterExpiry } - ) - #expect(relaunched.state(for: messageID) == .tombstoned) - #expect(!FileManager.default.fileExists(atPath: payload.path)) - #expect(!FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - } - - @Test - func retryAtSuffixedPathDeletesReceiptAndBubblePayloads() throws { - let root = makeRoot("retry-suffix") - defer { try? FileManager.default.removeItem(at: root) } - let original = try makePayload(in: root) - let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(seed.commitAccepted( - messageID: messageID, - storedURL: original - )) - - // Simulate an older build pruning only the receipt. A retry must use a - // suffixed filename while the old bubble still references image.jpg. - try FileManager.default.removeItem(at: receiptRecord(in: root)) - let retry = try makePayload(in: root, name: "image (1).jpg") - let retried = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(retried.commitAccepted( - messageID: messageID, - storedURL: retry - )) - - #expect(retried.recordDeleted( - messageIDs: [messageID], - payloadRelativePaths: [ - messageID: "images/incoming/image.jpg" - ] - )) - #expect(retried.state(for: messageID) == .tombstoned) - #expect(!FileManager.default.fileExists(atPath: original.path)) - #expect(!FileManager.default.fileExists(atPath: retry.path)) - #expect(!FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - - let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(relaunched.state(for: messageID) == .tombstoned) - #expect(!FileManager.default.fileExists(atPath: original.path)) - #expect(!FileManager.default.fileExists(atPath: retry.path)) - } - - @Test - func protectedUIPathRejectsAcceptedReceiptDeletion() throws { - let root = makeRoot("protected-ui-owner") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - let store = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(store.commitAccepted( - messageID: messageID, - storedURL: payload - )) - - #expect(!store.recordDeleted( - messageIDs: [messageID], - protectedPayloadRelativePaths: [ - "images/incoming/image.jpg" - ] - )) - #expect(store.state(for: messageID) == .accepted(payload)) - #expect(FileManager.default.fileExists(atPath: payload.path)) - } - - @Test - func pathlessNewDeletionFailsWithoutChangingReceiverState() { - let root = makeRoot("pathless-delete") - defer { try? FileManager.default.removeItem(at: root) } - let store = BLEPrivateMediaReceiptStore(baseDirectory: root) - - #expect(!store.recordDeleted(messageID: messageID)) - #expect(store.state(for: messageID) == .absent) - #expect(!FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - } - - @Test - func completedTombstoneNeverDeletesReusedPayloadPath() throws { - let root = makeRoot("path-reuse") - defer { try? FileManager.default.removeItem(at: root) } - let original = try makePayload(in: root) - let store = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(store.commitAccepted( - messageID: messageID, - storedURL: original - )) - #expect(store.recordDeleted(messageID: messageID)) - #expect(!FileManager.default.fileExists(atPath: original.path)) - - let reused = try makePayload(in: root) - #expect(store.commitAccepted( - messageID: secondMessageID, - storedURL: reused - )) - #expect(store.state(for: messageID) == .tombstoned) - #expect(FileManager.default.fileExists(atPath: reused.path)) - - let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(relaunched.state(for: messageID) == .tombstoned) - #expect(FileManager.default.fileExists(atPath: reused.path)) - #expect( - relaunched.state(for: secondMessageID) == .accepted(reused) - ) - } - - @Test - func expiredLegacyPathfulTombstoneDoesNotDeleteAcceptedOwner() throws { - let root = makeRoot("legacy-path-conflict") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - let receiptDirectory = receiptRecord(in: root) - .deletingLastPathComponent() - try FileManager.default.createDirectory( - at: receiptDirectory, - withIntermediateDirectories: true - ) - let recordedAt = Date(timeIntervalSince1970: 3_000) - try JSONEncoder().encode(ReceiptFixture( - kind: "tombstone", - relativePath: "images/incoming/image.jpg", - recordedAt: recordedAt - )).write( - to: receiptRecord(in: root), - options: .atomic - ) - try JSONEncoder().encode(ReceiptFixture( - kind: "accepted", - relativePath: "images/incoming/image.jpg", - recordedAt: recordedAt - )).write( - to: receiptRecord( - in: root, - messageID: secondMessageID - ), - options: .atomic - ) - - let store = BLEPrivateMediaReceiptStore( - baseDirectory: root, - ttl: 1, - now: { recordedAt.addingTimeInterval(2) } - ) - #expect(store.state(for: messageID) == .absent) - #expect(FileManager.default.fileExists(atPath: payload.path)) - #expect( - store.state(for: secondMessageID) == .accepted(payload) - ) - #expect(FileManager.default.fileExists(atPath: payload.path)) - } - - @Test - func expiredLegacyPathfulTombstonePreservesAmbiguousPayload() throws { - let root = makeRoot("legacy-expired-cleanup") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - let receiptURL = receiptRecord(in: root) - try FileManager.default.createDirectory( - at: receiptURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let recordedAt = Date(timeIntervalSince1970: 3_000) - try JSONEncoder().encode(ReceiptFixture( - kind: "tombstone", - relativePath: "images/incoming/image.jpg", - recordedAt: recordedAt - )).write(to: receiptURL, options: .atomic) - - let store = BLEPrivateMediaReceiptStore( - baseDirectory: root, - ttl: 1, - now: { recordedAt.addingTimeInterval(2) } - ) - - #expect(store.state(for: messageID) == .absent) - #expect(FileManager.default.fileExists(atPath: payload.path)) - #expect(!FileManager.default.fileExists(atPath: receiptURL.path)) - } - - @Test - func deletionJournalNeverRecursivelyRemovesDirectoryTarget() throws { - let root = makeRoot("journal-directory") - defer { try? FileManager.default.removeItem(at: root) } - let directory = root.appendingPathComponent( - "files/images/incoming/archive", - isDirectory: true - ) - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - let child = directory.appendingPathComponent("child.jpg") - try Data([0x01]).write(to: child) - let receiptDirectory = receiptRecord(in: root) - .deletingLastPathComponent() - try FileManager.default.createDirectory( - at: receiptDirectory, - withIntermediateDirectories: true - ) - #expect(!BLEPrivateMediaReceiptStore( - baseDirectory: root - ).recordDeleted( - messageIDs: [messageID], - payloadRelativePaths: [ - messageID: "images/incoming/archive" - ] - )) - #expect(!FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - try JSONEncoder().encode(JournalFixture( - version: 1, - entries: [messageID: JournalEntryFixture( - relativePaths: ["images/incoming/archive"], - recordedAt: Date() - )] - )).write(to: deletionJournal(in: root), options: .atomic) - - let store = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(store.state(for: messageID) == .tombstoned) - #expect(FileManager.default.fileExists(atPath: directory.path)) - #expect(FileManager.default.fileExists(atPath: child.path)) - #expect(FileManager.default.fileExists( - atPath: deletionJournal(in: root).path - )) - } - - @Test - func corruptDeletionJournalFailsClosedWithoutRemovingPayload() throws { - let root = makeRoot("corrupt-journal") - defer { try? FileManager.default.removeItem(at: root) } - let payload = try makePayload(in: root) - #expect(BLEPrivateMediaReceiptStore( - baseDirectory: root - ).commitAccepted( - messageID: messageID, - storedURL: payload - )) - let journal = deletionJournal(in: root) - try Data("{not-json".utf8).write(to: journal, options: .atomic) - - let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(relaunched.state(for: messageID) == .unavailable) - #expect(!relaunched.commitAccepted( - messageID: messageID, - storedURL: payload - )) - #expect(FileManager.default.fileExists(atPath: payload.path)) - #expect(try Data(contentsOf: journal) == Data("{not-json".utf8)) - } - - @Test - func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws { - let root = makeRoot("no-legacy-migration") - defer { try? FileManager.default.removeItem(at: root) } - let files = root.appendingPathComponent("files", isDirectory: true) - try FileManager.default.createDirectory( - at: files, - withIntermediateDirectories: true - ) - let legacy = files.appendingPathComponent( - ".private-media-receipts.json", - isDirectory: false - ) - let bytes = Data( - #"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"# - .utf8 - ) - try bytes.write(to: legacy, options: .atomic) - - let store = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(store.state(for: messageID) == .absent) - #expect(try Data(contentsOf: legacy) == bytes) - } - - private func makeRoot(_ label: String) -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent( - "private-media-receipt-\(label)-\(UUID().uuidString)", - isDirectory: true - ) - } - - private func makePayload( - in root: URL, - name: String = "image.jpg" - ) throws -> URL { - let directory = root.appendingPathComponent( - "files/images/incoming", - isDirectory: true - ) - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - let payload = directory.appendingPathComponent(name) - try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) - return payload - } - - private func receiptRecord( - in root: URL, - messageID requestedMessageID: String? = nil - ) -> URL { - root - .appendingPathComponent( - "files/.private-media-receipts", - isDirectory: true - ) - .appendingPathComponent(requestedMessageID ?? messageID) - .appendingPathExtension("json") - } - - private func quarantinedRecord(in root: URL) -> URL { - receiptRecord(in: root).appendingPathExtension("corrupt") - } - - private func deletionJournal(in root: URL) -> URL { - root - .appendingPathComponent( - "files/.private-media-receipts", - isDirectory: true - ) - .appendingPathComponent(".deletion-journal.json") - } -} - -/// Collects the URLs a store's data reader touched. A reference type so the -/// `@Sendable`-shaped reader closure can record without mutating captures. -private final class ReadTracker: @unchecked Sendable { - private(set) var urls: [URL] = [] - func append(_ url: URL) { urls.append(url) } -} diff --git a/bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift b/bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift deleted file mode 100644 index b04e6c41..00000000 --- a/bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift +++ /dev/null @@ -1,192 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEPrivateMediaSessionStoreTests { - private let peer = PeerID(str: "aaaaaaaaaaaaaaaa") - private let fingerprint = "ABCDEF0123456789" - - @Test func sameGenerationReconciliationDoesNotRearmProofMachinery() { - let store = BLEPrivateMediaSessionStore() - let generation = UUID() - let fresh = store.beginAuthenticatedGeneration( - for: peer, fingerprint: fingerprint, generation: generation - ) - #expect(fresh != nil) - // A quarantine-restore of the same generation is a reconciliation, - // not a new session: no new watchdog, no waiter churn. - let again = store.beginAuthenticatedGeneration( - for: peer, fingerprint: fingerprint, generation: generation - ) - #expect(again == nil) - // The original watchdog identity survives. - #expect(store.proofTimeoutTarget(for: peer)?.nonce == fresh?.watchdogNonce) - } - - @Test func freshGenerationRejectsFingerprintMismatchedWaiters() { - let store = BLEPrivateMediaSessionStore() - var completed = 0 - _ = store.registerPolicyResolution( - for: peer, fingerprint: fingerprint, requestID: UUID(), - completion: { _ in completed += 1 } - ) - // The replacement session authenticates a DIFFERENT identity: the - // old waiters must come back for rejection instead of riding along. - let fresh = store.beginAuthenticatedGeneration( - for: peer, fingerprint: "FEDCBA9876543210", generation: UUID() - ) - #expect(fresh?.rejected.count == 1) - #expect(!store.hasPendingPolicyResolution(for: peer)) - } - - @Test func applyPeerStateRequiresTheCurrentGenerationAndReleasesWaiters() { - let store = BLEPrivateMediaSessionStore() - let generation = UUID() - _ = store.beginAuthenticatedGeneration( - for: peer, fingerprint: fingerprint, generation: generation - ) - _ = store.registerPolicyResolution( - for: peer, fingerprint: fingerprint, requestID: UUID(), - completion: { _ in } - ) - - // A proof bound to a superseded generation must not classify the - // replacement session. - let stale = store.applyAuthenticatedPeerState( - for: peer, fingerprint: fingerprint, generation: UUID(), - capabilities: [.privateMedia] - ) - #expect(stale == nil) - - let released = store.applyAuthenticatedPeerState( - for: peer, fingerprint: fingerprint, generation: generation, - capabilities: [.privateMedia] - ) - #expect(released?.count == 1) - // Proof landed: the watchdog is retired, so nothing can time out. - #expect(store.proofTimeoutTarget(for: peer) == nil) - } - - @Test func expiryRequiresTheLiveDeadlineIdentityAndPinsTheMarker() { - let store = BLEPrivateMediaSessionStore() - let generation = UUID() - let fresh = store.beginAuthenticatedGeneration( - for: peer, fingerprint: fingerprint, generation: generation - ) - let nonce = fresh?.watchdogNonce ?? UUID() - - // A stale nonce (superseded deadline) must not expire anything. - let stale = store.expireProofDeadline( - for: peer, fingerprint: fingerprint, - sessionGeneration: generation, nonce: UUID() - ) - #expect(!stale.expired) - - let expired = store.expireProofDeadline( - for: peer, fingerprint: fingerprint, - sessionGeneration: generation, nonce: nonce - ) - #expect(expired.expired) - #expect(!expired.deferredOutbound) - // The timeout marker now classifies this generation as unproven. - #expect(store.policyInputs(for: peer).timedOut != nil) - } - - @Test func expiryReportsTheConvergenceDeferralSoDrainsStayParked() { - let store = BLEPrivateMediaSessionStore() - let generation = UUID() - let fresh = store.beginAuthenticatedGeneration( - for: peer, fingerprint: fingerprint, generation: generation - ) - store.setOutboundDeferredUntilConvergence(peer) - - let expired = store.expireProofDeadline( - for: peer, fingerprint: fingerprint, - sessionGeneration: generation, nonce: fresh?.watchdogNonce ?? UUID() - ) - #expect(expired.expired) - #expect(expired.deferredOutbound) - - store.clearOutboundDeferredUntilConvergence(peer) - // Deferral is per-peer state, not per-deadline: once convergence - // clears it, a later expiry may drain. - _ = store.beginAuthenticatedGeneration( - for: peer, fingerprint: fingerprint, generation: UUID() - ) - let next = store.proofTimeoutTarget(for: peer) - let afterClear = store.expireProofDeadline( - for: peer, fingerprint: fingerprint, - sessionGeneration: next?.generation ?? nil, nonce: next?.nonce ?? UUID() - ) - #expect(afterClear.expired) - #expect(!afterClear.deferredOutbound) - } - - @Test func policyWaitersReuseTheLiveWatchdogDeadline() { - let store = BLEPrivateMediaSessionStore() - let generation = UUID() - let fresh = store.beginAuthenticatedGeneration( - for: peer, fingerprint: fingerprint, generation: generation - ) - - // First waiter piggybacks on the watchdog's deadline (case-insensitive - // fingerprint match): no second timeout gets scheduled. - let first = store.registerPolicyResolution( - for: peer, fingerprint: fingerprint.lowercased(), requestID: UUID(), - completion: { _ in } - ) - #expect(first.registered) - #expect(!first.shouldSchedule) - #expect(first.nonce == fresh?.watchdogNonce) - - // Later waiters join the existing set. - let second = store.registerPolicyResolution( - for: peer, fingerprint: fingerprint, requestID: UUID(), - completion: { _ in } - ) - #expect(second.registered) - #expect(!second.shouldSchedule) - } - - @Test func clearSessionRebasesWaitersOntoANilGenerationDeadline() { - let store = BLEPrivateMediaSessionStore() - let generation = UUID() - _ = store.beginAuthenticatedGeneration( - for: peer, fingerprint: fingerprint, generation: generation - ) - _ = store.registerPolicyResolution( - for: peer, fingerprint: fingerprint, requestID: UUID(), - completion: { _ in } - ) - store.setOutboundDeferredUntilConvergence(peer) - - let rearm = store.clearSession(for: peer) - - // Waiters survive the clear but their deadline is rebased so the - // old generation's timeout can no longer claim them. - #expect(rearm != nil) - #expect(store.currentGeneration(for: peer) == nil) - #expect(store.hasPendingPolicyResolution(for: peer)) - let target = store.proofTimeoutTarget(for: peer) - #expect(target?.generation == nil) - #expect(target?.nonce == rearm?.nonce) - } - - @Test func peerStateSendsAreOncePerGenerationPerKind() { - let store = BLEPrivateMediaSessionStore() - _ = store.beginAuthenticatedGeneration( - for: peer, fingerprint: fingerprint, generation: UUID() - ) - #expect(store.markPeerStateSend(for: peer, echo: false)) - #expect(!store.markPeerStateSend(for: peer, echo: false)) - #expect(store.markPeerStateSend(for: peer, echo: true)) - #expect(!store.markPeerStateSend(for: peer, echo: true)) - - // A fresh generation resets both slots. - _ = store.beginAuthenticatedGeneration( - for: peer, fingerprint: fingerprint, generation: UUID() - ) - #expect(store.markPeerStateSend(for: peer, echo: false)) - } -} diff --git a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift deleted file mode 100644 index 4e9b027b..00000000 --- a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift +++ /dev/null @@ -1,327 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEPublicMessageHandlerTests { - private final class Recorder { - var localNickname = "Me" - var peers: [PeerID: BLEPeerInfo] = [:] - var signedName: String? - var verifyPacketSignatureResult = false - var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) - var selfBroadcastMessageID: String? - - var peersSnapshotReads = 0 - var verifyPacketSignatureQueries: [PeerID] = [] - var signedNameQueries: [PeerID] = [] - var trackedPackets: [BitchatPacket] = [] - var selfBroadcastTakes: [BitchatPacket] = [] - var deliveries: [(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)] = [] - } - - private let localPeerID = PeerID(str: "0102030405060708") - private let remotePeerID = PeerID(str: "1122334455667788") - - private func makeHandler( - recorder: Recorder, - localPeerID: PeerID? = nil, - now: Date = Date(timeIntervalSince1970: 1_000) - ) -> BLEPublicMessageHandler { - let resolvedLocalPeerID = localPeerID ?? self.localPeerID - let environment = BLEPublicMessageHandlerEnvironment( - localPeerID: { resolvedLocalPeerID }, - localNickname: { recorder.localNickname }, - now: { now }, - peersSnapshot: { - recorder.peersSnapshotReads += 1 - return recorder.peers - }, - verifyPacketSignature: { packet, _ in - recorder.verifyPacketSignatureQueries.append(PeerID(hexData: packet.senderID)) - return recorder.verifyPacketSignatureResult - }, - signedSenderDisplayName: { _, peerID in - recorder.signedNameQueries.append(peerID) - return recorder.signedName - }, - trackPacketSeen: { packet in - recorder.trackedPackets.append(packet) - }, - linkState: { _ in recorder.linkState }, - takeSelfBroadcastMessageID: { packet in - recorder.selfBroadcastTakes.append(packet) - return recorder.selfBroadcastMessageID - }, - deliverPublicMessage: { peerID, nickname, content, timestamp, messageID in - recorder.deliveries.append((peerID, nickname, content, timestamp, messageID)) - } - ) - return BLEPublicMessageHandler(environment: environment) - } - - @Test - func verifiedPeerBroadcastIsTrackedAndDelivered() { - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] - // A valid packet signature is required even for a registry-verified peer: - // senderID is spoofable, so registry membership alone is not authentication. - recorder.signedName = "SignedAlice" - let handler = makeHandler(recorder: recorder, now: now) - let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now)) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.peersSnapshotReads == 1) - // Signature is verified, then the registry's collision-resolved name is preferred. - #expect(recorder.signedNameQueries == [remotePeerID]) - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.selfBroadcastTakes.isEmpty) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.peerID == remotePeerID) - #expect(recorder.deliveries.first?.nickname == "Alice") - #expect(recorder.deliveries.first?.content == "hello mesh") - #expect(recorder.deliveries.first?.timestamp == now) - // No message ID on the wire: the handler derives the stable one - // every device agrees on for the same sender/timestamp/content. - #expect(recorder.deliveries.first?.messageID == MeshMessageIdentity.stableID( - senderIDHex: remotePeerID.id, - timestampMs: timestamp(now), - content: "hello mesh" - )) - } - - @Test - func selfEchoIsDropped() { - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - let handler = makeHandler(recorder: recorder, now: now) - let packet = makeMessagePacket(sender: localPeerID, content: "echo", timestamp: timestamp(now), ttl: 3) - - handler.handle(packet, from: localPeerID) - - expectNoSideEffects(recorder) - } - - @Test - func staleBroadcastIsDropped() { - let now = Date(timeIntervalSince1970: 1_000_000) - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] - let handler = makeHandler(recorder: recorder, now: now) - let staleTimestamp = UInt64((now.timeIntervalSince1970 - TransportConfig.syncPublicMessageMaxAgeSeconds - 1) * 1000) - let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp) - - handler.handle(packet, from: remotePeerID) - - expectNoSideEffects(recorder) - } - - @Test - func unknownPeerWithoutValidSignatureIsDroppedBeforeSyncTracking() { - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - let handler = makeHandler(recorder: recorder, now: now) - let packet = makeMessagePacket(sender: remotePeerID, content: "hi", timestamp: timestamp(now)) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.signedNameQueries == [remotePeerID]) - // The sender must resolve before the packet is tracked for sync. - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.deliveries.isEmpty) - } - - @Test - func connectedButUnverifiedPeerIsDropped() { - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)] - let handler = makeHandler(recorder: recorder, now: now) - let packet = makeMessagePacket(sender: remotePeerID, content: "hi", timestamp: timestamp(now)) - - handler.handle(packet, from: remotePeerID) - - // Public messages never accept connected-but-unverified registry entries. - #expect(recorder.signedNameQueries == [remotePeerID]) - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.deliveries.isEmpty) - } - - @Test - func registryVerifiedPeerDeliveredBeforeIdentityCachePersists() { - // A freshly verified announce updates the peer registry synchronously, - // but identity-cache persistence is async. A message arriving in that - // window has a valid signature and a registry signing key, yet the - // persisted-identity lookup (signedName) would still return nil. It must - // be verified against the registry key and delivered, not dropped. - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo( - remotePeerID, - nickname: "Alice", - isVerified: true, - signingPublicKey: Data(repeating: 0xAB, count: 32) - )] - recorder.verifyPacketSignatureResult = true - recorder.signedName = nil - let handler = makeHandler(recorder: recorder, now: now) - let packet = makeMessagePacket(sender: remotePeerID, content: "first msg", timestamp: timestamp(now)) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.verifyPacketSignatureQueries == [remotePeerID]) - // Verified via the registry key, so no fallback to the persisted lookup. - #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.nickname == "Alice") - #expect(recorder.deliveries.first?.content == "first msg") - } - - @Test - func registryPeerWithInvalidSignatureFallsBackAndDrops() { - // Spoofed senderID: the peer is in the registry with a signing key, but - // the packet signature does not verify against it. The handler must fall - // back to the persisted lookup and, finding nothing, drop the message. - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo( - remotePeerID, - nickname: "Alice", - isVerified: true, - signingPublicKey: Data(repeating: 0xAB, count: 32) - )] - recorder.verifyPacketSignatureResult = false - recorder.signedName = nil - let handler = makeHandler(recorder: recorder, now: now) - let packet = makeMessagePacket(sender: remotePeerID, content: "spoofed", timestamp: timestamp(now)) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.verifyPacketSignatureQueries == [remotePeerID]) - #expect(recorder.signedNameQueries == [remotePeerID]) - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.deliveries.isEmpty) - } - - @Test - func signedSenderFallbackDeliversWithSignedName() { - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - recorder.signedName = "SignedAlice" - let handler = makeHandler(recorder: recorder, now: now) - let packet = makeMessagePacket(sender: remotePeerID, content: "signed hello", timestamp: timestamp(now)) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.signedNameQueries == [remotePeerID]) - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.nickname == "SignedAlice") - } - - @Test - func invalidUTF8PayloadIsTrackedForSyncButNotDelivered() { - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] - recorder.signedName = "SignedAlice" - let handler = makeHandler(recorder: recorder, now: now) - let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now)) - - handler.handle(packet, from: remotePeerID) - - // Sync tracking happens before payload decoding, matching the original order. - #expect(recorder.trackedPackets.count == 1) - #expect(recorder.deliveries.isEmpty) - } - - @Test - func selfSyncReplayResolvesOriginalMessageID() { - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - recorder.selfBroadcastMessageID = "original-id" - let handler = makeHandler(recorder: recorder, now: now) - // TTL 0 marks a sync replay of our own broadcast, not a self echo. - let packet = makeMessagePacket(sender: localPeerID, content: "mine", timestamp: timestamp(now), ttl: 0) - - handler.handle(packet, from: localPeerID) - - #expect(recorder.selfBroadcastTakes.count == 1) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.peerID == localPeerID) - #expect(recorder.deliveries.first?.nickname == recorder.localNickname) - #expect(recorder.deliveries.first?.messageID == "original-id") - } - - @Test - func directedMessageIsDeliveredWithoutSyncTracking() { - let now = Date(timeIntervalSince1970: 1_000) - let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] - recorder.signedName = "SignedAlice" - let handler = makeHandler(recorder: recorder, now: now) - let packet = makeMessagePacket( - sender: remotePeerID, - content: "direct", - timestamp: timestamp(now), - recipientID: Data(hexString: localPeerID.id) - ) - - handler.handle(packet, from: remotePeerID) - - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.deliveries.count == 1) - #expect(recorder.deliveries.first?.content == "direct") - } - - private func expectNoSideEffects(_ recorder: Recorder) { - #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.trackedPackets.isEmpty) - #expect(recorder.selfBroadcastTakes.isEmpty) - #expect(recorder.deliveries.isEmpty) - } - - private func makePeerInfo( - _ peerID: PeerID, - nickname: String, - isVerified: Bool, - isConnected: Bool = true, - signingPublicKey: Data? = nil - ) -> BLEPeerInfo { - BLEPeerInfo( - peerID: peerID, - nickname: nickname, - isConnected: isConnected, - noisePublicKey: nil, - signingPublicKey: signingPublicKey, - isVerifiedNickname: isVerified, - lastSeen: Date(timeIntervalSince1970: 999) - ) - } - - private func makeMessagePacket( - sender: PeerID, - content: String? = nil, - payload: Data? = nil, - timestamp: UInt64, - ttl: UInt8 = TransportConfig.messageTTLDefault, - recipientID: Data? = nil - ) -> BitchatPacket { - BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: recipientID, - timestamp: timestamp, - payload: payload ?? Data((content ?? "").utf8), - signature: nil, - ttl: ttl - ) - } - - private func timestamp(_ date: Date) -> UInt64 { - UInt64(date.timeIntervalSince1970 * 1000) - } -} diff --git a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift deleted file mode 100644 index dc6d7271..00000000 --- a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift +++ /dev/null @@ -1,127 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLEPublicMessagePolicyTests { - @Test - func selfAuthoredNonSyncReplayIsRejected() { - let localPeerID = PeerID(str: "1122334455667788") - let packet = makePacket(sender: localPeerID, ttl: 3) - - let decision = BLEPublicMessagePolicy.evaluate( - packet: packet, - from: localPeerID, - localPeerID: localPeerID, - now: Date(timeIntervalSince1970: 1_000) - ) - - #expect(decision == .reject(.selfEcho)) - } - - @Test - func selfAuthoredSyncReplayIsAccepted() { - let localPeerID = PeerID(str: "1122334455667788") - let packet = makePacket(sender: localPeerID, ttl: 0) - - let decision = BLEPublicMessagePolicy.evaluate( - packet: packet, - from: localPeerID, - localPeerID: localPeerID, - now: Date(timeIntervalSince1970: 1_000) - ) - - #expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: true))) - } - - @Test - func staleBroadcastIsRejectedWithAge() { - // The acceptance window matches the gossip public-history window. - let staleAge = TransportConfig.syncPublicMessageMaxAgeSeconds + 1 - let now = Date(timeIntervalSince1970: 1_000_000) - let sender = PeerID(str: "8877665544332211") - let packet = makePacket( - sender: sender, - timestamp: UInt64((now.timeIntervalSince1970 - staleAge) * 1000), - recipientID: nil - ) - - let decision = BLEPublicMessagePolicy.evaluate( - packet: packet, - from: sender, - localPeerID: PeerID(str: "1122334455667788"), - now: now - ) - - #expect(decision == .reject(.staleBroadcast(ageSeconds: staleAge))) - } - - @Test - func staleDirectedMessageIsAcceptedAndNotTrackedForSync() { - let now = Date(timeIntervalSince1970: 1_000) - let sender = PeerID(str: "8877665544332211") - let recipient = PeerID(str: "1122334455667788") - let packet = makePacket( - sender: sender, - timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000), - recipientID: Data(hexString: recipient.id) - ) - - let decision = BLEPublicMessagePolicy.evaluate( - packet: packet, - from: sender, - localPeerID: recipient, - now: now - ) - - #expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: false))) - } - - @Test - func freshBroadcastMessageIsTrackedForSync() { - let sender = PeerID(str: "8877665544332211") - let packet = makePacket(sender: sender) - - let decision = BLEPublicMessagePolicy.evaluate( - packet: packet, - from: sender, - localPeerID: PeerID(str: "1122334455667788"), - now: Date(timeIntervalSince1970: 1_000) - ) - - #expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: true))) - } - - @Test - func broadcastNonMessageIsNotTrackedForSync() { - let sender = PeerID(str: "8877665544332211") - let packet = makePacket(sender: sender, type: .leave) - - let decision = BLEPublicMessagePolicy.evaluate( - packet: packet, - from: sender, - localPeerID: PeerID(str: "1122334455667788"), - now: Date(timeIntervalSince1970: 1_000) - ) - - #expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: false))) - } - - private func makePacket( - sender: PeerID, - type: MessageType = .message, - timestamp: UInt64 = 900_000, - ttl: UInt8 = 3, - recipientID: Data? = nil - ) -> BitchatPacket { - BitchatPacket( - type: type.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: recipientID, - timestamp: timestamp, - payload: Data("Hello".utf8), - signature: nil, - ttl: ttl - ) - } -} diff --git a/bitchatTests/Services/BLEQueueContractTests.swift b/bitchatTests/Services/BLEQueueContractTests.swift deleted file mode 100644 index 138eeb5f..00000000 --- a/bitchatTests/Services/BLEQueueContractTests.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation -import Testing - -/// Pins the BLE transport's sync-edge order so it stays structural instead -/// of decaying back into per-site discipline: -/// -/// main / test threads ──sync──▶ engine ──sync──▶ bleQueue -/// └──sync──▶ noise / identity queues -/// -/// and never the reverse. `onEngine` is the single place allowed to -/// sync-enter the engine — it carries the debug trap that catches a -/// bleQueue caller before it can pair with `readLinkState` into an ABBA -/// deadlock (the class of the July 9 field freeze). A raw -/// `messageQueue.sync` bypasses that trap, and a `DispatchQueue.main.sync` -/// from transport code completes a cycle with the main actor's sync reads. -/// -/// Waive a line with `queue-contract-ok:` plus a reason. -struct BLEQueueContractTests { - static let waiver = "queue-contract-ok:" - - private static let bleRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // Services - .deletingLastPathComponent() // bitchatTests - .deletingLastPathComponent() // repo root - .appendingPathComponent("bitchat/Services/BLE") - - private struct Line { - let file: String - let number: Int - let text: String - let waived: Bool - } - - private static func bleLines() throws -> [Line] { - let enumerator = FileManager.default.enumerator( - at: bleRoot, - includingPropertiesForKeys: nil - ) - var out: [Line] = [] - while let url = enumerator?.nextObject() as? URL { - guard url.pathExtension == "swift" else { continue } - let name = url.lastPathComponent - let texts = try String(contentsOf: url, encoding: .utf8) - .components(separatedBy: .newlines) - for (index, text) in texts.enumerated() { - var waived = text.contains(waiver) - var back = index - 1 - while !waived, back >= 0 { - let previous = texts[back].trimmingCharacters(in: .whitespaces) - guard previous.hasPrefix("//") else { break } - waived = previous.contains(waiver) - back -= 1 - } - out.append(Line(file: name, number: index + 1, text: text, waived: waived)) - } - } - return out - } - - private func offenders(matching pattern: String) throws -> [String] { - try Self.bleLines() - .filter { !$0.waived && $0.text.contains(pattern) } - .map { "\($0.file):\($0.number): \($0.text.trimmingCharacters(in: .whitespaces))" } - } - - @Test func onlyOnEngineSyncEntersTheEngine() throws { - let hits = try offenders(matching: "messageQueue.sync") - #expect(hits.isEmpty, "Raw messageQueue.sync bypasses onEngine's bleQueue trap; route through onEngine (or waive with a reason): \(hits)") - } - - @Test func transportCodeNeverSyncDispatchesToMain() throws { - let hits = try offenders(matching: "DispatchQueue.main.sync") - #expect(hits.isEmpty, "A main.sync from transport code can complete an ABBA cycle with the main actor's sync reads: \(hits)") - } - - @Test func theCollectionsQueueStaysDeleted() throws { - let hits = try offenders(matching: "collectionsQueue") - #expect(hits.isEmpty, "Engine state has exactly one serial domain; do not reintroduce a side queue: \(hits)") - } - - @Test func deferredEngineWorkGoesThroughTheScheduler() throws { - let hits = try offenders(matching: "messageQueue.asyncAfter") - #expect(hits.isEmpty, "Engine delays must use BLEEngineScheduling so tests can drive protocol deadlines with a manual clock: \(hits)") - } -} diff --git a/bitchatTests/Services/BLEReceivePipelineTests.swift b/bitchatTests/Services/BLEReceivePipelineTests.swift deleted file mode 100644 index d434f7db..00000000 --- a/bitchatTests/Services/BLEReceivePipelineTests.swift +++ /dev/null @@ -1,108 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE receive pipeline tests") -struct BLEReceivePipelineTests { - @Test("context includes sender, type-scoped message ID, and logging policy") - func contextBuildsTypeScopedMessageID() { - let sender = PeerID(str: "1122334455667788") - let local = PeerID(str: "8877665544332211") - let packet = makePacket(type: .message, sender: sender, timestamp: 1234) - - let context = BLEReceivePipeline.context(for: packet, localPeerID: local) - - #expect(context.senderID == sender) - // The message ID includes a payload digest so distinct packets sharing a - // sender/timestamp(ms)/type are not collapsed as duplicates. - let digest = packet.payload.sha256Hash().prefix(4).hexEncodedString() - #expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)-\(digest)") - #expect(context.messageType == .message) - #expect(context.shouldDeduplicate) - #expect(context.logsHandlingDetails) - } - - @Test("fragments and self sync replays bypass global deduplication") - func contextBypassesDeduplicationForFragmentsAndSelfSyncReplay() { - let local = PeerID(str: "1122334455667788") - let remote = PeerID(str: "8877665544332211") - - let fragment = BLEReceivePipeline.context( - for: makePacket(type: .fragment, sender: remote), - localPeerID: local - ) - let selfReplay = BLEReceivePipeline.context( - for: makePacket(type: .message, sender: local, ttl: 0), - localPeerID: local - ) - - #expect(!fragment.shouldDeduplicate) - #expect(!selfReplay.shouldDeduplicate) - } - - @Test("dense duplicate traffic cancels pending relays but sparse traffic does not") - func duplicateRelayCancellationUsesGraphDensity() { - #expect(!BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: 2)) - #expect(BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: 3)) - } - - @Test("relay decision maps packet context and suppresses local recipient traffic") - func relayDecisionSuppressesLocalRecipientTraffic() { - let sender = PeerID(str: "1122334455667788") - let local = PeerID(str: "8877665544332211") - let packet = makePacket( - type: .noiseEncrypted, - sender: sender, - recipient: local, - ttl: 7 - ) - - let decision = BLEReceivePipeline.relayDecision( - for: packet, - senderID: sender, - localPeerID: local, - degree: 3, - highDegreeThreshold: TransportConfig.bleHighDegreeThreshold - ) - - #expect(!decision.shouldRelay) - } - - @Test("recent traffic tracker prunes by count and time window") - func recentTrafficTrackerPrunesByCountAndWindow() { - var tracker = BLERecentTrafficTracker() - let now = Date(timeIntervalSince1970: 1_000) - - tracker.recordPacket(at: now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds - 1)) - #expect(!tracker.hasTraffic(within: TransportConfig.bleRecentPacketWindowSeconds, now: now)) - - for index in 0...TransportConfig.bleRecentPacketWindowMaxCount { - tracker.recordPacket(at: now.addingTimeInterval(Double(index) * 0.001)) - } - - #expect(tracker.count == TransportConfig.bleRecentPacketWindowMaxCount) - #expect(tracker.hasTraffic(within: 1.0, now: now.addingTimeInterval(0.1))) - - tracker.removeAll() - #expect(tracker.count == 0) - } - - private func makePacket( - type: MessageType, - sender: PeerID, - recipient: PeerID? = nil, - timestamp: UInt64 = 1, - ttl: UInt8 = 7 - ) -> BitchatPacket { - BitchatPacket( - type: type.rawValue, - senderID: Data(hexString: sender.id) ?? Data(), - recipientID: recipient.flatMap { Data(hexString: $0.id) }, - timestamp: timestamp, - payload: Data([0x01, 0x02]), - signature: nil, - ttl: ttl - ) - } -} diff --git a/bitchatTests/Services/BLERecentPeripheralCacheTests.swift b/bitchatTests/Services/BLERecentPeripheralCacheTests.swift deleted file mode 100644 index 15eb057e..00000000 --- a/bitchatTests/Services/BLERecentPeripheralCacheTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// BLERecentPeripheralCacheTests.swift -// bitchatTests -// -// Eviction, expiry, and reconnect-target selection for the background -// wake-on-proximity peripheral cache. -// - -import Testing -import Foundation -@testable import bitchat - -struct BLERecentPeripheralCacheTests { - - private let base = Date(timeIntervalSince1970: 1_700_000_000) - - private func makeCache(capacity: Int = 4, maxAge: TimeInterval = 900) -> BLERecentPeripheralCache { - BLERecentPeripheralCache(capacity: capacity, maxAge: maxAge) - } - - @Test - func recordUpsertsByPeripheralID() { - let cache = makeCache() - cache.record("p1", peripheralID: "A", at: base) - cache.record("p1-updated", peripheralID: "A", at: base.addingTimeInterval(10)) - - #expect(cache.count == 1) - let targets = cache.reconnectTargets(now: base.addingTimeInterval(11), limit: 10) { _ in false } - #expect(targets.map(\.peripheral) == ["p1-updated"]) - } - - @Test - func overCapacityEvictsStalestEntry() { - let cache = makeCache(capacity: 2) - cache.record("p1", peripheralID: "A", at: base) - cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1)) - cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(2)) - - #expect(cache.count == 2) - let targets = cache.reconnectTargets(now: base.addingTimeInterval(3), limit: 10) { _ in false } - #expect(targets.map(\.peripheralID) == ["C", "B"]) - } - - @Test - func refreshingAnEntryProtectsItFromEviction() { - let cache = makeCache(capacity: 2) - cache.record("p1", peripheralID: "A", at: base) - cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1)) - // A becomes the freshest again; adding C must evict B, not A - cache.record("p1", peripheralID: "A", at: base.addingTimeInterval(2)) - cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(3)) - - let targets = cache.reconnectTargets(now: base.addingTimeInterval(4), limit: 10) { _ in false } - #expect(targets.map(\.peripheralID) == ["C", "A"]) - } - - @Test - func expiredEntriesArePruned() { - let cache = makeCache(maxAge: 100) - cache.record("p1", peripheralID: "A", at: base) - cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(50)) - - let targets = cache.reconnectTargets(now: base.addingTimeInterval(120), limit: 10) { _ in false } - #expect(targets.map(\.peripheralID) == ["B"]) - #expect(cache.count == 1) - } - - @Test - func targetsAreFreshestFirstAndCappedAtLimit() { - let cache = makeCache(capacity: 8) - for (index, id) in ["A", "B", "C", "D"].enumerated() { - cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index))) - } - - let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { _ in false } - #expect(targets.map(\.peripheralID) == ["D", "C"]) - } - - @Test - func excludedPeripheralsAreSkippedWithoutConsumingTheLimit() { - let cache = makeCache(capacity: 8) - for (index, id) in ["A", "B", "C"].enumerated() { - cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index))) - } - - // C (freshest) is already connected; the two slots go to B and A - let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { $0 == "C" } - #expect(targets.map(\.peripheralID) == ["B", "A"]) - } - - @Test - func nonPositiveLimitReturnsNothing() { - let cache = makeCache() - cache.record("p1", peripheralID: "A", at: base) - - #expect(cache.reconnectTargets(now: base, limit: 0) { _ in false }.isEmpty) - #expect(cache.reconnectTargets(now: base, limit: -3) { _ in false }.isEmpty) - } -} diff --git a/bitchatTests/Services/BLERedundantLinkPolicyTests.swift b/bitchatTests/Services/BLERedundantLinkPolicyTests.swift deleted file mode 100644 index d544535b..00000000 --- a/bitchatTests/Services/BLERedundantLinkPolicyTests.swift +++ /dev/null @@ -1,274 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLERedundantLinkPolicyTests { - private let peer = PeerID(str: "1122334455667788") - private let otherPeer = PeerID(str: "8877665544332211") - - private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true, connectedAt: Date? = nil) -> BLERedundantLinkPolicy.PeripheralLink { - BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable, lastConnectedAt: connectedAt) - } - - @Test - func singleBoundLinkNeedsNoConsolidation() { - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p1", - mostRecentlyBoundUUID: "p1", - links: [link("p1", peer), link("p2", otherPeer)], - peerID: peer - ) - #expect(kept == nil) - } - - @Test - func ingressLinkOfVerifiedAnnounceWinsOverReverseMap() { - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-ingress", - mostRecentlyBoundUUID: "p-reverse", - links: [link("p-ingress", peer), link("p-reverse", peer), link("p-stale", peer)], - peerID: peer - ) - #expect(kept == "p-ingress") - } - - @Test - func centralIngressFallsBackToMostRecentlyBoundLink() { - // The announce arrived on the central link (a write), so no ingress - // peripheral exists; the peer's reverse-mapped link survives. - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: nil, - mostRecentlyBoundUUID: "p-reverse", - links: [link("p-reverse", peer), link("p-stale", peer)], - peerID: peer - ) - #expect(kept == "p-reverse") - } - - @Test - func noLiveCandidateAmongBoundLinksRetiresNothing() { - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: nil, - mostRecentlyBoundUUID: "p-disconnected", - links: [link("p-disconnected", peer, connected: false), link("p1", peer), link("p2", peer)], - peerID: peer - ) - #expect(kept == nil) - } - - @Test - func characteristicLessAnchorLosesToWritableDuplicate() { - // The ingress link is mid-service-rediscovery (no characteristic): - // keeping it and cancelling the writable duplicate would strand - // outbound traffic on the central link, so the writable - // reverse-mapped link wins. - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-charless", - mostRecentlyBoundUUID: "p-writable", - links: [link("p-charless", peer, writable: false), link("p-writable", peer)], - peerID: peer - ) - #expect(kept == "p-writable") - } - - @Test - func writableDuplicateThatIsNoAnchorDefersConsolidation() { - // Both anchors are characteristic-less but a writable third link - // exists: never keep a charless link over it — wait for a later - // announce instead of guessing which link to keep. - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-charless-1", - mostRecentlyBoundUUID: "p-charless-2", - links: [ - link("p-charless-1", peer, writable: false), - link("p-charless-2", peer, writable: false), - link("p-writable", peer) - ], - peerID: peer - ) - #expect(kept == nil) - } - - @Test - func allCharacteristicLessDuplicatesStillConsolidateOnIngress() { - // No writable link exists at all (all mid-rediscovery): the ingress - // anchor still consolidates — no writable duplicate is at risk. - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-ingress", - mostRecentlyBoundUUID: "p-stale", - links: [link("p-ingress", peer, writable: false), link("p-stale", peer, writable: false)], - peerID: peer - ) - #expect(kept == "p-ingress") - } - - @Test - func retirementSparesKeptLinkUnboundLinksAndOtherPeers() { - let retiring = BLERedundantLinkPolicy.peripheralUUIDsToRetire( - links: [ - link("p-kept", peer), - link("p-dup-1", peer), - link("p-dup-2", peer), - link("p-gone", peer, connected: false), - link("p-unbound", nil), - link("p-other", otherPeer) - ], - peerID: peer, - keeping: "p-kept" - ) - #expect(Set(retiring) == Set(["p-dup-1", "p-dup-2"])) - } - - @Test - func rotationCleanupWithNoSurvivorRetiresEveryBoundLink() { - // Rotated-away identity: the rebound link now belongs to the new ID, - // so every link still bound to the old ID is a stale duplicate. - let retiring = BLERedundantLinkPolicy.peripheralUUIDsToRetire( - links: [link("p-stale-1", peer), link("p-stale-2", peer)], - peerID: peer, - keeping: "" - ) - #expect(Set(retiring) == Set(["p-stale-1", "p-stale-2"])) - } - - // MARK: Connect-recency preference (the July 31 retire↔reconnect fix) - - @Test - func newestConnectionWinsOverIngressAndBindingAnchors() { - // Field oscillation: the restored old-address link (no connect - // timestamp) carried the announce ingress AND the binding, so it - // kept winning — and the cancelled fresh-address link kept getting - // rediscovered and reconnected. Physical connect recency must beat - // both announce anchors. - let now = Date() - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-restored", - mostRecentlyBoundUUID: "p-restored", - links: [ - link("p-restored", peer), - link("p-fresh", peer, connectedAt: now) - ], - peerID: peer - ) - #expect(kept == "p-fresh") - } - - @Test - func amongTimestampedLinksTheNewestWins() { - let now = Date() - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-older", - mostRecentlyBoundUUID: "p-older", - links: [ - link("p-older", peer, connectedAt: now.addingTimeInterval(-30)), - link("p-newer", peer, connectedAt: now) - ], - peerID: peer - ) - #expect(kept == "p-newer") - } - - @Test - func newestLinkMidDiscoveryDefersInsteadOfKeepingOlderWritable() { - // The fresh connection hasn't finished service discovery, so it is - // not writable yet. Keeping the older writable (restored) link now - // would cancel the one connection on the currently advertised - // address and recreate the oscillation — defer to a later announce. - let now = Date() - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-writable", - mostRecentlyBoundUUID: "p-writable", - links: [ - link("p-writable", peer, connectedAt: now.addingTimeInterval(-30)), - link("p-fresh-bare", peer, writable: false, connectedAt: now) - ], - peerID: peer - ) - #expect(kept == nil) - } - - @Test - func restoredWritableAnchorAlsoDefersToFreshUnwritableLink() { - // Same discovery window as above, but the writable duplicate is a - // restored link with no connect timestamp at all — the exact field - // topology. It must not win just because the fresh link is bare. - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-restored", - mostRecentlyBoundUUID: "p-restored", - links: [ - link("p-restored", peer), - link("p-fresh-bare", peer, writable: false, connectedAt: Date()) - ], - peerID: peer - ) - #expect(kept == nil) - } - - @Test - func coNewestWritableLinkStillWinsOverBareTwin() { - // Two links share the newest timestamp and one is writable: no - // discovery window to wait out — the writable co-newest survives. - let now = Date() - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: nil, - mostRecentlyBoundUUID: nil, - links: [ - link("p-bare", peer, writable: false, connectedAt: now), - link("p-writable", peer, connectedAt: now) - ], - peerID: peer - ) - #expect(kept == "p-writable") - } - - @Test - func allUnwritableDuplicatesConsolidateByConnectRecency() { - // No writable link exists at all: nothing can be stranded, so the - // newest connection consolidates immediately. - let now = Date() - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-older", - mostRecentlyBoundUUID: "p-older", - links: [ - link("p-older", peer, writable: false, connectedAt: now.addingTimeInterval(-30)), - link("p-newer", peer, writable: false, connectedAt: now) - ], - peerID: peer - ) - #expect(kept == "p-newer") - } - - @Test - func allRestoredLinksFallBackToAnnounceAnchors() { - // No connect timestamps at all (every link restored): the legacy - // ingress-then-binding preference still decides. - let kept = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-ingress", - mostRecentlyBoundUUID: "p-bound", - links: [link("p-ingress", peer), link("p-bound", peer)], - peerID: peer - ) - #expect(kept == "p-ingress") - } - - @Test - func timestampTiesBreakByAnchorsThenDeterministically() { - let now = Date() - let anchored = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: "p-b", - mostRecentlyBoundUUID: nil, - links: [link("p-a", peer, connectedAt: now), link("p-b", peer, connectedAt: now)], - peerID: peer - ) - #expect(anchored == "p-b") - - let unanchored = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: nil, - mostRecentlyBoundUUID: nil, - links: [link("p-b", peer, connectedAt: now), link("p-a", peer, connectedAt: now)], - peerID: peer - ) - #expect(unanchored == "p-a") - } -} diff --git a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift deleted file mode 100644 index 7e580098..00000000 --- a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift +++ /dev/null @@ -1,184 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE route forwarding policy tests") -struct BLERouteForwardingPolicyTests { - @Test("local recipient suppresses flood relay") - func localRecipientSuppressesFloodRelay() { - let local = peer("1111111111111111") - let remote = peer("2222222222222222") - let packet = makePacket(sender: remote, recipient: local) - - let plan = forwardingPlan(packet, local: local) - - #expect(plan.shouldSuppressFloodRelay) - #expect(plan.forwardPacket == nil) - #expect(plan.nextHop == nil) - } - - @Test("unrouted packets are left for flood relay") - func unroutedPacketAllowsFloodRelay() { - let local = peer("1111111111111111") - let remote = peer("2222222222222222") - let packet = makePacket(sender: remote, recipient: peer("3333333333333333"), route: nil) - - let plan = forwardingPlan(packet, local: local) - - #expect(!plan.shouldSuppressFloodRelay) - #expect(plan.forwardPacket == nil) - #expect(plan.nextHop == nil) - } - - @Test("origin forwards routed packet to first hop") - func originForwardsToFirstHop() { - let local = peer("1111111111111111") - let firstHop = peer("2222222222222222") - let destination = peer("3333333333333333") - let packet = makePacket(sender: local, recipient: destination, ttl: 6, route: [routeData(firstHop)]) - - let plan = forwardingPlan(packet, local: local, connected: [firstHop]) - - #expect(plan.shouldSuppressFloodRelay) - #expect(plan.nextHop == firstHop) - #expect(plan.forwardPacket?.ttl == 5) - } - - @Test("origin leaves packet for flood relay when first hop is unavailable") - func originAllowsFloodWhenFirstHopUnavailable() { - let local = peer("1111111111111111") - let firstHop = peer("2222222222222222") - let destination = peer("3333333333333333") - let packet = makePacket(sender: local, recipient: destination, route: [routeData(firstHop)]) - - let plan = forwardingPlan(packet, local: local, connected: []) - - #expect(!plan.shouldSuppressFloodRelay) - #expect(plan.forwardPacket == nil) - #expect(plan.nextHop == nil) - } - - @Test("intermediate forwards routed packet to next route hop") - func intermediateForwardsToNextHop() { - let previous = peer("1111111111111111") - let local = peer("2222222222222222") - let nextHop = peer("3333333333333333") - let destination = peer("4444444444444444") - let packet = makePacket( - sender: previous, - recipient: destination, - ttl: 4, - route: [routeData(local), routeData(nextHop)] - ) - - let plan = forwardingPlan(packet, local: local, connected: [nextHop]) - - #expect(plan.shouldSuppressFloodRelay) - #expect(plan.nextHop == nextHop) - #expect(plan.forwardPacket?.ttl == 3) - } - - @Test("last intermediate forwards routed packet to destination") - func lastIntermediateForwardsToDestination() { - let previous = peer("1111111111111111") - let local = peer("2222222222222222") - let destination = peer("3333333333333333") - let packet = makePacket( - sender: previous, - recipient: destination, - ttl: 4, - route: [routeData(local)] - ) - - let plan = forwardingPlan(packet, local: local, connected: [destination]) - - #expect(plan.shouldSuppressFloodRelay) - #expect(plan.nextHop == destination) - #expect(plan.forwardPacket?.ttl == 3) - } - - @Test("expired routed packets suppress further relay") - func expiredRoutedPacketSuppressesRelay() { - let local = peer("1111111111111111") - let firstHop = peer("2222222222222222") - let destination = peer("3333333333333333") - let packet = makePacket(sender: local, recipient: destination, ttl: 1, route: [routeData(firstHop)]) - - let plan = forwardingPlan(packet, local: local, connected: [firstHop]) - - #expect(plan.shouldSuppressFloodRelay) - #expect(plan.forwardPacket == nil) - #expect(plan.nextHop == nil) - } - - @Test("REQUEST_SYNC is never route-forwarded even with a route and TTL headroom") - func requestSyncNeverRouteForwarded() { - let previous = peer("1111111111111111") - let local = peer("2222222222222222") - let nextHop = peer("3333333333333333") - let destination = peer("4444444444444444") - var packet = makePacket( - sender: previous, - recipient: destination, - ttl: 7, - route: [routeData(local), routeData(nextHop)] - ) - packet = BitchatPacket( - type: MessageType.requestSync.rawValue, - senderID: packet.senderID, - recipientID: packet.recipientID, - timestamp: packet.timestamp, - payload: packet.payload, - signature: nil, - ttl: packet.ttl, - route: packet.route - ) - - let plan = forwardingPlan(packet, local: local, connected: [nextHop]) - - #expect(plan.shouldSuppressFloodRelay) - #expect(plan.forwardPacket == nil) - #expect(plan.nextHop == nil) - } - - private func forwardingPlan( - _ packet: BitchatPacket, - local: PeerID, - connected: Set = [] - ) -> BLERouteForwardingPlan { - BLERouteForwardingPolicy.plan( - for: packet, - localPeerID: local, - localRoutingData: local.routingData, - routingPeer: PeerID.init(routingData:), - isPeerConnected: { connected.contains($0) } - ) - } - - private func makePacket( - sender: PeerID, - recipient: PeerID?, - ttl: UInt8 = 7, - route: [Data]? = [] - ) -> BitchatPacket { - BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: routeData(sender), - recipientID: recipient.map { routeData($0) }, - timestamp: 1, - payload: Data([0x01, 0x02]), - signature: nil, - ttl: ttl, - route: route - ) - } - - private func peer(_ id: String) -> PeerID { - PeerID(str: id) - } - - private func routeData(_ peerID: PeerID) -> Data { - peerID.routingData ?? Data() - } -} diff --git a/bitchatTests/Services/BLEScanDutyPolicyTests.swift b/bitchatTests/Services/BLEScanDutyPolicyTests.swift deleted file mode 100644 index 965f01b8..00000000 --- a/bitchatTests/Services/BLEScanDutyPolicyTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Testing -@testable import bitchat - -struct BLEScanDutyPolicyTests { - @Test - func continuousScanWhenDutyIsDisabledInactiveDisconnectedOrTrafficIsRecent() { - #expect(BLEScanDutyPolicy.plan( - dutyEnabled: false, - appIsActive: true, - connectedCount: 4, - hasRecentTraffic: false - ) == .continuous) - - #expect(BLEScanDutyPolicy.plan( - dutyEnabled: true, - appIsActive: false, - connectedCount: 4, - hasRecentTraffic: false - ) == .continuous) - - #expect(BLEScanDutyPolicy.plan( - dutyEnabled: true, - appIsActive: true, - connectedCount: 0, - hasRecentTraffic: false - ) == .continuous) - - #expect(BLEScanDutyPolicy.plan( - dutyEnabled: true, - appIsActive: true, - connectedCount: 4, - hasRecentTraffic: true - ) == .continuous) - } - - @Test - func continuousScanForSmallMeshes() { - #expect(BLEScanDutyPolicy.plan( - dutyEnabled: true, - appIsActive: true, - connectedCount: 2, - hasRecentTraffic: false - ) == .continuous) - } - - @Test - func dutyCyclesSparseActiveMeshes() { - let plan = BLEScanDutyPolicy.plan( - dutyEnabled: true, - appIsActive: true, - connectedCount: 3, - hasRecentTraffic: false - ) - - #expect(plan == .dutyCycle( - onDuration: TransportConfig.bleDutyOnDuration, - offDuration: TransportConfig.bleDutyOffDuration - )) - } - - @Test - func dutyCyclesDenseActiveMeshesWithDenseDurations() { - let plan = BLEScanDutyPolicy.plan( - dutyEnabled: true, - appIsActive: true, - connectedCount: TransportConfig.bleHighDegreeThreshold, - hasRecentTraffic: false - ) - - #expect(plan == .dutyCycle( - onDuration: TransportConfig.bleDutyOnDurationDense, - offDuration: TransportConfig.bleDutyOffDurationDense - )) - } -} diff --git a/bitchatTests/Services/BLEScheduledRelayStoreTests.swift b/bitchatTests/Services/BLEScheduledRelayStoreTests.swift deleted file mode 100644 index 84de9978..00000000 --- a/bitchatTests/Services/BLEScheduledRelayStoreTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE scheduled relay store tests") -struct BLEScheduledRelayStoreTests { - @Test("schedule tracks relay count and remove returns work item") - func scheduleAndRemove() { - var store = BLEScheduledRelayStore() - let first = DispatchWorkItem {} - let second = DispatchWorkItem {} - - store.schedule(first, messageID: "one") - store.schedule(second, messageID: "two") - - #expect(store.count == 2) - #expect(!store.isEmpty) - let removed = store.remove(messageID: "one") - #expect(removed.map { $0 === first } == true) - #expect(store.count == 1) - let missing = store.remove(messageID: "missing") - #expect(missing == nil) - } - - @Test("cancel removes and cancels a scheduled relay") - func cancelScheduledRelay() { - var store = BLEScheduledRelayStore() - let workItem = DispatchWorkItem {} - - store.schedule(workItem, messageID: "relay") - - let didCancel = store.cancel(messageID: "relay") - #expect(didCancel) - #expect(workItem.isCancelled) - #expect(store.isEmpty) - let didCancelAgain = store.cancel(messageID: "relay") - #expect(!didCancelAgain) - } - - @Test("cancel all cancels every scheduled relay") - func cancelAllScheduledRelays() { - var store = BLEScheduledRelayStore() - let first = DispatchWorkItem {} - let second = DispatchWorkItem {} - - store.schedule(first, messageID: "first") - store.schedule(second, messageID: "second") - store.cancelAll() - - #expect(first.isCancelled) - #expect(second.isCancelled) - #expect(store.isEmpty) - } - - @Test("capacity guard only removes when over limit") - func capacityGuardRemovesOnlyWhenOverLimit() { - var store = BLEScheduledRelayStore() - - store.schedule(DispatchWorkItem {}, messageID: "one") - store.schedule(DispatchWorkItem {}, messageID: "two") - store.removeAllIfOverCapacity(2) - #expect(store.count == 2) - - store.schedule(DispatchWorkItem {}, messageID: "three") - store.removeAllIfOverCapacity(2) - #expect(store.isEmpty) - } -} diff --git a/bitchatTests/Services/BLESelfBroadcastTrackerTests.swift b/bitchatTests/Services/BLESelfBroadcastTrackerTests.swift deleted file mode 100644 index 2f08c9ed..00000000 --- a/bitchatTests/Services/BLESelfBroadcastTrackerTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -struct BLESelfBroadcastTrackerTests { - @Test - func recordAndTakeMessageIDForMatchingPacket() { - let packet = makePacket(timestamp: 1234) - var tracker = BLESelfBroadcastTracker() - - tracker.record(messageID: "local-message", packet: packet, sentAt: Date()) - - #expect(tracker.takeMessageID(for: packet) == "local-message") - #expect(tracker.takeMessageID(for: packet) == nil) - #expect(tracker.isEmpty) - } - - @Test - func differentPacketDoesNotResolveMessageID() { - let packet = makePacket(timestamp: 1234) - let otherPacket = makePacket(timestamp: 1235) - var tracker = BLESelfBroadcastTracker() - - tracker.record(messageID: "local-message", packet: packet, sentAt: Date()) - - #expect(tracker.takeMessageID(for: otherPacket) == nil) - #expect(tracker.count == 1) - } - - @Test - func pruneRemovesEntriesBeforeCutoff() { - let now = Date(timeIntervalSince1970: 100) - let oldPacket = makePacket(timestamp: 1) - let freshPacket = makePacket(timestamp: 2) - var tracker = BLESelfBroadcastTracker() - - tracker.record(messageID: "old", packet: oldPacket, sentAt: now.addingTimeInterval(-10)) - tracker.record(messageID: "fresh", packet: freshPacket, sentAt: now) - - tracker.prune(before: now.addingTimeInterval(-5)) - - #expect(tracker.takeMessageID(for: oldPacket) == nil) - #expect(tracker.takeMessageID(for: freshPacket) == "fresh") - } - - @Test - func removeAllClearsTrackedMessages() { - let packet = makePacket(timestamp: 1234) - var tracker = BLESelfBroadcastTracker() - - tracker.record(messageID: "local-message", packet: packet, sentAt: Date()) - tracker.removeAll() - - #expect(tracker.isEmpty) - #expect(tracker.takeMessageID(for: packet) == nil) - } - - private func makePacket(timestamp: UInt64) -> BitchatPacket { - BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data(hexString: "8877665544332211") ?? Data(), - recipientID: nil, - timestamp: timestamp, - payload: Data("hello".utf8), - signature: nil, - ttl: TransportConfig.messageTTLDefault - ) - } -} diff --git a/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift b/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift deleted file mode 100644 index 5d3315f6..00000000 --- a/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// BLESourceRouteFailureCacheTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -struct BLESourceRouteFailureCacheTests { - private let recipient = PeerID(str: "0102030405060708") - private let config = BLESourceRouteFailureCache.Config( - confirmationWindowSeconds: 10, - suppressionSeconds: 60 - ) - - private func attempts(_ cache: inout BLESourceRouteFailureCache, at date: Date) -> Bool { - cache.shouldAttemptRoute(to: recipient, now: date) - } - - @Test func allowsRoutingByDefault() { - var cache = BLESourceRouteFailureCache(config: config) - #expect(attempts(&cache, at: Date())) - } - - @Test func unconfirmedRoutedSendSuppressesRouting() { - var cache = BLESourceRouteFailureCache(config: config) - let t0 = Date() - - cache.noteRoutedSend(to: recipient, now: t0) - // Inside the confirmation window: keep routing. - #expect(attempts(&cache, at: t0.addingTimeInterval(5))) - // Past the window with no inbound traffic: route failed, flood. - #expect(!attempts(&cache, at: t0.addingTimeInterval(11))) - // Still suppressed for the suppression TTL. - #expect(!attempts(&cache, at: t0.addingTimeInterval(40))) - // Suppression lapses: routing may be attempted again. - #expect(attempts(&cache, at: t0.addingTimeInterval(11 + 61))) - } - - @Test func inboundActivityConfirmsPendingSend() { - var cache = BLESourceRouteFailureCache(config: config) - let t0 = Date() - - cache.noteRoutedSend(to: recipient, now: t0) - cache.noteInboundActivity(from: recipient) - // Confirmed: no suppression even long after the window. - #expect(attempts(&cache, at: t0.addingTimeInterval(30))) - } - - @Test func inboundActivityDoesNotLiftActiveSuppression() { - var cache = BLESourceRouteFailureCache(config: config) - let t0 = Date() - - cache.noteRoutedSend(to: recipient, now: t0) - // Trip the failure → suppression starts at t0+15. - #expect(!attempts(&cache, at: t0.addingTimeInterval(15))) - // Inbound traffic may have arrived via flood; suppression holds. - cache.noteInboundActivity(from: recipient) - #expect(!attempts(&cache, at: t0.addingTimeInterval(20))) - #expect(attempts(&cache, at: t0.addingTimeInterval(15 + 61))) - } - - @Test func backToBackSendsShareOneDeadline() { - var cache = BLESourceRouteFailureCache(config: config) - let t0 = Date() - - cache.noteRoutedSend(to: recipient, now: t0) - cache.noteRoutedSend(to: recipient, now: t0.addingTimeInterval(8)) - // Deadline runs from the first unconfirmed send. - #expect(!attempts(&cache, at: t0.addingTimeInterval(11))) - } - - @Test func pruneDropsExpiredEntries() { - var cache = BLESourceRouteFailureCache(config: config) - let t0 = Date() - - cache.noteRoutedSend(to: recipient, now: t0) - // Past confirmation + suppression: the entry can no longer matter. - cache.prune(now: t0.addingTimeInterval(75)) - #expect(attempts(&cache, at: t0.addingTimeInterval(76))) - } - - @Test func pruneKeepsEntriesThatStillMatter() { - var cache = BLESourceRouteFailureCache(config: config) - let t0 = Date() - - cache.noteRoutedSend(to: recipient, now: t0) - cache.prune(now: t0.addingTimeInterval(30)) - // The unconverted pending entry survives pruning and still converts - // into a suppression on the next routing decision. - #expect(!attempts(&cache, at: t0.addingTimeInterval(31))) - } -} diff --git a/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift deleted file mode 100644 index c762a0c7..00000000 --- a/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// BLESourceRouteOriginationPolicyTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -struct BLESourceRouteOriginationPolicyTests { - private let localPeerIDData = Data(hexString: "0102030405060708")! - private let recipient = PeerID(str: "1112131415161718") - private let hop = Data(hexString: "2122232425262728")! - - private func makePacket( - senderID: Data? = nil, - recipientID: Data? = Data(hexString: "1112131415161718"), - ttl: UInt8 = 7 - ) -> BitchatPacket { - BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: senderID ?? localPeerIDData, - recipientID: recipientID, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: Data([0x01]), - signature: nil, - ttl: ttl - ) - } - - private func route( - packet: BitchatPacket, - isRecipientConnected: Bool = false, - shouldAttemptRoute: Bool = true, - computedRoute: [Data]? = nil - ) -> [Data]? { - BLESourceRouteOriginationPolicy.route( - for: packet, - to: recipient, - localPeerIDData: localPeerIDData, - isRecipientConnected: { _ in isRecipientConnected }, - shouldAttemptRoute: { _ in shouldAttemptRoute }, - computeRoute: { _ in computedRoute ?? [self.hop] } - ) - } - - @Test func routesWhenAllGatesPass() { - #expect(route(packet: makePacket()) == [hop]) - } - - @Test func relayedPacketNeverGetsRoute() { - let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011")) - #expect(route(packet: relayed) == nil) - } - - @Test func broadcastRecipientNeverGetsRoute() { - let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8)) - #expect(route(packet: broadcast) == nil) - let noRecipient = makePacket(recipientID: nil) - #expect(route(packet: noRecipient) == nil) - } - - @Test func linkLocalTTLNeverGetsRoute() { - // TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops. - #expect(route(packet: makePacket(ttl: 0)) == nil) - #expect(route(packet: makePacket(ttl: 1)) == nil) - } - - @Test func directlyConnectedRecipientNeverGetsRoute() { - #expect(route(packet: makePacket(), isRecipientConnected: true) == nil) - } - - @Test func suppressedRecipientFallsBackToFlood() { - #expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil) - } - - @Test func missingOrEmptyRouteFallsBackToFlood() { - var sawComputeRoute = false - let result = BLESourceRouteOriginationPolicy.route( - for: makePacket(), - to: recipient, - localPeerIDData: localPeerIDData, - isRecipientConnected: { _ in false }, - shouldAttemptRoute: { _ in true }, - computeRoute: { _ in - sawComputeRoute = true - return nil - } - ) - #expect(result == nil) - #expect(sawComputeRoute) - #expect(route(packet: makePacket(), computedRoute: []) == nil) - } -} diff --git a/bitchatTests/Services/BLESubscriptionAnnounceLimiterTests.swift b/bitchatTests/Services/BLESubscriptionAnnounceLimiterTests.swift deleted file mode 100644 index 4f6966c3..00000000 --- a/bitchatTests/Services/BLESubscriptionAnnounceLimiterTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -@Suite("BLE subscription announce limiter tests") -struct BLESubscriptionAnnounceLimiterTests { - @Test("first subscription is allowed and repeated subscriptions are rate limited") - func repeatedSubscriptionsAreRateLimited() { - var limiter = BLESubscriptionAnnounceLimiter() - let centralID = "central-a" - let now = Date(timeIntervalSince1970: 100) - - #expect(limiter.decision(for: centralID, now: now) == .allowed) - #expect(limiter.trackedCentralCount == 1) - - let second = limiter.decision(for: centralID, now: now.addingTimeInterval(0.1)) - #expect(second == .rateLimited( - backoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds, - attemptCount: 1, - suppressAnnounce: false - )) - } - - @Test("rapid subscription attempts eventually suppress announces") - func rapidAttemptsSuppressAnnouncesAtThreshold() { - var limiter = BLESubscriptionAnnounceLimiter() - let centralID = "central-a" - let now = Date(timeIntervalSince1970: 100) - - #expect(limiter.decision(for: centralID, now: now) == .allowed) - - var decision = BLESubscriptionAnnounceDecision.allowed - for attempt in 2...TransportConfig.bleSubscriptionRateLimitMaxAttempts { - decision = limiter.decision( - for: centralID, - now: now.addingTimeInterval(Double(attempt) * 0.01) - ) - } - - if case let .rateLimited(_, _, suppressAnnounce) = decision { - #expect(suppressAnnounce) - } else { - Issue.record("Expected rate-limited decision at suppression threshold") - } - } - - @Test("stale limiter entries are pruned on the next decision") - func staleEntriesArePruned() { - var limiter = BLESubscriptionAnnounceLimiter() - let staleCentralID = "central-a" - let freshCentralID = "central-b" - let now = Date(timeIntervalSince1970: 100) - - #expect(limiter.decision(for: staleCentralID, now: now) == .allowed) - #expect(limiter.trackedCentralCount == 1) - - let afterWindow = now.addingTimeInterval(TransportConfig.bleSubscriptionRateLimitWindowSeconds + 1) - #expect(limiter.decision(for: freshCentralID, now: afterWindow) == .allowed) - #expect(limiter.trackedCentralCount == 1) - } -} diff --git a/bitchatTests/Services/BoardAlertsModelTests.swift b/bitchatTests/Services/BoardAlertsModelTests.swift deleted file mode 100644 index df891cea..00000000 --- a/bitchatTests/Services/BoardAlertsModelTests.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// BoardAlertsModelTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Combine -import Foundation -import Testing -@testable import bitchat - -@MainActor -struct BoardAlertsModelTests { - - private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) - private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } - private let ownKey = Data(repeating: 7, count: 32) - - private final class Harness { - var lines: [(content: String, geohash: String)] = [] - var pendingFlushes: [@MainActor () -> Void] = [] - - @MainActor - func flushAll() { - let flushes = pendingFlushes - pendingFlushes = [] - for flush in flushes { flush() } - } - } - - private func makeModel(harness: Harness, now: Date? = nil) -> BoardAlertsModel { - let fixedNow = now ?? baseDate - return BoardAlertsModel( - arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(), - dependencies: BoardAlertsModel.Dependencies( - isOwnPost: { [ownKey] in $0.authorSigningKey == ownKey }, - emitSystemLine: { content, geohash in - harness.lines.append((content, geohash)) - }, - now: { fixedNow }, - scheduleFlush: { flush in - harness.pendingFlushes.append(flush) - } - ) - ) - } - - private func makePost( - content: String = "hello", - geohash: String = "9q8yy", - nickname: String = "alice", - createdAt: UInt64? = nil, - urgent: Bool = false, - authorKey: Data = Data(repeating: 1, count: 32), - postID: Data? = nil - ) -> BoardPostPacket { - BoardPostPacket( - postID: postID ?? Data((0..<16).map { _ in UInt8.random(in: 0...255) }), - geohash: geohash, - content: content, - authorSigningKey: authorKey, - authorNickname: nickname, - createdAt: createdAt ?? baseMs, - expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000, - flags: urgent ? BoardPostPacket.urgentFlag : 0, - signature: Data(repeating: 2, count: 64) - ) - } - - @Test - func ownPosts_neverBadgeOrAlert() { - let harness = Harness() - let model = makeModel(harness: harness) - - model.handleArrival(makePost(urgent: true, authorKey: ownKey)) - harness.flushAll() - - #expect(model.unseenCount(forGeohash: "9q8yy") == 0) - #expect(harness.lines.isEmpty) - } - - @Test - func routinePost_badgesWithoutChatLine() { - let harness = Harness() - let model = makeModel(harness: harness) - - model.handleArrival(makePost(geohash: "")) - harness.flushAll() - - #expect(model.unseenCount(forGeohash: "") == 1) - #expect(model.unseenCount(forGeohash: "9q8yy") == 0) - #expect(harness.lines.isEmpty) - } - - @Test - func urgentRecentPost_emitsLineInMatchingScope() { - let harness = Harness() - let model = makeModel(harness: harness) - - model.handleArrival(makePost(content: "road closed", geohash: "9q8yy", urgent: true)) - #expect(harness.lines.isEmpty) - harness.flushAll() - - #expect(harness.lines.count == 1) - #expect(harness.lines[0].geohash == "9q8yy") - #expect(harness.lines[0].content.contains("road closed")) - #expect(harness.lines[0].content.contains("@alice")) - } - - @Test - func urgentBackfilledPost_badgesOnly() { - let harness = Harness() - let arrivalTime = baseDate.addingTimeInterval(BoardAlertsModel.inlineRecencyWindow + 120) - let model = makeModel(harness: harness, now: arrivalTime) - - model.handleArrival(makePost(createdAt: baseMs, urgent: true)) - harness.flushAll() - - #expect(model.unseenCount(forGeohash: "9q8yy") == 1) - #expect(harness.lines.isEmpty) - } - - @Test - func simultaneousUrgentPosts_collapseIntoOneLine() { - let harness = Harness() - let model = makeModel(harness: harness) - - model.handleArrival(makePost(content: "one", urgent: true)) - model.handleArrival(makePost(content: "two", urgent: true)) - model.handleArrival(makePost(content: "three", urgent: true)) - harness.flushAll() - - #expect(harness.lines.count == 1) - #expect(harness.lines[0].content.contains("3")) - #expect(harness.pendingFlushes.isEmpty) - } - - @Test - func urgentPostsInDifferentScopes_alertEachScope() { - let harness = Harness() - let model = makeModel(harness: harness) - - model.handleArrival(makePost(content: "geo pin", geohash: "9q8yy", urgent: true)) - model.handleArrival(makePost(content: "mesh pin", geohash: "", urgent: true)) - harness.flushAll() - - #expect(harness.lines.count == 2) - #expect(Set(harness.lines.map(\.geohash)) == ["9q8yy", ""]) - } - - @Test - func duplicateArrival_isHandledOnce() { - let harness = Harness() - let model = makeModel(harness: harness) - let id = Data(repeating: 3, count: 16) - - model.handleArrival(makePost(urgent: true, postID: id)) - model.handleArrival(makePost(urgent: true, postID: id)) - harness.flushAll() - - #expect(model.unseenCount(forGeohash: "9q8yy") == 1) - #expect(harness.lines.count == 1) - #expect(!harness.lines[0].content.contains("2")) - } - - @Test - func markSeen_clearsOnlyVisibleScopes() { - let harness = Harness() - let model = makeModel(harness: harness) - - model.handleArrival(makePost(geohash: "")) - model.handleArrival(makePost(geohash: "9q8yy")) - model.handleArrival(makePost(geohash: "u4pruyd")) - - // Opening the sheet on mesh + 9q8yy must not eat the badge for the - // never-shown u4pruyd channel. - model.markSeen(forScopes: ["", "9q8yy"]) - - #expect(model.unseenCount(forGeohash: "") == 0) - #expect(model.unseenCount(forGeohash: "9q8yy") == 0) - #expect(model.unseenCount(forGeohash: "u4pruyd") == 1) - } - - @Test - func reset_dropsPendingUrgentLinesAndBadges() { - let harness = Harness() - let model = makeModel(harness: harness) - - model.handleArrival(makePost(content: "pre-wipe secret", urgent: true)) - #expect(harness.pendingFlushes.count == 1) - - // Panic wipe lands before the collapse flush fires. - model.reset() - harness.flushAll() - - #expect(harness.lines.isEmpty) - #expect(model.unseenCount(forGeohash: "9q8yy") == 0) - } - - @Test - func longUrgentContent_isTruncatedInLine() { - let harness = Harness() - let model = makeModel(harness: harness) - let long = String(repeating: "a", count: 400) - - model.handleArrival(makePost(content: long, urgent: true)) - harness.flushAll() - - #expect(harness.lines.count == 1) - #expect(harness.lines[0].content.count < 200) - #expect(harness.lines[0].content.contains("…")) - } -} diff --git a/bitchatTests/Services/BoardStoreTests.swift b/bitchatTests/Services/BoardStoreTests.swift deleted file mode 100644 index d501de39..00000000 --- a/bitchatTests/Services/BoardStoreTests.swift +++ /dev/null @@ -1,385 +0,0 @@ -// -// BoardStoreTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import CryptoKit -import Foundation -import Testing -@testable import bitchat - -struct BoardStoreTests { - - private final class MutableClock: @unchecked Sendable { - var now: Date - init(now: Date) { self.now = now } - } - - private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) - private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } - - private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore { - BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now }) - } - - private func tempFileURL() -> URL { - FileManager.default.temporaryDirectory - .appendingPathComponent("board-store-\(UUID().uuidString).json") - } - - private func makePost( - author: Curve25519.Signing.PrivateKey, - geohash: String = "9q8yy", - content: String = "note", - createdAt: UInt64, - lifetimeMs: UInt64 = 24 * 60 * 60 * 1000 - ) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) { - let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) - let key = author.publicKey.rawRepresentation - let expiresAt = createdAt + lifetimeMs - let signingBytes = BoardPostPacket.signingBytes( - postID: postID, - geohash: geohash, - content: content, - authorSigningKey: key, - authorNickname: "tester", - createdAt: createdAt, - expiresAt: expiresAt, - flags: 0 - ) - let post = BoardPostPacket( - postID: postID, - geohash: geohash, - content: content, - authorSigningKey: key, - authorNickname: "tester", - createdAt: createdAt, - expiresAt: expiresAt, - flags: 0, - signature: try author.signature(for: signingBytes) - ) - let wire = BoardWire.post(post) - return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post) - } - - private func makeTombstone( - for post: BoardPostPacket, - author: Curve25519.Signing.PrivateKey, - deletedAt: UInt64, - claimKey: Data? = nil - ) throws -> (wire: BoardWire, packet: BitchatPacket) { - let tombstone = BoardTombstonePacket( - postID: post.postID, - authorSigningKey: claimKey ?? author.publicKey.rawRepresentation, - deletedAt: deletedAt, - signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)) - ) - let wire = BoardWire.tombstone(tombstone) - return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt)) - } - - private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket { - BitchatPacket( - type: MessageType.boardPost.rawValue, - senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }), - recipientID: nil, - timestamp: timestamp, - payload: payload, - signature: nil, - ttl: 7 - ) - } - - // MARK: - Ingest basics - - @Test func ingestStoresAndDeduplicates() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - let entry = try makePost(author: author, createdAt: baseMs) - - #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) - #expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate) - #expect(store.posts(forGeohash: "9q8yy").count == 1) - #expect(store.posts(forGeohash: "").isEmpty) - #expect(store.syncCandidates().count == 1) - } - - @Test func rejectsAlreadyExpiredPost() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000) - - #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) - #expect(store.posts(forGeohash: "9q8yy").isEmpty) - } - - // MARK: - Receive-time timestamp policy - - @Test func rejectsPostCreatedBeyondClockSkew() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs + 60 * 1000) - - #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) - #expect(store.posts(forGeohash: "9q8yy").isEmpty) - } - - @Test func acceptsPostCreatedWithinClockSkew() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs - 60 * 1000) - - #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) - #expect(store.posts(forGeohash: "9q8yy").count == 1) - } - - @Test func rejectsPostExpiringTooFarInTheFuture() throws { - // Builds the wire directly, bypassing the decoder's span check, to - // exercise the ingest-level expiresAt bound on its own. - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 30 * 24 * 60 * 60 * 1000) - - #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) - #expect(store.posts(forGeohash: "9q8yy").isEmpty) - } - - // MARK: - Caps and eviction - - @Test func perAuthorCapEvictsOldest() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - - var oldestID: Data? - for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) { - let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000) - if index == 0 { oldestID = entry.post.postID } - #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) - } - - let posts = store.posts(forGeohash: "9q8yy") - #expect(posts.count == BoardStore.Limits.maxPostsPerAuthor) - #expect(!posts.contains { $0.postID == oldestID }) - } - - @Test func globalCapEvictsOldest() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - - var oldestID: Data? - var author = Curve25519.Signing.PrivateKey() - for index in 0..<(BoardStore.Limits.maxPosts + 1) { - if index % BoardStore.Limits.maxPostsPerAuthor == 0 { - author = Curve25519.Signing.PrivateKey() - } - let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000) - if index == 0 { oldestID = entry.post.postID } - #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) - } - - let posts = store.posts(forGeohash: "9q8yy") - #expect(posts.count == BoardStore.Limits.maxPosts) - #expect(!posts.contains { $0.postID == oldestID }) - } - - // MARK: - Expiry sweep - - @Test func expiredPostsAreSwept() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000) - let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000) - store.ingest(shortLived.wire, packet: shortLived.packet) - store.ingest(longLived.wire, packet: longLived.packet) - #expect(store.posts(forGeohash: "9q8yy").count == 2) - - clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later - let remaining = store.posts(forGeohash: "9q8yy") - #expect(remaining.count == 1) - #expect(remaining.first?.postID == longLived.post.postID) - #expect(store.syncCandidates().count == 1) - } - - // MARK: - Tombstones - - @Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000) - store.ingest(entry.wire, packet: entry.packet) - - let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) - #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) - - // Post is gone, tombstone still syncs so the delete propagates. - #expect(store.posts(forGeohash: "9q8yy").isEmpty) - #expect(store.syncCandidates().count == 1) - - // Replayed copy of the deleted post is refused. - #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) - - // After the post's original expiry the tombstone is dropped too. - clock.now = baseDate.addingTimeInterval(25 * 60 * 60) - #expect(store.syncCandidates().isEmpty) - } - - @Test func tombstoneFromWrongKeyIsRejected() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - let attacker = Curve25519.Signing.PrivateKey() - let entry = try makePost(author: author, createdAt: baseMs) - store.ingest(entry.wire, packet: entry.packet) - - // Attacker signs with their own key (self-consistent wire, so it - // passes signature verification) but targets the victim's post. - let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000) - #expect(store.ingest(forged.wire, packet: forged.packet) == .rejected) - #expect(store.posts(forGeohash: "9q8yy").count == 1) - } - - @Test func tombstoneArrivingBeforePostSuppressesIt() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - let entry = try makePost(author: author, createdAt: baseMs) - - let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) - #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) - #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) - #expect(store.posts(forGeohash: "9q8yy").isEmpty) - } - - @Test func orphanTombstoneRetentionIsBoundedByReceiveTime() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - let entry = try makePost(author: author, createdAt: baseMs) // never ingested - - // Attacker-chosen far-future deletedAt must not extend retention. - let farFuture = baseMs + 365 * 24 * 60 * 60 * 1000 - let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: farFuture) - #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) - #expect(store.syncCandidates().count == 1) - - // No post can outlive 7 days from receipt, so neither may an orphan - // tombstone (plus skew allowance). - clock.now = baseDate.addingTimeInterval(8 * 24 * 60 * 60) - #expect(store.syncCandidates().isEmpty) - } - - @Test func orphanTombstonePerAuthorCapEvictsOldest() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - - var unseenPosts: [(wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket)] = [] - for index in 0..<(BoardStore.Limits.maxOrphanTombstonesPerAuthor + 1) { - let entry = try makePost(author: author, createdAt: baseMs + UInt64(index)) - unseenPosts.append(entry) - let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) - #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) - } - - #expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstonesPerAuthor) - // The oldest orphan was evicted, so its post is no longer suppressed… - #expect(store.ingest(unseenPosts[0].wire, packet: unseenPosts[0].packet) == .accepted) - // …while the surviving orphans still suppress theirs. - #expect(store.ingest(unseenPosts[1].wire, packet: unseenPosts[1].packet) == .rejected) - } - - @Test func orphanTombstoneGlobalCapEvictsOldest() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - - var author = Curve25519.Signing.PrivateKey() - for index in 0..<(BoardStore.Limits.maxOrphanTombstones + 1) { - if index % BoardStore.Limits.maxOrphanTombstonesPerAuthor == 0 { - author = Curve25519.Signing.PrivateKey() - } - let entry = try makePost(author: author, createdAt: baseMs + UInt64(index)) - let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) - #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) - } - - #expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstones) - } - - @Test func matchedTombstonesAreExemptFromOrphanCaps() throws { - let clock = MutableClock(now: baseDate) - let store = makeStore(clock: clock) - let author = Curve25519.Signing.PrivateKey() - - // Post-then-delete more times than the per-author orphan cap; every - // tombstone matched a live post, so none may be evicted. - let cycles = BoardStore.Limits.maxOrphanTombstonesPerAuthor + 2 - for index in 0.. -// - -import BitFoundation -import CryptoKit -import Foundation -import Testing -@testable import bitchat - -@Suite("Courier over the bridge") -@MainActor -struct BridgeCourierServiceTests { - /// Closure-injected harness around `BridgeCourierService`. - @MainActor - private final class Fixture { - var bridgeOn = true - var relaysConnected = true - var myKey: Data? = Fixture.randomKey() - var localPeers: [(peerID: PeerID, noiseKey: Data)] = [] - var held: [CourierEnvelope] = [] - var sealResult: CourierEnvelope? - var deliverResult = true - var openResult = true - /// nil leaves the simulated relay confirmation in flight. - var automaticPublishResult: Bool? = true - - private(set) var publishedEvents: [NostrEvent] = [] - private(set) var openedSubscriptions: [[String]] = [] - private(set) var closedSubscriptions = 0 - private(set) var openedEnvelopes: [CourierEnvelope] = [] - private(set) var delivered: [(envelope: CourierEnvelope, peer: PeerID)] = [] - private(set) var sealRequests: [(content: String, messageID: String, key: Data)] = [] - private(set) var heldCooldowns: [TimeInterval] = [] - private(set) var markedHeldEnvelopes: [CourierEnvelope] = [] - private(set) var scheduledTimers: [(delay: TimeInterval, fire: @MainActor () -> Void)] = [] - private(set) var pendingPublishCompletions: [@MainActor (Bool) -> Void] = [] - - let service: BridgeCourierService - - init(now: @escaping () -> Date = Date.init, dedupStore: BridgeDropDedupStore? = nil) { - service = BridgeCourierService(now: now, dedupStore: dedupStore) - service.bridgeEnabled = { [weak self] in self?.bridgeOn ?? false } - service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } - service.publishEvent = { [weak self] event, completion in - guard let self else { - completion(false) - return - } - self.publishedEvents.append(event) - if let result = self.automaticPublishResult { - completion(result) - } else { - self.pendingPublishCompletions.append(completion) - } - } - service.openSubscription = { [weak self] tags in self?.openedSubscriptions.append(tags) } - service.closeSubscription = { [weak self] in self?.closedSubscriptions += 1 } - service.myNoiseKey = { [weak self] in self?.myKey } - service.localVerifiedPeers = { [weak self] in self?.localPeers ?? [] } - service.sealEnvelope = { [weak self] content, messageID, key in - self?.sealRequests.append((content, messageID, key)) - return self?.sealResult - } - service.openEnvelope = { [weak self] envelope in - self?.openedEnvelopes.append(envelope) - return self?.openResult ?? false - } - service.deliverToPeer = { [weak self] envelope, peer in - self?.delivered.append((envelope, peer)) - return self?.deliverResult ?? false - } - service.heldEnvelopes = { [weak self] cooldown in - self?.heldCooldowns.append(cooldown) - return self?.held ?? [] - } - service.markHeldEnvelopePublished = { [weak self] envelope in - self?.markedHeldEnvelopes.append(envelope) - } - service.scheduleTimer = { [weak self] delay, fire in - self?.scheduledTimers.append((delay, fire)) - } - } - - func resolveNextPublish(_ succeeded: Bool) { - guard !pendingPublishCompletions.isEmpty else { return } - pendingPublishCompletions.removeFirst()(succeeded) - } - - static func randomKey() -> Data { - Data((0..<32).map { _ in UInt8.random(in: 0...255) }) - } - } - - private func makeEnvelope(recipientKey: Data, ciphertext: Data = Data(repeating: 7, count: 64)) -> CourierEnvelope { - CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag( - noiseStaticKey: recipientKey, - epochDay: CourierEnvelope.epochDay(for: Date()) - ), - expiry: UInt64((Date().timeIntervalSince1970 + 3600) * 1000), - ciphertext: ciphertext, - copies: 1 - ) - } - - private func makeDropEvent(for envelope: CourierEnvelope) throws -> NostrEvent { - let encoded = try #require(envelope.encode()) - let identity = try #require(BridgeCourierService.makeThrowawayIdentity()) - return try NostrProtocol.createCourierDropEvent( - envelope: encoded, - recipientTagHex: envelope.recipientTag.hexEncodedString(), - expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000), - senderIdentity: identity - ) - } - - // MARK: - Sender role - - @Test func depositSealsAndPublishesOnce() throws { - let fixture = Fixture() - let recipientKey = Fixture.randomKey() - fixture.sealResult = makeEnvelope(recipientKey: recipientKey) - let messageID = UUID().uuidString - - fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) - fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) - - #expect(fixture.sealRequests.count == 1) - #expect(fixture.publishedEvents.count == 1) - let event = try #require(fixture.publishedEvents.first) - #expect(event.kind == NostrProtocol.EventKind.courierDrop.rawValue) - #expect(event.isValidSignature()) - #expect(event.tags.contains { $0.count >= 2 && $0[0] == "x" && $0[1] == fixture.sealResult?.recipientTag.hexEncodedString() }) - #expect(event.tags.contains { $0.count >= 2 && $0[0] == "expiration" }) - } - - @Test func depositRequiresBridgeToggle() { - let fixture = Fixture() - fixture.bridgeOn = false - let key = Fixture.randomKey() - fixture.sealResult = makeEnvelope(recipientKey: key) - - fixture.service.depositDrop(content: "hi", messageID: UUID().uuidString, recipientNoiseKey: key) - - #expect(fixture.publishedEvents.isEmpty) - #expect(fixture.sealRequests.isEmpty) - } - - @Test func missingRelayPublisherDoesNotConsumeDurableDedupSlot() { - let fixture = Fixture() - let key = Fixture.randomKey() - fixture.sealResult = makeEnvelope(recipientKey: key) - fixture.service.publishEvent = nil - let messageID = UUID().uuidString - var results: [Bool] = [] - - fixture.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { results.append($0) } - fixture.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { results.append($0) } - #expect(fixture.sealRequests.count == 2) - #expect(fixture.publishedEvents.isEmpty) - #expect(results == [false, false]) - } - - @Test func relayRejectionDoesNotPersistDedupAndRetryCanSucceed() { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - let key = Fixture.randomKey() - let messageID = UUID().uuidString - - let failed = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - failed.sealResult = makeEnvelope(recipientKey: key) - failed.automaticPublishResult = false - var failedResults: [Bool] = [] - failed.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { - failedResults.append($0) - } - failed.service.flushDedupSnapshot() - #expect(failedResults == [false]) - #expect(failed.publishedEvents.count == 1) - - // A relaunch over the failed attempt must be allowed to send again. - let retry = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - retry.sealResult = makeEnvelope(recipientKey: key) - var retryResults: [Bool] = [] - retry.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { - retryResults.append($0) - } - retry.service.flushDedupSnapshot() - #expect(retryResults == [true]) - #expect(retry.publishedEvents.count == 1) - - // Only confirmed relay acceptance consumes durable dedup. - let confirmed = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - confirmed.sealResult = makeEnvelope(recipientKey: key) - confirmed.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) - #expect(confirmed.publishedEvents.isEmpty) - #expect(confirmed.sealRequests.isEmpty) - } - - @Test func sameMessageIDIsScopedByRecipientAcrossRejectedActiveAndPersistedState() { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - let rejectedKey = Fixture.randomKey() - let firstKey = Fixture.randomKey() - let secondKey = Fixture.randomKey() - let thirdKey = Fixture.randomKey() - let messageID = "recipient-scoped-collision" - - let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - fixture.sealResult = makeEnvelope( - recipientKey: rejectedKey, - ciphertext: Data( - repeating: 7, - count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1 - ) - ) - var rejectedResults: [Bool] = [] - fixture.service.depositDrop( - content: "rejected", - messageID: messageID, - recipientNoiseKey: rejectedKey - ) { rejectedResults.append($0) } - #expect(rejectedResults == [false]) - - fixture.sealResult = makeEnvelope(recipientKey: firstKey) - fixture.automaticPublishResult = nil - var firstResults: [Bool] = [] - var secondResults: [Bool] = [] - var duplicateFirstResults: [Bool] = [] - fixture.service.depositDrop( - content: "first", - messageID: messageID, - recipientNoiseKey: firstKey - ) { firstResults.append($0) } - fixture.service.depositDrop( - content: "second", - messageID: messageID, - recipientNoiseKey: secondKey - ) { secondResults.append($0) } - fixture.service.depositDrop( - content: "first duplicate", - messageID: messageID, - recipientNoiseKey: firstKey - ) { duplicateFirstResults.append($0) } - - #expect(fixture.publishedEvents.count == 2) - #expect(fixture.pendingPublishCompletions.count == 2) - #expect(duplicateFirstResults == [false]) - #expect(firstResults.isEmpty) - #expect(secondResults.isEmpty) - - fixture.resolveNextPublish(true) - fixture.resolveNextPublish(true) - #expect(firstResults == [true]) - #expect(secondResults == [true]) - fixture.service.flushDedupSnapshot() - - let relaunched = Fixture( - dedupStore: BridgeDropDedupStore(fileURL: fileURL) - ) - relaunched.sealResult = makeEnvelope(recipientKey: thirdKey) - var relaunchResults: [Bool] = [] - relaunched.service.depositDrop( - content: "first", - messageID: messageID, - recipientNoiseKey: firstKey - ) { relaunchResults.append($0) } - relaunched.service.depositDrop( - content: "second", - messageID: messageID, - recipientNoiseKey: secondKey - ) { relaunchResults.append($0) } - relaunched.service.depositDrop( - content: "third", - messageID: messageID, - recipientNoiseKey: thirdKey - ) { relaunchResults.append($0) } - - #expect(relaunchResults == [false, false, true]) - #expect(relaunched.sealRequests.count == 1) - #expect(relaunched.sealRequests.first?.key == thirdKey) - #expect(relaunched.publishedEvents.count == 1) - } - - @Test func legacyPublishedMessageIDIsWildcardUntilItsOriginalExpiry() { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - var date = Date() - let messageID = "legacy-wildcard" - let recipientKey = Fixture.randomKey() - let store = BridgeDropDedupStore(fileURL: fileURL) - store.save(BridgeDropDedupStore.Snapshot( - publishedDropKeys: [messageID: date], - seenDropEventIDs: [:] - )) - - let fixture = Fixture( - now: { date }, - dedupStore: BridgeDropDedupStore(fileURL: fileURL) - ) - fixture.sealResult = makeEnvelope(recipientKey: recipientKey) - var results: [Bool] = [] - fixture.service.depositDrop( - content: "legacy", - messageID: messageID, - recipientNoiseKey: recipientKey - ) { results.append($0) } - #expect(results == [false]) - #expect(fixture.sealRequests.isEmpty) - - date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1) - fixture.service.depositDrop( - content: "after expiry", - messageID: messageID, - recipientNoiseKey: recipientKey - ) { results.append($0) } - #expect(results == [false, true]) - #expect(fixture.publishedEvents.count == 1) - fixture.service.flushDedupSnapshot() - - let snapshot = BridgeDropDedupStore(fileURL: fileURL).load() - #expect(snapshot.publishedDropKeys[messageID] == nil) - #expect(snapshot.publishedDropKeys.count == 1) - } - - @Test func panicWipeInvalidatesInFlightPublishCompletion() throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - let key = Fixture.randomKey() - let messageID = UUID().uuidString - let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - fixture.sealResult = makeEnvelope(recipientKey: key) - fixture.automaticPublishResult = nil - var results: [Bool] = [] - - fixture.service.depositDrop(content: "in flight", messageID: messageID, recipientNoiseKey: key) { - results.append($0) - } - let staleCompletion = try #require(fixture.pendingPublishCompletions.first) - fixture.service.wipe() - #expect(results == [false]) - - // The pre-wipe relay completion cannot resurrect durable dedup or - // complete the caller a second time. - staleCompletion(true) - fixture.service.flushDedupSnapshot() - #expect(results == [false]) - - let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - relaunched.sealResult = makeEnvelope(recipientKey: key) - relaunched.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) - #expect(relaunched.publishedEvents.count == 1) - } - - @Test func bridgeDisableCancelsPendingAndInFlightPublishes() throws { - let fixture = Fixture() - let key = Fixture.randomKey() - fixture.sealResult = makeEnvelope(recipientKey: key) - fixture.automaticPublishResult = nil - var results: [Bool] = [] - - fixture.service.depositDrop(content: "in flight", messageID: "in-flight", recipientNoiseKey: key) { - results.append($0) - } - let staleCompletion = try #require(fixture.pendingPublishCompletions.first) - - fixture.relaysConnected = false - fixture.service.depositDrop(content: "pending", messageID: "pending", recipientNoiseKey: key) { - results.append($0) - } - #expect(fixture.service.pendingDrops.count == 1) - - fixture.bridgeOn = false - fixture.service.refresh() - #expect(results == [false, false]) - #expect(fixture.service.pendingDrops.isEmpty) - - staleCompletion(true) - #expect(results == [false, false]) - } - - @Test func depositQueuesWithoutRelaysAndFlushesOnReconnect() { - let fixture = Fixture() - fixture.relaysConnected = false - let key = Fixture.randomKey() - fixture.sealResult = makeEnvelope(recipientKey: key) - - fixture.service.depositDrop(content: "later", messageID: UUID().uuidString, recipientNoiseKey: key) - #expect(fixture.publishedEvents.isEmpty) - #expect(fixture.service.pendingDrops.count == 1) - - fixture.relaysConnected = true - fixture.service.flushPendingDrops() - #expect(fixture.publishedEvents.count == 1) - #expect(fixture.service.pendingDrops.isEmpty) - } - - @Test func evictedPendingDropStaysRetryable() { - // Regression: a drop queued while relays are down but then evicted - // (oldest-out at capacity) before it ever published must release its - // sender-side dedup slot, or the router marks it "carried" and can - // never re-deposit it. - let fixture = Fixture() - fixture.relaysConnected = false - let key = Fixture.randomKey() - fixture.sealResult = makeEnvelope(recipientKey: key) - - let firstID = UUID().uuidString - var firstResults: [Bool] = [] - fixture.service.depositDrop(content: "0", messageID: firstID, recipientNoiseKey: key) { firstResults.append($0) } - // Fill past capacity so the first drop is evicted. - for i in 1...BridgeCourierService.Limits.maxPendingDrops { - fixture.service.depositDrop(content: "\(i)", messageID: UUID().uuidString, recipientNoiseKey: key) - } - #expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops) - #expect(firstResults == [false]) - - // The evicted first drop is deposit-able again (slot released). - let sealCountBeforeRetry = fixture.sealRequests.count - fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key) - #expect(fixture.sealRequests.count == sealCountBeforeRetry + 1) - #expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops) - } - - @Test func oversizeDropConsumesSlotInsteadOfChurning() { - // An envelope that encodes over the size cap fails identically on - // every attempt; the dedup slot must be consumed so the retry sweep - // doesn't re-run Noise sealing forever. - let fixture = Fixture() - let key = Fixture.randomKey() - fixture.sealResult = makeEnvelope( - recipientKey: key, - ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1) - ) - let messageID = UUID().uuidString - var results: [Bool] = [] - - fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key) { results.append($0) } - #expect(fixture.publishedEvents.isEmpty) - - // The retry sweep must not seal the same payload again. - fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key) { results.append($0) } - #expect(fixture.sealRequests.count == 1) - #expect(results == [false, false]) - } - - @Test func rejectedOversizeDropKeysExpireAndStayBounded() { - var date = Date(timeIntervalSince1970: 1_750_000_000) - let fixture = Fixture(now: { date }) - let key = Fixture.randomKey() - fixture.sealResult = makeEnvelope( - recipientKey: key, - ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1) - ) - - let firstID = "oversize-0" - fixture.service.depositDrop(content: "big", messageID: firstID, recipientNoiseKey: key) - for index in 1...BridgeCourierService.Limits.maxTrackedIDs { - date = date.addingTimeInterval(1) - fixture.service.depositDrop(content: "big", messageID: "oversize-\(index)", recipientNoiseKey: key) - } - let afterCapacityFill = fixture.sealRequests.count - date = date.addingTimeInterval(1) - fixture.service.depositDrop(content: "big", messageID: firstID, recipientNoiseKey: key) - #expect(fixture.sealRequests.count == afterCapacityFill + 1) - - let newestID = "oversize-\(BridgeCourierService.Limits.maxTrackedIDs)" - let beforeExpiry = fixture.sealRequests.count - fixture.service.depositDrop(content: "big", messageID: newestID, recipientNoiseKey: key) - #expect(fixture.sealRequests.count == beforeExpiry) - - date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1) - fixture.service.depositDrop(content: "big", messageID: newestID, recipientNoiseKey: key) - #expect(fixture.sealRequests.count == beforeExpiry + 1) - } - - @Test func publishedDropDedupSurvivesRelaunch() throws { - // Regression (field-verified amplification storm): the outbox that - // drives re-deposits is persisted, but the sender-side drop dedup was - // in-memory only — every relaunch republished the same undelivered - // message as a fresh drop, and relays hold each for 24h. - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - let recipientKey = Fixture.randomKey() - let messageID = UUID().uuidString - - let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - fixture.sealResult = makeEnvelope(recipientKey: recipientKey) - var publishResults: [Bool] = [] - fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) { publishResults.append($0) } - #expect(fixture.publishedEvents.count == 1) - #expect(publishResults == [true]) - // Persistence is coalesced; a real launch flushes within a second or - // on backgrounding — tests flush explicitly. - fixture.service.flushDedupSnapshot() - - // "Relaunch": a fresh service over the same store must refuse to - // publish the same message ID again (before even re-sealing it). - let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - relaunched.sealResult = makeEnvelope(recipientKey: recipientKey) - var relaunchResults: [Bool] = [] - relaunched.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) { relaunchResults.append($0) } - #expect(relaunched.publishedEvents.isEmpty) - #expect(relaunched.sealRequests.isEmpty) - #expect(relaunchResults == [false]) - } - - @Test func seenDropEventDedupSurvivesRelaunch() throws { - // Same storm, gateway side: relays redeliver the whole 24h drop - // backlog on every launch; a relaunch must not re-open (and re-ack) - // events it already handled. - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - let myKey = try #require(fixture.myKey) - fixture.service.refresh() - let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey)) - fixture.service.handleDropEvent(event) - #expect(fixture.openedEnvelopes.count == 1) - fixture.service.flushDedupSnapshot() - - let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - relaunched.myKey = myKey - relaunched.service.refresh() - relaunched.service.handleDropEvent(event) - #expect(relaunched.openedEnvelopes.isEmpty) - } - - @Test func offlineQueuedDropStaysRedepositableAfterRelaunch() throws { - // A deposit made while relays are down only joins the in-memory - // pending queue. Its dedup key must NOT be durable yet: if the app is - // killed before relays connect, the relaunch loses the queued drop — - // a persisted key would then block every 120s re-deposit for 24h and - // the message would silently never reach a relay. - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - let recipientKey = Fixture.randomKey() - let messageID = UUID().uuidString - - let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - fixture.relaysConnected = false - fixture.sealResult = makeEnvelope(recipientKey: recipientKey) - fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey) - #expect(fixture.publishedEvents.isEmpty) - // Even a flush while the drop is still pending must exclude its key. - fixture.service.flushDedupSnapshot() - - // "App killed before relays connected": pendingDrops were memory-only. - let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - relaunched.sealResult = makeEnvelope(recipientKey: recipientKey) - relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey) - #expect(relaunched.publishedEvents.count == 1) - } - - @Test func publishedPendingDropBecomesDurableAfterFlush() throws { - // Counterpart: once the queued drop actually publishes on reconnect, - // its key becomes durable and a relaunch must not republish. - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - let recipientKey = Fixture.randomKey() - let messageID = UUID().uuidString - - let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - fixture.relaysConnected = false - fixture.sealResult = makeEnvelope(recipientKey: recipientKey) - fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey) - fixture.relaysConnected = true - fixture.service.flushPendingDrops() - #expect(fixture.publishedEvents.count == 1) - fixture.service.flushDedupSnapshot() - - let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) - relaunched.sealResult = makeEnvelope(recipientKey: recipientKey) - var relaunchResults: [Bool] = [] - relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey) { relaunchResults.append($0) } - #expect(relaunched.publishedEvents.isEmpty) - #expect(relaunchResults == [false]) - } - - @Test func failedGatewayHandoffReleasesSeenSlot() throws { - // A gateway's deliverToPeer handoff is best-effort: when it fails - // (the peer walked away between relay fetch and mesh send), the drop - // event must stay retryable — for a single-gateway mesh island this - // gateway is the recipient's only carrier. - let fixture = Fixture() - let peerKey = Fixture.randomKey() - let peer = PeerID(str: "aabbccdd00112233") - fixture.localPeers = [(peer, peerKey)] - fixture.service.refresh() - let event = try makeDropEvent(for: makeEnvelope(recipientKey: peerKey)) - - fixture.deliverResult = false - fixture.service.handleDropEvent(event) - #expect(fixture.delivered.count == 1) - - // Redelivery (relaunch/backlog re-fetch) retries the handoff … - fixture.deliverResult = true - fixture.service.handleDropEvent(event) - #expect(fixture.delivered.count == 2) - - // … and a successful handoff consumes the event for good. - fixture.service.handleDropEvent(event) - #expect(fixture.delivered.count == 2) - } - - @Test func staleWatchSetDeliveryIsNotConsumedBeforePeerBecomesCurrent() throws { - let fixture = Fixture() - let peerKey = Fixture.randomKey() - let peer = PeerID(str: "aabbccdd00112233") - let event = try makeDropEvent(for: makeEnvelope(recipientKey: peerKey)) - - // A callback from the previous relay subscription can land after its - // peer was removed from the bounded watch set. Ignore it without - // poisoning the persistent event-ID dedup record. - fixture.service.refresh() - fixture.service.handleDropEvent(event) - #expect(fixture.delivered.isEmpty) - - fixture.localPeers = [(peer, peerKey)] - fixture.service.refresh() - fixture.service.handleDropEvent(event) - #expect(fixture.delivered.count == 1) - - // Once the current peer's physical handoff succeeds, normal durable - // dedup applies. - fixture.service.handleDropEvent(event) - #expect(fixture.delivered.count == 1) - } - - @Test func distinctDropsUseDistinctThrowawayKeys() { - let fixture = Fixture() - let keyA = Fixture.randomKey() - let keyB = Fixture.randomKey() - fixture.sealResult = makeEnvelope(recipientKey: keyA) - fixture.service.depositDrop(content: "a", messageID: UUID().uuidString, recipientNoiseKey: keyA) - fixture.sealResult = makeEnvelope(recipientKey: keyB) - fixture.service.depositDrop(content: "b", messageID: UUID().uuidString, recipientNoiseKey: keyB) - - #expect(fixture.publishedEvents.count == 2) - #expect(fixture.publishedEvents[0].pubkey != fixture.publishedEvents[1].pubkey) - } - - @Test func bridgingPublishesHeldEnvelopesWithCooldown() { - let fixture = Fixture() - fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())] - - fixture.service.publishHeldEnvelopes() - - #expect(fixture.publishedEvents.count == 1) - #expect(fixture.heldCooldowns == [BridgeCourierService.Limits.heldEnvelopePublishCooldown]) - #expect(fixture.markedHeldEnvelopes == fixture.held) - } - - @Test func rejectedHeldPublishDoesNotStartCooldown() { - let fixture = Fixture() - fixture.automaticPublishResult = false - fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())] - - fixture.service.publishHeldEnvelopes() - - #expect(fixture.publishedEvents.count == 1) - #expect(fixture.markedHeldEnvelopes.isEmpty) - } - - @Test func heldPublishIsSingleFlightAndRetryableAfterRejection() { - let fixture = Fixture() - fixture.automaticPublishResult = nil - fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())] - - fixture.service.publishHeldEnvelopes() - fixture.service.publishHeldEnvelopes() - #expect(fixture.publishedEvents.count == 1) - - fixture.resolveNextPublish(false) - fixture.service.publishHeldEnvelopes() - #expect(fixture.publishedEvents.count == 2) - #expect(fixture.markedHeldEnvelopes.isEmpty) - - fixture.resolveNextPublish(true) - #expect(fixture.markedHeldEnvelopes == fixture.held) - } - - @Test func bridgeDisableInvalidatesHeldPublishOperation() throws { - let fixture = Fixture() - fixture.automaticPublishResult = nil - fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())] - - fixture.service.publishHeldEnvelopes() - let staleCompletion = try #require(fixture.pendingPublishCompletions.first) - fixture.bridgeOn = false - fixture.service.refresh() - staleCompletion(true) - - #expect(fixture.markedHeldEnvelopes.isEmpty) - } - - // MARK: - Subscription management - - @Test func refreshSubscribesOwnCandidateTags() throws { - let fixture = Fixture() - fixture.service.refresh() - - let tags = try #require(fixture.openedSubscriptions.last) - let myKey = try #require(fixture.myKey) - let expected = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).map { $0.hexEncodedString() }) - #expect(Set(tags) == expected) - #expect(tags.count == 3) // adjacent UTC days - } - - @Test func refreshAlsoWatchesLocalVerifiedPeers() throws { - let fixture = Fixture() - let peerKey = Fixture.randomKey() - fixture.localPeers = [(PeerID(str: "aabbccdd00112233"), peerKey)] - - fixture.service.refresh() - - let tags = try #require(fixture.openedSubscriptions.last) - #expect(tags.count == 6) // 3 own + 3 watched - } - - @Test func refreshClosesSubscriptionWhenBridgeOff() { - let fixture = Fixture() - fixture.service.refresh() - #expect(fixture.openedSubscriptions.count == 1) - - fixture.bridgeOn = false - fixture.service.refresh() - #expect(fixture.closedSubscriptions == 1) - } - - @Test func announceDebounceSchedulesTrailingRefreshForPeersLearnedInsideWindow() throws { - var date = Date(timeIntervalSince1970: 1_750_000_000) - let fixture = Fixture(now: { date }) - - // Leading edge opens the own-tag subscription immediately. - fixture.service.refreshAfterVerifiedAnnounce() - #expect(fixture.openedSubscriptions.count == 1) - - // A second peer learned inside the debounce window must not wait for - // the 30-minute periodic timer. - date = date.addingTimeInterval(10) - fixture.localPeers = [(PeerID(str: "aabbccdd00112233"), Fixture.randomKey())] - fixture.service.refreshAfterVerifiedAnnounce() - fixture.service.refreshAfterVerifiedAnnounce() // coalesces, not a second timer - - let trailingTimers = fixture.scheduledTimers.filter { $0.delay < 100 } - #expect(trailingTimers.count == 1) - let trailing = try #require(trailingTimers.first) - #expect(trailing.delay == 50) - date = date.addingTimeInterval(50) - trailing.fire() - - #expect(fixture.openedSubscriptions.count == 2) - #expect(fixture.openedSubscriptions.last?.count == 6) - } - - // MARK: - Inbound drops - - @Test func dropForUsIsOpened() throws { - let fixture = Fixture() - let myKey = try #require(fixture.myKey) - fixture.service.refresh() - let envelope = makeEnvelope(recipientKey: myKey) - - fixture.service.handleDropEvent(try makeDropEvent(for: envelope)) - - #expect(fixture.openedEnvelopes.count == 1) - #expect(fixture.delivered.isEmpty) - } - - @Test func transientOwnDropOpenFailureRemainsRetryable() throws { - let fixture = Fixture() - let myKey = try #require(fixture.myKey) - fixture.service.refresh() - let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey)) - - fixture.openResult = false - fixture.service.handleDropEvent(event) - fixture.openResult = true - fixture.service.handleDropEvent(event) - fixture.service.handleDropEvent(event) - - #expect(fixture.openedEnvelopes.count == 2) - } - - @Test func duplicateDropEventOpensOnce() throws { - let fixture = Fixture() - let myKey = try #require(fixture.myKey) - fixture.service.refresh() - let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey)) - - fixture.service.handleDropEvent(event) - fixture.service.handleDropEvent(event) - - #expect(fixture.openedEnvelopes.count == 1) - } - - @Test func dropForWatchedLocalPeerIsDelivered() throws { - let fixture = Fixture() - let peerKey = Fixture.randomKey() - let peer = PeerID(str: "aabbccdd00112233") - fixture.localPeers = [(peer, peerKey)] - fixture.service.refresh() - let envelope = makeEnvelope(recipientKey: peerKey) - - fixture.service.handleDropEvent(try makeDropEvent(for: envelope)) - - #expect(fixture.delivered.count == 1) - #expect(fixture.delivered.first?.peer == peer) - #expect(fixture.openedEnvelopes.isEmpty) - } - - @Test func dropForStrangerIsIgnored() throws { - let fixture = Fixture() - fixture.service.refresh() - let envelope = makeEnvelope(recipientKey: Fixture.randomKey()) - - fixture.service.handleDropEvent(try makeDropEvent(for: envelope)) - - #expect(fixture.openedEnvelopes.isEmpty) - #expect(fixture.delivered.isEmpty) - } - - @Test func mislabeledDropTagIsRejected() throws { - // The event's filterable #x tag must match the envelope's own tag. - let fixture = Fixture() - let myKey = try #require(fixture.myKey) - fixture.service.refresh() - let envelope = makeEnvelope(recipientKey: Fixture.randomKey()) - let encoded = try #require(envelope.encode()) - let identity = try #require(BridgeCourierService.makeThrowawayIdentity()) - let mislabeled = try NostrProtocol.createCourierDropEvent( - envelope: encoded, - recipientTagHex: CourierEnvelope.recipientTag( - noiseStaticKey: myKey, - epochDay: CourierEnvelope.epochDay(for: Date()) - ).hexEncodedString(), // labeled for us, addressed to a stranger - expiresAt: Date().addingTimeInterval(3600), - senderIdentity: identity - ) - - fixture.service.handleDropEvent(mislabeled) - - #expect(fixture.openedEnvelopes.isEmpty) - #expect(fixture.delivered.isEmpty) - } - - @Test func expiredDropIsIgnored() throws { - let fixture = Fixture() - let myKey = try #require(fixture.myKey) - fixture.service.refresh() - let expired = CourierEnvelope( - recipientTag: CourierEnvelope.recipientTag(noiseStaticKey: myKey, epochDay: CourierEnvelope.epochDay(for: Date())), - expiry: UInt64((Date().timeIntervalSince1970 - 60) * 1000), - ciphertext: Data(repeating: 1, count: 32), - copies: 1 - ) - - fixture.service.handleDropEvent(try makeDropEvent(for: expired)) - - #expect(fixture.openedEnvelopes.isEmpty) - } -} diff --git a/bitchatTests/Services/BridgeDropDedupStoreTests.swift b/bitchatTests/Services/BridgeDropDedupStoreTests.swift deleted file mode 100644 index 9662798a..00000000 --- a/bitchatTests/Services/BridgeDropDedupStoreTests.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// BridgeDropDedupStoreTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -@testable import bitchat - -@Suite("Bridge drop dedup persistence") -struct BridgeDropDedupStoreTests { - - // MARK: - ExpiringIDSet - - @Test func entriesExpireAfterLifetime() { - let start = Date(timeIntervalSince1970: 1_700_000_000) - var set = ExpiringIDSet(capacity: 8, lifetime: 60) - - let inserted = set.insert("a", now: start) - #expect(inserted) - #expect(set.contains("a", now: start)) - let duplicate = set.insert("a", now: start.addingTimeInterval(30)) - #expect(!duplicate) - - // Past the lifetime the slot is free again. - let later = start.addingTimeInterval(61) - #expect(!set.contains("a", now: later)) - let reinserted = set.insert("a", now: later) - #expect(reinserted) - } - - @Test func capacityEvictsOldestFirst() { - let start = Date(timeIntervalSince1970: 1_700_000_000) - var set = ExpiringIDSet(capacity: 2, lifetime: 3600) - - set.insert("oldest", now: start) - set.insert("middle", now: start.addingTimeInterval(1)) - set.insert("newest", now: start.addingTimeInterval(2)) - - let check = start.addingTimeInterval(3) - #expect(!set.contains("oldest", now: check)) - #expect(set.contains("middle", now: check)) - #expect(set.contains("newest", now: check)) - } - - @Test func removeReleasesSlot() { - let now = Date() - var set = ExpiringIDSet(capacity: 8, lifetime: 3600) - set.insert("a", now: now) - set.remove("a") - #expect(!set.contains("a", now: now)) - let reinserted = set.insert("a", now: now) - #expect(reinserted) - } - - @Test func initPrunesExpiredPersistedEntries() { - let now = Date() - let set = ExpiringIDSet( - capacity: 8, - lifetime: 3600, - entries: [ - "stale": now.addingTimeInterval(-7200), - "fresh": now.addingTimeInterval(-60) - ], - now: now - ) - #expect(!set.contains("stale", now: now)) - #expect(set.contains("fresh", now: now)) - } - - // MARK: - Store round trip - - @Test func snapshotRoundTripsThroughDisk() { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("bridge-dedup-store-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - let recorded = Date(timeIntervalSince1970: 1_700_000_000) - - let store = BridgeDropDedupStore(fileURL: fileURL) - store.save(BridgeDropDedupStore.Snapshot( - publishedDropKeys: ["msg-1": recorded], - seenDropEventIDs: ["event-1": recorded] - )) - - let reloaded = BridgeDropDedupStore(fileURL: fileURL).load() - #expect(reloaded.publishedDropKeys["msg-1"] == recorded) - #expect(reloaded.seenDropEventIDs["event-1"] == recorded) - } - - @Test func wipeRemovesTheRecord() { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("bridge-dedup-store-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let store = BridgeDropDedupStore(fileURL: fileURL) - store.save(BridgeDropDedupStore.Snapshot( - publishedDropKeys: ["msg-1": Date()], - seenDropEventIDs: [:] - )) - store.wipe() - - let reloaded = BridgeDropDedupStore(fileURL: fileURL).load() - #expect(reloaded.publishedDropKeys.isEmpty) - #expect(reloaded.seenDropEventIDs.isEmpty) - } - - @Test func nonPersistingStoreStaysEmpty() { - let store = BridgeDropDedupStore(persistsToDisk: false) - store.save(BridgeDropDedupStore.Snapshot( - publishedDropKeys: ["msg-1": Date()], - seenDropEventIDs: [:] - )) - #expect(store.load().publishedDropKeys.isEmpty) - } -} diff --git a/bitchatTests/Services/BridgeServiceTests.swift b/bitchatTests/Services/BridgeServiceTests.swift deleted file mode 100644 index 24ffb54e..00000000 --- a/bitchatTests/Services/BridgeServiceTests.swift +++ /dev/null @@ -1,900 +0,0 @@ -// -// BridgeServiceTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("Mesh bridge policy") -@MainActor -struct BridgeServiceTests { - nonisolated private static let cell = "u4pruy" - - /// Closure-injected harness around `BridgeService` recording every side - /// effect, with a controllable clock, location, and connectivity. - @MainActor - private final class Fixture { - private final class ClockBox { - var now = Date() - } - - var relaysConnected = true - var locationCell: String? = BridgeServiceTests.cell - var meshAdvertisedCell: String? - var bridgePeers: [PeerID] = [] - var sendSucceeds = true - var locallySeenMessageIDs: Set = [] - var injectedPresenceOverride: ((String) -> Bool)? - var nickname = "tester" - - private(set) var published: [(event: NostrEvent, cell: String)] = [] - - /// Published chat messages only — the fixture's own presence - /// heartbeats (kind 20001, sent on enable) are filtered out. - var publishedMessages: [(event: NostrEvent, cell: String)] { - published.filter { $0.event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue } - } - private(set) var broadcasts: [Data] = [] - private(set) var injected: [BridgeService.InboundBridgeMessage] = [] - private(set) var removedInjectedMessageIDs: [String] = [] - private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = [] - private(set) var openedSubscriptions: [[String]] = [] - private(set) var closedSubscriptions = 0 - private(set) var enabledChanges: [Bool] = [] - private(set) var locationFixRequests = 0 - private(set) var cellChanges: [String?] = [] - private(set) var scheduledTimers: [(delay: TimeInterval, work: @MainActor () -> Void)] = [] - - private let clock = ClockBox() - let identity: NostrIdentity - let defaults: UserDefaults - let service: BridgeService - - init( - enabled: Bool = true, - verifyEventSignature: @escaping (NostrEvent) -> Bool = { $0.isValidSignature() } - ) { - let suite = "BridgeServiceTests-\(UUID().uuidString)" - defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - identity = try! NostrIdentity.generate() - let clock = clock - service = BridgeService( - defaults: defaults, - now: { clock.now }, - verifyEventSignature: verifyEventSignature - ) - service.publishToRelays = { [weak self] event, cell in - self?.published.append((event, cell)) - } - service.openSubscription = { [weak self] cells in - self?.openedSubscriptions.append(cells) - } - service.closeSubscription = { [weak self] in - self?.closedSubscriptions += 1 - } - service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } - service.locationCell = { [weak self] in self?.locationCell } - service.requestLocationFix = { [weak self] in self?.locationFixRequests += 1 } - service.meshAdvertisedCell = { [weak self] in self?.meshAdvertisedCell } - service.sendToBridgePeer = { [weak self] payload, peer in - guard let self, self.sendSucceeds else { return false } - self.uplinkSends.append((payload, peer)) - return true - } - service.availableBridgePeers = { [weak self] in self?.bridgePeers ?? [] } - service.broadcastToMesh = { [weak self] payload in - self?.broadcasts.append(payload) - } - service.injectInbound = { [weak self] message in - self?.injected.append(message) - } - service.removeInjectedInbound = { [weak self] messageID in - self?.removedInjectedMessageIDs.append(messageID) - } - service.isInjectedInboundPresent = { [weak self] messageID in - guard let self else { return false } - return self.injectedPresenceOverride?(messageID) - ?? self.injected.contains { $0.messageID == messageID } - } - service.isMessageSeenLocally = { [weak self] id in - self?.locallySeenMessageIDs.contains(id) ?? false - } - service.deriveIdentity = { [weak self] _ in - guard let self else { throw NostrError.invalidEvent } - return self.identity - } - service.myNickname = { [weak self] in self?.nickname ?? "" } - service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) } - service.onActiveCellChanged = { [weak self] cell in self?.cellChanges.append(cell) } - service.scheduleTimer = { [weak self] delay, work in - self?.scheduledTimers.append((delay, work)) - } - if enabled { - service.setEnabled(true) - } - } - - func advance(_ seconds: TimeInterval) { - clock.now = clock.now.addingTimeInterval(seconds) - } - - func fireScheduledTimers() { - let due = scheduledTimers - scheduledTimers.removeAll() - for item in due { item.work() } - } - } - - // MARK: Event helpers - - nonisolated private static let remoteMeshSenderID = "feedfacecafef00d" - nonisolated private static let remoteMeshTimestampMs: UInt64 = 1_750_000_000_000 - - private func makeRemoteEvent( - cell: String = BridgeServiceTests.cell, - content: String = "hi \(UUID().uuidString.prefix(8))", - meshSenderID: String = BridgeServiceTests.remoteMeshSenderID, - meshTimestampMs: UInt64 = BridgeServiceTests.remoteMeshTimestampMs, - identity: NostrIdentity? = nil - ) throws -> NostrEvent { - try NostrProtocol.createBridgeMeshEvent( - content: content, - cell: cell, - senderIdentity: identity ?? NostrIdentity.generate(), - nickname: "remote", - meshSenderID: meshSenderID, - meshTimestampMs: meshTimestampMs - ) - } - - /// The dedup key receivers derive for an event built by `makeRemoteEvent`. - private func stableID( - content: String, - meshSenderID: String = BridgeServiceTests.remoteMeshSenderID, - meshTimestampMs: UInt64 = BridgeServiceTests.remoteMeshTimestampMs - ) -> String { - MeshMessageIdentity.stableID(senderIDHex: meshSenderID, timestampMs: meshTimestampMs, content: content) - } - - private func makePresenceEvent(cell: String = BridgeServiceTests.cell) throws -> NostrEvent { - try NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: NostrIdentity.generate()) - } - - private func carrier( - _ event: NostrEvent, - direction: NostrCarrierPacket.Direction, - cell: String = BridgeServiceTests.cell - ) throws -> NostrCarrierPacket { - try #require(NostrCarrierPacket(direction: direction, geohash: cell, event: event)) - } - - // MARK: - Lifecycle & rendezvous - - @Test func enablingOpensSubscriptionForCellAndNeighbors() throws { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - - #expect(fixture.service.activeCell == Self.cell) - let cells = try #require(fixture.openedSubscriptions.first) - #expect(cells.first == Self.cell) - #expect(cells.count == 9) // own cell + 8 neighbors - #expect(fixture.service.subscribedCells.count == 9) - } - - @Test func missingCellRequestsALocationFix() { - // Field bug: the bridge waited passively for availableChannels, - // which only flow while some other feature pumps location. Bridging - // without a cell must ask for a fix itself. - let fixture = Fixture(enabled: true) - fixture.locationCell = nil - fixture.service.refreshRendezvous() - - #expect(fixture.locationFixRequests >= 1) - #expect(fixture.service.activeCell == nil) - - // The fix lands, channels flow, and the sink re-enters here: - fixture.locationCell = Self.cell - fixture.service.refreshRendezvous() - #expect(fixture.service.activeCell == Self.cell) - } - - @Test func noLocationFallsBackToMeshAdvertisedCell() { - let fixture = Fixture(enabled: true) - fixture.locationCell = nil - fixture.meshAdvertisedCell = "u4prux" - fixture.service.refreshRendezvous() - - #expect(fixture.service.activeCell == "u4prux") - } - - @Test func disablingClosesSubscriptionAndClearsState() { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - fixture.service.setEnabled(false) - - #expect(fixture.closedSubscriptions >= 1) - #expect(fixture.service.activeCell == nil) - #expect(fixture.service.bridgedPeerCount == 0) - } - - @Test func togglePersistsAcrossInstances() { - let fixture = Fixture(enabled: true) - let revived = BridgeService(defaults: fixture.defaults) - #expect(revived.isEnabled) - } - - // MARK: - Outgoing - - @Test func outgoingPublishesSignedRendezvousEventWithOriginCoordinates() throws { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - let sender = PeerID(str: "0011223344556677") - let timestamp = Date() - - fixture.service.bridgeOutgoing(content: "hello hill", senderPeerID: sender, timestamp: timestamp) - - let published = try #require(fixture.published.last) - #expect(published.cell == Self.cell) - #expect(published.event.isValidSignature()) - #expect(published.event.content == "hello hill") - #expect(published.event.tags.contains(["r", Self.cell])) - let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp) - #expect(published.event.tags.contains([ - "m", - MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "hello hill"), - sender.id, - String(timestampMs) - ])) - #expect(published.event.tags.contains(["n", "tester"])) - } - - @Test func newMeshTagStaysPerMessageUniqueForOldParsers() throws { - // v1.7.0 parsers take m[1] unconditionally as the dedup key whenever - // the tag has >= 2 elements. A constant m[1] (e.g. the bare sender - // ID) would make old receivers inject-dedup away every message from - // a sender after their first — so element 1 must be the - // per-message-unique stable ID itself. - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - let sender = PeerID(str: "0011223344556677") - let timestamp = Date() - - fixture.service.bridgeOutgoing(content: "first", senderPeerID: sender, timestamp: timestamp) - fixture.service.bridgeOutgoing(content: "second", senderPeerID: sender, timestamp: timestamp) - - // Old-parser extraction: m[1] of the first `m` tag with >= 2 elements. - let oldParserKeys = fixture.publishedMessages.compactMap { - $0.event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1] - } - #expect(oldParserKeys.count == 2) - #expect(oldParserKeys[0] != oldParserKeys[1]) - // And old and new receivers key the same message identically: m[1] - // equals the ID the new parser recomputes from elements 2-3. - let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp) - #expect(oldParserKeys == [ - MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "first"), - MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "second") - ]) - } - - @Test func nearbyOnlySuppressesTheBridgedCopy() { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - fixture.service.nearbyOnly = true - - fixture.service.bridgeOutgoing(content: "just us", senderPeerID: PeerID(str: "0011223344556677"), timestamp: Date()) - - #expect(fixture.publishedMessages.isEmpty) - #expect(fixture.uplinkSends.isEmpty) - } - - @Test func outgoingWithoutRelaysDepositsWithBridgePeer() throws { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - fixture.relaysConnected = false - fixture.bridgePeers = [PeerID(str: "abcdef0123456789")] - - fixture.service.bridgeOutgoing(content: "no internet here", senderPeerID: PeerID(str: "0011223344556677"), timestamp: Date()) - - #expect(fixture.publishedMessages.isEmpty) - let sent = try #require(fixture.uplinkSends.first) - let carrier = try #require(NostrCarrierPacket.decode(sent.payload)) - #expect(carrier.direction == .toBridge) - #expect(carrier.geohash == Self.cell) - } - - @Test func ownRelayBackfilledEventIsIgnoredAfterRestart() throws { - // Field bug: a relaunch wipes the published-ID cache, and relay - // backfill then re-delivered the device's own pre-restart events as - // "bridged". Self-recognition by the deterministic rendezvous pubkey - // must catch them with no cache state at all. - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - let ownOldEvent = try NostrProtocol.createBridgeMeshEvent( - content: "sent before the restart", - cell: Self.cell, - senderIdentity: fixture.identity, // == deriveIdentity(cell) - nickname: "tester", - meshSenderID: "0011223344556677", - meshTimestampMs: Self.remoteMeshTimestampMs - ) - - fixture.service.handleRendezvousEvent(ownOldEvent) - - #expect(fixture.injected.isEmpty) - #expect(fixture.broadcasts.isEmpty) - #expect(fixture.service.bridgedPeerCount == 0) - } - - @Test func ownEventComingBackFromSubscriptionIsIgnored() { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - fixture.service.bridgeOutgoing(content: "echo me", senderPeerID: PeerID(str: "0011223344556677"), timestamp: Date()) - let ownEvent = fixture.published[0].event - - fixture.service.handleRendezvousEvent(ownEvent) - - #expect(fixture.injected.isEmpty) - #expect(fixture.broadcasts.isEmpty) - #expect(fixture.service.bridgedPeerCount == 0) - } - - // MARK: - Subscription ingress - - @Test func remoteMessageInjectsAndDownlinks() throws { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - let event = try makeRemoteEvent() - - fixture.service.handleRendezvousEvent(event) - - #expect(fixture.injected.count == 1) - #expect(fixture.injected.first?.content == event.content) - #expect(fixture.injected.first?.messageID == event.id) - #expect(fixture.injected.first?.senderNickname == "remote#\(event.pubkey.suffix(4))") - #expect(fixture.service.bridgedPeerCount == 1) - // Serving duty: after the jitter holdoff, the remote event rides out - // as a fromBridge broadcast — one switch, no gateway toggle. - #expect(fixture.broadcasts.isEmpty) - fixture.fireScheduledTimers() - let broadcast = try #require(fixture.broadcasts.first) - let carrier = try #require(NostrCarrierPacket.decode(broadcast)) - #expect(carrier.direction == .fromBridge) - } - - @Test func jitterHoldoffSuppressesAlreadyBroadcastEvents() throws { - // Two gateways, one island: while our drain waits out the jitter, - // the other gateway's fromBridge broadcast arrives — ours must yield. - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - let event = try makeRemoteEvent() - - fixture.service.handleRendezvousEvent(event) // queued behind jitter - fixture.service.handleMeshCarrier( - try carrier(event, direction: .fromBridge), - from: PeerID(str: "aabbccdd00112233"), - directedToUs: false - ) - fixture.fireScheduledTimers() - - #expect(fixture.broadcasts.isEmpty) - #expect(fixture.injected.count == 1) // rendered once, either path - } - - @Test func neighborCellEventIsAccepted() throws { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - let neighbor = try #require(Geohash.neighbors(of: Self.cell).first) - let event = try makeRemoteEvent(cell: neighbor) - - fixture.service.handleRendezvousEvent(event) - - #expect(fixture.injected.count == 1) - } - - @Test func outOfRingCellEventIsRejected() throws { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - let event = try makeRemoteEvent(cell: "9q8yyk") - - fixture.service.handleRendezvousEvent(event) - - #expect(fixture.injected.isEmpty) - #expect(fixture.broadcasts.isEmpty) - } - - @Test func locallySeenMessageIsNeitherInjectedNorDownlinked() throws { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - let content = "heard on the radio" - // The radio copy's timeline row keys on the same derived stable ID. - fixture.locallySeenMessageIDs = [stableID(content: content)] - let event = try makeRemoteEvent(content: content) - - fixture.service.handleRendezvousEvent(event) - - // The island already heard this over radio: no duplicate render or - // wasted airtime. The public hint cannot attribute the Nostr signer, - // so it does not mutate participant state either. - #expect(fixture.injected.isEmpty) - #expect(fixture.broadcasts.isEmpty) - #expect(fixture.service.bridgedPeerCount == 0) - } - - @Test func bridgeFirstThenAuthenticatedRadioReplacesAliasesAndCancelsDownlink() throws { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - let content = "bridge arrived first" - let radioMessageID = stableID(content: content) - let event = try makeRemoteEvent(content: content) - - fixture.service.handleRendezvousEvent(event) - #expect(fixture.injected.map(\.messageID) == [event.id]) - #expect(fixture.service.bridgedPeerCount == 1) - - // This entry point is called only after the BLE packet signature has - // authenticated. The radio row must win; the public m-tag hint is not - // trusted enough to suppress it. - fixture.service.handleAuthenticatedRadioMessage(messageID: radioMessageID) - fixture.fireScheduledTimers() - - #expect(fixture.removedInjectedMessageIDs == [event.id]) - #expect(fixture.broadcasts.isEmpty) - #expect(fixture.service.bridgedPeerCount == 1) - - // A different signer copying the same public mesh coordinates after - // radio authentication cannot put a bridge alias back into the row. - fixture.service.handleRendezvousEvent(try makeRemoteEvent(content: content)) - #expect(fixture.injected.count == 1) - } - - @Test func disablingBridgeDoesNotForgetAliasNeededByLaterRadioCopy() throws { - let fixture = Fixture(enabled: true) - fixture.service.refreshRendezvous() - let content = "radio arrives after opt-out" - let event = try makeRemoteEvent(content: content) - - fixture.service.handleRendezvousEvent(event) - fixture.service.setEnabled(false) - fixture.service.handleAuthenticatedRadioMessage(messageID: stableID(content: content)) - - #expect(fixture.removedInjectedMessageIDs == [event.id]) - } - - @Test func aliasPruningUsesExactRowLivenessWithoutDeletingHistory() throws { - let fixture = Fixture(enabled: true, verifyEventSignature: { _ in true }) - fixture.service.refreshRendezvous() - - func event(index: Int) -> NostrEvent { - let senderID = String(format: "%016llx", UInt64(index + 1)) - let content = "bridge overflow \(index)" - let timestampMs = UInt64(1_750_000_000_000) + UInt64(index) - var event = NostrEvent( - pubkey: String(format: "%064llx", UInt64(index + 1)), - createdAt: Date(), - kind: .ephemeralEvent, - tags: [ - ["r", Self.cell], - ["n", "remote"], - ["m", "unused", senderID, String(timestampMs)] - ], - content: content - ) - event.id = String(format: "%064llx", UInt64(index + 10_000)) - event.sig = String(repeating: "0", count: 128) - return event - } - - let oldest = event(index: 0) - fixture.service.handleRendezvousEvent(oldest) - for index in 1...BridgeService.Limits.maxTrackedEventIDs { - if index.isMultiple(of: 500) { fixture.advance(61) } - fixture.service.handleRendezvousEvent(event(index: index)) - } - - // The loop/dedup caches are intentionally smaller, but valid ingress - // through that boundary must not delete a still-visible bridge row. - #expect(fixture.removedInjectedMessageIDs.isEmpty) - - for index in (BridgeService.Limits.maxTrackedEventIDs + 1).. -// - -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("Gateway mode policy") -@MainActor -struct GatewayServiceTests { - nonisolated private static let geohash = "u4pruy" - - /// Closure-injected harness around `GatewayService` recording every - /// side effect, with a controllable clock and relay connectivity. - @MainActor - private final class Fixture { - private final class ClockBox { - var now = Date() - } - - var relaysConnected = true - var currentGeohash: String? = GatewayServiceTests.geohash - var gatewayPeers: [PeerID] = [] - var sendToGatewaySucceeds = true - - private(set) var published: [(event: NostrEvent, geohash: String)] = [] - private(set) var broadcasts: [Data] = [] - private(set) var injected: [NostrEvent] = [] - private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = [] - private(set) var enabledChanges: [Bool] = [] - private(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = [] - - private let clock = ClockBox() - let defaults: UserDefaults - let service: GatewayService - - init(enabled: Bool = true, suite: String = "GatewayServiceTests-\(UUID().uuidString)") { - defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - let clock = clock - service = GatewayService(defaults: defaults) { clock.now } - service.publishToRelays = { [weak self] event, geohash in - self?.published.append((event, geohash)) - } - service.broadcastToMesh = { [weak self] payload in - self?.broadcasts.append(payload) - } - service.sendToGatewayPeer = { [weak self] payload, peer in - guard let self, self.sendToGatewaySucceeds else { return false } - self.uplinkSends.append((payload, peer)) - return true - } - service.availableGatewayPeers = { [weak self] in self?.gatewayPeers ?? [] } - service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } - service.currentGeohash = { [weak self] in self?.currentGeohash } - service.injectInbound = { [weak self] event in self?.injected.append(event) } - service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) } - // Capture drain timers instead of arming a real Task so the drain - // is deterministic under the fake clock. - service.scheduleDrainTimer = { [weak self] delay, work in - self?.scheduledDrains.append((delay, work)) - } - if enabled { - service.setEnabled(true) - } - } - - func advance(_ seconds: TimeInterval) { - clock.now = clock.now.addingTimeInterval(seconds) - } - - /// Fires every currently-scheduled drain timer (simulating the window - /// freeing), as the real Task would after its delay. - func fireScheduledDrains() { - let due = scheduledDrains - scheduledDrains.removeAll() - for item in due { item.work() } - } - } - - // MARK: Event helpers - - private func makeEvent( - geohash: String = GatewayServiceTests.geohash, - content: String = "hello \(UUID().uuidString.prefix(8))" - ) throws -> NostrEvent { - let identity = try NostrIdentity.generate() - return try NostrProtocol.createEphemeralGeohashEvent( - content: content, - geohash: geohash, - senderIdentity: identity, - nickname: "tester" - ) - } - - /// A copy of `event` with tampered content but the original ID and - /// signature — what a forging gateway or mesh peer would produce. - private func forge(_ event: NostrEvent) throws -> NostrEvent { - let dict: [String: Any] = [ - "id": event.id, - "pubkey": event.pubkey, - "created_at": event.created_at, - "kind": event.kind, - "tags": event.tags, - "content": event.content + " (tampered)", - "sig": event.sig ?? "" - ] - return try NostrEvent(from: dict) - } - - private func carrierPayload( - _ event: NostrEvent, - direction: NostrCarrierPacket.Direction = .toGateway, - geohash: String = GatewayServiceTests.geohash - ) throws -> Data { - let packet = try #require(NostrCarrierPacket(direction: direction, geohash: geohash, event: event)) - return try #require(packet.encode()) - } - - private func deposit( - _ event: NostrEvent, - into fixture: Fixture, - from depositor: PeerID = PeerID(str: "1122334455667788"), - geohash: String = GatewayServiceTests.geohash - ) throws { - let payload = try carrierPayload(event, direction: .toGateway, geohash: geohash) - fixture.service.handleMeshCarrier(payload, from: depositor, directedToUs: true) - } - - // MARK: - Uplink verification gates - - @Test("publishes a verified deposit to the geo relays") - func verifiedDepositPublished() throws { - let fixture = Fixture() - let event = try makeEvent() - try deposit(event, into: fixture) - - #expect(fixture.published.count == 1) - #expect(fixture.published.first?.event.id == event.id) - #expect(fixture.published.first?.geohash == Self.geohash) - // Viewing the same geohash: the carried message shows on our own timeline. - #expect(fixture.injected.map(\.id) == [event.id]) - } - - @Test("rejects a forged signature") - func forgedSignatureRejected() throws { - let fixture = Fixture() - let forged = try forge(try makeEvent()) - try deposit(forged, into: fixture) - - #expect(fixture.published.isEmpty) - #expect(fixture.injected.isEmpty) - } - - @Test("rejects wrong kind, geohash mismatch, and stale events") - func structuralGates() throws { - let fixture = Fixture() - - // Wrong kind (kind-1 text note instead of kind-20000 ephemeral). - let identity = try NostrIdentity.generate() - let note = try NostrProtocol.createGeohashTextNote( - content: "note", - geohash: Self.geohash, - senderIdentity: identity - ) - try deposit(note, into: fixture) - #expect(fixture.published.isEmpty) - - // Carrier geohash disagreeing with the event's #g tag. - let mismatched = try makeEvent(geohash: "9q8yyk") - try deposit(mismatched, into: fixture, geohash: Self.geohash) - #expect(fixture.published.isEmpty) - - // Stale event (beyond accepted clock skew). - let stale = try makeEvent() - fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60) - try deposit(stale, into: fixture) - #expect(fixture.published.isEmpty) - } - - @Test("does nothing while the toggle is off") - func disabledGatewayIgnoresDeposits() throws { - let fixture = Fixture(enabled: false) - try deposit(try makeEvent(), into: fixture) - #expect(fixture.published.isEmpty) - #expect(fixture.service.queuedUplinks.isEmpty) - - fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash) - #expect(fixture.broadcasts.isEmpty) - } - - // MARK: - Uplink quotas and rate limit - - @Test("rate-limits deposits per depositor per minute") - func uplinkRateLimit() throws { - let fixture = Fixture() - let depositor = PeerID(str: "aabbccddeeff0011") - - for _ in 0.. -// - -import Foundation -import Testing -@testable import bitchat - -@MainActor -struct GeohashChatActivityTrackerTests { - - private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) - - private func makeTracker(window: TimeInterval = 900, now: Date? = nil) -> (GeohashChatActivityTracker, (Date) -> Void) { - var currentNow = now ?? baseDate - let tracker = GeohashChatActivityTracker(window: window, now: { currentNow }) - return (tracker, { currentNow = $0 }) - } - - private func channel(_ geohash: String, _ level: GeohashChannelLevel) -> GeohashChannel { - GeohashChannel(level: level, geohash: geohash) - } - - @Test - func recordsAndCountsMessagesInWindow() { - let (tracker, _) = makeTracker() - tracker.recordChatMessage(geohash: "9Q8YY", senderName: "alice#ab12", content: "hi", timestamp: baseDate) - tracker.recordChatMessage(geohash: "9q8yy", senderName: "bob#cd34", content: "yo", timestamp: baseDate) - - #expect(tracker.messageCount(for: "9q8yy") == 2) - #expect(tracker.lastMessage(for: "9q8YY")?.senderName == "bob#cd34") - } - - @Test - func dropsMessagesOlderThanWindow() { - let (tracker, advance) = makeTracker(window: 900) - tracker.recordChatMessage(geohash: "9q8yy", senderName: "alice#ab12", content: "hi", timestamp: baseDate) - - advance(baseDate.addingTimeInterval(901)) - - #expect(tracker.messageCount(for: "9q8yy") == 0) - #expect(tracker.lastMessage(for: "9q8yy") == nil) - } - - @Test - func ignoresMessagesAlreadyOutsideWindow() { - let (tracker, _) = makeTracker(window: 900) - tracker.recordChatMessage( - geohash: "9q8yy", - senderName: "alice#ab12", - content: "old", - timestamp: baseDate.addingTimeInterval(-1000) - ) - - #expect(tracker.messageCount(for: "9q8yy") == 0) - } - - @Test - func keepsNewestPreview() { - let (tracker, _) = makeTracker() - tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "newer", timestamp: baseDate) - tracker.recordChatMessage(geohash: "9q8yy", senderName: "b#2222", content: "older", timestamp: baseDate.addingTimeInterval(-60)) - - #expect(tracker.lastMessage(for: "9q8yy")?.content == "newer") - #expect(tracker.messageCount(for: "9q8yy") == 2) - } - - @Test - func mostActivePicksBusiestChannel() { - let (tracker, _) = makeTracker() - tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "one", timestamp: baseDate) - tracker.recordChatMessage(geohash: "9q8", senderName: "b#2222", content: "two", timestamp: baseDate) - tracker.recordChatMessage(geohash: "9q8", senderName: "c#3333", content: "three", timestamp: baseDate) - - let channels = [channel("9q8yy", .city), channel("9q8", .province)] - let best = tracker.mostActiveConversation(among: channels) - - #expect(best?.channel.geohash == "9q8") - #expect(best?.messageCount == 2) - } - - @Test - func mostActiveTieGoesToMoreLocalChannel() { - let (tracker, _) = makeTracker() - tracker.recordChatMessage(geohash: "9q8yyzz1", senderName: "a#1111", content: "local", timestamp: baseDate) - tracker.recordChatMessage(geohash: "9q", senderName: "b#2222", content: "regional", timestamp: baseDate) - - let channels = [channel("9q", .region), channel("9q8yyzz1", .building)] - let best = tracker.mostActiveConversation(among: channels) - - #expect(best?.channel.geohash == "9q8yyzz1") - } - - @Test - func mostActiveIsNilWithoutMessages() { - let (tracker, _) = makeTracker() - #expect(tracker.mostActiveConversation(among: [channel("9q8yy", .city)]) == nil) - } - - @Test - func clearRemovesEverything() { - let (tracker, _) = makeTracker() - tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "hi", timestamp: baseDate) - tracker.clear() - - #expect(tracker.messageCount(for: "9q8yy") == 0) - #expect(tracker.mostActiveConversation(among: [channel("9q8yy", .city)]) == nil) - } -} diff --git a/bitchatTests/Services/GeohashPresenceServiceTests.swift b/bitchatTests/Services/GeohashPresenceServiceTests.swift index 127c0052..f6be1f25 100644 --- a/bitchatTests/Services/GeohashPresenceServiceTests.swift +++ b/bitchatTests/Services/GeohashPresenceServiceTests.swift @@ -76,7 +76,6 @@ final class GeohashPresenceServiceTests: XCTestCase { burstMaxDelay: 0 ) - service.start() service.performHeartbeat() let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 } @@ -84,7 +83,7 @@ final class GeohashPresenceServiceTests: XCTestCase { XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"])) XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"])) XCTAssertEqual(sleptNanoseconds.count, 3) - XCTAssertEqual(scheduler.intervals, [17, 17]) + XCTAssertEqual(scheduler.intervals, [17]) } func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async { @@ -98,12 +97,11 @@ final class GeohashPresenceServiceTests: XCTestCase { loopMaxInterval: 21 ) - service.start() service.performHeartbeat() try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(sendCount, 0) - XCTAssertEqual(scheduler.intervals, [21, 21]) + XCTAssertEqual(scheduler.intervals, [21]) } func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async { @@ -117,45 +115,11 @@ final class GeohashPresenceServiceTests: XCTestCase { loopMaxInterval: 22 ) - service.start() service.performHeartbeat() try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(sendCount, 0) - XCTAssertEqual(scheduler.intervals, [22, 22]) - } - - func test_stopForPanic_cancelsTimerAndSuppressesDelayedBroadcast() async throws { - let identity = try NostrIdentity.generate() - let scheduler = MockGeohashPresenceScheduler() - var sleeperContinuation: CheckedContinuation? - var sendCount = 0 - let service = makeService( - scheduler: scheduler, - deriveIdentity: { _ in identity }, - relaySender: { _, _ in sendCount += 1 }, - sleeper: { _ in - await withCheckedContinuation { continuation in - sleeperContinuation = continuation - } - }, - burstMinDelay: 1, - burstMaxDelay: 1 - ) - - service.start() - service.performHeartbeat() - let delayStarted = await waitUntil { - sleeperContinuation != nil - } - XCTAssertTrue(delayStarted) - - service.stopForPanic() - sleeperContinuation?.resume() - try? await Task.sleep(nanoseconds: 20_000_000) - - XCTAssertEqual(sendCount, 0) - XCTAssertEqual(scheduler.timers.first?.invalidateCallCount, 1) + XCTAssertEqual(scheduler.intervals, [22]) } func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws { @@ -227,7 +191,7 @@ final class GeohashPresenceServiceTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = TestConstants.settleTimeout, + timeout: TimeInterval = 1.0, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -266,4 +230,8 @@ private final class MockGeohashPresenceTimer: GeohashPresenceTimerProtocol { invalidateCallCount += 1 isValid = false } + + func fire() { + handler() + } } diff --git a/bitchatTests/Services/GroupProtocolTests.swift b/bitchatTests/Services/GroupProtocolTests.swift deleted file mode 100644 index f9f19e7f..00000000 --- a/bitchatTests/Services/GroupProtocolTests.swift +++ /dev/null @@ -1,374 +0,0 @@ -// -// GroupProtocolTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import CryptoKit -import Foundation -import Testing -import BitFoundation -@testable import bitchat - -struct GroupProtocolTests { - - // MARK: - Fixtures - - /// Deterministic member identity: an Ed25519 keypair plus the 64-hex - /// fingerprint the roster pins. - private struct TestIdentity { - let signingKey: Curve25519.Signing.PrivateKey - let fingerprint: String - - init(seed: UInt8) { - signingKey = Curve25519.Signing.PrivateKey() - fingerprint = Data(repeating: seed, count: 32).hexEncodedString() - } - - var member: GroupMember { - GroupMember( - fingerprint: fingerprint, - signingKey: signingKey.publicKey.rawRepresentation, - nickname: "peer-\(fingerprint.prefix(4))" - ) - } - - func sign(_ data: Data) -> Data? { - try? signingKey.signature(for: data) - } - } - - private let creator = TestIdentity(seed: 0xC1) - private let member = TestIdentity(seed: 0xA2) - private let outsider = TestIdentity(seed: 0xE3) - - private let groupID = Data((0..<16).map { UInt8($0) }) - private let key = Data(repeating: 0x42, count: 32) - - private func makeGroup(extraMembers: [GroupMember] = [], epoch: UInt32 = 1) -> BitchatGroup { - BitchatGroup( - groupID: groupID, - name: "trail crew", - epoch: epoch, - members: [creator.member, member.member] + extraMembers, - creatorFingerprint: creator.fingerprint - ) - } - - // MARK: - State payload (invite / key update) - - @Test func statePayloadRoundTripAndSignatureVerify() throws { - let group = makeGroup() - let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) - let encoded = try #require(payload.encode()) - - let decoded = try #require(GroupStatePayload.decode(encoded)) - #expect(decoded == payload) - #expect(decoded.groupID == groupID) - #expect(decoded.name == "trail crew") - #expect(decoded.key == key) - #expect(decoded.epoch == 1) - #expect(decoded.members == group.members) - #expect(decoded.creatorFingerprint == creator.fingerprint) - #expect(decoded.verifyCreatorSignature()) - #expect(decoded.asGroup == group) - } - - @Test func forgedCreatorSignatureIsRejected() throws { - let group = makeGroup() - // Signed by a member who is in the roster but is NOT the creator. - let forged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: member.sign)) - #expect(!forged.verifyCreatorSignature()) - - // An outsider signing is equally rejected. - let outsiderForged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: outsider.sign)) - #expect(!outsiderForged.verifyCreatorSignature()) - } - - @Test func tamperedStateFailsSignature() throws { - let group = makeGroup() - let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) - - // Bumping the epoch invalidates the signature. - let epochTampered = GroupStatePayload( - groupID: payload.groupID, - name: payload.name, - key: payload.key, - epoch: payload.epoch + 1, - members: payload.members, - creatorFingerprint: payload.creatorFingerprint, - signature: payload.signature - ) - #expect(!epochTampered.verifyCreatorSignature()) - - // So does swapping the key. - let keyTampered = GroupStatePayload( - groupID: payload.groupID, - name: payload.name, - key: Data(repeating: 0x99, count: 32), - epoch: payload.epoch, - members: payload.members, - creatorFingerprint: payload.creatorFingerprint, - signature: payload.signature - ) - #expect(!keyTampered.verifyCreatorSignature()) - - // And so does adding a member to the roster. - let rosterTampered = GroupStatePayload( - groupID: payload.groupID, - name: payload.name, - key: payload.key, - epoch: payload.epoch, - members: payload.members + [outsider.member], - creatorFingerprint: payload.creatorFingerprint, - signature: payload.signature - ) - #expect(!rosterTampered.verifyCreatorSignature()) - } - - @Test func creatorMissingFromRosterIsRejected() throws { - // State claiming a creator whose fingerprint is not in the roster has - // no key to verify against and must fail closed. - let group = BitchatGroup( - groupID: groupID, - name: "orphan", - epoch: 1, - members: [member.member], - creatorFingerprint: creator.fingerprint - ) - guard let rosterBlob = GroupRosterCoding.encode(group.members) else { - Issue.record("roster should encode") - return - } - let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name) - let payload = GroupStatePayload( - groupID: groupID, - name: group.name, - key: key, - epoch: 1, - members: group.members, - creatorFingerprint: creator.fingerprint, - signature: creator.sign(content) ?? Data() - ) - #expect(!payload.verifyCreatorSignature()) - } - - @Test func rosterCapIsEnforcedOnTheWire() { - // 17 members cannot be encoded (hard cap is 16)… - let seventeen = (0..<17).map { TestIdentity(seed: UInt8($0 + 1)).member } - #expect(GroupRosterCoding.encode(seventeen) == nil) - - // …and a hand-built blob claiming 17 members fails to decode. - let sixteen = (0..<16).map { TestIdentity(seed: UInt8($0 + 1)).member } - guard var blob = GroupRosterCoding.encode(sixteen) else { - Issue.record("16-member roster should encode") - return - } - #expect(GroupRosterCoding.decode(blob)?.count == 16) - blob[blob.startIndex] = 17 - #expect(GroupRosterCoding.decode(blob) == nil) - } - - // MARK: - Message seal / open - - @Test func messageRoundTrip() throws { - let group = makeGroup() - let timestampMs: UInt64 = 1_750_000_000_000 - let sealed = try GroupCrypto.sealMessage( - content: "summit at noon", - messageID: "msg-1", - senderNickname: "alice", - senderSigningKey: member.member.signingKey, - timestampMs: timestampMs, - groupID: groupID, - epoch: group.epoch, - key: key, - sign: member.sign - ) - - let envelope = try #require(GroupMessageEnvelope.decode(sealed)) - #expect(envelope.groupID == groupID) - #expect(envelope.epoch == group.epoch) - - let plaintext = try GroupCrypto.openMessage(envelope, key: key) - #expect(plaintext.messageID == "msg-1") - #expect(plaintext.content == "summit at noon") - #expect(plaintext.senderNickname == "alice") - #expect(plaintext.timestampMs == timestampMs) - #expect(plaintext.senderSigningKey == member.member.signingKey) - - // The roster resolves the sender; an outsider's key would not. - #expect(group.member(withSigningKey: plaintext.senderSigningKey) != nil) - #expect(group.member(withSigningKey: outsider.member.signingKey) == nil) - } - - @Test func wrongKeyFailsToDecrypt() throws { - let sealed = try GroupCrypto.sealMessage( - content: "hi", - messageID: "msg-2", - senderNickname: "alice", - senderSigningKey: member.member.signingKey, - timestampMs: 1, - groupID: groupID, - epoch: 1, - key: key, - sign: member.sign - ) - let envelope = try #require(GroupMessageEnvelope.decode(sealed)) - #expect(throws: GroupCryptoError.decryptionFailed) { - _ = try GroupCrypto.openMessage(envelope, key: Data(repeating: 0x7F, count: 32)) - } - } - - @Test func epochIsBoundIntoTheCiphertext() throws { - // Re-labeling an epoch-1 envelope as epoch 2 must break the AEAD: - // a rotated-out member cannot replay old ciphertext into a new epoch. - let sealed = try GroupCrypto.sealMessage( - content: "hi", - messageID: "msg-3", - senderNickname: "alice", - senderSigningKey: member.member.signingKey, - timestampMs: 1, - groupID: groupID, - epoch: 1, - key: key, - sign: member.sign - ) - let envelope = try #require(GroupMessageEnvelope.decode(sealed)) - let relabeled = GroupMessageEnvelope( - groupID: envelope.groupID, - epoch: 2, - nonce: envelope.nonce, - ciphertext: envelope.ciphertext - ) - #expect(throws: GroupCryptoError.decryptionFailed) { - _ = try GroupCrypto.openMessage(relabeled, key: key) - } - } - - @Test func badSenderSignatureIsRejected() throws { - // A key-holder who signs with a key other than the one they claim - // (or garbage) is dropped even though decryption succeeds. - let sealed = try GroupCrypto.sealMessage( - content: "spoof", - messageID: "msg-4", - senderNickname: "mallory", - senderSigningKey: member.member.signingKey, // claims member's key… - timestampMs: 1, - groupID: groupID, - epoch: 1, - key: key, - sign: outsider.sign // …but signs with the outsider's - ) - let envelope = try #require(GroupMessageEnvelope.decode(sealed)) - #expect(throws: GroupCryptoError.badSenderSignature) { - _ = try GroupCrypto.openMessage(envelope, key: key) - } - } - - @Test func tamperedCiphertextFailsToOpen() throws { - let sealed = try GroupCrypto.sealMessage( - content: "hi", - messageID: "msg-5", - senderNickname: "alice", - senderSigningKey: member.member.signingKey, - timestampMs: 1, - groupID: groupID, - epoch: 1, - key: key, - sign: member.sign - ) - let envelope = try #require(GroupMessageEnvelope.decode(sealed)) - var flipped = envelope.ciphertext - flipped[flipped.startIndex] ^= 0x01 - let tampered = GroupMessageEnvelope( - groupID: envelope.groupID, - epoch: envelope.epoch, - nonce: envelope.nonce, - ciphertext: flipped - ) - #expect(throws: GroupCryptoError.decryptionFailed) { - _ = try GroupCrypto.openMessage(tampered, key: key) - } - } - - @Test func malformedEnvelopesAreRejected() { - #expect(GroupMessageEnvelope.decode(Data()) == nil) - #expect(GroupMessageEnvelope.decode(Data([0x01, 0x00])) == nil) - #expect(GroupStatePayload.decode(Data([0xFF, 0x00, 0x01])) == nil) - } - - // MARK: - Oversize / UTF-8 safety (Codex findings) - - @Test func oversizeMessageContentFailsToSealInsteadOfTruncating() { - // A content whose UTF-8 exceeds the 16-bit TLV length must fail to - // seal (surfacing send_failed) rather than silently truncate into a - // ciphertext recipients would drop. - let oversize = String(repeating: "a", count: 70_000) - #expect(throws: (any Error).self) { - _ = try GroupCrypto.sealMessage( - content: oversize, - messageID: "big", - senderNickname: "alice", - senderSigningKey: member.member.signingKey, - timestampMs: 1, - groupID: groupID, - epoch: 1, - key: key, - sign: member.sign - ) - } - } - - @Test func multiByteNicknameTruncatesOnScalarBoundary() throws { - // 40 euro signs = 120 UTF-8 bytes; a raw 64-byte prefix would split - // the 21st scalar and make the roster undecodable. Truncation must - // land on a Character boundary so the blob round-trips. - let euros = String(repeating: "€", count: 40) - let wide = GroupMember( - fingerprint: creator.fingerprint, - signingKey: creator.member.signingKey, - nickname: euros - ) - let blob = try #require(GroupRosterCoding.encode([wide])) - let decoded = try #require(GroupRosterCoding.decode(blob)) - #expect(decoded.count == 1) - #expect(Data(decoded[0].nickname.utf8).count <= 64) - #expect(decoded[0].nickname.allSatisfy { $0 == "€" }) - #expect(!decoded[0].nickname.isEmpty) - } - - // MARK: - Signable-bytes forward-proofing - - @Test func creatorSignatureCoversName() throws { - let group = makeGroup() - let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) - #expect(payload.verifyCreatorSignature()) - - // Swapping only the display name must invalidate the creator signature. - let renamed = GroupStatePayload( - groupID: payload.groupID, - name: "totally different name", - key: payload.key, - epoch: payload.epoch, - members: payload.members, - creatorFingerprint: payload.creatorFingerprint, - signature: payload.signature - ) - #expect(!renamed.verifyCreatorSignature()) - } - - @Test func messageSignatureCoversEpoch() { - // The signed bytes differ by epoch, so a signature captured at one - // epoch cannot verify when re-sealed under a later epoch key. - let atEpoch1 = GroupCrypto.messageSigningContent( - groupID: groupID, epoch: 1, messageID: "m", timestampMs: 1, content: "x" - ) - let atEpoch2 = GroupCrypto.messageSigningContent( - groupID: groupID, epoch: 2, messageID: "m", timestampMs: 1, content: "x" - ) - #expect(atEpoch1 != atEpoch2) - } -} diff --git a/bitchatTests/Services/GroupStoreTests.swift b/bitchatTests/Services/GroupStoreTests.swift deleted file mode 100644 index adf04806..00000000 --- a/bitchatTests/Services/GroupStoreTests.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// GroupStoreTests.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -import BitFoundation -@testable import bitchat - -@MainActor -struct GroupStoreTests { - - private func makeMember(seed: UInt8, nickname: String = "peer") -> GroupMember { - GroupMember( - fingerprint: Data(repeating: seed, count: 32).hexEncodedString(), - signingKey: Data(repeating: seed &+ 1, count: 32), - nickname: nickname - ) - } - - private func tempFileURL() -> URL { - FileManager.default.temporaryDirectory - .appendingPathComponent("group-store-tests-\(UUID().uuidString)", isDirectory: true) - .appendingPathComponent("groups.json") - } - - // MARK: - Create / read - - @Test func createGroupStoresMetadataAndKey() throws { - let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) - let creator = makeMember(seed: 0xC1, nickname: "me") - - let group = try #require(store.createGroup(named: "ops", creator: creator)) - #expect(group.groupID.count == BitchatGroup.groupIDLength) - #expect(group.epoch == 1) - #expect(group.members == [creator]) - #expect(group.creatorFingerprint == creator.fingerprint) - - #expect(store.group(withID: group.groupID) == group) - #expect(store.group(for: group.peerID) == group) - let key = try #require(store.key(forGroupID: group.groupID)) - #expect(key.count == BitchatGroup.keyLength) - #expect(group.peerID.isGroup) - #expect(group.peerID.groupIDData == group.groupID) - } - - // MARK: - Roster cap - - @Test func rosterCapIsEnforced() throws { - let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) - let creator = makeMember(seed: 0xC1) - let group = try #require(store.createGroup(named: "big", creator: creator)) - - // Filling to the cap works… - let fifteen = (1...15).map { makeMember(seed: UInt8($0)) } - #expect(store.updateRoster(groupID: group.groupID, members: [creator] + fifteen) != nil) - #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers) - - // …one more is rejected. - let overflow = [creator] + fifteen + [makeMember(seed: 0x99)] - #expect(store.updateRoster(groupID: group.groupID, members: overflow) == nil) - #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers) - - // Direct upsert past the cap is rejected too. - var oversized = group - oversized.members = overflow - #expect(!store.upsert(oversized, key: Data(repeating: 1, count: 32))) - } - - @Test func rosterMustRetainCreator() throws { - let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) - let creator = makeMember(seed: 0xC1) - let other = makeMember(seed: 0xA1) - let group = try #require(store.createGroup(named: "crew", creator: creator)) - - #expect(store.updateRoster(groupID: group.groupID, members: [other]) == nil) - #expect(store.group(withID: group.groupID)?.members == [creator]) - } - - // MARK: - Rotation - - @Test func rotateKeyBumpsEpochAndReplacesKey() throws { - let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) - let creator = makeMember(seed: 0xC1) - let removed = makeMember(seed: 0xA1) - let group = try #require(store.createGroup(named: "crew", creator: creator)) - #expect(store.updateRoster(groupID: group.groupID, members: [creator, removed]) != nil) - let oldKey = try #require(store.key(forGroupID: group.groupID)) - - let rotation = try #require(store.rotateKey(groupID: group.groupID, members: [creator])) - #expect(rotation.group.epoch == 2) - #expect(rotation.group.members == [creator]) - #expect(rotation.key != oldKey) - #expect(store.key(forGroupID: group.groupID) == rotation.key) - #expect(store.group(withID: group.groupID)?.epoch == 2) - } - - // MARK: - Persistence - - @Test func persistsAcrossInstances() throws { - let keychain = MockKeychain() - let fileURL = tempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let creator = makeMember(seed: 0xC1, nickname: "me") - let group: BitchatGroup - do { - let store = GroupStore(keychain: keychain, fileURL: fileURL) - group = try #require(store.createGroup(named: "hike", creator: creator)) - } - - let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) - #expect(reloaded.groups == [group]) - #expect(reloaded.key(forGroupID: group.groupID) != nil) - } - - @Test func groupsWithoutKeysAreDroppedOnLoad() throws { - let keychain = MockKeychain() - let fileURL = tempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let group: BitchatGroup - do { - let store = GroupStore(keychain: keychain, fileURL: fileURL) - group = try #require(store.createGroup(named: "stale", creator: makeMember(seed: 0xC1))) - } - // Simulate a keychain wipe without the metadata file being removed. - _ = keychain.deleteAllKeychainData() - - let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) - #expect(reloaded.groups.isEmpty) - #expect(reloaded.group(withID: group.groupID) == nil) - } - - // MARK: - Panic wipe - - @Test func wipeRemovesMetadataAndKeys() throws { - let keychain = MockKeychain() - let fileURL = tempFileURL() - defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - - let store = GroupStore(keychain: keychain, fileURL: fileURL) - let group = try #require(store.createGroup(named: "gone", creator: makeMember(seed: 0xC1))) - #expect(FileManager.default.fileExists(atPath: fileURL.path)) - - store.wipe() - - #expect(store.groups.isEmpty) - #expect(store.key(forGroupID: group.groupID) == nil) - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - - // A fresh instance sees nothing either. - let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) - #expect(reloaded.groups.isEmpty) - } - - @Test func removeGroupDeletesItsKey() throws { - let keychain = MockKeychain() - let store = GroupStore(keychain: keychain, persistsToDisk: false) - let group = try #require(store.createGroup(named: "bye", creator: makeMember(seed: 0xC1))) - - store.removeGroup(withID: group.groupID) - #expect(store.groups.isEmpty) - #expect(store.key(forGroupID: group.groupID) == nil) - } -} diff --git a/bitchatTests/Services/LocationStateManagerTests.swift b/bitchatTests/Services/LocationStateManagerTests.swift index 46114d26..c7a950f0 100644 --- a/bitchatTests/Services/LocationStateManagerTests.swift +++ b/bitchatTests/Services/LocationStateManagerTests.swift @@ -86,46 +86,6 @@ final class LocationStateManagerTests: XCTestCase { XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters) } - func test_permissionRevocation_endsLiveRefreshWithoutDiscardingExplicitState() async { - let storage = makeStorage() - let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways) - let manager = LocationStateManager( - storage: storage, - locationManager: locationManager, - geocoder: MockLocationGeocoder(), - shouldInitializeCoreLocation: true - ) - let authorized = await waitUntil { manager.permissionState == .authorized } - XCTAssertTrue(authorized) - - manager.locationManager( - CLLocationManager(), - didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)] - ) - let channelsLoaded = await waitUntil { !manager.availableChannels.isEmpty } - XCTAssertTrue(channelsLoaded) - let cachedChannels = manager.availableChannels - manager.addBookmark("u4pru") - manager.markTeleported(for: "9q8yy", true) - manager.select(.location(GeohashChannel(level: .city, geohash: "9q8yy"))) - let teleported = await waitUntil { manager.teleported } - XCTAssertTrue(teleported) - - manager.beginLiveRefresh() - XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1) - - manager.locationManager(CLLocationManager(), didChangeAuthorization: .restricted) - - let restricted = await waitUntil { manager.permissionState == .restricted } - XCTAssertTrue(restricted) - XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1) - XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters) - XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters) - XCTAssertEqual(manager.availableChannels, cachedChannels, "cached display state can remain") - XCTAssertEqual(manager.bookmarks, ["u4pru"]) - XCTAssertTrue(manager.teleported, "an explicit remote selection survives device revocation") - } - func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async { let geocoder = MockLocationGeocoder() geocoder.enqueue( @@ -207,54 +167,7 @@ final class LocationStateManagerTests: XCTestCase { XCTAssertFalse(reloaded.teleported) } - func test_loadPersistedState_migratesLowPrecisionBookmarkNamesOnce() throws { - let storage = makeStorage() - let staleNames = [ - "gc": "England", - "u3": "Île-de-France", - "u4pr": "Paris", - "u4pruy": "Le Marais" - ] - storage.set(try JSONEncoder().encode(staleNames), forKey: "locationChannel.bookmarkNames") - - let migrated = LocationStateManager( - storage: storage, - locationManager: MockLocationManager(authorizationStatus: .denied), - geocoder: MockLocationGeocoder(), - shouldInitializeCoreLocation: false - ) - - XCTAssertNil(migrated.bookmarkNames["gc"]) - XCTAssertNil(migrated.bookmarkNames["u3"]) - XCTAssertEqual(migrated.bookmarkNames["u4pr"], "Paris") - XCTAssertEqual(migrated.bookmarkNames["u4pruy"], "Le Marais") - XCTAssertEqual(storage.integer(forKey: "locationChannel.bookmarkNamesSchemaVersion"), 1) - - let persistedData = try XCTUnwrap(storage.data(forKey: "locationChannel.bookmarkNames")) - let persistedNames = try JSONDecoder().decode([String: String].self, from: persistedData) - XCTAssertEqual(persistedNames, [ - "u4pr": "Paris", - "u4pruy": "Le Marais" - ]) - - let correctedNames = [ - "gc": "United Kingdom", - "u4pr": "Paris" - ] - storage.set(try JSONEncoder().encode(correctedNames), forKey: "locationChannel.bookmarkNames") - - let reloaded = LocationStateManager( - storage: storage, - locationManager: MockLocationManager(authorizationStatus: .denied), - geocoder: MockLocationGeocoder(), - shouldInitializeCoreLocation: false - ) - - XCTAssertEqual(reloaded.bookmarkNames["gc"], "United Kingdom") - XCTAssertEqual(reloaded.bookmarkNames["u4pr"], "Paris") - } - - func test_addBookmark_lowPrecisionPrefersCountryOverAdministrativeAreas() async { + func test_addBookmark_lowPrecisionResolvesCompositeAdminName() async { let geocoder = MockLocationGeocoder() geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")]) geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")]) @@ -270,48 +183,10 @@ final class LocationStateManagerTests: XCTestCase { manager.addBookmark("9q") - let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "United States" } - XCTAssertTrue(bookmarkResolved) - XCTAssertEqual(geocoder.reverseRequests.count, 5) - XCTAssertEqual(manager.bookmarks, ["9q"]) - } - - func test_addBookmark_lowPrecisionFallsBackToDistinctAdministrativeAreas() async { - let geocoder = MockLocationGeocoder() - geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "California")]) - geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Nevada")]) - geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "California")]) - geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Arizona")]) - geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Nevada")]) - let manager = LocationStateManager( - storage: makeStorage(), - locationManager: MockLocationManager(authorizationStatus: .denied), - geocoder: geocoder, - shouldInitializeCoreLocation: false - ) - - manager.addBookmark("9q") - let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" } XCTAssertTrue(bookmarkResolved) XCTAssertEqual(geocoder.reverseRequests.count, 5) - } - - func test_addBookmark_higherPrecisionStillPrefersAdministrativeArea() async { - let geocoder = MockLocationGeocoder() - geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")]) - let manager = LocationStateManager( - storage: makeStorage(), - locationManager: MockLocationManager(authorizationStatus: .denied), - geocoder: geocoder, - shouldInitializeCoreLocation: false - ) - - manager.addBookmark("9q8") - - let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q8"] == "California" } - XCTAssertTrue(bookmarkResolved) - XCTAssertEqual(geocoder.reverseRequests.count, 1) + XCTAssertEqual(manager.bookmarks, ["9q"]) } private func makeStorage() -> UserDefaults { @@ -355,7 +230,7 @@ final class LocationStateManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = TestConstants.settleTimeout, + timeout: TimeInterval = 1.0, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Services/MediaRetentionTests.swift b/bitchatTests/Services/MediaRetentionTests.swift deleted file mode 100644 index 6c92af10..00000000 --- a/bitchatTests/Services/MediaRetentionTests.swift +++ /dev/null @@ -1,196 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -/// Media used to be bounded only by a 100 MB incoming quota, so a received -/// photo or a sent voice note could sit on disk indefinitely — outliving the -/// conversation it belonged to, which is exactly what a seized device gives up. -/// These cover the age-based sweep that bounds it in time as well. -struct MediaRetentionTests { - private func makeRoot() -> URL { - FileManager.default.temporaryDirectory - .appendingPathComponent("media-retention-\(UUID().uuidString)", isDirectory: true) - } - - private func write( - _ name: String, - in directory: URL, - modified: Date - ) throws -> URL { - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - let url = directory.appendingPathComponent(name) - try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: url) - try FileManager.default.setAttributes( - [.modificationDate: modified], - ofItemAtPath: url.path - ) - return url - } - - @Test - func expiresOutgoingMediaPastRetentionAndKeepsFreshMedia() throws { - let root = makeRoot() - defer { try? FileManager.default.removeItem(at: root) } - let store = BLEIncomingFileStore(baseDirectory: root) - let outgoing = root.appendingPathComponent("files/images/outgoing", isDirectory: true) - - // Outgoing media had no lifetime at all before this sweep: the quota - // only ever considered incoming directories. - let stale = try write( - "sent_old.jpg", - in: outgoing, - modified: Date(timeIntervalSinceNow: -8 * 24 * 60 * 60) - ) - let fresh = try write( - "sent_new.jpg", - in: outgoing, - modified: Date(timeIntervalSinceNow: -60) - ) - - let removed = store.expireAgedMedia() - - #expect(removed == 1) - #expect(!FileManager.default.fileExists(atPath: stale.path)) - #expect(FileManager.default.fileExists(atPath: fresh.path)) - } - - @Test - func expiresIncomingMediaPastRetention() throws { - let root = makeRoot() - defer { try? FileManager.default.removeItem(at: root) } - let store = BLEIncomingFileStore(baseDirectory: root) - let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") - - let stale = try write( - "received.m4a", - in: incoming, - modified: Date(timeIntervalSinceNow: -8 * 24 * 60 * 60) - ) - - #expect(store.expireAgedMedia() == 1) - #expect(!FileManager.default.fileExists(atPath: stale.path)) - } - - @Test - func retentionSweepSkipsInFlightLiveCaptures() throws { - let root = makeRoot() - defer { try? FileManager.default.removeItem(at: root) } - let store = BLEIncomingFileStore(baseDirectory: root) - let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") - - // Deleting a live capture mid-stream unlinks the inode under the - // coordinator's open FileHandle, so age must not override the guard. - let inFlight = try write( - "\(BLEIncomingFileStore.liveCapturePrefix)00112233445566ff_dm.aac", - in: incoming, - modified: Date(timeIntervalSinceNow: -30 * 24 * 60 * 60) - ) - - #expect(store.expireAgedMedia() == 0) - #expect(FileManager.default.fileExists(atPath: inFlight.path)) - } - - @Test - func nonPositiveRetentionIsANoOp() throws { - let root = makeRoot() - defer { try? FileManager.default.removeItem(at: root) } - let store = BLEIncomingFileStore(baseDirectory: root) - let incoming = try store.incomingDirectory(subdirectory: "images/incoming") - - let file = try write( - "received.jpg", - in: incoming, - modified: Date(timeIntervalSinceNow: -365 * 24 * 60 * 60) - ) - - #expect(store.expireAgedMedia(retention: 0) == 0) - #expect(FileManager.default.fileExists(atPath: file.path)) - } - - @Test - func defaultRetentionIsSevenDays() { - #expect(BLEIncomingFileStore.defaultMediaRetention == 7 * 24 * 60 * 60) - } - - #if os(iOS) - /// Media was the one persistence layer that never stated a protection - /// class at its write site, so payloads inherited the container - /// default. Saves must survive the added write option, - /// and on device the class must read back. The simulator's filesystem - /// does not model data protection (the attribute reads back nil there), - /// so the readback assertion is device-only. - @Test - func savedMediaSurvivesExplicitProtectionClass() throws { - let root = makeRoot() - defer { try? FileManager.default.removeItem(at: root) } - let store = BLEIncomingFileStore(baseDirectory: root) - - let payload = Data([0xFF, 0xD8, 0xFF, 0xD9]) - let saved = try #require(store.save( - data: payload, - preferredName: "note.m4a", - subdirectory: "voicenotes/incoming", - fallbackExtension: "m4a", - defaultPrefix: "voice" - )) - - #expect(try Data(contentsOf: saved) == payload) - #if !targetEnvironment(simulator) - let protection = try FileManager.default.attributesOfItem( - atPath: saved.path - )[.protectionKey] as? FileProtectionType - #expect(protection == .completeUntilFirstUserAuthentication) - #endif - } - - /// Files written before payloads carried an explicit class are stamped - /// by the launch-time migration that follows the retention sweep: the - /// directory plus each resident file, without error. In-flight live - /// captures are left alone, exactly as the sweep leaves them: the - /// coordinator may still be writing to one through an open FileHandle, - /// and new captures receive the class at creation. Readback is device-only for the same - /// reason as above. - @Test - func migrationStampsPreexistingMediaAndSkipsLiveCaptures() throws { - let root = makeRoot() - defer { try? FileManager.default.removeItem(at: root) } - let store = BLEIncomingFileStore(baseDirectory: root) - let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") - - let legacy = try write( - "received.m4a", - in: incoming, - modified: Date(timeIntervalSinceNow: -60) - ) - _ = try write( - "\(BLEIncomingFileStore.liveCapturePrefix)00112233445566ff_dm.aac", - in: incoming, - modified: Date(timeIntervalSinceNow: -60) - ) - - // Exactly the directory itself plus the legacy file; strict equality - // is what proves the live capture was not stamped. - #expect(store.migrateFileProtectionIfNeeded() == 2) - #expect(FileManager.default.fileExists(atPath: legacy.path)) - #if !targetEnvironment(simulator) - let protection = try FileManager.default.attributesOfItem( - atPath: legacy.path - )[.protectionKey] as? FileProtectionType - #expect(protection == .completeUntilFirstUserAuthentication) - #endif - } - - /// A store with no media on disk has nothing to stamp. - @Test - func migrationWithNoMediaIsANoOp() { - let root = makeRoot() - defer { try? FileManager.default.removeItem(at: root) } - let store = BLEIncomingFileStore(baseDirectory: root) - - #expect(store.migrateFileProtectionIfNeeded() == 0) - } - #endif -} diff --git a/bitchatTests/Services/MeshDiagnosticsTests.swift b/bitchatTests/Services/MeshDiagnosticsTests.swift deleted file mode 100644 index b565840d..00000000 --- a/bitchatTests/Services/MeshDiagnosticsTests.swift +++ /dev/null @@ -1,300 +0,0 @@ -// -// MeshDiagnosticsTests.swift -// bitchatTests -// -// Tests for /ping and /trace command handling and the topology snapshot -// types backing the mesh topology map. -// This is free and unencumbered software released into the public domain. -// - -import Foundation -import Testing -import BitFoundation -@testable import bitchat - -@Suite(.serialized) -struct MeshDiagnosticsTests { - - // MARK: - Helpers - - @MainActor - private func makeProcessor( - context: DiagnosticsMockContext, - transport: MockTransport - ) -> CommandProcessor { - CommandProcessor( - contextProvider: context, - meshService: transport, - identityManager: MockIdentityManager(MockKeychain()) - ) - } - - /// Waits for the async ping completion (MainActor hop) to land. - @MainActor - private func waitForCommandOutput(_ context: DiagnosticsMockContext) async { - for _ in 0..<100 { - if !context.commandOutputs.isEmpty { return } - await Task.yield() - try? await Task.sleep(nanoseconds: 5_000_000) - } - } - - // MARK: - /ping - - @MainActor - @Test func pingWithoutArgumentShowsUsage() { - let context = DiagnosticsMockContext() - let processor = makeProcessor(context: context, transport: MockTransport()) - let result = processor.process("/ping") - switch result { - case .error(let message): - #expect(message == "usage: /ping ") - default: - Issue.record("Expected usage error") - } - } - - @MainActor - @Test func pingUnknownPeerFails() { - let context = DiagnosticsMockContext() - let processor = makeProcessor(context: context, transport: MockTransport()) - let result = processor.process("/ping @ghost") - switch result { - case .error(let message): - #expect(message == "cannot ping ghost: not found on mesh") - default: - Issue.record("Expected error result") - } - } - - @MainActor - @Test func pingGeoDMPeerIsRejected() { - let context = DiagnosticsMockContext() - context.nicknameToPeerID["alice"] = PeerID(nostr_: "aabbccddeeff00112233445566778899") - let processor = makeProcessor(context: context, transport: MockTransport()) - let result = processor.process("/ping @alice") - switch result { - case .error(let message): - #expect(message == "cannot ping alice: not found on mesh") - default: - Issue.record("Expected error for geo peer") - } - } - - @MainActor - @Test func pingSuccessReportsRttAndHops() async { - let context = DiagnosticsMockContext() - let peerID = PeerID(str: "abcd1234abcd1234") - context.nicknameToPeerID["alice"] = peerID - let transport = MockTransport() - transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2) - let processor = makeProcessor(context: context, transport: transport) - - let result = processor.process("/ping @alice") - switch result { - case .success(let message): - #expect(message == "pinging alice…") - default: - Issue.record("Expected immediate 'pinging' feedback") - } - #expect(transport.sentMeshPings == [peerID]) - - await waitForCommandOutput(context) - #expect(context.commandOutputs == ["pong from alice: 42 ms · 2 hops"]) - } - - @MainActor - @Test func pingDirectPeerReportsSingleHop() async { - let context = DiagnosticsMockContext() - context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234") - let transport = MockTransport() - transport.meshPingResult = MeshPingResult(rttMs: 8, hops: 1) - let processor = makeProcessor(context: context, transport: transport) - - _ = processor.process("/ping alice") - await waitForCommandOutput(context) - #expect(context.commandOutputs == ["pong from alice: 8 ms · direct (1 hop)"]) - } - - @MainActor - @Test func pingTimeoutReportsNoReply() async { - let context = DiagnosticsMockContext() - context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234") - let transport = MockTransport() - transport.meshPingResult = nil - let processor = makeProcessor(context: context, transport: transport) - - _ = processor.process("/ping @alice") - await waitForCommandOutput(context) - #expect(context.commandOutputs == ["no reply from alice"]) - } - - @MainActor - @Test func pingOutputRoutesToConversationWhereCommandWasIssued() async { - let context = DiagnosticsMockContext() - let alice = PeerID(str: "abcd1234abcd1234") - let bob = PeerID(str: "b0b0b0b0b0b0b0b0") - context.nicknameToPeerID["alice"] = alice - let transport = MockTransport() - transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2) - let processor = makeProcessor(context: context, transport: transport) - - // Issue the ping from bob's DM, then switch chats before the async - // result lands. The output must follow the origin conversation, not - // whatever is selected at callback time. - context.selectedPrivateChatPeer = bob - _ = processor.process("/ping @alice") - context.selectedPrivateChatPeer = nil - - await waitForCommandOutput(context) - #expect(context.commandOutputDestinations == [.privateChat(bob)]) - } - - @MainActor - @Test func pingIssuedFromPublicTimelineRoutesToMeshTimeline() async { - let context = DiagnosticsMockContext() - let alice = PeerID(str: "abcd1234abcd1234") - context.nicknameToPeerID["alice"] = alice - let transport = MockTransport() - transport.meshPingResult = MeshPingResult(rttMs: 7, hops: 1) - let processor = makeProcessor(context: context, transport: transport) - - _ = processor.process("/ping @alice") - // Opening a DM afterwards must not swallow the public-timeline result. - context.selectedPrivateChatPeer = alice - - await waitForCommandOutput(context) - #expect(context.commandOutputDestinations == [.meshTimeline]) - } - - // MARK: - /trace - - @MainActor - @Test func traceDirectPeerShowsOneHop() { - let context = DiagnosticsMockContext() - let bob = PeerID(str: "b0b0b0b0b0b0b0b0") - context.nicknameToPeerID["bob"] = bob - let transport = MockTransport() - transport.meshPaths[bob] = [] - let processor = makeProcessor(context: context, transport: transport) - - let result = processor.process("/trace @bob") - switch result { - case .success(let message): - #expect(message == "estimated path: you → bob (1 hop)") - default: - Issue.record("Expected success result") - } - } - - @MainActor - @Test func traceMultiHopUsesNicknamesWithShortIDFallback() { - let context = DiagnosticsMockContext() - let bob = PeerID(str: "b0b0b0b0b0b0b0b0") - let alice = PeerID(str: "a11cea11cea11cea") - let unknown = PeerID(str: "dead00beef001234") - context.nicknameToPeerID["bob"] = bob - let transport = MockTransport() - transport.peerNicknames = [alice: "alice"] - transport.meshPaths[bob] = [alice, unknown] - let processor = makeProcessor(context: context, transport: transport) - - let result = processor.process("/trace bob") - switch result { - case .success(let message): - #expect(message == "estimated path: you → alice → dead00be… → bob (3 hops)") - default: - Issue.record("Expected success result") - } - } - - @MainActor - @Test func traceWithoutPathReportsNoKnownPath() { - let context = DiagnosticsMockContext() - context.nicknameToPeerID["bob"] = PeerID(str: "b0b0b0b0b0b0b0b0") - let processor = makeProcessor(context: context, transport: MockTransport()) - - let result = processor.process("/trace @bob") - switch result { - case .success(let message): - #expect(message == "no known path to bob") - default: - Issue.record("Expected success result") - } - } - - // MARK: - Topology snapshot types - - @Test func topologyEdgeNormalizesEndpointOrder() { - let a = PeerID(str: "aaaa000000000000") - let b = PeerID(str: "bbbb000000000000") - #expect(MeshTopologyEdge(a, b) == MeshTopologyEdge(b, a)) - #expect(Set([MeshTopologyEdge(a, b), MeshTopologyEdge(b, a)]).count == 1) - } - - @Test func topologyLayoutPlacesSelfInCenter() { - let nodes: [MeshTopologyDisplayModel.Node] = [ - .init(id: "self", label: "me", isSelf: true), - .init(id: "a", label: "alice", isSelf: false), - .init(id: "b", label: "bob", isSelf: false) - ] - let size = CGSize(width: 200, height: 200) - let positions = MeshTopologyView.layout(nodes: nodes, in: size) - - #expect(positions["self"] == CGPoint(x: 100, y: 100)) - #expect(positions.count == 3) - // Ring nodes sit on the same radius around the center. - let radiusA = hypot((positions["a"]?.x ?? 0) - 100, (positions["a"]?.y ?? 0) - 100) - let radiusB = hypot((positions["b"]?.x ?? 0) - 100, (positions["b"]?.y ?? 0) - 100) - #expect(abs(radiusA - radiusB) < 0.001) - #expect(radiusA > 0) - } -} - -/// Minimal CommandContextProvider for diagnostics tests; records deferred -/// command output so async /ping results can be asserted. -@MainActor -private final class DiagnosticsMockContext: CommandContextProvider { - var nickname: String = "tester" - var activeChannel: ChannelID = .mesh - var selectedPrivateChatPeer: PeerID? - var blockedUsers: Set = [] - let idBridge = NostrIdentityBridge(keychain: MockKeychain()) - - var nicknameToPeerID: [String: PeerID] = [:] - private(set) var commandOutputs: [String] = [] - private(set) var commandOutputDestinations: [CommandOutputDestination] = [] - - func getPeerIDForNickname(_ nickname: String) -> PeerID? { - nicknameToPeerID[nickname] - } - - func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] } - func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil } - func startPrivateChat(with peerID: PeerID) {} - func sendPrivateMessage(_ content: String, to peerID: PeerID) {} - func clearCurrentPublicTimeline() {} - func clearPrivateChat(_ peerID: PeerID) {} - func sendPublicRaw(_ content: String) {} - func sendPublicMessage(_ content: String) {} - func groupCreate(named name: String) -> CommandResult { .handled } - func groupInvite(nickname: String) -> CommandResult { .handled } - func groupRemove(nickname: String) -> CommandResult { .handled } - func groupLeave() -> CommandResult { .handled } - func groupList() -> CommandResult { .handled } - func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {} - func addPublicSystemMessage(_ content: String) {} - func toggleFavorite(peerID: PeerID) {} - - func currentCommandDestination() -> CommandOutputDestination { - if let peerID = selectedPrivateChatPeer { - return .privateChat(peerID) - } - return .meshTimeline - } - - func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { - commandOutputs.append(content) - commandOutputDestinations.append(destination) - } -} diff --git a/bitchatTests/Services/MeshSightingsTrackerTests.swift b/bitchatTests/Services/MeshSightingsTrackerTests.swift deleted file mode 100644 index e6795e7b..00000000 --- a/bitchatTests/Services/MeshSightingsTrackerTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// MeshSightingsTrackerTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -@testable import bitchat -import BitFoundation - -@MainActor -struct MeshSightingsTrackerTests { - - private func makeDefaults() -> UserDefaults { - let suite = "MeshSightingsTrackerTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - return defaults - } - - private let noon = Date(timeIntervalSince1970: 1_700_000_000) - - @Test - func countsDistinctPeersOnce() { - let defaults = makeDefaults() - let tracker = MeshSightingsTracker(defaults: defaults, now: { self.noon }) - - tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) - tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) - tracker.recordSighting(peerID: PeerID(str: "bbbb444455556666")) - - #expect(tracker.todayCount == 2) - #expect(tracker.lastSightingAt == noon) - } - - @Test - func persistsAcrossInstances() { - let defaults = makeDefaults() - let first = MeshSightingsTracker(defaults: defaults, now: { self.noon }) - first.recordSighting(peerID: PeerID(str: "aaaa111122223333")) - - let second = MeshSightingsTracker(defaults: defaults, now: { self.noon.addingTimeInterval(60) }) - #expect(second.todayCount == 1) - - // Same peer again does not double count after a relaunch. - second.recordSighting(peerID: PeerID(str: "aaaa111122223333")) - #expect(second.todayCount == 1) - } - - @Test - func rollsOverOnNewDay() { - let defaults = makeDefaults() - var currentNow = noon - let tracker = MeshSightingsTracker(defaults: defaults, now: { currentNow }) - tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) - #expect(tracker.todayCount == 1) - - currentNow = noon.addingTimeInterval(2 * 24 * 60 * 60) - tracker.recordSighting(peerID: PeerID(str: "bbbb444455556666")) - - #expect(tracker.todayCount == 1) - } - - @Test - func clearResetsEverything() { - let defaults = makeDefaults() - let tracker = MeshSightingsTracker(defaults: defaults, now: { self.noon }) - tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) - - tracker.clear() - - #expect(tracker.todayCount == 0) - #expect(tracker.lastSightingAt == nil) - - let reloaded = MeshSightingsTracker(defaults: defaults, now: { self.noon }) - #expect(reloaded.todayCount == 0) - } -} diff --git a/bitchatTests/Services/MeshTopologyTrackerTests.swift b/bitchatTests/Services/MeshTopologyTrackerTests.swift index fa5d2a4d..1ae0f5f5 100644 --- a/bitchatTests/Services/MeshTopologyTrackerTests.swift +++ b/bitchatTests/Services/MeshTopologyTrackerTests.swift @@ -94,132 +94,10 @@ struct MeshTopologyTrackerTests { tracker.updateNeighbors(for: a, neighbors: [b]) tracker.updateNeighbors(for: b, neighbors: [a]) - + // When start == end, route should be empty (no intermediate hops needed) let route = try #require(tracker.computeRoute(from: a, to: a)) #expect(route == []) } - @Test func noPathReturnsNil() throws { - let tracker = MeshTopologyTracker() - let a = try hex("0101010101010101") - let b = try hex("0202020202020202") - let c = try hex("0303030303030303") - let d = try hex("0404040404040404") - - // Two disconnected islands: A-B and C-D - tracker.updateNeighbors(for: a, neighbors: [b]) - tracker.updateNeighbors(for: b, neighbors: [a]) - tracker.updateNeighbors(for: c, neighbors: [d]) - tracker.updateNeighbors(for: d, neighbors: [c]) - - #expect(tracker.computeRoute(from: a, to: d) == nil) - } - - /// Build a confirmed line topology n0 - n1 - ... - n(count-1). - private func makeLine(_ tracker: MeshTopologyTracker, count: Int) throws -> [Data] { - let nodes = try (0.. 0 { neighbors.append(nodes[i - 1]) } - if i < count - 1 { neighbors.append(nodes[i + 1]) } - tracker.updateNeighbors(for: nodes[i], neighbors: neighbors) - } - return nodes - } - - @Test func maxHopsCapsIntermediateHopCount() throws { - let tracker = MeshTopologyTracker() - // 7 nodes: source + 5 intermediates + target - let nodes = try makeLine(tracker, count: 7) - - // 5 intermediates exceed a 4-hop cap - #expect(tracker.computeRoute(from: nodes[0], to: nodes[6], maxHops: 4) == nil) - // 4 intermediates fit exactly - let route = try #require(tracker.computeRoute(from: nodes[0], to: nodes[5], maxHops: 4)) - #expect(route == Array(nodes[1...4])) - } - - @Test func staleNeighborBlocksRoute() throws { - let tracker = MeshTopologyTracker() - let a = try hex("0101010101010101") - let b = try hex("0202020202020202") - let c = try hex("0303030303030303") - - let staleDate = Date().addingTimeInterval(-120) // past 60s freshness - tracker.updateNeighbors(for: a, neighbors: [b]) - tracker.updateNeighbors(for: b, neighbors: [a, c], at: staleDate) - tracker.updateNeighbors(for: c, neighbors: [b]) - - #expect(tracker.computeRoute(from: a, to: c) == nil) - - // Refreshing B restores the route. - tracker.updateNeighbors(for: b, neighbors: [a, c]) - let route = try #require(tracker.computeRoute(from: a, to: c)) - #expect(route == [b]) - } - - @Test func versionGateBlocksV1AndUnknownHops() throws { - let tracker = MeshTopologyTracker() - let a = try hex("0101010101010101") - let b = try hex("0202020202020202") - let c = try hex("0303030303030303") - - tracker.updateNeighbors(for: a, neighbors: [b]) - tracker.updateNeighbors(for: b, neighbors: [a, c]) - tracker.updateNeighbors(for: c, neighbors: [b]) - - // Without the gate the route exists. - #expect(tracker.computeRoute(from: a, to: c) == [b]) - // Version-unknown hops are assumed v1-only and block gated routes. - #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) - - // A v1 observation does not unlock the gate. - tracker.recordObservedVersion(1, for: b) - tracker.recordObservedVersion(2, for: c) - #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) - - // Once the hop is observed speaking v2 the route opens. - tracker.recordObservedVersion(2, for: b) - let route = try #require(tracker.computeRoute(from: a, to: c, requiringVersion: 2)) - #expect(route == [b]) - } - - @Test func versionGateRequiresV2Target() throws { - let tracker = MeshTopologyTracker() - let a = try hex("0101010101010101") - let b = try hex("0202020202020202") - let c = try hex("0303030303030303") - - tracker.updateNeighbors(for: a, neighbors: [b]) - tracker.updateNeighbors(for: b, neighbors: [a, c]) - tracker.updateNeighbors(for: c, neighbors: [b]) - tracker.recordObservedVersion(2, for: b) - - // The recipient must decode the v2 frame too. - #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) - - tracker.recordObservedVersion(2, for: c) - #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b]) - } - - @Test func pruneDropsStaleObservedVersions() throws { - let tracker = MeshTopologyTracker() - let a = try hex("0101010101010101") - let b = try hex("0202020202020202") - let c = try hex("0303030303030303") - - tracker.updateNeighbors(for: a, neighbors: [b]) - tracker.updateNeighbors(for: b, neighbors: [a, c]) - tracker.updateNeighbors(for: c, neighbors: [b]) - let old = Date().addingTimeInterval(-120) - tracker.recordObservedVersion(2, for: b, at: old) - tracker.recordObservedVersion(2, for: c, at: old) - - #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b]) - tracker.prune(olderThan: 60) - // Claims are fresh but the version observations aged out. - #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) - } - } diff --git a/bitchatTests/Services/MessageOutboxStoreTests.swift b/bitchatTests/Services/MessageOutboxStoreTests.swift deleted file mode 100644 index c8059827..00000000 --- a/bitchatTests/Services/MessageOutboxStoreTests.swift +++ /dev/null @@ -1,332 +0,0 @@ -// -// MessageOutboxStoreTests.swift -// bitchatTests -// -// Tests for the encrypted-at-rest outbox persistence. -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -struct MessageOutboxStoreTests { - - private func makeTempURL() -> URL { - FileManager.default.temporaryDirectory - .appendingPathComponent("outbox-\(UUID().uuidString).sealed") - } - - private func makeMessage(_ id: String, content: String = "hello") -> MessageOutboxStore.QueuedMessage { - MessageOutboxStore.QueuedMessage( - content: content, - nickname: "peer", - messageID: id, - timestamp: Date(timeIntervalSince1970: 1_750_000_000), - sendAttempts: 2, - depositedCourierKeys: [Data(repeating: 0xC1, count: 32)] - ) - } - - @Test func roundTripAcrossInstances() { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "0000000000000001") - - let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL) - store.save([peerID: [makeMessage("m1")]]) - - // Same keychain (encryption key) reads it back, fields intact. - let reloaded = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() - #expect(reloaded[peerID]?.count == 1) - #expect(reloaded[peerID]?.first?.messageID == "m1") - #expect(reloaded[peerID]?.first?.sendAttempts == 2) - #expect(reloaded[peerID]?.first?.depositedCourierKeys.count == 1) - } - - @Test func contentIsNotPlaintextOnDisk() throws { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL) - store.save([PeerID(str: "0000000000000001"): [makeMessage("m1", content: "very secret message")]]) - - let raw = try Data(contentsOf: fileURL) - #expect(!raw.isEmpty) - // Sealed bytes must not contain the message plaintext. - #expect(raw.range(of: Data("very secret message".utf8)) == nil) - } - - @Test func loadWithoutKeyReturnsEmpty() { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL) - store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]]) - - // A different keychain (fresh device / wiped key) cannot read the file. - let other = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL) - #expect(other.load().isEmpty) - } - - @Test func permanentlyMissingDeviceKeyDiscardsOrphanAndNewSavesSurviveRelaunch() { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "0000000000000001") - - MessageOutboxStore(keychain: keychain, fileURL: fileURL) - .save([peerID: [makeMessage("orphaned")]]) - #expect(FileManager.default.fileExists(atPath: fileURL.path)) - - // Models a device restore: Application Support brought the sealed - // file across, but its AfterFirstUnlockThisDeviceOnly key cannot. - keychain.deleteAll(service: "chat.bitchat.outbox") - let restored = MessageOutboxStore(keychain: keychain, fileURL: fileURL) - #expect(restored.load().isEmpty) - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - - restored.save([peerID: [makeMessage("fresh")]]) - let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() - #expect(relaunched[peerID]?.map(\.messageID) == ["fresh"]) - } - - @Test func temporarilyLockedKeyDoesNotDiscardDurableSnapshot() { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "0000000000000001") - - MessageOutboxStore(keychain: keychain, fileURL: fileURL) - .save([peerID: [makeMessage("durable")]]) - let durableBytes = try? Data(contentsOf: fileURL) - - keychain.simulatedGenericReadError = .deviceLocked - let restored = MessageOutboxStore(keychain: keychain, fileURL: fileURL) - #expect(restored.load().isEmpty) - #expect((try? Data(contentsOf: fileURL)) == durableBytes) - - keychain.simulatedGenericReadError = nil - let recovered = restored.retryDeferredLoad() - #expect(recovered?[peerID]?.map(\.messageID) == ["durable"]) - } - - @Test @MainActor - func panicWipeInvalidatesQueuedRecoveryCallback() async { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "0000000000000001") - MessageOutboxStore(keychain: keychain, fileURL: fileURL) - .save([peerID: [makeMessage("durable")]]) - - var protectedDataUnavailable = true - let restored = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - #expect(restored.load().isEmpty) - var deliveredRecoveries: [MessageOutboxStore.Snapshot] = [] - restored.setRecoveryHandler { deliveredRecoveries.append($0) } - - protectedDataUnavailable = false - restored.retryDeferredLoad() // queues the handler onto MainActor - restored.wipe() // invalidates it before the queued Task runs - await Task.yield() - - #expect(deliveredRecoveries.isEmpty) - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - } - - @Test @MainActor - func wipeBetweenRecoveryUnlockAndNotificationDropsRecoveredSnapshot() async { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "0000000000000001") - MessageOutboxStore(keychain: keychain, fileURL: fileURL) - .save([peerID: [makeMessage("durable")]]) - - var protectedDataUnavailable = true - var gapAction: (() -> Void)? - let restored = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - }, - beforeRecoveryNotification: { gapAction?() } - ) - #expect(restored.load().isEmpty) - var deliveredRecoveries: [MessageOutboxStore.Snapshot] = [] - restored.setRecoveryHandler { deliveredRecoveries.append($0) } - gapAction = { restored.wipe() } - - protectedDataUnavailable = false - restored.retryDeferredLoad() - await Task.yield() - - #expect(deliveredRecoveries.isEmpty) - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - } - - @Test func deferredRemovalTombstoneFiltersUnseenDurableMessage() { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "0000000000000001") - MessageOutboxStore(keychain: keychain, fileURL: fileURL) - .save([peerID: [makeMessage("durable")]]) - - var protectedDataUnavailable = true - let restored = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - #expect(restored.load().isEmpty) - restored.recordRemoval(messageID: "durable") - restored.save([:]) - - protectedDataUnavailable = false - let recovered = restored.retryDeferredLoad() - #expect(recovered?.isEmpty == true) - #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) - } - - @Test func deferredScopedRemovalTombstoneFiltersOnlySelectedPeer() { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let acknowledgedPeer = PeerID(str: "0000000000000001") - let otherPeer = PeerID(str: "0000000000000002") - MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([ - acknowledgedPeer: [makeMessage("shared-id", content: "for acknowledged peer")], - otherPeer: [makeMessage("shared-id", content: "for other peer")] - ]) - - var protectedDataUnavailable = true - let restored = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - #expect(restored.load().isEmpty) - restored.recordRemoval(messageID: "shared-id", for: [acknowledgedPeer]) - restored.save([:]) - - protectedDataUnavailable = false - let recovered = restored.retryDeferredLoad() - #expect(recovered?[acknowledgedPeer] == nil) - #expect(recovered?[otherPeer]?.map(\.messageID) == ["shared-id"]) - - let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() - #expect(relaunched[acknowledgedPeer] == nil) - #expect(relaunched[otherPeer]?.map(\.messageID) == ["shared-id"]) - } - - @Test func wipeRemovesFileAndKey() { - let fileURL = makeTempURL() - let keychain = MockKeychain() - let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL) - store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]]) - #expect(FileManager.default.fileExists(atPath: fileURL.path)) - - store.wipe() - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - #expect(store.load().isEmpty) - } - - @Test func savingEmptyOutboxRemovesFile() { - let fileURL = makeTempURL() - let keychain = MockKeychain() - let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL) - let peerID = PeerID(str: "0000000000000001") - store.save([peerID: [makeMessage("m1")]]) - store.save([peerID: []]) - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - } - - @Test func protectedDataReadFailureDefersWriteAndMergesOnRecovery() { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "0000000000000001") - - let seed = MessageOutboxStore(keychain: keychain, fileURL: fileURL) - seed.save([peerID: [makeMessage("durable")]]) - let durableBytes = try? Data(contentsOf: fileURL) - - var protectedDataUnavailable = true - let restored = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - #expect(restored.load().isEmpty) - restored.save([peerID: [makeMessage("during-wake")]]) - - // The unreadable durable snapshot was not replaced by the partial - // in-memory outbox from the locked restoration. - #expect((try? Data(contentsOf: fileURL)) == durableBytes) - - protectedDataUnavailable = false - let recovered = restored.retryDeferredLoad() - #expect(Set(recovered?[peerID]?.map(\.messageID) ?? []) == ["durable", "during-wake"]) - - let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() - #expect(Set(relaunched[peerID]?.map(\.messageID) ?? []) == ["durable", "during-wake"]) - } - - @Test func lockedWakeRemovalDoesNotResurrectPendingMessageOnRecovery() { - let fileURL = makeTempURL() - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "0000000000000001") - MessageOutboxStore(keychain: keychain, fileURL: fileURL) - .save([peerID: [makeMessage("durable")]]) - - var protectedDataUnavailable = true - let restored = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - #expect(restored.load().isEmpty) - restored.save([peerID: [makeMessage("wake")]]) - // A later delivery ack produces the complete empty in-memory view. - restored.save([:]) - - protectedDataUnavailable = false - let recovered = restored.retryDeferredLoad() - #expect(recovered?[peerID]?.map(\.messageID) == ["durable"]) - } -} diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift index a34acd7e..89289320 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -42,379 +42,6 @@ struct MessageRouterTests { #expect(transport.sentPrivateMessages.count == 1) } - @Test @MainActor - func sendPrivate_prefersConnectedTransportOverEarlierReachableOne() async { - let peerID = PeerID(str: "0000000000000005") - let reachableOnly = MockTransport() - reachableOnly.reachablePeers.insert(peerID) - let connected = MockTransport() - connected.connectedPeers.insert(peerID) - connected.reachablePeers.insert(peerID) - - let router = MessageRouter(transports: [reachableOnly, connected]) - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m5") - - #expect(reachableOnly.sentPrivateMessages.isEmpty) - #expect(connected.sentPrivateMessages.count == 1) - } - - @Test @MainActor - func sendPrivate_reachableOnlySendRetainsUntilDeliveryAck() async { - let peerID = PeerID(str: "0000000000000006") - let transport = MockTransport() - transport.reachablePeers.insert(peerID) - - let router = MessageRouter(transports: [transport]) - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m6") - #expect(transport.sentPrivateMessages.count == 1) - - // No ack yet: a flush retries over the weak signal. - router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.count == 2) - - // Ack clears the retained copy; later flushes stop resending. - router.markDelivered("m6") - router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.count == 2) - } - - @Test @MainActor - func peerBoundDeliveryAckCannotClearAnotherPeersRetainedMessage() async { - let intendedPeer = PeerID(str: "0000000000000023") - let otherPeer = PeerID(str: "0000000000000024") - let transport = MockTransport() - transport.reachablePeers = [intendedPeer, otherPeer] - - let router = MessageRouter(transports: [transport]) - router.sendPrivate( - "Secret", - to: intendedPeer, - recipientNickname: "Intended", - messageID: "peer-bound-ack" - ) - #expect(transport.sentPrivateMessages.count == 1) - - // Even a receipt arriving over another authenticated conversation - // must not terminalize the intended peer's retained retry. - router.markDelivered("peer-bound-ack", from: [otherPeer]) - router.flushOutbox(for: intendedPeer) - #expect(transport.sentPrivateMessages.count == 2) - - router.markDelivered("peer-bound-ack", from: [intendedPeer]) - router.flushOutbox(for: intendedPeer) - #expect(transport.sentPrivateMessages.count == 2) - } - - @Test @MainActor - func sendPrivate_connectedSecureSendRetainsUntilDeliveryAck() async { - let peerID = PeerID(str: "0000000000000007") - let transport = MockTransport() - transport.connectedPeers.insert(peerID) - transport.reachablePeers.insert(peerID) - transport.securePeers = [peerID] - - let router = MessageRouter(transports: [transport]) - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7") - #expect(transport.sentPrivateMessages.count == 1) - - // A newly authenticated/replacement session retries the retained - // message instead of losing the first ciphertext to a stale session. - router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) - #expect(transport.sentPrivateMessages.count == 2) - - router.markDelivered("m7") - router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) - #expect(transport.sentPrivateMessages.count == 2) - } - - @Test @MainActor - func authenticationRetry_matchesStableOutboxAliasWithoutDoubleSending() async { - let shortPeerID = PeerID(str: "0000000000000019") - let stablePeerID = PeerID(hexData: Data(repeating: 0x19, count: 32)) - let transport = MockTransport() - transport.connectedPeers.insert(stablePeerID) - transport.securePeers = [stablePeerID] - - let router = MessageRouter(transports: [transport]) - router.sendPrivate("Hello", to: stablePeerID, recipientNickname: "Peer", messageID: "alias-retry") - - router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID, stablePeerID]) - - #expect(transport.sentPrivateMessages.map(\.messageID) == ["alias-retry", "alias-retry"]) - #expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == stablePeerID }) - } - - @Test @MainActor - func authenticationRetry_preservesFIFOAcrossSplitAliases() async { - let shortPeerID = PeerID(str: "0000000000000022") - let stablePeerID = PeerID(hexData: Data(repeating: 0x22, count: 32)) - let transport = MockTransport() - transport.connectedPeers = [shortPeerID, stablePeerID] - transport.securePeers = [shortPeerID, stablePeerID] - let clock = MutableTestClock() - let router = MessageRouter(transports: [transport], now: { clock.now }) - - // The older message lives under the stable key, even though the auth - // callback supplies the ephemeral alias first. - router.sendPrivate("Older", to: stablePeerID, recipientNickname: "Peer", messageID: "fifo-old") - clock.now = clock.now.addingTimeInterval(1) - router.sendPrivate("Newer", to: shortPeerID, recipientNickname: "Peer", messageID: "fifo-new") - transport.resetRecordings() - - router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID]) - - #expect(transport.sentPrivateMessages.map(\.messageID) == ["fifo-old", "fifo-new"]) - #expect(transport.sentPrivateMessages.map(\.peerID) == [stablePeerID, shortPeerID]) - } - - @Test @MainActor - func authenticationRetry_doesNotDuplicateNormalPendingHandshakeSend() async { - let peerID = PeerID(str: "0000000000000020") - let transport = MockTransport() - transport.connectedPeers.insert(peerID) - transport.securePeers = [] - - let router = MessageRouter(transports: [transport]) - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "normal-handshake") - #expect(transport.sentPrivateMessages.count == 1) - - // BLE owns this pending send and drains it after authentication. Once - // the session becomes secure, the router's targeted auth retry must - // stay silent instead of producing a second copy. - transport.securePeers = [peerID] - router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) - #expect(transport.sentPrivateMessages.count == 1) - - router.markDelivered("normal-handshake") - } - - @Test @MainActor - func authenticationRetry_scopesCollidingMessageIDsByPeer() async { - let securePeer = PeerID(str: "0000000000000025") - let pendingPeer = PeerID(str: "0000000000000026") - let transport = MockTransport() - transport.connectedPeers = [securePeer, pendingPeer] - transport.securePeers = [securePeer] - - let router = MessageRouter(transports: [transport]) - let promotedID = "collision-promoted" - let clearedID = "collision-cleared" - - // Pending B then secure A: an ID-global marker falsely promotes B. - router.sendPrivate( - "pending promoted", - to: pendingPeer, - recipientNickname: "Pending", - messageID: promotedID - ) - router.sendPrivate( - "secure promoted", - to: securePeer, - recipientNickname: "Secure", - messageID: promotedID - ) - - // Secure A then pending B: an ID-global removal falsely clears A. - router.sendPrivate( - "secure cleared", - to: securePeer, - recipientNickname: "Secure", - messageID: clearedID - ) - router.sendPrivate( - "pending cleared", - to: pendingPeer, - recipientNickname: "Pending", - messageID: clearedID - ) - - transport.resetRecordings() - transport.securePeers = [securePeer, pendingPeer] - - router.retrySecurePrivateMessagesAfterAuthentication(for: [pendingPeer]) - #expect(transport.sentPrivateMessages.isEmpty) - - router.retrySecurePrivateMessagesAfterAuthentication(for: [securePeer]) - #expect(transport.sentPrivateMessages.count == 2) - #expect(Set(transport.sentPrivateMessages.map(\.messageID)) == [promotedID, clearedID]) - #expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == securePeer }) - } - - @Test @MainActor - func authenticationRetry_doesNotDuplicateMessageRequeuedByBLEForHandshake() async { - let peerID = PeerID(str: "0000000000000021") - let transport = MockTransport() - transport.connectedPeers.insert(peerID) - transport.securePeers = [peerID] - - let router = MessageRouter(transports: [transport]) - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "session-lost") - #expect(transport.sentPrivateMessages.count == 1) - - // The session disappears before a normal outbox flush. That send is - // now owned by BLE's pending-handshake queue, so it clears the - // router's secure-auth retry marker. - transport.securePeers = [] - router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.count == 2) - - transport.securePeers = [peerID] - router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) - #expect(transport.sentPrivateMessages.count == 2) - - router.markDelivered("session-lost") - } - - @Test @MainActor - func sendPrivate_fastDeliveryAckCannotRaceAheadOfRetention() async { - let peerID = PeerID(str: "0000000000000017") - let transport = MockTransport() - transport.connectedPeers.insert(peerID) - transport.securePeers = [peerID] - - let router = MessageRouter(transports: [transport]) - transport.onSendPrivateMessage = { messageID in - router.markDelivered(messageID) - } - - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "fast-ack") - #expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"]) - - transport.onSendPrivateMessage = nil - router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"]) - } - - @Test @MainActor - func flushOutbox_synchronousAckDoesNotResurrectSnapshotEntry() async { - let peerID = PeerID(str: "0000000000000018") - let transport = MockTransport() - transport.connectedPeers.insert(peerID) - transport.securePeers = [peerID] - - let router = MessageRouter(transports: [transport]) - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "flush-fast-ack") - transport.onSendPrivateMessage = { messageID in - router.markDelivered(messageID) - } - - router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.count == 2) - - transport.onSendPrivateMessage = nil - router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.count == 2) - } - - @Test @MainActor - func flushOutbox_dropsUnackedMessageAfterAttemptCap() async { - let peerID = PeerID(str: "0000000000000008") - let transport = MockTransport() - transport.reachablePeers.insert(peerID) - - let router = MessageRouter(transports: [transport]) - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m8") - - for _ in 0..<10 { - router.flushOutbox(for: peerID) - } - - // Initial send plus capped resends; never unbounded. - #expect(transport.sentPrivateMessages.count == 8) - } - - // MARK: - Drop visibility (onMessageDropped) - - @Test @MainActor - func flushOutbox_attemptCapDropInvokesOnMessageDropped() async { - let peerID = PeerID(str: "0000000000000009") - let transport = MockTransport() - transport.reachablePeers.insert(peerID) - - let router = MessageRouter(transports: [transport]) - var dropped: [(messageID: String, peerID: PeerID)] = [] - router.onMessageDropped = { dropped.append(($0, $1)) } - - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m9") - for _ in 0..<10 { - router.flushOutbox(for: peerID) - } - - #expect(dropped.count == 1) - #expect(dropped.first?.messageID == "m9") - #expect(dropped.first?.peerID == peerID) - } - - @Test @MainActor - func flushOutbox_ttlExpiryInvokesOnMessageDroppedAndDoesNotResend() async { - let peerID = PeerID(str: "000000000000000a") - let transport = MockTransport() - let clock = MutableTestClock() - - let router = MessageRouter(transports: [transport], now: { clock.now }) - var dropped: [String] = [] - router.onMessageDropped = { messageID, _ in dropped.append(messageID) } - - // No reachable transport: the message is queued, never sent. - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m10") - #expect(transport.sentPrivateMessages.isEmpty) - - // Past the 24h TTL the flush must drop it (visibly), not send it. - clock.now = clock.now.addingTimeInterval(24 * 60 * 60 + 1) - transport.reachablePeers.insert(peerID) - router.flushOutbox(for: peerID) - - #expect(dropped == ["m10"]) - #expect(transport.sentPrivateMessages.isEmpty) - - // The drop is final: nothing is retained for later flushes. - router.flushOutbox(for: peerID) - #expect(dropped == ["m10"]) - } - - @Test @MainActor - func cleanupExpiredMessages_invokesOnMessageDroppedForExpiredOnly() async { - let peerID = PeerID(str: "000000000000000b") - let transport = MockTransport() - let clock = MutableTestClock() - - let router = MessageRouter(transports: [transport], now: { clock.now }) - var dropped: [String] = [] - router.onMessageDropped = { messageID, _ in dropped.append(messageID) } - - router.sendPrivate("Old", to: peerID, recipientNickname: "Peer", messageID: "m11-old") - clock.now = clock.now.addingTimeInterval(24 * 60 * 60 - 60) - router.sendPrivate("Fresh", to: peerID, recipientNickname: "Peer", messageID: "m11-fresh") - clock.now = clock.now.addingTimeInterval(120) - - router.cleanupExpiredMessages() - - #expect(dropped == ["m11-old"]) - - // The fresh message survived and still flushes once reachable. - transport.reachablePeers.insert(peerID) - router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.map(\.messageID) == ["m11-fresh"]) - } - - @Test @MainActor - func enqueue_perPeerOverflowEvictionInvokesOnMessageDropped() async { - let peerID = PeerID(str: "000000000000000c") - let transport = MockTransport() - - let router = MessageRouter(transports: [transport]) - var dropped: [String] = [] - router.onMessageDropped = { messageID, _ in dropped.append(messageID) } - - // No reachable transport: everything queues. The cap is 100 per peer, - // so the 101st enqueue evicts the oldest. - for i in 0...100 { - router.sendPrivate("Hello \(i)", to: peerID, recipientNickname: "Peer", messageID: "q\(i)") - } - - #expect(dropped == ["q0"]) - } - @Test @MainActor func sendReadReceipt_usesReachableTransport() async { let peerID = PeerID(str: "0000000000000003") @@ -439,804 +66,4 @@ struct MessageRouterTests { #expect(transport.sentFavoriteNotifications.count == 1) } - - // MARK: - Courier deposits - - private static func snapshot(_ peerID: PeerID, key: Data, verified: Bool) -> TransportPeerSnapshot { - TransportPeerSnapshot( - peerID: peerID, - nickname: "peer", - isConnected: true, - noisePublicKey: key, - lastSeen: Date(), - isVerified: verified - ) - } - - /// Directory that resolves one offline recipient and treats a fixed key - /// set as mutual favorites. - private static func directory(recipient: PeerID, recipientKey: Data, favoriteKeys: Set = []) -> CourierDirectory { - CourierDirectory( - noiseKey: { peerID in peerID == recipient ? recipientKey : nil }, - isTrustedCourier: { favoriteKeys.contains($0) } - ) - } - - @Test @MainActor - func sendPrivate_depositsWithVerifiedStrangerWhenNoFavoriteAround() async { - let recipient = PeerID(str: "00000000000000aa") - let recipientKey = Data(repeating: 0xBB, count: 32) - let courier = PeerID(str: "00000000000000cc") - let courierKey = Data(repeating: 0xCC, count: 32) - - let transport = MockTransport() - transport.connectedPeers.insert(courier) - transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) - - let router = MessageRouter( - transports: [transport], - courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) - ) - router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv1") - - #expect(transport.sentCourierMessages.count == 1) - #expect(transport.sentCourierMessages.first?.couriers == [courier]) - } - - @Test @MainActor - func sendPrivate_neverDepositsWithUnverifiedStranger() async { - let recipient = PeerID(str: "00000000000000aa") - let courier = PeerID(str: "00000000000000cc") - - let transport = MockTransport() - transport.connectedPeers.insert(courier) - transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: false)]) - - let router = MessageRouter( - transports: [transport], - courierDirectory: Self.directory(recipient: recipient, recipientKey: Data(repeating: 0xBB, count: 32)) - ) - router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv2") - - #expect(transport.sentCourierMessages.isEmpty) - } - - @Test @MainActor - func sendPrivate_prefersFavoriteCouriersOverVerifiedOnes() async { - let recipient = PeerID(str: "00000000000000aa") - let recipientKey = Data(repeating: 0xBB, count: 32) - let favorite = PeerID(str: "00000000000000f0") - let favoriteKey = Data(repeating: 0xF0, count: 32) - var snapshots = [Self.snapshot(favorite, key: favoriteKey, verified: false)] - let transport = MockTransport() - transport.connectedPeers.insert(favorite) - // Three verified strangers compete for the three courier slots. - for byte: UInt8 in [0xC1, 0xC2, 0xC3] { - let peer = PeerID(str: String(format: "00000000000000%02x", byte)) - transport.connectedPeers.insert(peer) - snapshots.append(Self.snapshot(peer, key: Data(repeating: byte, count: 32), verified: true)) - } - transport.updatePeerSnapshots(snapshots) - - let router = MessageRouter( - transports: [transport], - courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey, favoriteKeys: [favoriteKey]) - ) - router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv3") - - let couriers = transport.sentCourierMessages.first?.couriers ?? [] - #expect(couriers.count == 3) - #expect(couriers.contains(favorite)) - } - - /// Residual gap after the rotation-heal containment: a replayed "direct" - /// announce (TTL is unsigned) can bind an absent victim's peer ID to the - /// replayer's link, leaving the victim "connected" on a link whose Noise - /// handshake can never complete. The connected fast-path must not trust - /// that outright: the send still goes out (a genuine link finishes the - /// handshake), but a copy is retained and a sealed copy goes to couriers - /// so nothing is silently lost. - @Test @MainActor - func sendPrivate_connectedWithoutSecureSessionRetainsAndDepositsWithCourier() async { - let victim = PeerID(str: "00000000000000aa") - let victimKey = Data(repeating: 0xBB, count: 32) - let courier = PeerID(str: "00000000000000cc") - let courierKey = Data(repeating: 0xCC, count: 32) - - let transport = MockTransport() - transport.connectedPeers.insert(victim) - transport.connectedPeers.insert(courier) - transport.securePeers = [] // no established Noise session with anyone - transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) - - let router = MessageRouter( - transports: [transport], - courierDirectory: Self.directory(recipient: victim, recipientKey: victimKey) - ) - router.sendPrivate("Hello", to: victim, recipientNickname: "Peer", messageID: "cs1") - - // The send is still attempted (it kicks the handshake on a genuine - // link) but not trusted outright: a courier gets a sealed copy now … - #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs1"]) - #expect(transport.sentCourierMessages.count == 1) - #expect(transport.sentCourierMessages.first?.messageID == "cs1") - #expect(transport.sentCourierMessages.first?.recipientNoiseKey == victimKey) - #expect(transport.sentCourierMessages.first?.couriers == [courier]) - - // … and the retained copy keeps flushing until a delivery ack — a - // flush over the insecure link must resend without dropping it. - router.flushOutbox(for: victim) - router.flushOutbox(for: victim) - #expect(transport.sentPrivateMessages.count == 3) - router.markDelivered("cs1") - router.flushOutbox(for: victim) - #expect(transport.sentPrivateMessages.count == 3) - } - - /// Flushes over a connected-but-insecure link never count toward the - /// attempt-cap drop: the message was actually transmitted over a live - /// link, so a peer whose Noise handshake stalls across reconnect flapping - /// must not burn through the cap and lose the store-and-forward copy the - /// secure-session gate exists to preserve. Retention stays bounded by - /// the 24h outbox TTL and the per-peer FIFO cap; an ack clears it. - @Test @MainActor - func flushOutbox_connectedInsecureFlushesNeverDropTheRetainedCopy() async { - let peerID = PeerID(str: "00000000000000ac") - let transport = MockTransport() - transport.connectedPeers.insert(peerID) - transport.securePeers = [] // handshake never completes - - let router = MessageRouter(transports: [transport]) - var dropped: [String] = [] - router.onMessageDropped = { messageID, _ in dropped.append(messageID) } - - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "ci1") - - // Well past maxSendAttempts (8): every flush resends, none drops. - for _ in 0..<10 { - router.flushOutbox(for: peerID) - } - #expect(dropped.isEmpty) - #expect(transport.sentPrivateMessages.count == 11) - - // The copy is still retained and an ack still clears it. - router.markDelivered("ci1") - router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.count == 11) - } - - @Test @MainActor - func authenticationRetry_capsActualSecureTransmissions() async { - let peerID = PeerID(str: "00000000000000ad") - let transport = MockTransport() - transport.connectedPeers.insert(peerID) - transport.securePeers = [peerID] - - let router = MessageRouter(transports: [transport]) - var dropped: [String] = [] - router.onMessageDropped = { messageID, _ in dropped.append(messageID) } - - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "secure-retry") - for _ in 0..<10 { - router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) - } - - #expect(dropped == ["secure-retry"]) - #expect(transport.sentPrivateMessages.count == 8) - } - - /// With an established secure session the connected fast-path sends - /// immediately and never leaks to couriers, but retains a local encrypted - /// outbox copy until the peer confirms receipt. - @Test @MainActor - func sendPrivate_connectedWithSecureSessionRetainsLocallyWithoutCourier() async { - let peerID = PeerID(str: "00000000000000ab") - let peerKey = Data(repeating: 0xAB, count: 32) - let courier = PeerID(str: "00000000000000cc") - - let transport = MockTransport() - transport.connectedPeers.insert(peerID) - transport.connectedPeers.insert(courier) - transport.securePeers = [peerID] - transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: true)]) - - let router = MessageRouter( - transports: [transport], - courierDirectory: Self.directory(recipient: peerID, recipientKey: peerKey) - ) - router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "cs2") - - #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"]) - #expect(transport.sentCourierMessages.isEmpty) - router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.count == 2) - #expect(transport.sentCourierMessages.isEmpty) - router.markDelivered("cs2") - router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.count == 2) - } - - @Test @MainActor - func courierBecameAvailable_retriesDepositOnceWithoutDoubleBurn() async { - let recipient = PeerID(str: "00000000000000aa") - let recipientKey = Data(repeating: 0xBB, count: 32) - let courier = PeerID(str: "00000000000000cc") - let courierKey = Data(repeating: 0xCC, count: 32) - - let transport = MockTransport() - let router = MessageRouter( - transports: [transport], - courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) - ) - // Nobody around at send time: the message just queues. - router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr1") - #expect(transport.sentCourierMessages.isEmpty) - - // A verified courier appears later: the deposit retries. - transport.connectedPeers.insert(courier) - transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) - router.courierBecameAvailable(courier) - #expect(transport.sentCourierMessages.count == 1) - #expect(transport.sentCourierMessages.first?.couriers == [courier]) - - // The same courier reconnecting does not receive the same mail twice. - router.courierBecameAvailable(courier) - #expect(transport.sentCourierMessages.count == 1) - } - - @Test @MainActor - func enqueueReplacementCarriesOverDepositedCourierKeys() async { - // Re-sending a queued message ID replaces the outbox entry; the - // replacement must inherit which couriers already carry the message, - // or the deposit retry re-burns the same courier slots (duplicate - // sealed copies to the same peer). - let recipient = PeerID(str: "00000000000000aa") - let recipientKey = Data(repeating: 0xBB, count: 32) - let courier = PeerID(str: "00000000000000cc") - let courierKey = Data(repeating: 0xCC, count: 32) - - let transport = MockTransport() - transport.connectedPeers.insert(courier) - transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) - - let router = MessageRouter( - transports: [transport], - courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) - ) - router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "ck1") - #expect(transport.sentCourierMessages.count == 1) - - // Same message ID re-sent (e.g. a resend while still queued): the - // courier already carrying it must not receive a second copy. - router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "ck1") - #expect(transport.sentCourierMessages.count == 1) - } - - @Test @MainActor - func courierBecameAvailable_ignoresTheRecipientThemselves() async { - let recipient = PeerID(str: "00000000000000aa") - let recipientKey = Data(repeating: 0xBB, count: 32) - - let transport = MockTransport() - let router = MessageRouter( - transports: [transport], - courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) - ) - router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr2") - - // The recipient connecting is a flush, not a courier opportunity. - transport.connectedPeers.insert(recipient) - transport.updatePeerSnapshots([Self.snapshot(recipient, key: recipientKey, verified: true)]) - router.courierBecameAvailable(recipient) - #expect(transport.sentCourierMessages.isEmpty) - } - - @Test @MainActor - func bridgeDepositCarriesOnlyAfterConfirmedSendAndRetriesFailure() async { - let recipient = PeerID(str: "00000000000000ba") - let recipientKey = Data(repeating: 0xBA, count: 32) - let transport = MockTransport() - let router = MessageRouter( - transports: [transport], - courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) - ) - var completions: [@MainActor (Bool) -> Void] = [] - router.bridgeCourierDeposit = { _, _, _, completion in - completions.append(completion) - } - var carried: [String] = [] - router.onMessageCarried = { messageID, _ in carried.append(messageID) } - - router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "bridge-ack") - #expect(completions.count == 1) - #expect(carried.isEmpty) - - // Sweeps while the WebSocket write is pending must not duplicate it. - router.retryBridgeCourierDeposits() - #expect(completions.count == 1) - - completions.removeFirst()(false) - #expect(carried.isEmpty) - - // A failed socket write releases the in-flight slot for a real retry. - router.retryBridgeCourierDeposits() - #expect(completions.count == 1) - completions.removeFirst()(true) - #expect(carried == ["bridge-ack"]) - } - - @Test @MainActor - func bridgeDepositsScopeCollidingMessageIDsByRecipient() async { - let firstRecipient = PeerID(str: "00000000000000b1") - let secondRecipient = PeerID(str: "00000000000000b2") - let firstKey = Data(repeating: 0xB1, count: 32) - let secondKey = Data(repeating: 0xB2, count: 32) - let recipientKeys = [ - firstRecipient: firstKey, - secondRecipient: secondKey - ] - let router = MessageRouter( - transports: [MockTransport()], - courierDirectory: CourierDirectory( - noiseKey: { recipientKeys[$0] }, - isTrustedCourier: { _ in false } - ) - ) - var requestedKeys: [Data] = [] - var completions: [@MainActor (Bool) -> Void] = [] - router.bridgeCourierDeposit = { _, _, recipientKey, completion in - requestedKeys.append(recipientKey) - completions.append(completion) - } - var carriedPeers: [PeerID] = [] - router.onMessageCarried = { _, peerID in carriedPeers.append(peerID) } - - router.sendPrivate( - "First", - to: firstRecipient, - recipientNickname: "First", - messageID: "bridge-collision" - ) - router.sendPrivate( - "Second", - to: secondRecipient, - recipientNickname: "Second", - messageID: "bridge-collision" - ) - - #expect(completions.count == 2) - #expect(Set(requestedKeys) == [firstKey, secondKey]) - - completions.forEach { $0(true) } - #expect(Set(carriedPeers) == [firstRecipient, secondRecipient]) - } - - // MARK: - Outbox persistence - - @Test @MainActor - func queuedMessagesSurviveRouterRestart() async { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "00000000000000dd") - - let transport = MockTransport() - let router = MessageRouter( - transports: [transport], - outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) - ) - router.sendPrivate("Survive", to: peerID, recipientNickname: "Peer", messageID: "p1") - #expect(transport.sentPrivateMessages.isEmpty) - - // "App restart": a fresh router over the same store, peer now around. - let transport2 = MockTransport() - transport2.reachablePeers.insert(peerID) - let router2 = MessageRouter( - transports: [transport2], - outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) - ) - router2.flushOutbox(for: peerID) - #expect(transport2.sentPrivateMessages.map(\.messageID) == ["p1"]) - } - - @Test @MainActor - func deliveredMessagesDoNotResurrectAfterRestart() async { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "00000000000000de") - - let transport = MockTransport() - let router = MessageRouter( - transports: [transport], - outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) - ) - router.sendPrivate("Once", to: peerID, recipientNickname: "Peer", messageID: "p2") - router.markDelivered("p2") - - let transport2 = MockTransport() - transport2.reachablePeers.insert(peerID) - let router2 = MessageRouter( - transports: [transport2], - outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) - ) - router2.flushOutbox(for: peerID) - #expect(transport2.sentPrivateMessages.isEmpty) - } - - @Test @MainActor - func scopedDeliveryAckClearsOnlySelectedPeerWhenMessageIDsCollide() async { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("router-outbox-scoped-ack-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let acknowledgedPeer = PeerID(str: "00000000000000d1") - let otherPeer = PeerID(str: "00000000000000d2") - let transport = MockTransport() - let router = MessageRouter( - transports: [transport], - outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) - ) - router.sendPrivate("For acknowledged peer", to: acknowledgedPeer, recipientNickname: "One", messageID: "shared-id") - router.sendPrivate("For other peer", to: otherPeer, recipientNickname: "Two", messageID: "shared-id") - - #expect(router.markDelivered("shared-id", for: [acknowledgedPeer])) - transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer]) - router.flushOutbox(for: acknowledgedPeer) - router.flushOutbox(for: otherPeer) - - #expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer]) - let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() - #expect(persisted[acknowledgedPeer] == nil) - #expect(persisted[otherPeer]?.map(\.messageID) == ["shared-id"]) - } - - @Test @MainActor - func scopedAckWhileColdLoadIsLockedPreventsOnlyTargetPeerResurrection() async { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("router-locked-scoped-ack-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let acknowledgedPeer = PeerID(str: "00000000000000d3") - let otherPeer = PeerID(str: "00000000000000d4") - let durable = MessageOutboxStore.QueuedMessage( - content: "Queued before reboot", - nickname: "Peer", - messageID: "shared-locked-id", - timestamp: Date() - ) - MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([ - acknowledgedPeer: [durable], - otherPeer: [durable] - ]) - - var protectedDataUnavailable = true - let restoredStore = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - let transport = MockTransport() - transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer]) - let router = MessageRouter(transports: [transport], outboxStore: restoredStore) - - router.markDelivered( - "shared-locked-id", - from: [acknowledgedPeer] - ) - protectedDataUnavailable = false - restoredStore.retryDeferredLoad() - await Task.yield() - await Task.yield() - - #expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer]) - let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() - #expect(persisted[acknowledgedPeer] == nil) - #expect(persisted[otherPeer]?.map(\.messageID) == ["shared-locked-id"]) - } - - @Test @MainActor - func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("router-protected-data-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "00000000000000df") - - let durable = MessageOutboxStore.QueuedMessage( - content: "Before reboot", - nickname: "Peer", - messageID: "durable", - timestamp: Date() - ) - MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) - - var protectedDataUnavailable = true - let restoredStore = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - let transport = MockTransport() - let router = MessageRouter(transports: [transport], outboxStore: restoredStore) - router.sendPrivate("During wake", to: peerID, recipientNickname: "Peer", messageID: "wake") - - transport.reachablePeers.insert(peerID) - protectedDataUnavailable = false - restoredStore.retryDeferredLoad() - // Recovery is delivered to the main actor without blocking the - // protected-data notification callback. - await Task.yield() - await Task.yield() - - #expect(Set(transport.sentPrivateMessages.map(\.messageID)) == ["durable", "wake"]) - } - - @Test @MainActor - func ackWhileColdLoadIsLockedDoesNotResurrectUnseenDurableMessage() async { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("router-locked-ack-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "00000000000000e0") - let durable = MessageOutboxStore.QueuedMessage( - content: "Already delivered", - nickname: "Peer", - messageID: "acked-while-locked", - timestamp: Date() - ) - MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) - - var protectedDataUnavailable = true - let restoredStore = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - let transport = MockTransport() - transport.reachablePeers.insert(peerID) - let router = MessageRouter(transports: [transport], outboxStore: restoredStore) - - // The router's locked cold-load view is empty, but the ack still has - // to suppress the durable message hidden on disk. - router.markDelivered("acked-while-locked") - protectedDataUnavailable = false - restoredStore.retryDeferredLoad() - await Task.yield() - await Task.yield() - - #expect(transport.sentPrivateMessages.isEmpty) - #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) - } - - @Test @MainActor - func ackAfterRecoveryCaptureBeforeMainActorMergeCannotResurrectMessage() async { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("router-recovery-gap-ack-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "00000000000000e1") - let durable = MessageOutboxStore.QueuedMessage( - content: "Captured then acked", - nickname: "Peer", - messageID: "recovery-gap-ack", - timestamp: Date() - ) - MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) - - var protectedDataUnavailable = true - let restoredStore = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - let transport = MockTransport() - transport.reachablePeers.insert(peerID) - let router = MessageRouter(transports: [transport], outboxStore: restoredStore) - - protectedDataUnavailable = false - restoredStore.retryDeferredLoad() // captures recovery and queues MainActor merge - router.markDelivered("recovery-gap-ack") // runs before that queued Task - await Task.yield() - await Task.yield() - - #expect(transport.sentPrivateMessages.isEmpty) - #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) - } - - @Test @MainActor - func enqueueAfterRecoveryCapturePreservesUnseenDurableAndNewMessages() async { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("router-recovery-gap-enqueue-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "00000000000000e2") - let durable = MessageOutboxStore.QueuedMessage( - content: "Before unlock", - nickname: "Peer", - messageID: "recovery-gap-durable", - timestamp: Date() - ) - MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) - - var protectedDataUnavailable = true - let restoredStore = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - let transport = MockTransport() - let router = MessageRouter(transports: [transport], outboxStore: restoredStore) - - protectedDataUnavailable = false - restoredStore.retryDeferredLoad() // queues merge of the durable message - router.sendPrivate("During gap", to: peerID, recipientNickname: "Peer", messageID: "recovery-gap-new") - transport.reachablePeers.insert(peerID) - await Task.yield() - await Task.yield() - - #expect(Set(transport.sentPrivateMessages.map(\.messageID)) == ["recovery-gap-durable", "recovery-gap-new"]) - let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() - #expect(Set(relaunched[peerID]?.map(\.messageID) ?? []) == ["recovery-gap-durable", "recovery-gap-new"]) - } - - @Test @MainActor - func removingKnownWakeMessageInRecoveryGapPreservesOnlyUnseenDurableState() async { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("router-recovery-gap-known-removal-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "00000000000000e3") - let durable = MessageOutboxStore.QueuedMessage( - content: "Unseen durable", - nickname: "Peer", - messageID: "recovery-gap-unseen", - timestamp: Date() - ) - MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) - - var protectedDataUnavailable = true - let restoredStore = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - } - ) - let transport = MockTransport() - let router = MessageRouter(transports: [transport], outboxStore: restoredStore) - router.sendPrivate("Known wake", to: peerID, recipientNickname: "Peer", messageID: "recovery-gap-known") - - protectedDataUnavailable = false - restoredStore.retryDeferredLoad() // captures unseen durable + known wake - - // A secure direct retry followed by its delivery ack removes the wake - // message before recovery's MainActor merge. It must remain removed, - // while the unseen durable message still arrives through the pending - // recovery claim. - transport.connectedPeers.insert(peerID) - transport.securePeers = [peerID] - router.flushOutbox(for: peerID) - router.markDelivered("recovery-gap-known") - await Task.yield() - await Task.yield() - - let sentIDs = transport.sentPrivateMessages.map(\.messageID) - #expect(sentIDs.filter { $0 == "recovery-gap-known" }.count == 1) - #expect(sentIDs.filter { $0 == "recovery-gap-unseen" }.count == 1) - } - - @Test @MainActor - func failedRecoveryWriteStillPreservesUnseenDurableStateAcrossGapRemoval() async { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("router-recovery-write-failure-\(UUID().uuidString).sealed") - defer { try? FileManager.default.removeItem(at: fileURL) } - let keychain = MockKeychain() - let peerID = PeerID(str: "00000000000000e4") - let durable = MessageOutboxStore.QueuedMessage( - content: "Unseen before unlock", - nickname: "Peer", - messageID: "recovery-write-failure-unseen", - timestamp: Date() - ) - MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]]) - - var protectedDataUnavailable = true - var failWrites = false - var injectedFailureCount = 0 - let restoredStore = MessageOutboxStore( - keychain: keychain, - fileURL: fileURL, - readData: { url in - if protectedDataUnavailable { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) - } - return try Data(contentsOf: url) - }, - writeData: { data, url, options in - if failWrites { - injectedFailureCount += 1 - throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError) - } - try data.write(to: url, options: options) - } - ) - let transport = MockTransport() - let router = MessageRouter(transports: [transport], outboxStore: restoredStore) - router.sendPrivate( - "Known wake", - to: peerID, - recipientNickname: "Peer", - messageID: "recovery-write-failure-known" - ) - - // The first post-unlock save reads durable D and knows router state W, - // but its D+W write fails. A later retry must retain the original - // unseen-D classification instead of mistaking the cached union for - // a fully router-known snapshot. - protectedDataUnavailable = false - failWrites = true - router.sendPrivate( - "Known wake", - to: peerID, - recipientNickname: "Peer", - messageID: "recovery-write-failure-known" - ) - failWrites = false - #expect(injectedFailureCount == 1) - - restoredStore.retryDeferredLoad() // persists D+W and queues recovery - transport.connectedPeers.insert(peerID) - transport.securePeers = [peerID] - router.flushOutbox(for: peerID) - router.markDelivered("recovery-write-failure-known") // removes W before queued callback - - // The gap save may remove W, but it must leave unseen D durable until - // MessageRouter receives the pending recovery callback. - let gapSnapshot = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() - #expect(gapSnapshot[peerID]?.map(\.messageID) == ["recovery-write-failure-unseen"]) - - await Task.yield() - await Task.yield() - - let sentIDs = transport.sentPrivateMessages.map(\.messageID) - #expect(sentIDs.filter { $0 == "recovery-write-failure-known" }.count == 1) - #expect(sentIDs.filter { $0 == "recovery-write-failure-unseen" }.count == 1) - } -} - -/// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable -/// without real waiting. -private final class MutableTestClock { - var now = Date(timeIntervalSince1970: 1_700_000_000) } diff --git a/bitchatTests/Services/NetworkActivationServiceTests.swift b/bitchatTests/Services/NetworkActivationServiceTests.swift index 7faa4681..5523ccdc 100644 --- a/bitchatTests/Services/NetworkActivationServiceTests.swift +++ b/bitchatTests/Services/NetworkActivationServiceTests.swift @@ -63,7 +63,7 @@ final class NetworkActivationServiceTests: XCTestCase { context.service.start() context.service.setUserTorEnabled(false) - wait(for: [notified], timeout: TestConstants.negativeWaitWindow) + wait(for: [notified], timeout: 1.0) context.notificationCenter.removeObserver(token) XCTAssertFalse(context.service.userTorEnabled) @@ -91,114 +91,9 @@ final class NetworkActivationServiceTests: XCTestCase { XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1) } - func test_stopForPanic_synchronouslyStopsAndIgnoresPublisherUpdates() async { - let context = makeService(permission: .authorized, favorites: []) - - context.service.start() - context.service.stopForPanic() - let connectCountAfterStop = context.relayController.connectCallCount - let startCountAfterStop = context.torController.startIfNeededCallCount - - context.favoritesSubject.send([Data([0x01])]) - context.reachability.set(false) - context.reachability.set(true) - try? await Task.sleep(nanoseconds: 30_000_000) - - XCTAssertFalse(context.service.activationAllowed) - XCTAssertEqual(context.reachability.stopCallCount, 1) - XCTAssertEqual(context.torController.autoStartAllowedValues.last, false) - XCTAssertEqual(context.proxyController.proxyModes.last, false) - XCTAssertGreaterThanOrEqual( - context.torController.shutdownCompletelyCallCount, - 1 - ) - XCTAssertGreaterThanOrEqual( - context.relayController.disconnectCallCount, - 1 - ) - XCTAssertEqual( - context.relayController.connectCallCount, - connectCountAfterStop - ) - XCTAssertEqual( - context.torController.startIfNeededCallCount, - startCountAfterStop - ) - } - - func test_start_afterPanicStop_reestablishesSubscriptions() { - let context = makeService(permission: .authorized, favorites: []) - - context.service.start() - context.service.stopForPanic() - context.service.start() - - XCTAssertTrue(context.service.activationAllowed) - XCTAssertEqual(context.reachability.startCallCount, 2) - XCTAssertEqual(context.relayController.connectCallCount, 2) - } - - /// Teleporting into a geohash needs no location permission, so someone who - /// denied location and has no mutual favorites could previously sit in a - /// channel that never connected: the gate suppressed Tor and the relays, - /// and nothing explained why. - func test_start_enablesNetworkForALocationChannelWithoutPermissionOrFavorites() { - let context = makeService( - permission: .denied, - favorites: [], - selectedChannel: .location(GeohashChannel(level: .city, geohash: "u4pruy")) - ) - - context.service.start() - - XCTAssertTrue(context.service.activationAllowed) - XCTAssertEqual(context.torController.startIfNeededCallCount, 1) - XCTAssertEqual(context.relayController.connectCallCount, 1) - } - - func test_selectedChannelPublisher_activatesOnEnteringALocationChannel() async { - let channelSubject = CurrentValueSubject(.mesh) - let context = makeService( - permission: .denied, - favorites: [], - selectedChannelSubject: channelSubject - ) - - context.service.start() - XCTAssertFalse(context.service.activationAllowed) - - channelSubject.send(.location(GeohashChannel(level: .city, geohash: "u4pruy"))) - - let activated = await waitUntil { context.service.activationAllowed } - XCTAssertTrue(activated) - } - - /// Leaving the channel must close the gate again, or the exception would - /// quietly become permanent for the rest of the session. - func test_selectedChannelPublisher_deactivatesOnReturningToMesh() async { - let channelSubject = CurrentValueSubject( - .location(GeohashChannel(level: .city, geohash: "u4pruy")) - ) - let context = makeService( - permission: .denied, - favorites: [], - selectedChannelSubject: channelSubject - ) - - context.service.start() - XCTAssertTrue(context.service.activationAllowed) - - channelSubject.send(.mesh) - - let deactivated = await waitUntil { !context.service.activationAllowed } - XCTAssertTrue(deactivated) - } - private func makeService( permission: LocationChannelManager.PermissionState, - favorites: Set, - selectedChannel: ChannelID = .mesh, - selectedChannelSubject: CurrentValueSubject? = nil + favorites: Set ) -> NetworkActivationTestContext { let suiteName = "NetworkActivationServiceTests-\(UUID().uuidString)" let storage = UserDefaults(suiteName: suiteName)! @@ -206,12 +101,9 @@ final class NetworkActivationServiceTests: XCTestCase { let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>(favorites) - let channelSubject = selectedChannelSubject - ?? CurrentValueSubject(selectedChannel) let torController = MockNetworkActivationTorController() let relayController = MockNetworkActivationRelayController() let proxyController = MockNetworkActivationProxyController() - let reachability = MockNetworkActivationReachability() let notificationCenter = NotificationCenter() let service = NetworkActivationService( storage: storage, @@ -219,12 +111,6 @@ final class NetworkActivationServiceTests: XCTestCase { mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), permissionProvider: { permissionSubject.value }, mutualFavoritesProvider: { favoritesSubject.value }, - selectedChannelPublisher: channelSubject.eraseToAnyPublisher(), - locationChannelSelectedProvider: { - if case .location = channelSubject.value { return true } - return false - }, - reachabilityMonitor: reachability, torController: torController, relayController: relayController, proxyController: proxyController, @@ -233,8 +119,8 @@ final class NetworkActivationServiceTests: XCTestCase { return NetworkActivationTestContext( service: service, storage: storage, + permissionSubject: permissionSubject, favoritesSubject: favoritesSubject, - reachability: reachability, torController: torController, relayController: relayController, proxyController: proxyController, @@ -243,7 +129,7 @@ final class NetworkActivationServiceTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = TestConstants.settleTimeout, + timeout: TimeInterval = 1.0, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -261,39 +147,14 @@ final class NetworkActivationServiceTests: XCTestCase { private struct NetworkActivationTestContext { let service: NetworkActivationService let storage: UserDefaults + let permissionSubject: CurrentValueSubject let favoritesSubject: CurrentValueSubject, Never> - let reachability: MockNetworkActivationReachability let torController: MockNetworkActivationTorController let relayController: MockNetworkActivationRelayController let proxyController: MockNetworkActivationProxyController let notificationCenter: NotificationCenter } -@MainActor -private final class MockNetworkActivationReachability: - NetworkReachabilityMonitoring { - private let subject = CurrentValueSubject(true) - private(set) var startCallCount = 0 - private(set) var stopCallCount = 0 - - var isReachable: Bool { subject.value } - var reachabilityPublisher: AnyPublisher { - subject.removeDuplicates().dropFirst().eraseToAnyPublisher() - } - - func start() { - startCallCount += 1 - } - - func stop() { - stopCallCount += 1 - } - - func set(_ reachable: Bool) { - subject.send(reachable) - } -} - @MainActor private final class MockNetworkActivationTorController: NetworkActivationTorControlling { private(set) var autoStartAllowedValues: [Bool] = [] diff --git a/bitchatTests/Services/NetworkReachabilityGateTests.swift b/bitchatTests/Services/NetworkReachabilityGateTests.swift deleted file mode 100644 index 525f417a..00000000 --- a/bitchatTests/Services/NetworkReachabilityGateTests.swift +++ /dev/null @@ -1,271 +0,0 @@ -import Combine -import XCTest -@testable import bitchat - -/// Covers the reachability-gate decision logic (pure debounce) and the -/// `NetworkActivationService` wiring that suppresses Tor/relay startup when -/// there is provably no network. -@MainActor -final class NetworkReachabilityGateTests: XCTestCase { - - // MARK: - Pure debounce logic - - func test_debounce_satisfiedStaysReachable() { - var d = ReachabilityDebounce(interval: 2.5, initial: true) - let t0 = Date() - // An interface remains present: no change, no pending. - XCTAssertNil(d.observe(reachable: true, at: t0)) - XCTAssertTrue(d.committed) - XCTAssertFalse(d.hasPendingChange) - } - - func test_debounce_unsatisfiedSuppressesAfterInterval() { - var d = ReachabilityDebounce(interval: 2.5, initial: true) - let t0 = Date() - // Path drops: not committed immediately (within debounce window). - XCTAssertNil(d.observe(reachable: false, at: t0)) - XCTAssertTrue(d.committed) - XCTAssertTrue(d.hasPendingChange) - // Still within window. - XCTAssertNil(d.flush(at: t0.addingTimeInterval(1.0))) - XCTAssertTrue(d.committed) - // Past the window: commit unreachable. - XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), false) - XCTAssertFalse(d.committed) - XCTAssertFalse(d.hasPendingChange) - } - - func test_debounce_flapIsIgnored() { - var d = ReachabilityDebounce(interval: 2.5, initial: true) - let t0 = Date() - // Drop then recover well within the window — must never commit a change. - XCTAssertNil(d.observe(reachable: false, at: t0)) - XCTAssertTrue(d.hasPendingChange) - XCTAssertNil(d.observe(reachable: true, at: t0.addingTimeInterval(0.5))) - XCTAssertFalse(d.hasPendingChange, "recovery should cancel the pending drop") - // A late flush after the original deadline is a no-op (nothing pending). - XCTAssertNil(d.flush(at: t0.addingTimeInterval(3.0))) - XCTAssertTrue(d.committed) - } - - func test_debounce_recoverAfterOutageCommitsAfterInterval() { - var d = ReachabilityDebounce(interval: 2.5, initial: false) - let t0 = Date() - XCTAssertNil(d.observe(reachable: true, at: t0)) - XCTAssertTrue(d.hasPendingChange) - XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), true) - XCTAssertTrue(d.committed) - } - - func test_debounce_duplicateObservationsPreservePendingDeadline() { - var d = ReachabilityDebounce(interval: 2.5, initial: true) - let t0 = Date() - XCTAssertNil(d.observe(reachable: false, at: t0)) - // Duplicate unsatisfied updates mid-window keep the original deadline. - XCTAssertNil(d.observe(reachable: false, at: t0.addingTimeInterval(1.0))) - XCTAssertEqual(d.pendingRemaining(at: t0.addingTimeInterval(1.0)), 1.5) - // A duplicate arriving past the deadline commits immediately. - XCTAssertEqual(d.observe(reachable: false, at: t0.addingTimeInterval(2.5)), false) - XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5))) - } - - /// Wiring only: a duplicate mid-window still yields exactly one committed - /// `false`, published through the monitor's debounce. - /// - /// This deliberately makes no assertion about *when* the flush fires. It - /// used to bound elapsed wall-clock time at 1.4 s to prove the deadline was - /// not restarted, which flaked on loaded CI runners — one observed run took - /// 3.75 s, because `Task.sleep` and the `asyncAfter` flush are both real - /// time and neither is bounded above on a busy machine. No wall-clock bound - /// can distinguish "deadline preserved" from "runner is slow", so the timing - /// property is asserted where it is computable instead: - /// `test_debounce_duplicateObservationsPreservePendingDeadline` drives - /// `ReachabilityDebounce` with injected timestamps and checks - /// `pendingRemaining` directly. - /// - /// The clock is injected here so the debounce arithmetic is deterministic - /// even though the flush itself is scheduled in real time. - func test_monitor_duplicateUpdatesCommitOnceThroughTheDebounce() async { - let clock = MutableDate(now: Date(timeIntervalSince1970: 1_784_000_000)) - let monitor = NWPathReachabilityMonitor( - debounceInterval: 0.2, - now: { clock.now } - ) - var received: [Bool] = [] - let cancellable = monitor.reachabilityPublisher.sink { received.append($0) } - defer { cancellable.cancel() } - - monitor.ingest(reachable: false) - // Duplicate unsatisfied update mid-window (e.g. an interface detail - // change while still offline). - clock.now = clock.now.addingTimeInterval(0.1) - monitor.ingest(reachable: false) - // Past the original deadline, so the scheduled flush commits. - clock.now = clock.now.addingTimeInterval(0.2) - - // Generous: this is a liveness check, not a latency bound. A real - // regression — never committing — still fails, just later. - let committed = await waitUntil(timeout: 10.0) { !received.isEmpty } - XCTAssertTrue(committed) - XCTAssertEqual(received, [false]) - } - - // MARK: - Service gating - - func test_start_whenUnreachable_suppressesTorAndRelays() { - let ctx = makeService(permission: .authorized, reachable: false) - ctx.service.start() - - XCTAssertFalse(ctx.service.activationAllowed) - XCTAssertFalse(ctx.service.isNetworkReachable) - XCTAssertTrue(ctx.reachability.startCalled) - XCTAssertEqual(ctx.torController.startIfNeededCallCount, 0) - XCTAssertEqual(ctx.torController.autoStartAllowedValues, [false]) - XCTAssertEqual(ctx.relayController.connectCallCount, 0) - XCTAssertEqual(ctx.relayController.disconnectCallCount, 1) - } - - func test_start_whenReachable_allowsTorAndRelays() { - let ctx = makeService(permission: .authorized, reachable: true) - ctx.service.start() - - XCTAssertTrue(ctx.service.activationAllowed) - XCTAssertEqual(ctx.torController.startIfNeededCallCount, 1) - XCTAssertEqual(ctx.relayController.connectCallCount, 1) - } - - func test_reachabilityRecovery_resumesTorAndRelays() async { - let ctx = makeService(permission: .authorized, reachable: false) - ctx.service.start() - XCTAssertFalse(ctx.service.activationAllowed) - - ctx.reachability.set(true) - let resumed = await waitUntil { ctx.service.activationAllowed } - XCTAssertTrue(resumed) - XCTAssertTrue(ctx.service.isNetworkReachable) - XCTAssertGreaterThanOrEqual(ctx.torController.startIfNeededCallCount, 1) - XCTAssertGreaterThanOrEqual(ctx.relayController.connectCallCount, 1) - } - - func test_reachabilityLoss_disconnectsRelaysAndStopsTor() async { - let ctx = makeService(permission: .authorized, reachable: true) - ctx.service.start() - XCTAssertTrue(ctx.service.activationAllowed) - let disconnectsBefore = ctx.relayController.disconnectCallCount - - ctx.reachability.set(false) - let suppressed = await waitUntil { !ctx.service.activationAllowed } - XCTAssertTrue(suppressed) - XCTAssertFalse(ctx.service.isNetworkReachable) - XCTAssertGreaterThan(ctx.relayController.disconnectCallCount, disconnectsBefore) - XCTAssertTrue(ctx.torController.autoStartAllowedValues.contains(false)) - XCTAssertGreaterThanOrEqual(ctx.torController.shutdownCompletelyCallCount, 1) - } - - // MARK: - Harness - - private func makeService( - permission: LocationChannelManager.PermissionState, - reachable: Bool - ) -> Context { - let suiteName = "NetworkReachabilityGateTests-\(UUID().uuidString)" - let storage = UserDefaults(suiteName: suiteName)! - storage.removePersistentDomain(forName: suiteName) - - let permissionSubject = CurrentValueSubject(permission) - let favoritesSubject = CurrentValueSubject, Never>([]) - let reachability = ControllableReachabilityMonitor(initial: reachable) - let torController = GateMockTorController() - let relayController = GateMockRelayController() - let proxyController = GateMockProxyController() - let service = NetworkActivationService( - storage: storage, - locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), - mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), - permissionProvider: { permissionSubject.value }, - mutualFavoritesProvider: { favoritesSubject.value }, - reachabilityMonitor: reachability, - torController: torController, - relayController: relayController, - proxyController: proxyController, - notificationCenter: NotificationCenter() - ) - return Context( - service: service, - reachability: reachability, - torController: torController, - relayController: relayController - ) - } - - private func waitUntil( - timeout: TimeInterval = TestConstants.settleTimeout, - condition: @escaping @MainActor () -> Bool - ) async -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if condition() { return true } - try? await Task.sleep(nanoseconds: 10_000_000) - } - return condition() - } -} - -@MainActor -private struct Context { - let service: NetworkActivationService - let reachability: ControllableReachabilityMonitor - let torController: GateMockTorController - let relayController: GateMockRelayController -} - -@MainActor -private final class ControllableReachabilityMonitor: NetworkReachabilityMonitoring { - private let subject: CurrentValueSubject - private(set) var startCalled = false - - init(initial: Bool) { - subject = CurrentValueSubject(initial) - } - - var isReachable: Bool { subject.value } - var reachabilityPublisher: AnyPublisher { - subject.removeDuplicates().dropFirst().eraseToAnyPublisher() - } - func start() { startCalled = true } - func stop() { startCalled = false } - func set(_ reachable: Bool) { subject.send(reachable) } -} - -@MainActor -private final class GateMockTorController: NetworkActivationTorControlling { - private(set) var autoStartAllowedValues: [Bool] = [] - private(set) var startIfNeededCallCount = 0 - private(set) var shutdownCompletelyCallCount = 0 - func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) } - func startIfNeeded() { startIfNeededCallCount += 1 } - func shutdownCompletely() { shutdownCompletelyCallCount += 1 } -} - -@MainActor -private final class GateMockRelayController: NetworkActivationRelayControlling { - private(set) var connectCallCount = 0 - private(set) var disconnectCallCount = 0 - func connect() { connectCallCount += 1 } - func disconnect() { disconnectCallCount += 1 } -} - -private final class GateMockProxyController: NetworkActivationProxyControlling { - private(set) var proxyModes: [Bool] = [] - func setProxyMode(useTor: Bool) { proxyModes.append(useTor) } -} - -/// Controllable clock, so debounce arithmetic is deterministic even where the -/// flush itself is scheduled in real time. -private final class MutableDate: @unchecked Sendable { - var now: Date - - init(now: Date) { - self.now = now - } -} diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index c0cd293f..6642f97d 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -3,7 +3,7 @@ import Testing import BitFoundation @testable import bitchat -@Suite("NoiseEncryptionService Tests", .serialized) +@Suite("NoiseEncryptionService Tests") struct NoiseEncryptionServiceTests { @Test("Encryption status accessors cover all cases") @@ -91,1250 +91,39 @@ struct NoiseEncryptionServiceTests { func handshakeEncryptionAndFingerprintLifecycle() async throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + let alicePeerID = PeerID(str: "0011223344556677") + let bobPeerID = PeerID(str: "8899aabbccddeeff") let recorder = AuthenticationRecorder() #expect(alice.onPeerAuthenticated == nil) - #expect(bob.onPeerAuthenticatedWithGeneration == nil) alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:) - bob.onPeerAuthenticatedWithGeneration = recorder.record( - peerID:fingerprint:sessionGeneration: - ) - try establishSessions(alice: alice, bob: bob) + try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID) - let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: TestConstants.settleTimeout) + let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 0.5) #expect(authenticated) - let generationAuthenticated = await TestHelpers.waitUntil( - { recorder.generationCount >= 1 }, - timeout: TestConstants.settleTimeout - ) - #expect(generationAuthenticated) - #expect(alice.hasEstablishedSession(with: bobPeerID)) - #expect(bob.hasEstablishedSession(with: alicePeerID)) - #expect(alice.hasSession(with: bobPeerID)) - #expect(bob.hasSession(with: alicePeerID)) - #expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32) - #expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32) - #expect(alice.getPeerFingerprint(bobPeerID) != nil) - #expect(bob.getPeerFingerprint(alicePeerID) != nil) - #expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID)) + #expect(alice.hasEstablishedSession(with: alicePeerID)) + #expect(bob.hasEstablishedSession(with: bobPeerID)) + #expect(alice.hasSession(with: alicePeerID)) + #expect(bob.hasSession(with: bobPeerID)) + #expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32) + #expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32) + #expect(alice.getPeerFingerprint(alicePeerID) != nil) + #expect(bob.getPeerFingerprint(bobPeerID) != nil) let plaintext = Data("secret payload".utf8) - let ciphertext = try alice.encrypt(plaintext, for: bobPeerID) - let decrypted = try bob.decrypt(ciphertext, from: alicePeerID) + let ciphertext = try alice.encrypt(plaintext, for: alicePeerID) + let decrypted = try bob.decrypt(ciphertext, from: bobPeerID) #expect(decrypted == plaintext) - alice.clearSession(for: bobPeerID) - #expect(!alice.hasSession(with: bobPeerID)) - #expect(alice.getPeerFingerprint(bobPeerID) == nil) + alice.clearSession(for: alicePeerID) + #expect(!alice.hasSession(with: alicePeerID)) + #expect(alice.getPeerFingerprint(alicePeerID) == nil) bob.clearEphemeralStateForPanic() - #expect(!bob.hasSession(with: alicePeerID)) - #expect(bob.getPeerFingerprint(alicePeerID) == nil) - } - - @Test("Handshake rejects a claimed peer ID that does not match the authenticated static key") - func handshakeRejectsClaimedPeerIDStaticKeyMismatch() async throws { - let receiver = NoiseEncryptionService(keychain: MockKeychain()) - let claimedAlice = NoiseEncryptionService(keychain: MockKeychain()) - let mallory = NoiseEncryptionService(keychain: MockKeychain()) - let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData()) - let claimedAlicePeerID = PeerID(publicKey: claimedAlice.getStaticPublicKeyData()) - let recorder = AuthenticationRecorder() - receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) - - let message1 = try mallory.initiateHandshake(with: receiverPeerID) - let message2 = try #require( - try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message1) - ) - let message3 = try #require( - try mallory.processHandshakeMessage(from: receiverPeerID, message: message2) - ) - - do { - _ = try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message3) - Issue.record("Expected the authenticated Mallory key to be rejected for Alice's peer ID") - } catch let error as NoiseSessionError { - #expect(error == .peerIdentityMismatch) - } catch { - Issue.record("Unexpected mismatch error: \(error)") - } - - #expect(!receiver.hasSession(with: claimedAlicePeerID)) - let emittedAuthentication = await TestHelpers.waitUntil( - { recorder.count > 0 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!emittedAuthentication) - } - - @Test("Failed forged reconnect restores the established peer session") - func forgedReconnectRestoresEstablishedSession() async throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let receiver = NoiseEncryptionService(keychain: MockKeychain()) - let mallory = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData()) - let recorder = AuthenticationRecorder() - receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) - - try establishSessions(alice: alice, bob: receiver) - let initialAuthentication = await TestHelpers.waitUntil( - { recorder.count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(initialAuthentication) - - let before = try alice.encrypt(Data("before".utf8), for: receiverPeerID) - #expect(try receiver.decrypt(before, from: alicePeerID) == Data("before".utf8)) - - let forgedMessage1 = try mallory.initiateHandshake(with: receiverPeerID) - let forgedMessage2 = try #require( - try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1) - ) - // Outbound/session-generation APIs fail closed while the old - // transport is retained solely for receive and bounded rollback. - #expect(!receiver.hasEstablishedSession(with: alicePeerID)) - let forgedMessage3 = try #require( - try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2) - ) - - do { - _ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3) - Issue.record("Expected forged reconnect to fail peer binding") - } catch let error as NoiseSessionError { - #expect(error == .peerIdentityMismatch) - } catch { - Issue.record("Unexpected reconnect error: \(error)") - } - - #expect(receiver.hasEstablishedSession(with: alicePeerID)) - let after = try alice.encrypt(Data("after".utf8), for: receiverPeerID) - #expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8)) - let emittedReplacementAuthentication = await TestHelpers.waitUntil( - { recorder.count > 1 }, - timeout: TestConstants.negativeWaitWindow - ) - #expect(!emittedReplacementAuthentication) - } - - @Test("Valid ordinary rehandshake atomically replaces the established session") - func validOrdinaryRehandshakeReplacesEstablishedSession() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let receiver = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData()) - - try establishSessions(alice: alice, bob: receiver) - alice.clearSession(for: receiverPeerID) - - let message1 = try alice.initiateHandshake(with: receiverPeerID) - let message2 = try #require( - try receiver.processHandshakeMessage(from: alicePeerID, message: message1) - ) - #expect(!receiver.hasEstablishedSession(with: alicePeerID)) - let message3 = try #require( - try alice.processHandshakeMessage(from: receiverPeerID, message: message2) - ) - _ = try receiver.processHandshakeMessage(from: alicePeerID, message: message3) - - #expect(alice.hasEstablishedSession(with: receiverPeerID)) - #expect(receiver.hasEstablishedSession(with: alicePeerID)) - let ciphertext = try alice.encrypt(Data("new session".utf8), for: receiverPeerID) - #expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8)) - } - - @Test("Automatic rekey exposes and completes its exact handshake bytes") - func automaticRekeyHandshakeIsNotStranded() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - try establishSessions(alice: alice, bob: bob) - let originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID)) - var leaseRan = false - let leased = alice.withCurrentSessionGeneration( - for: bobPeerID, - expected: originalGeneration - ) { - leaseRan = true - return true - } - #expect(leased == true) - #expect(leaseRan) - - var emittedPeerID: PeerID? - var emittedInitiation: NoiseHandshakeInitiation? - alice.onRekeyHandshakeReady = { peerID, initiation in - emittedPeerID = peerID - emittedInitiation = initiation - } - try alice._test_initiateAutomaticRekey(for: bobPeerID) - - #expect(emittedPeerID == bobPeerID) - #expect(alice.sessionGeneration(for: bobPeerID) == nil) - leaseRan = false - let staleLease = alice.withCurrentSessionGeneration( - for: bobPeerID, - expected: originalGeneration - ) { - leaseRan = true - return true - } - #expect(staleLease == nil) - #expect(!leaseRan) - let initiation = try #require(emittedInitiation) - let message1 = try #require( - alice.claimHandshakeInitiation(initiation, for: bobPeerID) - ) - #expect(!message1.isEmpty) - #expect(alice.hasSession(with: bobPeerID)) - #expect(!alice.hasEstablishedSession(with: bobPeerID)) - - let message2 = try #require( - try bob.processHandshakeMessage(from: alicePeerID, message: message1) - ) - let message3 = try #require( - try alice.processHandshakeMessage(from: bobPeerID, message: message2) - ) - _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) - - #expect(alice.hasEstablishedSession(with: bobPeerID)) - #expect(bob.hasEstablishedSession(with: alicePeerID)) - #expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration) - } - - @Test("Large private-file payloads use the bounded Noise extension") - func largePrivateFileNoiseRoundTrip() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - try establishSessions(alice: alice, bob: bob) - - let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024) - let file = BitchatFilePacket( - fileName: "large-private.pdf", - fileSize: UInt64(content.count), - mimeType: "application/pdf", - content: content - ) - let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file)) - #expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize) - #expect(typedPayload.first == NoisePayloadType.privateFile.rawValue) - #expect( - typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize, - "typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)" - ) - - do { - _ = try alice.encrypt(typedPayload, for: bobPeerID) - Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling") - } catch NoiseSecurityError.messageTooLarge { - // Expected: only the purpose-specific private-file API may extend it. - } - - let ciphertext: Data - do { - ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: bobPeerID) - } catch { - Issue.record("Private-file encryption failed: \(error)") - return - } - let decrypted: Data - do { - decrypted = try bob.decrypt(ciphertext, from: alicePeerID) - } catch { - Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)") - return - } - - #expect(ciphertext.range(of: content) == nil) - #expect(decrypted == typedPayload) - } - - @Test("Concurrent BLE starts preserve one ordinary attempt") - func duplicateHandshakeIfNeededPreservesFirstAttempt() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - let starts = HandshakeInitiationRecorder() - - DispatchQueue.concurrentPerform(iterations: 20) { _ in - do { - starts.record( - try alice.initiateHandshakeIfNeeded(with: bobPeerID) - ) - } catch { - starts.record(error: error) - } - } - - #expect(starts.errorCount == 0) - let attempt = try #require(starts.initiations.first) - #expect(starts.initiations.count == 1) - let message1 = try #require( - alice.claimHandshakeInitiation(attempt, for: bobPeerID) - ) - let message2 = try #require( - try bob.processHandshakeMessage(from: alicePeerID, message: message1) - ) - let message3 = try #require( - try alice.processHandshakeMessage(from: bobPeerID, message: message2) - ) - _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) - - let ciphertext = try alice.encrypt( - Data("one atomic start".utf8), - for: bobPeerID - ) - #expect( - try bob.decrypt(ciphertext, from: alicePeerID) - == Data("one atomic start".utf8) - ) - } - - @Test("Restarted peer establishes against a retained ordinary session") - func restartedPeerCompletesRetainedRemoteRehandshake() throws { - let aliceKeychain = MockKeychain() - let alice = NoiseEncryptionService(keychain: aliceKeychain) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - try establishSessions(alice: alice, bob: bob) - - let restartedAlice = NoiseEncryptionService(keychain: aliceKeychain) - let attempt = try #require( - try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID) - ) - #expect( - try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID) - == nil - ) - let message1 = try #require( - restartedAlice.claimHandshakeInitiation( - attempt, - for: bobPeerID - ) - ) - let message2 = try #require( - try bob.processHandshakeMessage(from: alicePeerID, message: message1) - ) - let message3 = try #require( - try restartedAlice.processHandshakeMessage( - from: bobPeerID, - message: message2 - ) - ) - _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) - - let forward = try restartedAlice.encrypt( - Data("after restart".utf8), - for: bobPeerID - ) - #expect( - try bob.decrypt(forward, from: alicePeerID) - == Data("after restart".utf8) - ) - } - - @Test("Crossed ordinary initiations choose one deterministic initiator") - func crossedOrdinaryInitiationsResolveDeterministically() throws { - let endpoints = orderedServices() - let lowerAttempt = try #require( - try endpoints.lower.initiateHandshakeIfNeeded( - with: endpoints.higherPeerID - ) - ) - let higherAttempt = try #require( - try endpoints.higher.initiateHandshakeIfNeeded( - with: endpoints.lowerPeerID - ) - ) - let lowerMessage1 = try #require( - endpoints.lower.claimHandshakeInitiation( - lowerAttempt, - for: endpoints.higherPeerID - ) - ) - let higherMessage1 = try #require( - endpoints.higher.claimHandshakeInitiation( - higherAttempt, - for: endpoints.lowerPeerID - ) - ) - - #expect( - try endpoints.lower.processHandshakeMessage( - from: endpoints.higherPeerID, - message: higherMessage1 - ) == nil - ) - let message2 = try #require( - try endpoints.higher.processHandshakeMessage( - from: endpoints.lowerPeerID, - message: lowerMessage1 - ) - ) - let message3 = try #require( - try endpoints.lower.processHandshakeMessage( - from: endpoints.higherPeerID, - message: message2 - ) - ) - _ = try endpoints.higher.processHandshakeMessage( - from: endpoints.lowerPeerID, - message: message3 - ) - - let ciphertext = try endpoints.lower.encrypt( - Data("crossed".utf8), - for: endpoints.higherPeerID - ) - #expect( - try endpoints.higher.decrypt( - ciphertext, - from: endpoints.lowerPeerID - ) == Data("crossed".utf8) - ) - } - - @Test("Delayed losing message one cannot replace the fresh winner") - func delayedCrossedInitiationIsSuppressed() throws { - let endpoints = orderedServices() - let lowerAttempt = try #require( - try endpoints.lower.initiateHandshakeIfNeeded( - with: endpoints.higherPeerID - ) - ) - let higherAttempt = try #require( - try endpoints.higher.initiateHandshakeIfNeeded( - with: endpoints.lowerPeerID - ) - ) - let lowerMessage1 = try #require( - endpoints.lower.claimHandshakeInitiation( - lowerAttempt, - for: endpoints.higherPeerID - ) - ) - let delayedHigherMessage1 = try #require( - endpoints.higher.claimHandshakeInitiation( - higherAttempt, - for: endpoints.lowerPeerID - ) - ) - - let message2 = try #require( - try endpoints.higher.processHandshakeMessage( - from: endpoints.lowerPeerID, - message: lowerMessage1 - ) - ) - let message3 = try #require( - try endpoints.lower.processHandshakeMessage( - from: endpoints.higherPeerID, - message: message2 - ) - ) - #expect( - try endpoints.lower.processHandshakeMessage( - from: endpoints.higherPeerID, - message: delayedHigherMessage1 - ) == nil - ) - _ = try endpoints.higher.processHandshakeMessage( - from: endpoints.lowerPeerID, - message: message3 - ) - - let ciphertext = try endpoints.higher.encrypt( - Data("winner intact".utf8), - for: endpoints.lowerPeerID - ) - #expect( - try endpoints.lower.decrypt( - ciphertext, - from: endpoints.higherPeerID - ) == Data("winner intact".utf8) - ) - } - - @Test("Automatic rekey token dies if an inbound initiation wins first") - func automaticRekeyClaimsOnlyAtTransportHandoff() throws { - let endpoints = orderedServices() - try establishSessions( - alice: endpoints.lower, - bob: endpoints.higher - ) - - var preparedRekey: NoiseHandshakeInitiation? - endpoints.higher.onRekeyHandshakeReady = { _, initiation in - preparedRekey = initiation - } - try endpoints.higher._test_initiateAutomaticRekey( - for: endpoints.lowerPeerID - ) - let staleRekey = try #require(preparedRekey) - - let winningAttempt = try endpoints.lower.initiateReconnectHandshake( - with: endpoints.higherPeerID - ) - let winningMessage1 = try #require( - endpoints.lower.claimHandshakeInitiation( - winningAttempt, - for: endpoints.higherPeerID - ) - ) - let message2 = try #require( - try endpoints.higher.processHandshakeMessage( - from: endpoints.lowerPeerID, - message: winningMessage1 - ) - ) - #expect( - endpoints.higher.claimHandshakeInitiation( - staleRekey, - for: endpoints.lowerPeerID - ) == nil - ) - let message3 = try #require( - try endpoints.lower.processHandshakeMessage( - from: endpoints.higherPeerID, - message: message2 - ) - ) - _ = try endpoints.higher.processHandshakeMessage( - from: endpoints.lowerPeerID, - message: message3 - ) - #expect( - endpoints.higher.hasEstablishedSession( - with: endpoints.lowerPeerID - ) - ) - } - - @Test("Ordinary timeout produces exactly one bounded retry") - func ordinaryInitiationTimeoutIsBounded() async throws { - let service = NoiseEncryptionService( - keychain: MockKeychain(), - ordinaryHandshakeTimeout: 0.03 - ) - let peerID = PeerID(str: "1021324354657687") - let recorder = HandshakeStartRecorder() - service.onHandshakeRecoveryRequired = { [weak service] request in - guard let service else { return } - do { - let payload = try claimPreparedRecoveryPayload( - service, - request: request - ) - recorder.recordTimeout() - recorder.record(message: payload) - } catch { - recorder.record(error: error) - } - } - - let first = try #require( - try service.initiateHandshakeIfNeeded( - with: peerID, - retryOnTimeout: true - ) - ) - #expect( - service.claimHandshakeInitiation(first, for: peerID) != nil - ) - let retried = await TestHelpers.waitUntil( - { recorder.messages.count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(retried) - let retryExpired = await TestHelpers.waitUntil( - { !service.hasSession(with: peerID) }, - timeout: TestConstants.longTimeout - ) - #expect(retryExpired) - #expect(recorder.timeoutCount == 1) - #expect(recorder.errorCount == 0) - } - - @Test("Claim gives an attempt a full on-wire timeout window") - func handshakeClaimRearmsDeadline() async throws { - let timeoutInterval: TimeInterval = 1 - let service = NoiseEncryptionService( - keychain: MockKeychain(), - ordinaryHandshakeTimeout: timeoutInterval - ) - let peerID = PeerID(str: "1021324354657687") - let recorder = HandshakeStartRecorder() - service.onHandshakeRecoveryRequired = { [weak service] request in - let firedAt = DispatchTime.now().uptimeNanoseconds - service?.cancelHandshakeRecovery(request) - recorder.recordTimeout(at: firedAt) - } - - let attempt = try #require( - try service.initiateHandshakeIfNeeded( - with: peerID, - retryOnTimeout: true - ) - ) - try? await Task.sleep(nanoseconds: 500_000_000) - let claimed = service.claimHandshakeInitiation(attempt, for: peerID) - let claimedAt = DispatchTime.now().uptimeNanoseconds - #expect(claimed == attempt.payload) - let expired = await TestHelpers.waitUntil( - { recorder.timeoutCount == 1 }, - timeout: 5 - ) - #expect(expired) - let firedAt = try #require(recorder.firstTimeoutUptimeNanoseconds) - try #require(firedAt >= claimedAt) - let elapsed = TimeInterval(firedAt - claimedAt) / 1_000_000_000 - // A non-rearmed deadline would fire roughly 0.5 seconds after the - // claim. Measure on the timeout queue instead of relying on a task to - // resume inside a narrow pre-deadline window under parallel CI load. - #expect(elapsed >= timeoutInterval * 0.75) - #expect(!service.hasSession(with: peerID)) - #expect(recorder.timeoutCount == 1) - } - - @Test("Duplicate spoofed message one cannot extend rollback or repause during cooldown") - func pacedMessageOneCannotHoldOutboundPaused() async throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService( - keychain: MockKeychain(), - // Generous for the same reason as the quarantine-restore test - // (#1483): this timeout also arms during the `establishSessions` - // setup handshake below, where bob is the responder. At 0.06 a - // preempted runner could fire it mid-setup, tear down the half-open - // responder, and make message 3 be answered as a fresh initiation. - ordinaryResponderHandshakeTimeout: 1.0, - ordinaryReconnectRollbackCooldown: 0.3 - ) - let mallory = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - let recovery = HandshakeStartRecorder() - bob.onHandshakeRecoveryRequired = { [weak bob] request in - bob?.cancelHandshakeRecovery(request) - recovery.recordTimeout() - } - try establishSessions(alice: alice, bob: bob) - - let spoofedMessage1 = try mallory.initiateHandshake(with: bobPeerID) - _ = try #require( - try bob.processHandshakeMessage( - from: alicePeerID, - message: spoofedMessage1 - ) - ) - // Exercise replacement before yielding: the test runner may resume a - // short sleep after the fixed responder deadline under parallel load. - _ = try #require( - try bob.processHandshakeMessage( - from: alicePeerID, - message: spoofedMessage1 - ) - ) - - let restored = await TestHelpers.waitUntil( - { bob.hasEstablishedSession(with: alicePeerID) }, - timeout: TestConstants.longTimeout - ) - #expect(restored) - let callbackArrived = await TestHelpers.waitUntil( - { recovery.timeoutCount == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(callbackArrived) - - // Still inside cooldown: the same unauthenticated initiation is - // coalesced without removing the restored outbound generation. - #expect( - try bob.processHandshakeMessage( - from: alicePeerID, - message: spoofedMessage1 - ) == nil - ) - #expect(bob.hasEstablishedSession(with: alicePeerID)) - let ciphertext = try alice.encrypt( - Data("not repaused".utf8), - for: bobPeerID - ) - #expect( - try bob.decrypt(ciphertext, from: alicePeerID) - == Data("not repaused".utf8) - ) - try? await Task.sleep(nanoseconds: 100_000_000) - #expect(recovery.timeoutCount == 1) - } - - @Test("Lost reconnect message three restores then retries once") - func lostReconnectCompletionGetsOneLocalRetry() async throws { - let alice = NoiseEncryptionService( - keychain: MockKeychain(), - ordinaryHandshakeTimeout: 0.04 - ) - let bob = NoiseEncryptionService( - keychain: MockKeychain(), - ordinaryHandshakeTimeout: 0.04, - // Also arms during the `establishSessions` setup handshake below, - // where bob is the responder. Observed failing on a loaded CI - // runner with exactly the signature #1483 documented: the setup's - // `#expect(finalMessage == nil)` saw a 96-byte message 2, because - // the half-open responder had already been torn down and message 3 - // was answered as a fresh initiation. - ordinaryResponderHandshakeTimeout: 1.0 - ) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - try establishSessions(alice: alice, bob: bob) - alice.clearSession(for: bobPeerID) - - let recovery = HandshakeStartRecorder() - bob.onHandshakeRecoveryRequired = { [weak bob] request in - guard let bob else { return } - do { - recovery.recordTimeout() - recovery.record( - message: try claimPreparedRecoveryPayload( - bob, - request: request - ) - ) - } catch { - recovery.record(error: error) - } - } - - let message1 = try alice.initiateHandshake(with: bobPeerID) - let message2 = try #require( - try bob.processHandshakeMessage(from: alicePeerID, message: message1) - ) - _ = try #require( - try alice.processHandshakeMessage(from: bobPeerID, message: message2) - ) - // Drop message 3. Bob restores its old receive-only transport and - // initiates one bounded convergence retry; drop that message 1 too. - let retryPrepared = await TestHelpers.waitUntil( - { recovery.messages.count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(retryPrepared) - let retryExpired = await TestHelpers.waitUntil( - { !bob.hasSession(with: alicePeerID) }, - timeout: TestConstants.longTimeout - ) - #expect(retryExpired) - #expect(recovery.timeoutCount == 1) - #expect(recovery.errorCount == 0) - } - - @Test("Deterministic responder recovers once from an always-yield peer") - func yieldedResponderRecoversFromLegacyDoubleYield() async throws { - let timeoutInterval: TimeInterval = 1 - let endpoints = orderedServices( - ordinaryHandshakeTimeout: timeoutInterval, - ordinaryResponderHandshakeTimeout: timeoutInterval - ) - let modern = endpoints.higher - let legacy = endpoints.lower - let recovery = HandshakeStartRecorder() - modern.onHandshakeRecoveryRequired = { request in - // Preparing here would arm the retry before the test task can - // forward message 1. Record the token so preparation and the - // simulated on-wire exchange remain synchronous. - recovery.recordTimeout() - recovery.record(request: request) - } - - let modernAttempt = try #require( - try modern.initiateHandshakeIfNeeded( - with: endpoints.lowerPeerID, - retryOnTimeout: true - ) - ) - let legacyAttempt = try #require( - try legacy.initiateHandshakeIfNeeded( - with: endpoints.higherPeerID - ) - ) - let modernMessage1 = try #require( - modern.claimHandshakeInitiation( - modernAttempt, - for: endpoints.lowerPeerID - ) - ) - let legacyMessage1 = try #require( - legacy.claimHandshakeInitiation( - legacyAttempt, - for: endpoints.higherPeerID - ) - ) - - let modernMessage2 = try #require( - try modern.processHandshakeMessage( - from: endpoints.lowerPeerID, - message: legacyMessage1 - ) - ) - // Released peers yielded regardless of ID. Clearing their local - // initiator reproduces that role choice without changing wire bytes. - legacy.clearSession(for: endpoints.higherPeerID) - let legacyMessage2 = try #require( - try legacy.processHandshakeMessage( - from: endpoints.higherPeerID, - message: modernMessage1 - ) - ) - - do { - _ = try modern.processHandshakeMessage( - from: endpoints.lowerPeerID, - message: legacyMessage2 - ) - Issue.record("Expected the crossed responder message to fail") - } catch is NoiseManagedHandshakeFailure { - // The manager owns the single retry. - } catch { - Issue.record("Unexpected managed failure: \(error)") - } - do { - _ = try legacy.processHandshakeMessage( - from: endpoints.higherPeerID, - message: modernMessage2 - ) - Issue.record("Expected the legacy responder message to fail") - } catch { - // Expected; this side did not own retry intent. - } - - let recoveryRequested = await TestHelpers.waitUntil( - { recovery.requests.count == 1 }, - timeout: 5 - ) - #expect(recoveryRequested) - let recoveryRequest = try #require(recovery.requests.first) - let retryMessage1 = try #require( - try claimPreparedRecoveryPayload( - modern, - request: recoveryRequest - ) - ) - let retryMessage2 = try #require( - try legacy.processHandshakeMessage( - from: endpoints.higherPeerID, - message: retryMessage1 - ) - ) - let retryMessage3 = try #require( - try modern.processHandshakeMessage( - from: endpoints.lowerPeerID, - message: retryMessage2 - ) - ) - _ = try legacy.processHandshakeMessage( - from: endpoints.higherPeerID, - message: retryMessage3 - ) - try? await Task.sleep( - nanoseconds: UInt64(timeoutInterval * 1_200_000_000) - ) - #expect(recovery.timeoutCount == 1) - let ciphertext = try modern.encrypt( - Data("legacy converged".utf8), - for: endpoints.lowerPeerID - ) - #expect( - try legacy.decrypt( - ciphertext, - from: endpoints.higherPeerID - ) == Data("legacy converged".utf8) - ) - } - - @Test("Immediate legacy restart during completion grace converges once") - func immediateLegacyRestartDuringCompletionGrace() async throws { - // The grace period must still be open when the restart initiation - // arrives below. A small value races the wall clock on a starved - // runner, so inject one no test run can outlive; the recovery half - // is then fired explicitly instead of waiting out the timer. - let unlosableGracePeriod: TimeInterval = 600 - let firstKeychain = MockKeychain() - let secondKeychain = MockKeychain() - let first = NoiseEncryptionService( - keychain: firstKeychain, - recentInitiatorCompletionGracePeriod: unlosableGracePeriod - ) - let second = NoiseEncryptionService( - keychain: secondKeychain, - recentInitiatorCompletionGracePeriod: unlosableGracePeriod - ) - let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData()) - let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData()) - - let lower: NoiseEncryptionService - let higher: NoiseEncryptionService - let higherKeychain: MockKeychain - let lowerPeerID: PeerID - let higherPeerID: PeerID - if firstPeerID < secondPeerID { - lower = first - lowerPeerID = firstPeerID - higher = second - higherKeychain = secondKeychain - higherPeerID = secondPeerID - } else { - lower = second - lowerPeerID = secondPeerID - higher = first - higherKeychain = firstKeychain - higherPeerID = firstPeerID - } - try establishSessions(alice: lower, bob: higher) - - let restartedHigher = NoiseEncryptionService(keychain: higherKeychain) - let recovery = HandshakeStartRecorder() - lower.onHandshakeRecoveryRequired = { [weak lower] request in - guard let lower else { return } - do { - recovery.recordTimeout() - recovery.record( - message: try claimPreparedRecoveryPayload( - lower, - request: request - ) - ) - } catch { - recovery.record(error: error) - } - } - - let restartAttempt = try #require( - try restartedHigher.initiateHandshakeIfNeeded(with: lowerPeerID) - ) - let restartMessage1 = try #require( - restartedHigher.claimHandshakeInitiation( - restartAttempt, - for: lowerPeerID - ) - ) - #expect( - try lower.processHandshakeMessage( - from: higherPeerID, - message: restartMessage1 - ) == nil - ) - #expect( - try lower.processHandshakeMessage( - from: higherPeerID, - message: restartMessage1 - ) == nil - ) - #expect(lower.hasEstablishedSession(with: higherPeerID)) - - lower._test_fireSuppressedInitiationRecovery(for: higherPeerID) - let requested = await TestHelpers.waitUntil( - { recovery.messages.count == 1 }, - timeout: TestConstants.longTimeout - ) - #expect(requested) - let retryMessage1 = try #require(recovery.messages.first) - let retryMessage2 = try #require( - try restartedHigher.processHandshakeMessage( - from: lowerPeerID, - message: retryMessage1 - ) - ) - let retryMessage3 = try #require( - try lower.processHandshakeMessage( - from: higherPeerID, - message: retryMessage2 - ) - ) - _ = try restartedHigher.processHandshakeMessage( - from: lowerPeerID, - message: retryMessage3 - ) - try? await Task.sleep(nanoseconds: 100_000_000) - #expect(recovery.timeoutCount == 1) - #expect(recovery.errorCount == 0) - let ciphertext = try restartedHigher.encrypt( - Data("restart converged".utf8), - for: lowerPeerID - ) - #expect( - try lower.decrypt(ciphertext, from: higherPeerID) - == Data("restart converged".utf8) - ) - } - - @Test("Atomic reconnect retires old sending keys before message one") - func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - - try establishSessions(alice: alice, bob: bob) - let oldCiphertext = try alice.encrypt(Data("old transport".utf8), for: bobPeerID) - #expect(try bob.decrypt(oldCiphertext, from: alicePeerID) == Data("old transport".utf8)) - - let initiation = try alice.initiateReconnectHandshake(with: bobPeerID) - #expect(alice.hasSession(with: bobPeerID)) - #expect(!alice.hasEstablishedSession(with: bobPeerID)) - do { - _ = try alice.encrypt(Data("must queue".utf8), for: bobPeerID) - Issue.record("Expected encryption to wait for the reconnect") - } catch NoiseEncryptionError.handshakeRequired { - // Expected: BLE queues behind the ordinary handshaking session. - } catch { - Issue.record("Unexpected in-window encryption error: \(error)") - } - - let message1 = try #require( - alice.claimHandshakeInitiation(initiation, for: bobPeerID) - ) - bob.clearSession(for: alicePeerID) - let message2 = try #require( - try bob.processHandshakeMessage(from: alicePeerID, message: message1) - ) - let message3 = try #require( - try alice.processHandshakeMessage(from: bobPeerID, message: message2) - ) - _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) - - let fresh = try alice.encrypt(Data("fresh transport".utf8), for: bobPeerID) - #expect(try bob.decrypt(fresh, from: alicePeerID) == Data("fresh transport".utf8)) - } - - @Test("Inbound reconnect quarantines old sending keys until identity proof") - func inboundReconnectQuarantinesOldTransport() throws { - let restarted = NoiseEncryptionService(keychain: MockKeychain()) - let retained = NoiseEncryptionService(keychain: MockKeychain()) - let restartedPeerID = PeerID(publicKey: restarted.getStaticPublicKeyData()) - let retainedPeerID = PeerID(publicKey: retained.getStaticPublicKeyData()) - - try establishSessions(alice: restarted, bob: retained) - let inFlightOldCiphertext = try restarted.encrypt( - Data("old receive-only transport".utf8), - for: retainedPeerID - ) - restarted.clearSession(for: retainedPeerID) - - let message1 = try restarted.initiateHandshake(with: retainedPeerID) - let message2 = try #require( - try retained.processHandshakeMessage( - from: restartedPeerID, - message: message1 - ) - ) - #expect(!retained.hasEstablishedSession(with: restartedPeerID)) - #expect( - try retained.decrypt( - inFlightOldCiphertext, - from: restartedPeerID - ) == Data("old receive-only transport".utf8) - ) - do { - _ = try retained.encrypt( - Data("must queue at responder".utf8), - for: restartedPeerID - ) - Issue.record("Expected quarantined responder encryption to wait") - } catch NoiseEncryptionError.handshakeRequired { - // Expected. - } catch { - Issue.record("Unexpected quarantine encryption error: \(error)") - } - - let message3 = try #require( - try restarted.processHandshakeMessage( - from: retainedPeerID, - message: message2 - ) - ) - _ = try retained.processHandshakeMessage( - from: restartedPeerID, - message: message3 - ) - - let fresh = try retained.encrypt( - Data("identity proved".utf8), - for: restartedPeerID - ) - #expect( - try restarted.decrypt(fresh, from: retainedPeerID) - == Data("identity proved".utf8) - ) - } - - @Test("Malformed handshake bytes cannot tear down an established session") - func establishedSessionIgnoresNonInitialHandshakeGarbage() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - try establishSessions(alice: alice, bob: bob) - - #expect( - try bob.processHandshakeMessage( - from: alicePeerID, - message: Data(repeating: 0xA5, count: 31) - ) == nil - ) - #expect(bob.hasEstablishedSession(with: alicePeerID)) - let ciphertext = try alice.encrypt( - Data("session survived".utf8), - for: bobPeerID - ) - #expect( - try bob.decrypt(ciphertext, from: alicePeerID) - == Data("session survived".utf8) - ) - } - - @Test("Forged reconnect restores the quarantined transport") - func forgedReconnectRestoresQuarantinedTransport() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let mallory = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - - try establishSessions(alice: alice, bob: bob) - - let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID) - let forgedMessage2 = try #require( - try bob.processHandshakeMessage( - from: alicePeerID, - message: forgedMessage1 - ) - ) - #expect(!bob.hasEstablishedSession(with: alicePeerID)) - let forgedMessage3 = try #require( - try mallory.processHandshakeMessage( - from: bobPeerID, - message: forgedMessage2 - ) - ) - - do { - _ = try bob.processHandshakeMessage( - from: alicePeerID, - message: forgedMessage3 - ) - Issue.record("Expected forged static identity to be rejected") - } catch NoiseSessionError.peerIdentityMismatch { - // Expected; the manager restores the quarantined transport. - } catch { - Issue.record("Unexpected forged reconnect error: \(error)") - } - - #expect(bob.hasEstablishedSession(with: alicePeerID)) - let oldTransport = try alice.encrypt( - Data("rollback survived".utf8), - for: bobPeerID - ) - #expect( - try bob.decrypt(oldTransport, from: alicePeerID) - == Data("rollback survived".utf8) - ) - } - - @Test("Lost reconnect completion restores the quarantined transport") - func timedOutReconnectRestoresQuarantinedTransport() async throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - // The injected responder timeout also arms during the ordinary setup - // handshake below (bob is its responder), where the only work between - // message 1 and message 3 is two consecutive synchronous statements. - // It must be generous enough that a preempted runner cannot let the - // timeout fire mid-setup and tear down the half-open responder — at - // 20ms a loaded 2-core CI runner did exactly that, so message 3 was - // answered as a fresh initiation (96-byte message 2) and nothing was - // ever quarantined. - let bob = NoiseEncryptionService( - keychain: MockKeychain(), - ordinaryResponderHandshakeTimeout: 1.0 - ) - let mallory = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - - try establishSessions(alice: alice, bob: bob) - let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID) - _ = try #require( - try bob.processHandshakeMessage( - from: alicePeerID, - message: forgedMessage1 - ) - ) - #expect(!bob.hasEstablishedSession(with: alicePeerID)) - - // Poll instead of sleeping a fixed interval: the responder timeout - // fires on bob's manager queue at the quarantine deadline, and a - // starved runner can delay that work item well past the deadline. - let restored = await TestHelpers.waitUntil( - { bob.hasEstablishedSession(with: alicePeerID) }, - timeout: TestConstants.longTimeout - ) - try #require( - restored, - "Responder timeout should restore the quarantined transport" - ) - let oldTransport = try alice.encrypt( - Data("timeout rollback".utf8), - for: bobPeerID - ) - #expect( - try bob.decrypt(oldTransport, from: alicePeerID) - == Data("timeout rollback".utf8) - ) - } - - @Test("Failed reconnect authorization preserves the established transport") - func failedReconnectAuthorizationPreservesSession() throws { - let alice = NoiseEncryptionService(keychain: MockKeychain()) - let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) - let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) - - try establishSessions(alice: alice, bob: bob) - // The initiator exchange consumed two authorizations for this peer. - for _ in 2.. ( - lower: NoiseEncryptionService, - lowerPeerID: PeerID, - higher: NoiseEncryptionService, - higherPeerID: PeerID -) { - let first = NoiseEncryptionService( - keychain: MockKeychain(), - ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, - ordinaryResponderHandshakeTimeout: - ordinaryResponderHandshakeTimeout - ) - let second = NoiseEncryptionService( - keychain: MockKeychain(), - ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, - ordinaryResponderHandshakeTimeout: - ordinaryResponderHandshakeTimeout - ) - let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData()) - let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData()) - if firstPeerID < secondPeerID { - return (first, firstPeerID, second, secondPeerID) - } - return (second, secondPeerID, first, firstPeerID) -} - -private func claimPreparedRecoveryPayload( - _ service: NoiseEncryptionService, - request: NoiseHandshakeRecoveryRequest -) throws -> Data? { - guard let preparation = - try service.prepareHandshakeRecovery(request) else { - return nil - } - switch preparation { - case .ordinary(let initiation): - return service.claimHandshakeInitiation( - initiation, - for: request.peerID - ) - case .transferred: - return nil - } -} - private final class AuthenticationRecorder: @unchecked Sendable { private let lock = NSLock() private var entries: [(PeerID, String)] = [] - private var generationEntries: [(PeerID, UUID)] = [] var count: Int { lock.lock() @@ -1569,125 +224,9 @@ private final class AuthenticationRecorder: @unchecked Sendable { return entries.count } - var generationCount: Int { - lock.lock() - defer { lock.unlock() } - return generationEntries.count - } - func record(peerID: PeerID, fingerprint: String) { lock.lock() entries.append((peerID, fingerprint)) lock.unlock() } - - func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) { - lock.lock() - generationEntries.append((peerID, sessionGeneration)) - lock.unlock() - } - - func generation(for peerID: PeerID) -> UUID? { - lock.lock() - defer { lock.unlock() } - return generationEntries.last { $0.0 == peerID }?.1 - } -} - -private final class HandshakeInitiationRecorder: @unchecked Sendable { - private let lock = NSLock() - private var storedInitiations: [NoiseHandshakeInitiation] = [] - private var storedErrorCount = 0 - - var initiations: [NoiseHandshakeInitiation] { - lock.lock() - defer { lock.unlock() } - return storedInitiations - } - - var errorCount: Int { - lock.lock() - defer { lock.unlock() } - return storedErrorCount - } - - func record(_ initiation: NoiseHandshakeInitiation?) { - guard let initiation else { return } - lock.lock() - storedInitiations.append(initiation) - lock.unlock() - } - - func record(error _: Error) { - lock.lock() - storedErrorCount += 1 - lock.unlock() - } -} - -private final class HandshakeStartRecorder: @unchecked Sendable { - private let lock = NSLock() - private var storedMessages: [Data] = [] - private var storedRequests: [NoiseHandshakeRecoveryRequest] = [] - private var storedErrorCount = 0 - private var storedTimeoutCount = 0 - private var storedTimeoutUptimes: [UInt64] = [] - - var messages: [Data] { - lock.lock() - defer { lock.unlock() } - return storedMessages - } - - var requests: [NoiseHandshakeRecoveryRequest] { - lock.lock() - defer { lock.unlock() } - return storedRequests - } - - var errorCount: Int { - lock.lock() - defer { lock.unlock() } - return storedErrorCount - } - - var timeoutCount: Int { - lock.lock() - defer { lock.unlock() } - return storedTimeoutCount - } - - var firstTimeoutUptimeNanoseconds: UInt64? { - lock.lock() - defer { lock.unlock() } - return storedTimeoutUptimes.first - } - - func record(message: Data?) { - guard let message else { return } - lock.lock() - storedMessages.append(message) - lock.unlock() - } - - func record(request: NoiseHandshakeRecoveryRequest) { - lock.lock() - storedRequests.append(request) - lock.unlock() - } - - func record(error _: Error) { - lock.lock() - storedErrorCount += 1 - lock.unlock() - } - - func recordTimeout( - at uptimeNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds - ) { - lock.lock() - storedTimeoutCount += 1 - storedTimeoutUptimes.append(uptimeNanoseconds) - lock.unlock() - } } diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index e6c79201..5d3ce8df 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -4,8 +4,6 @@ import XCTest @MainActor final class NostrRelayManagerTests: XCTestCase { - private let expectedDefaultRelayCount = 4 - func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async { let context = makeContext(permission: .authorized, activationAllowed: false) @@ -15,7 +13,7 @@ final class NostrRelayManagerTests: XCTestCase { context.manager.connect() let connected = await waitUntil { - context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && + context.sessionFactory.requestedURLs.count == 5 && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connected) @@ -29,7 +27,7 @@ final class NostrRelayManagerTests: XCTestCase { context.permissionSubject.send(.authorized) let defaultRelaysConnected = await waitUntil { - context.manager.getRelayStatuses().count == self.expectedDefaultRelayCount && + context.manager.getRelayStatuses().count == 5 && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(defaultRelaysConnected) @@ -40,50 +38,10 @@ final class NostrRelayManagerTests: XCTestCase { context.manager.getRelayStatuses().isEmpty } XCTAssertTrue(defaultRelaysRemoved) - XCTAssertEqual(context.sessionFactory.allConnections.count, expectedDefaultRelayCount) + XCTAssertEqual(context.sessionFactory.allConnections.count, 5) XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 }) } - /// A relay removed while its connection is queued behind Tor bootstrap - /// must stay removed: draining the pending set used to resurrect it, - /// because `dropRelays` never touched `pendingTorConnectionURLs` and a - /// custom relay is in neither the default set nor the allow-list filter. - func test_relayRemovedWhileWaitingForTor_staysRemovedWhenTorBecomesReady() async { - let customURL = "wss://custom-removed.example" - let center = NotificationCenter() - let customRelays = MutableRelayList(urls: [customURL]) - let context = makeContext( - permission: .authorized, - userTorEnabled: true, - torEnforced: true, - torIsReady: false, - notificationCenter: center, - customRelays: customRelays - ) - - // Defaults plus the custom relay all queue while Tor bootstraps. - context.manager.connect() - XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) - XCTAssertEqual(context.torWaiter.awaitCallCount, 1) - - // The relay is removed by hand before Tor is ready. - customRelays.urls = [] - center.post(name: NostrRelaySettings.didChangeNotification, object: nil) - // The settings sink hops through the main queue; let it land. - try? await Task.sleep(nanoseconds: 20_000_000) - - context.torWaiter.resolve(true) - - let defaultsConnected = await waitUntil { - context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount - } - XCTAssertTrue(defaultsConnected) - XCTAssertFalse( - context.sessionFactory.requestedURLs.contains(customURL), - "a relay removed while Tor was bootstrapping must not reconnect when the pending queue drains" - ) - } - func test_connect_waitsForTorReadinessBeforeCreatingSessions() async { let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) @@ -94,26 +52,7 @@ final class NostrRelayManagerTests: XCTestCase { context.torWaiter.resolve(true) let connectedAfterTorReady = await waitUntil { - context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && - context.manager.relays.allSatisfy(\.isConnected) - } - XCTAssertTrue(connectedAfterTorReady) - } - - func test_connect_coalescesRepeatedCallsWhileWaitingForTor() async { - let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) - - context.manager.connect() - context.manager.connect() - context.manager.connect() - - XCTAssertEqual(context.torWaiter.awaitCallCount, 1) - XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) - - context.torWaiter.resolve(true) - - let connectedAfterTorReady = await waitUntil { - context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && + context.sessionFactory.requestedURLs.count == 5 && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connectedAfterTorReady) @@ -130,183 +69,6 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertFalse(context.manager.isConnected) } - func test_connect_retriesTorWaitAndConnectsWhenTorBecomesReady() async { - let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) - - context.manager.connect() - XCTAssertEqual(context.torWaiter.awaitCallCount, 1) - - context.torWaiter.resolve(false) - - // A failed wait re-queues the same targets and waits again instead of dropping them. - XCTAssertEqual(context.torWaiter.awaitCallCount, 2) - XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) - - context.torWaiter.resolve(true) - - let connected = await waitUntil { - context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && - context.manager.relays.allSatisfy(\.isConnected) - } - XCTAssertTrue(connected) - } - - func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async { - let relayURL = "wss://tor-eose-unblock.example" - let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) - var eoseCount = 0 - - context.manager.subscribe( - filter: makeFilter(), - id: "tor-sub-unblock", - relayUrls: [relayURL], - handler: { _ in }, - onEOSE: { eoseCount += 1 } - ) - - for _ in 0..= 0 } - XCTAssertTrue(quietDelivered, "relay B's event was never delivered") - - // The signal: B did not have to wait for A's entire backlog. If the two - // pipelines were globally serialized, B could only land after all 200 of - // A's frames, so busyDeliveredCount would be 200 when B arrived. - XCTAssertLessThan( - quietDeliveredAfterBusyCount, - busyEvents.count, - "relay B was head-of-line blocked behind relay A's backlog" - ) - - // Both relays still drain fully and in order. - let allDelivered = await waitUntil(timeout: TestConstants.settleTimeout) { - busyDeliveredCount == busyEvents.count - } - XCTAssertTrue(allDelivered) - } - func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws { let relayURL = "wss://missing-handler.example" let context = makeContext(permission: .denied) @@ -1155,12 +453,7 @@ final class NostrRelayManagerTests: XCTestCase { } XCTAssertTrue(subscribed) - // The fallback is scheduled but has not fired yet. - XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrSubscriptionEOSEFallbackSeconds) - XCTAssertEqual(eoseCount, 0) - - context.scheduler.runNext() - let timedOut = await waitUntil { eoseCount == 1 } + let timedOut = await waitUntil(timeout: 3.0) { eoseCount == 1 } XCTAssertTrue(timedOut) try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "timeout") @@ -1168,192 +461,6 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(eoseCount, 1) } - func test_eose_completesWhenRelayDisconnectsBeforeEOSE() async throws { - let relayOne = "wss://eose-drop-one.example" - let relayTwo = "wss://eose-drop-two.example" - let context = makeContext(permission: .denied) - var eoseCount = 0 - - context.manager.subscribe( - filter: makeFilter(), - id: "eose-drop", - relayUrls: [relayOne, relayTwo], - handler: { _ in }, - onEOSE: { eoseCount += 1 } - ) - - let subscribed = await waitUntil { - context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 && - context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1 - } - XCTAssertTrue(subscribed) - - try context.sessionFactory.latestConnection(for: relayOne)?.emitEOSE(subscriptionID: "eose-drop") - try? await Task.sleep(nanoseconds: 20_000_000) - XCTAssertEqual(eoseCount, 0) - - context.sessionFactory.latestConnection(for: relayTwo)?.fail( - error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) - ) - let completed = await waitUntil { eoseCount == 1 } - XCTAssertTrue(completed) - } - - func test_reconnect_replaysActiveSubscriptionsAndDeliversEvents() async throws { - let relayURL = "wss://replay.example" - let context = makeContext(permission: .denied) - var received: [NostrEvent] = [] - - context.manager.subscribe( - filter: makeFilter(), - id: "replay-sub", - relayUrls: [relayURL], - handler: { received.append($0) } - ) - let subscribed = await waitUntil { - context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.contains { $0.contains("replay-sub") } == true - } - XCTAssertTrue(subscribed) - - // Drop the socket; the relay forgets the subscription with it. - context.sessionFactory.latestConnection(for: relayURL)?.fail( - error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost) - ) - let retryScheduled = await waitUntil { !context.scheduler.scheduled.isEmpty } - XCTAssertTrue(retryScheduled) - context.scheduler.runNext() - - let replayed = await waitUntil { - let connections = context.sessionFactory.connectionsByURL[relayURL] ?? [] - return connections.count == 2 && - connections.last?.sentStrings.contains { $0.contains("replay-sub") } == true - } - XCTAssertTrue(replayed) - - let event = try makeSignedEvent(content: "after reconnect") - try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "replay-sub", event: event) - let delivered = await waitUntil { received.count == 1 } - XCTAssertTrue(delivered) - } - - func test_disconnectThenConnect_restoresSubscriptions() async { - let relayURL = "wss://restore.example" - let context = makeContext(permission: .denied) - - context.manager.subscribe(filter: makeFilter(), id: "restore-sub", relayUrls: [relayURL], handler: { _ in }) - let subscribed = await waitUntil { - context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.contains { $0.contains("restore-sub") } == true - } - XCTAssertTrue(subscribed) - - // Background → foreground: connections reset, subscriptions must survive. - context.manager.disconnect() - context.manager.connect() - - let resubscribed = await waitUntil { - let connections = context.sessionFactory.connectionsByURL[relayURL] ?? [] - return connections.count == 2 && - connections.last?.sentStrings.contains { $0.contains("restore-sub") } == true - } - XCTAssertTrue(resubscribed) - } - - func test_subscriptionSendFailure_retriesOnReconnect() async { - let relayURL = "wss://flaky-send.example" - let context = makeContext(permission: .denied) - context.sessionFactory.sendErrorByURL[relayURL] = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) - - context.manager.subscribe(filter: makeFilter(), id: "flaky-sub", relayUrls: [relayURL], handler: { _ in }) - let attempted = await waitUntil { - context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.isEmpty == false - } - XCTAssertTrue(attempted) - - // The REQ send failed; the subscription must survive for the next connection. - context.sessionFactory.sendErrorByURL[relayURL] = nil - context.sessionFactory.latestConnection(for: relayURL)?.fail( - error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost) - ) - let retryScheduled = await waitUntil { !context.scheduler.scheduled.isEmpty } - XCTAssertTrue(retryScheduled) - context.scheduler.runNext() - - let resubscribed = await waitUntil { - let connections = context.sessionFactory.connectionsByURL[relayURL] ?? [] - return connections.count == 2 && - connections.last?.sentStrings.contains { $0.contains("flaky-sub") } == true - } - XCTAssertTrue(resubscribed) - } - - func test_staleSendCompletionFromDeadSocket_doesNotBlockReplayOnNextConnection() async { - let relayURL = "wss://stale-completion.example" - let context = makeContext(permission: .denied) - - context.manager.subscribe(filter: makeFilter(), id: "stale-sub", relayUrls: [relayURL], handler: { _ in }) - // The connection exists synchronously; its REQ flush lands on a later - // main-queue tick, so deferring completions here is race-free. - let connectionA = context.sessionFactory.latestConnection(for: relayURL) - XCTAssertNotNil(connectionA) - connectionA?.deferSendCompletions = true - - let reqSent = await waitUntil { - connectionA?.sentStrings.contains { $0.contains("stale-sub") } == true - } - XCTAssertTrue(reqSent) - - // Socket dies while the REQ's send completion is still in flight. - connectionA?.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost)) - let disconnected = await waitUntil { - context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == false - } - XCTAssertTrue(disconnected) - - // The stale success completion must not mark the subscription active. - connectionA?.flushDeferredSendCompletions() - try? await Task.sleep(nanoseconds: 20_000_000) - - context.scheduler.runNext() - let replayed = await waitUntil { - let connections = context.sessionFactory.connectionsByURL[relayURL] ?? [] - return connections.count == 2 && - connections.last?.sentStrings.contains { $0.contains("stale-sub") } == true - } - XCTAssertTrue(replayed) - } - - func test_permanentFailure_decaysAfterCooldownAndRetries() async { - let relayURL = "wss://cooldown.example" - let context = makeContext(permission: .denied) - context.sessionFactory.pingErrorByURL[relayURL] = NSError( - domain: NSURLErrorDomain, - code: NSURLErrorCannotFindHost, - userInfo: [NSLocalizedDescriptionKey: "DNS failure"] - ) - - context.manager.ensureConnections(to: [relayURL]) - let failed = await waitUntil { - context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == TransportConfig.nostrRelayMaxReconnectAttempts - } - XCTAssertTrue(failed) - - // Within the cooldown the relay is skipped. - let countBefore = context.sessionFactory.requestedURLs.count - context.manager.ensureConnections(to: [relayURL]) - try? await Task.sleep(nanoseconds: 20_000_000) - XCTAssertEqual(context.sessionFactory.requestedURLs.count, countBefore) - - // After the cooldown it gets another chance and recovers. - context.sessionFactory.pingErrorByURL[relayURL] = nil - context.clock.now = context.clock.now.addingTimeInterval(TransportConfig.nostrRelayFailureCooldownSeconds + 1) - context.manager.ensureConnections(to: [relayURL]) - let retried = await waitUntil { - context.sessionFactory.requestedURLs.count == countBefore + 1 && - context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true - } - XCTAssertTrue(retried) - } - func test_receiveFailure_schedulesReconnectWithBackoff() async { let relayURL = "wss://retry.example" let context = makeContext(permission: .denied) @@ -1459,38 +566,6 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(firstConnection.cancelCallCount, 1) } - func test_staleSocketFailureCannotFailConfirmedSendOnReplacementConnection() async throws { - let relayURL = "wss://retry-stale-confirmation.example" - let context = makeContext(permission: .denied) - context.manager.ensureConnections(to: [relayURL]) - let initiallyConnected = await waitUntil { - context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true - } - XCTAssertTrue(initiallyConnected) - let oldConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL)) - - context.manager.retryConnection(to: relayURL) - let replaced = await waitUntil { - guard let current = context.sessionFactory.latestConnection(for: relayURL) else { return false } - return current !== oldConnection && - context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true - } - XCTAssertTrue(replaced) - let currentConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL)) - - let event = try makeSignedEvent(content: "replacement confirmation") - var results: [Bool] = [] - context.manager.sendEventImmediately(event, to: [relayURL]) { results.append($0) } - oldConnection.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)) - try? await Task.sleep(nanoseconds: 30_000_000) - XCTAssertTrue(results.isEmpty) - XCTAssertTrue(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true) - - try currentConnection.emitOK(eventID: event.id, success: true, reason: "accepted") - let confirmed = await waitUntil { results == [true] } - XCTAssertTrue(confirmed) - } - func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async { let relayURL = "wss://retry-tor.example" let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true) @@ -1621,212 +696,6 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(reconnected) } - func test_pendingSubscriptions_perRelayCapEvictsOldestByInsertionOrder() async { - let relayURL = "wss://pending-cap.example" - // Tor stalled: nothing flushes, so every REQ stays pending. - let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) - let cap = TransportConfig.nostrPendingSubscriptionsPerRelayCap - - for i in 0..<(cap + 3) { - context.manager.subscribe(filter: makeFilter(), id: "cap-sub-\(i)", relayUrls: [relayURL], handler: { _ in }) - } - - XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), cap) - let pendingIDs = context.manager.debugPendingSubscriptionIDs(for: relayURL) - // The three oldest entries were evicted; the newest survive. - for i in 0..<3 { - XCTAssertFalse(pendingIDs.contains("cap-sub-\(i)"), "expected cap-sub-\(i) to be evicted") - } - for i in 3..<(cap + 3) { - XCTAssertTrue(pendingIDs.contains("cap-sub-\(i)"), "expected cap-sub-\(i) to be retained") - } - } - - func test_pendingSubscriptions_staleEntriesSweptOnConnectAttempt() async { - let relayURL = "wss://pending-sweep.example" - // Tor stalled: the REQ stays pending and no socket ever opens. - let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) - - context.manager.subscribe(filter: makeFilter(), id: "stale-pending-sub", relayUrls: [relayURL], handler: { _ in }) - XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1) - - // Just under the TTL the entry survives a connect attempt. - context.clock.now = context.clock.now.addingTimeInterval(TransportConfig.nostrPendingSubscriptionTTLSeconds - 1) - context.manager.ensureConnections(to: [relayURL]) - XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1) - - // Past the TTL the next connect attempt sweeps it. - context.clock.now = context.clock.now.addingTimeInterval(2) - context.manager.ensureConnections(to: [relayURL]) - XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0) - } - - func test_resetForPanicWipe_dropsSessionRelayStateWithoutFiringCallbacks() async throws { - let relayURL = "wss://panic-reset.example" - let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) - let event = try makeSignedEvent(content: "queued before panic") - var handledEvents = 0 - var eoseCount = 0 - - context.manager.subscribe( - filter: makeFilter(), - id: "panic-sub", - relayUrls: [relayURL], - handler: { _ in handledEvents += 1 }, - onEOSE: { eoseCount += 1 } - ) - context.manager.sendEvent(event, to: [relayURL]) - - XCTAssertEqual(context.manager.debugMessageHandlerCount, 1) - XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 1) - XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1) - XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 1) - XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1) - XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) - - context.manager.resetForPanicWipe() - - XCTAssertEqual(context.manager.debugMessageHandlerCount, 0) - XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 0) - XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0) - XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 0) - XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0) - XCTAssertEqual(handledEvents, 0) - XCTAssertEqual(eoseCount, 0) - - // Stale Tor wait and fallback callbacks from the pre-wipe generation - // must not resurrect connections or settle callbacks after reset. - context.torWaiter.resolve(true) - context.scheduler.runNext() - try? await Task.sleep(nanoseconds: 20_000_000) - - XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) - XCTAssertEqual(eoseCount, 0) - } - - func test_resetForPanicWipe_marksConnectedRelaysDisconnected() async { - let relayURL = "wss://panic-connected.example" - let context = makeContext(permission: .denied) - - context.manager.ensureConnections(to: [relayURL]) - let connected = await waitUntil { - context.manager.isConnected && - context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true - } - XCTAssertTrue(connected) - - context.manager.resetForPanicWipe() - - XCTAssertFalse(context.manager.isConnected) - XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected, false) - XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts, 0) - XCTAssertNil(context.manager.relays.first(where: { $0.url == relayURL })?.lastError) - } - - func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async { - let relayURL = "wss://jitter-bounds.example" - // Pin the jitter source to the extremes and the midpoint of [0, 1). - let jitter = JitterSequence([0.0, 1.0.nextDown, 0.25]) - let context = makeContext(permission: .denied, jitterUnit: { jitter.next() }) - // Persistent ping failure: every connect attempt fails and schedules - // the next reconnect with an increasing attempt count. - context.sessionFactory.pingErrorByURL[relayURL] = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) - - context.manager.ensureConnections(to: [relayURL]) - - var delays: [TimeInterval] = [] - for attempt in 1...3 { - let scheduled = await waitUntil { context.scheduler.scheduled.count == 1 } - XCTAssertTrue(scheduled, "reconnect for attempt \(attempt) was not scheduled") - delays.append(context.scheduler.scheduled[0].delay) - context.scheduler.runNext() - } - - // Bases: 1s, 2s, 4s. Jitter factors: 0.8, ~1.2, 0.9. - XCTAssertEqual(delays[0], 0.8 * TransportConfig.nostrRelayInitialBackoffSeconds, accuracy: 1e-9) - XCTAssertEqual(delays[1], 1.2 * TransportConfig.nostrRelayInitialBackoffSeconds * TransportConfig.nostrRelayBackoffMultiplier, accuracy: 1e-6) - XCTAssertEqual(delays[2], 0.9 * TransportConfig.nostrRelayInitialBackoffSeconds * pow(TransportConfig.nostrRelayBackoffMultiplier, 2), accuracy: 1e-9) - } - - func test_reconnectBackoff_realRandomJitterStaysInBoundsAndVaries() async { - let relayURL = "wss://jitter-random.example" - let context = makeContext(permission: .denied, jitterUnit: { Double.random(in: 0..<1) }) - context.sessionFactory.pingErrorByURL[relayURL] = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) - - context.manager.ensureConnections(to: [relayURL]) - - var factors: [Double] = [] - for attempt in 1...5 { - let scheduled = await waitUntil { context.scheduler.scheduled.count == 1 } - XCTAssertTrue(scheduled, "reconnect for attempt \(attempt) was not scheduled") - let base = min( - TransportConfig.nostrRelayInitialBackoffSeconds * pow(TransportConfig.nostrRelayBackoffMultiplier, Double(attempt - 1)), - TransportConfig.nostrRelayMaxBackoffSeconds - ) - let factor = context.scheduler.scheduled[0].delay / base - XCTAssertGreaterThanOrEqual(factor, 1.0 - TransportConfig.nostrRelayBackoffJitterRatio) - XCTAssertLessThan(factor, 1.0 + TransportConfig.nostrRelayBackoffJitterRatio) - factors.append(factor) - context.scheduler.runNext() - } - - // A real RNG must not produce a constant delay across attempts - // (5 identical uniform doubles is probability ~0). - XCTAssertGreaterThan(Set(factors).count, 1) - } - - // MARK: - Hand-added relays - - /// Adding a relay has to take effect without a restart: the whole point is - /// recovering reachability when the built-in hostnames are blocked. - @MainActor - func testAddedRelayJoinsTheTargetSetOnSettingsChange() async { - let center = NotificationCenter() - let custom = MutableRelayList(urls: []) - let context = makeContext( - permission: .authorized, - notificationCenter: center, - customRelays: custom - ) - - XCTAssertFalse(context.manager.relays.contains { $0.url == "wss://added.example.com" }) - - custom.urls = ["wss://added.example.com"] - center.post(name: NostrRelaySettings.didChangeNotification, object: nil) - - let joined = await waitUntil { - context.manager.relays.contains { $0.url == "wss://added.example.com" } - } - XCTAssertTrue(joined) - } - - /// Removing a relay must actually close it. The teardown path iterates the - /// current target list, and a removed relay is no longer in it, so without - /// an explicit reconcile against the previous set its socket and queued - /// sends would linger. - @MainActor - func testRemovedRelayLeavesTheTargetSet() async { - let center = NotificationCenter() - let custom = MutableRelayList(urls: ["wss://added.example.com"]) - let context = makeContext( - permission: .authorized, - notificationCenter: center, - customRelays: custom - ) - - XCTAssertTrue(context.manager.relays.contains { $0.url == "wss://added.example.com" }) - - custom.urls = [] - center.post(name: NostrRelaySettings.didChangeNotification, object: nil) - - let dropped = await waitUntil { - !context.manager.relays.contains { $0.url == "wss://added.example.com" } - } - XCTAssertTrue(dropped) - // The built-in relays are untouched by a custom-relay removal. - XCTAssertTrue(context.manager.relays.contains { $0.url == "wss://nos.lol" }) - } - private func makeContext( permission: LocationChannelManager.PermissionState, favorites: Set = [], @@ -1834,10 +703,7 @@ final class NostrRelayManagerTests: XCTestCase { userTorEnabled: Bool = false, torEnforced: Bool = false, torIsReady: Bool = true, - torIsForeground: Bool = true, - notificationCenter: NotificationCenter = NotificationCenter(), - customRelays: MutableRelayList = MutableRelayList(urls: []), - jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter) + torIsForeground: Bool = true ) -> RelayManagerTestContext { let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>(favorites) @@ -1863,15 +729,13 @@ final class NostrRelayManagerTests: XCTestCase { scheduleAfter: { delay, action in scheduler.schedule(delay: delay, action: action) }, - now: { clock.now }, - jitterUnit: jitterUnit, - notificationCenter: notificationCenter, - customRelays: { customRelays.urls } + now: { clock.now } ) ) return RelayManagerTestContext( manager: manager, permissionSubject: permissionSubject, + favoritesSubject: favoritesSubject, sessionFactory: sessionFactory, scheduler: scheduler, clock: clock, @@ -1900,14 +764,8 @@ final class NostrRelayManagerTests: XCTestCase { return try event.sign(with: identity.schnorrSigningKey()) } - private func invalidSignatureCopy(of event: NostrEvent) -> NostrEvent { - var invalid = event - invalid.sig = String(repeating: "0", count: 128) - return invalid - } - private func waitUntil( - timeout: TimeInterval = TestConstants.settleTimeout, + timeout: TimeInterval = 1.0, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -1925,6 +783,7 @@ final class NostrRelayManagerTests: XCTestCase { private struct RelayManagerTestContext { let manager: NostrRelayManager let permissionSubject: CurrentValueSubject + let favoritesSubject: CurrentValueSubject, Never> let sessionFactory: MockRelaySessionFactory let scheduler: MockRelayScheduler let clock: MutableClock @@ -1941,30 +800,6 @@ private final class MutableClock { } } -/// Stand-in for the persisted hand-added relay list, so tests can change it -/// without writing to shared preferences. -private final class MutableRelayList { - var urls: [String] - - init(urls: [String]) { - self.urls = urls - } -} - -/// Deterministic jitter source: returns the queued values in order, then a -/// neutral 0.5 (jitter factor 1.0) once exhausted. -private final class JitterSequence { - private var values: [Double] - - init(_ values: [Double]) { - self.values = values - } - - func next() -> Double { - values.isEmpty ? 0.5 : values.removeFirst() - } -} - private final class MutableBool { var value: Bool @@ -1975,7 +810,6 @@ private final class MutableBool { private final class MockTorWaiter { private var completions: [(Bool) -> Void] = [] - private(set) var awaitCallCount = 0 var isReady: Bool init(isReady: Bool) { @@ -1983,7 +817,6 @@ private final class MockTorWaiter { } func await(completion: @escaping (Bool) -> Void) { - awaitCallCount += 1 completions.append(completion) } @@ -2041,8 +874,9 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol { } private final class MockRelayConnection: NostrRelayConnectionProtocol { + private let url: String private let pingError: Error? - var sendError: Error? + private let sendError: Error? private var receiveHandler: ((Result) -> Void)? private(set) var resumeCallCount = 0 private(set) var cancelCallCount = 0 @@ -2058,7 +892,8 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { } } - init(url _: String, pingError: Error? = nil, sendError: Error? = nil) { + init(url: String, pingError: Error? = nil, sendError: Error? = nil) { + self.url = url self.pingError = pingError self.sendError = sendError } @@ -2071,32 +906,13 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { cancelCallCount += 1 } - var deferSendCompletions = false - private var deferredSendCompletions: [(Error?) -> Void] = [] - func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) { sentMessages.append(message) - if deferSendCompletions { - deferredSendCompletions.append(completionHandler) - } else { - completionHandler(sendError) - } - } - - func flushDeferredSendCompletions() { - let pending = deferredSendCompletions - deferredSendCompletions = [] - pending.forEach { - $0(sendError) - } + completionHandler(sendError) } func receive(completionHandler: @escaping (Result) -> Void) { - if !pendingResults.isEmpty { - completionHandler(pendingResults.removeFirst()) - } else { - receiveHandler = completionHandler - } + receiveHandler = completionHandler } func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) { @@ -2129,24 +945,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { } func emitRawString(_ string: String) throws { - deliver(.success(.string(string))) + let handler = receiveHandler + receiveHandler = nil + handler?(.success(.string(string))) } private func emit(jsonObject: Any) throws { let data = try JSONSerialization.data(withJSONObject: jsonObject) - deliver(.success(.data(data))) - } - - // Frames emitted before the manager re-arms `receive` are queued so - // back-to-back emissions model a socket with several buffered frames. - private var pendingResults: [Result] = [] - - private func deliver(_ result: Result) { - if let handler = receiveHandler { - receiveHandler = nil - handler(result) - } else { - pendingResults.append(result) - } + let handler = receiveHandler + receiveHandler = nil + handler?(.success(.data(data))) } } diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index 5f657000..cb10d7fc 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -6,7 +6,6 @@ // For more information, see // -import Combine import Foundation import Testing import BitFoundation @@ -43,9 +42,7 @@ struct NostrTransportTests { ) ) - // Offline favorites are addressed by the full 64-hex noise key, so - // both forms must resolve to the same reachability answer. - #expect(transport.isPeerReachable(fullPeerID)) + #expect(!transport.isPeerReachable(fullPeerID)) #expect(transport.isPeerReachable(shortPeerID)) #expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed"))) } @@ -82,55 +79,10 @@ struct NostrTransportTests { ) notificationCenter.post(name: .favoriteStatusChanged, object: nil) - let didRefresh = await TestHelpers.waitUntil({ transport.isPeerReachable(peerID) }, timeout: 5.0) + let didRefresh = await TestHelpers.waitUntil({ transport.isPeerReachable(peerID) }, timeout: 0.5) #expect(didRefresh) } - @Test("Prompt delivery requires both a known npub and a relay connection") - @MainActor - func canDeliverPromptlyTracksRelayConnectivity() async throws { - let keychain = MockKeychain() - let idBridge = NostrIdentityBridge(keychain: keychain) - let recipient = try NostrIdentity.generate() - let noiseKey = Data((0..<32).map(UInt8.init)) - let peerID = PeerID(hexData: noiseKey) - let relationship = makeRelationship( - peerNoisePublicKey: noiseKey, - peerNostrPublicKey: recipient.npub, - peerNickname: "Alice" - ) - let connectivity = CurrentValueSubject(false) - - let transport = NostrTransport( - keychain: keychain, - idBridge: idBridge, - dependencies: makeDependencies( - loadFavorites: { [noiseKey: relationship] }, - relayConnectivity: { connectivity.eraseToAnyPublisher() } - ) - ) - - // Reachable (npub known) but relays down: the peer must not be - // treated as promptly deliverable, or the router would skip the - // courier and let the message rot in the Nostr send queue. - #expect(transport.isPeerReachable(peerID)) - #expect(!transport.canDeliverPromptly(to: peerID)) - - connectivity.send(true) - let deliverable = await TestHelpers.waitUntil( - { transport.canDeliverPromptly(to: peerID) }, - timeout: 5.0 - ) - #expect(deliverable) - - connectivity.send(false) - let undeliverable = await TestHelpers.waitUntil( - { !transport.canDeliverPromptly(to: peerID) }, - timeout: 5.0 - ) - #expect(undeliverable) - } - @Test("Private message resolves short peer ID and emits decryptable packet") @MainActor func sendPrivateMessageResolvesShortPeerID() async throws { @@ -164,7 +116,7 @@ struct NostrTransportTests { transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1") - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) let privateMessage = try decodePrivateMessage(from: result.payload) @@ -209,7 +161,7 @@ struct NostrTransportTests { transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true) - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) let privateMessage = try decodePrivateMessage(from: result.payload) @@ -250,7 +202,7 @@ struct NostrTransportTests { transport.sendDeliveryAck(for: "ack-1", to: fullPeerID) - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) @@ -288,7 +240,7 @@ struct NostrTransportTests { messageID: "geo-1" ) - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5) #expect(didSend) let event = probe.sentEvents[0] let result = try decodeEmbeddedPayload(from: event, recipient: recipient) @@ -337,10 +289,9 @@ struct NostrTransportTests { transport.sendReadReceipt(first, to: fullPeerID) transport.sendReadReceipt(second, to: fullPeerID) - let readReceiptTimeout: TimeInterval = 5.0 - let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout) + let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 1.5) try #require(sentFirst, "Expected first queued read receipt event") - let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout) + let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: 1.5) try #require(scheduledThrottle, "Expected queued throttle action after first read receipt") let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event") let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload @@ -349,24 +300,14 @@ struct NostrTransportTests { try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt") - let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout) + let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 1.5) try #require(sentSecond, "Expected second read receipt after running throttle action") let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event") let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload #expect(secondPayload.type == .readReceipt) #expect(String(data: secondPayload.data, encoding: .utf8) == "read-2") - withExtendedLifetime(transport) {} } - // These thread-safety tests must hammer from the dispatch pool - // (concurrentPerform), NOT a task group: transport calls block in - // queue.sync, and a 100-task group runs them on the Swift Concurrency - // cooperative pool — one thread per core, just 3 on CI runners. Parking - // every cooperative thread in a blocking sync violates the forward - // progress contract and wedged dispatch on the CI runners' macOS, - // deadlocking the whole app suite into the 15-minute job timeout - // (watchdog stacks: NostrTransport.isPeerReachable syncs holding all - // pool threads). Blocking is legal on dispatch worker threads. @Test("Concurrent read receipt enqueue does not crash") @MainActor func concurrentReadReceiptEnqueue() async throws { @@ -375,9 +316,9 @@ struct NostrTransportTests { let transport = NostrTransport(keychain: keychain, idBridge: idBridge) let iterations = 100 - await withCheckedContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { - DispatchQueue.concurrentPerform(iterations: iterations) { i in + await withTaskGroup(of: Void.self) { group in + for i in 0..) in - DispatchQueue.global().async { - DispatchQueue.concurrentPerform(iterations: iterations) { i in + await withTaskGroup(of: Bool.self) { group in + for i in 0.. NostrIdentity? = { nil }, registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in }, sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in }, - scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }, - relayConnectivity: @escaping @MainActor () -> AnyPublisher = { Just(false).eraseToAnyPublisher() } + scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in } ) -> NostrTransport.Dependencies { NostrTransport.Dependencies( notificationCenter: notificationCenter, @@ -432,8 +372,7 @@ struct NostrTransportTests { currentIdentity: currentIdentity, registerPendingGiftWrap: registerPendingGiftWrap, sendEvent: sendEvent, - scheduleAfter: scheduleAfter, - relayConnectivity: relayConnectivity + scheduleAfter: scheduleAfter ) } diff --git a/bitchatTests/Services/NotificationRedactionTests.swift b/bitchatTests/Services/NotificationRedactionTests.swift deleted file mode 100644 index be5c8109..00000000 --- a/bitchatTests/Services/NotificationRedactionTests.swift +++ /dev/null @@ -1,130 +0,0 @@ -import BitFoundation -import Foundation -import Testing -import UserNotifications -@testable import bitchat - -/// Lock-screen notifications used to carry the DM body and the sender's -/// nickname verbatim, and the geohash in the title, so a locked phone lying on -/// a table narrated conversations to anyone looking at it. These cover the -/// redaction that is now the default. -/// -/// The preference is injected rather than written to shared preferences. These -/// run in the same process as `NotificationServiceTests`, and mutating the -/// global store made whichever ran second depend on the other's cleanup — which -/// passed locally and failed in CI. -struct NotificationRedactionTests { - private final class RecordingDeliverer: NotificationRequestDelivering { - var requests: [UNNotificationRequest] = [] - - func add(_ request: UNNotificationRequest) { - requests.append(request) - } - } - - private struct StubAuthorizer: NotificationAuthorizing { - func requestAuthorization( - options: UNAuthorizationOptions, - completionHandler: @escaping (Bool, Error?) -> Void - ) { - completionHandler(true, nil) - } - } - - private func makeService( - hidePreviews: Bool - ) -> (NotificationService, RecordingDeliverer) { - let deliverer = RecordingDeliverer() - let service = NotificationService( - isRunningTestsProvider: { false }, - authorizer: StubAuthorizer(), - requestDeliverer: deliverer, - hidePreviewsProvider: { hidePreviews } - ) - return (service, deliverer) - } - - private func isolatedDefaults() -> UserDefaults { - UserDefaults(suiteName: "bitchat.tests.notifications.\(UUID().uuidString)")! - } - - @Test func previewsAreHiddenByDefault() { - // A fresh install must start quiet rather than opt-in quiet. - #expect(NotificationPrivacySettings.hideMessagePreviews(in: isolatedDefaults())) - } - - @Test func theSettingRoundTrips() { - let defaults = isolatedDefaults() - - NotificationPrivacySettings.setHideMessagePreviews(false, in: defaults) - #expect(!NotificationPrivacySettings.hideMessagePreviews(in: defaults)) - - // Panic wipe restores the safe default rather than the last choice. - NotificationPrivacySettings.reset(in: defaults) - #expect(NotificationPrivacySettings.hideMessagePreviews(in: defaults)) - } - - @Test func redactedDirectMessageWithholdsSenderAndBody() throws { - let (service, deliverer) = makeService(hidePreviews: true) - - service.sendPrivateMessageNotification( - from: "alice", - message: "meet at the north gate", - peerID: PeerID(str: "00112233445566ff") - ) - - let content = try #require(deliverer.requests.first).content - #expect(!content.title.contains("alice")) - #expect(!content.body.contains("north gate")) - #expect(!content.title.isEmpty) - // Still routable: userInfo is never rendered on the lock screen. - #expect(content.userInfo["peerID"] as? String == "00112233445566ff") - } - - @Test func redactedMentionWithholdsSenderAndBody() throws { - let (service, deliverer) = makeService(hidePreviews: true) - - service.sendMentionNotification(from: "bob", message: "regroup now") - - let content = try #require(deliverer.requests.first).content - #expect(!content.title.contains("bob")) - #expect(!content.body.contains("regroup")) - } - - @Test func redactedGeohashActivityWithholdsTheGeohash() throws { - let (service, deliverer) = makeService(hidePreviews: true) - - service.sendGeohashActivityNotification( - geohash: "u4pruyd", - bodyPreview: "someone said something" - ) - - let content = try #require(deliverer.requests.first).content - #expect(!content.title.contains("u4pruyd")) - #expect(!content.body.contains("someone said")) - // The deep link still carries it: tapping must land in the channel. - #expect(content.userInfo["deeplink"] as? String == "bitchat://geohash/u4pruyd") - } - - @Test func previewsShownWhenTheSettingIsOff() { - let (service, deliverer) = makeService(hidePreviews: false) - - service.sendPrivateMessageNotification( - from: "alice", - message: "meet at the north gate", - peerID: PeerID(str: "00112233445566ff") - ) - service.sendGeohashActivityNotification( - geohash: "u4pruyd", - bodyPreview: "someone said something" - ) - - #expect(deliverer.requests.count == 2) - let dm = deliverer.requests[0].content - #expect(dm.title.contains("alice")) - #expect(dm.body == "meet at the north gate") - let geo = deliverer.requests[1].content - #expect(geo.title.contains("u4pruyd")) - #expect(geo.body == "someone said something") - } -} diff --git a/bitchatTests/Services/NotificationServiceTests.swift b/bitchatTests/Services/NotificationServiceTests.swift index 384f0075..9ffad1f4 100644 --- a/bitchatTests/Services/NotificationServiceTests.swift +++ b/bitchatTests/Services/NotificationServiceTests.swift @@ -56,15 +56,12 @@ final class NotificationServiceTests: XCTestCase { XCTAssertNil(request?.trigger) } - /// Previews shown: the opt-in behavior. Stated explicitly rather than - /// inherited from the shared preference, which now defaults to hidden. func test_sendPrivateMessageNotification_populatesPeerMetadata() { let deliverer = RecordingNotificationRequestDeliverer() let service = NotificationService( isRunningTestsProvider: { false }, authorizer: RecordingNotificationAuthorizer(), - requestDeliverer: deliverer, - hidePreviewsProvider: { false } + requestDeliverer: deliverer ) let peerID = PeerID(str: "deadbeefdeadbeef") @@ -77,27 +74,6 @@ final class NotificationServiceTests: XCTestCase { XCTAssertEqual(request?.content.userInfo["senderName"] as? String, "Alice") } - /// Previews hidden: the default. The routing payload has to survive - /// redaction, or tapping the alert would not open the conversation. - func test_sendPrivateMessageNotification_withPreviewsHidden_keepsRoutingButDropsContent() { - let deliverer = RecordingNotificationRequestDeliverer() - let service = NotificationService( - isRunningTestsProvider: { false }, - authorizer: RecordingNotificationAuthorizer(), - requestDeliverer: deliverer, - hidePreviewsProvider: { true } - ) - let peerID = PeerID(str: "deadbeefdeadbeef") - - service.sendPrivateMessageNotification(from: "Alice", message: "hi", peerID: peerID) - - let request = deliverer.requests.singleValue - XCTAssertFalse(request?.content.title.contains("Alice") ?? true) - XCTAssertFalse(request?.content.body.contains("hi") ?? true) - XCTAssertFalse(request?.content.title.isEmpty ?? true) - XCTAssertEqual(request?.content.userInfo["peerID"] as? String, peerID.id) - } - func test_wrapperNotifications_setExpectedIdentifiersAndDeepLinks() { let deliverer = RecordingNotificationRequestDeliverer() let service = NotificationService( diff --git a/bitchatTests/Services/PrivateChatManagerTests.swift b/bitchatTests/Services/PrivateChatManagerTests.swift index 69b49aaf..a75dac5f 100644 --- a/bitchatTests/Services/PrivateChatManagerTests.swift +++ b/bitchatTests/Services/PrivateChatManagerTests.swift @@ -3,8 +3,6 @@ // bitchatTests // // Tests for PrivateChatManager read receipt and selection behavior. -// Message storage lives in the single-writer ConversationStore; the -// manager's privateChats/unreadMessages are derived views over it. // import Testing @@ -14,20 +12,13 @@ import BitFoundation struct PrivateChatManagerTests { - @MainActor - private static func makeManager(transport: MockTransport) -> (PrivateChatManager, ConversationStore) { - let store = ConversationStore() - let manager = PrivateChatManager(meshService: transport, conversationStore: store) - return (manager, store) - } - @Test @MainActor func startChat_setsSelectedAndClearsUnread() async { let transport = MockTransport() - let (manager, store) = Self.makeManager(transport: transport) + let manager = PrivateChatManager(meshService: transport) let peerID = PeerID(str: "00000000000000AA") - store.append( + manager.privateChats[peerID] = [ BitchatMessage( id: "pm-1", sender: "Peer", @@ -37,10 +28,9 @@ struct PrivateChatManagerTests { isPrivate: true, recipientNickname: "Me", senderPeerID: peerID - ), - to: .directPeer(peerID) - ) - store.markUnread(.directPeer(peerID)) + ) + ] + manager.unreadMessages.insert(peerID) manager.startChat(with: peerID) @@ -53,13 +43,13 @@ struct PrivateChatManagerTests { func markAsRead_sendsReadReceiptViaRouter() async { let transport = MockTransport() let router = MessageRouter(transports: [transport]) - let (manager, store) = Self.makeManager(transport: transport) + let manager = PrivateChatManager(meshService: transport) manager.messageRouter = router let peerID = PeerID(str: "00000000000000BB") transport.reachablePeers.insert(peerID) - store.append( + manager.privateChats[peerID] = [ BitchatMessage( id: "pm-2", sender: "Peer", @@ -69,10 +59,9 @@ struct PrivateChatManagerTests { isPrivate: true, recipientNickname: "Me", senderPeerID: peerID - ), - to: .directPeer(peerID) - ) - store.markUnread(.directPeer(peerID)) + ) + ] + manager.unreadMessages.insert(peerID) manager.markAsRead(from: peerID) try? await Task.sleep(nanoseconds: 100_000_000) @@ -85,10 +74,10 @@ struct PrivateChatManagerTests { @Test @MainActor func markAsRead_withoutRouterFallsBackToTransport() async { let transport = MockTransport() - let (manager, store) = Self.makeManager(transport: transport) + let manager = PrivateChatManager(meshService: transport) let peerID = PeerID(str: "00000000000000CC") - store.append( + manager.privateChats[peerID] = [ BitchatMessage( id: "pm-fallback", sender: "Peer", @@ -98,9 +87,8 @@ struct PrivateChatManagerTests { isPrivate: true, recipientNickname: "Me", senderPeerID: peerID - ), - to: .directPeer(peerID) - ) + ) + ] manager.markAsRead(from: peerID) @@ -108,80 +96,10 @@ struct PrivateChatManagerTests { #expect(transport.sentReadReceipts.first?.receipt.originalMessageID == "pm-fallback") } - @Test @MainActor - func markAsRead_calledTwiceSynchronously_routesOneReceiptPerMessage() async { - // Regression: opening a chat runs two read scans in the same - // synchronous MainActor stretch (beginPrivateChatSession and - // markPrivateMessagesAsRead). The receipt must be claimed before the - // routing task gets a chance to run, or both scans route a copy. - let transport = MockTransport() - let router = MessageRouter(transports: [transport]) - let (manager, store) = Self.makeManager(transport: transport) - manager.messageRouter = router - - let peerID = PeerID(str: "00000000000000DE") - transport.reachablePeers.insert(peerID) - - store.append( - BitchatMessage( - id: "pm-double", - sender: "Peer", - content: "Hi", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerID - ), - to: .directPeer(peerID) - ) - store.markUnread(.directPeer(peerID)) - - manager.markAsRead(from: peerID) - manager.markAsRead(from: peerID) - try? await Task.sleep(nanoseconds: 100_000_000) - - #expect(transport.sentReadReceipts.count == 1) - #expect(manager.sentReadReceipts.contains("pm-double")) - } - - @Test @MainActor - func markAsRead_failedRouteReleasesClaimForRetry() async { - // No reachable transport: the receipt is not sent, and the eager - // claim must be released so a later read scan retries. - let transport = MockTransport() - let router = MessageRouter(transports: [transport]) - let (manager, store) = Self.makeManager(transport: transport) - manager.messageRouter = router - - let peerID = PeerID(str: "00000000000000DF") - - store.append( - BitchatMessage( - id: "pm-unroutable", - sender: "Peer", - content: "Hi", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerID - ), - to: .directPeer(peerID) - ) - store.markUnread(.directPeer(peerID)) - - manager.markAsRead(from: peerID) - try? await Task.sleep(nanoseconds: 100_000_000) - - #expect(transport.sentReadReceipts.isEmpty) - #expect(!manager.sentReadReceipts.contains("pm-unroutable")) - } - @Test @MainActor func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async { let transport = MockTransport() - let (manager, store) = Self.makeManager(transport: transport) + let manager = PrivateChatManager(meshService: transport) let identityManager = MockIdentityManager(MockKeychain()) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager) @@ -202,7 +120,7 @@ struct PrivateChatManagerTests { ]) try? await Task.sleep(nanoseconds: 50_000_000) - store.append( + manager.privateChats[stablePeerID] = [ BitchatMessage( id: "stable-msg", sender: "Alice", @@ -212,10 +130,9 @@ struct PrivateChatManagerTests { isPrivate: true, recipientNickname: "Me", senderPeerID: stablePeerID - ), - to: .directPeer(stablePeerID) - ) - store.markUnread(.directPeer(stablePeerID)) + ) + ] + manager.unreadMessages.insert(stablePeerID) let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "Alice", persistedReadReceipts: []) @@ -229,11 +146,11 @@ struct PrivateChatManagerTests { @Test @MainActor func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async { let transport = MockTransport() - let (manager, store) = Self.makeManager(transport: transport) + let manager = PrivateChatManager(meshService: transport) let peerID = PeerID(str: "0011223344556677") let tempPeerID = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000042") - store.append( + manager.privateChats[tempPeerID] = [ BitchatMessage( id: "geo-msg", sender: "Alice", @@ -243,10 +160,9 @@ struct PrivateChatManagerTests { isPrivate: true, recipientNickname: "Me", senderPeerID: tempPeerID - ), - to: .directPeer(tempPeerID) - ) - store.markUnread(.directPeer(tempPeerID)) + ) + ] + manager.unreadMessages.insert(tempPeerID) let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "alice", persistedReadReceipts: []) @@ -261,10 +177,10 @@ struct PrivateChatManagerTests { @Test @MainActor func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async { let transport = MockTransport() - let (manager, store) = Self.makeManager(transport: transport) + let manager = PrivateChatManager(meshService: transport) let peerID = PeerID(str: "00000000000000DD") - let seeded = [ + manager.privateChats[peerID] = [ BitchatMessage( id: "sent-read", sender: "Me", @@ -299,9 +215,6 @@ struct PrivateChatManagerTests { deliveryStatus: .failed(reason: "nope") ) ] - for message in seeded { - store.append(message, to: .directPeer(peerID)) - } var externalReceipts = Set() manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: "Me", externalReceipts: &externalReceipts) @@ -310,36 +223,47 @@ struct PrivateChatManagerTests { #expect(manager.sentReadReceipts == Set(["sent-read", "sent-delivered"])) } - /// The store replaces `sanitizeChat`: inserts keep chronological order, - /// duplicate IDs are rejected on append, and `upsertByID` replaces the - /// stored message with the latest copy in place. @Test @MainActor - func store_keepsChronologicalOrderAndDedupsByID() async { + func sanitizeChat_sortsChronologicallyAndKeepsLatestDuplicate() async { let transport = MockTransport() - let (manager, store) = Self.makeManager(transport: transport) + let manager = PrivateChatManager(meshService: transport) let peerID = PeerID(str: "00000000000000EE") let base = Date(timeIntervalSince1970: 10) - func message(_ id: String, _ content: String, offset: TimeInterval) -> BitchatMessage { + manager.privateChats[peerID] = [ BitchatMessage( - id: id, + id: "same", sender: "Peer", - content: content, - timestamp: base.addingTimeInterval(offset), + content: "Older", + timestamp: base.addingTimeInterval(10), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ), + BitchatMessage( + id: "first", + sender: "Peer", + content: "First", + timestamp: base, + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerID + ), + BitchatMessage( + id: "same", + sender: "Peer", + content: "Newest", + timestamp: base.addingTimeInterval(20), isRelay: false, isPrivate: true, recipientNickname: "Me", senderPeerID: peerID ) - } + ] - #expect(store.append(message("same", "Older", offset: 10), to: .directPeer(peerID))) - // Out-of-order arrival is inserted in timestamp order. - #expect(store.append(message("first", "First", offset: 0), to: .directPeer(peerID))) - // Duplicate ID is rejected on append… - #expect(!store.append(message("same", "Newest", offset: 20), to: .directPeer(peerID))) - // …and replaced in place by upsert. - store.upsertByID(message("same", "Newest", offset: 20), in: .directPeer(peerID)) + manager.sanitizeChat(for: peerID) #expect(manager.privateChats[peerID]?.map(\.id) == ["first", "same"]) #expect(manager.privateChats[peerID]?.last?.content == "Newest") diff --git a/bitchatTests/Services/RelayControllerTests.swift b/bitchatTests/Services/RelayControllerTests.swift index cb18ec64..71553ab8 100644 --- a/bitchatTests/Services/RelayControllerTests.swift +++ b/bitchatTests/Services/RelayControllerTests.swift @@ -50,26 +50,6 @@ struct RelayControllerTests { #expect(decision.delayMs >= 10 && decision.delayMs <= 35) } - @Test - func localRecipientDoesNotRelayDirectedTraffic() async { - let decision = RelayController.decide( - ttl: 7, - senderIsSelf: false, - recipientIsSelf: true, - isEncrypted: true, - isDirectedEncrypted: true, - isFragment: false, - isDirectedFragment: false, - isHandshake: false, - isAnnounce: false, - degree: 3, - highDegreeThreshold: TransportConfig.bleHighDegreeThreshold - ) - - #expect(!decision.shouldRelay) - #expect(decision.newTTL == 7) - } - @Test func fragment_relaysWithFragmentCap() async { let decision = RelayController.decide( @@ -94,105 +74,6 @@ struct RelayControllerTests { #expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs) } - @Test - func sparseChain_relaysAtFullIncomingDepth() async { - // Thin chains (degree <= 2) are exactly the topology that needs every - // hop, so no clamp below the incoming TTL is applied. - let decision = RelayController.decide( - ttl: 7, - senderIsSelf: false, - isEncrypted: false, - isDirectedEncrypted: false, - isFragment: false, - isDirectedFragment: false, - isHandshake: false, - isAnnounce: false, - degree: 2, - highDegreeThreshold: TransportConfig.bleHighDegreeThreshold - ) - - #expect(decision.shouldRelay) - #expect(decision.newTTL == 6) - } - - @Test - func denseGraph_clampsFragmentTTLHarder() async { - let decision = RelayController.decide( - ttl: 10, - senderIsSelf: false, - isEncrypted: false, - isDirectedEncrypted: false, - isFragment: true, - isDirectedFragment: false, - isHandshake: false, - isAnnounce: false, - degree: TransportConfig.bleHighDegreeThreshold, - highDegreeThreshold: TransportConfig.bleHighDegreeThreshold - ) - - #expect(decision.shouldRelay) - #expect(decision.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1) - } - - @Test - func voiceFrame_relaysWithFragmentPolicy() async { - // Sparse graph: fragment cap, tight jitter (multi-hop latency must - // stay inside the receiver's jitter buffer). - let sparse = RelayController.decide( - ttl: 7, - senderIsSelf: false, - isEncrypted: false, - isDirectedEncrypted: false, - isFragment: false, - isDirectedFragment: false, - isHandshake: false, - isAnnounce: false, - isVoiceFrame: true, - degree: 3, - highDegreeThreshold: TransportConfig.bleHighDegreeThreshold - ) - #expect(sparse.shouldRelay) - #expect(sparse.newTTL == min(UInt8(7), TransportConfig.bleFragmentRelayTtlCap) &- 1) - #expect(sparse.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs) - #expect(sparse.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs) - - // Dense graph: harder clamp contains the sustained per-talker stream. - let dense = RelayController.decide( - ttl: 7, - senderIsSelf: false, - isEncrypted: false, - isDirectedEncrypted: false, - isFragment: false, - isDirectedFragment: false, - isHandshake: false, - isAnnounce: false, - isVoiceFrame: true, - degree: TransportConfig.bleHighDegreeThreshold, - highDegreeThreshold: TransportConfig.bleHighDegreeThreshold - ) - #expect(dense.shouldRelay) - #expect(dense.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1) - } - - @Test - func requestSync_neverRelaysEvenWithTTLHeadroom() async { - let decision = RelayController.decide( - ttl: 7, - senderIsSelf: false, - isEncrypted: false, - isDirectedEncrypted: false, - isFragment: false, - isDirectedFragment: false, - isHandshake: false, - isAnnounce: false, - isRequestSync: true, - degree: 3, - highDegreeThreshold: TransportConfig.bleHighDegreeThreshold - ) - - #expect(!decision.shouldRelay) - } - @Test func denseGraph_capsTTL() async { let decision = RelayController.decide( diff --git a/bitchatTests/Services/SafeRegexTests.swift b/bitchatTests/Services/SafeRegexTests.swift deleted file mode 100644 index fe92a6b8..00000000 --- a/bitchatTests/Services/SafeRegexTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// SafeRegexTests.swift -// bitchatTests -// -// SafeRegex must never trap: valid patterns compile normally, invalid ones -// degrade to a regex that matches nothing. The production-pattern test keeps -// the compile-time guarantee try! used to provide - a typo in any bundled -// pattern fails here instead of crashing the app at startup. -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -@testable import bitchat - -struct SafeRegexTests { - - private func matchCount(_ regex: NSRegularExpression, _ text: String) -> Int { - regex.numberOfMatches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) - } - - @Test - func validPatternCompilesAndMatches() { - let regex = SafeRegex.compile("#([a-zA-Z0-9_]+)") - #expect(matchCount(regex, "tag #bitchat here") == 1) - } - - @Test - func invalidPatternDegradesToNeverMatching() { - let regex = SafeRegex.compile("(unclosed") - #expect(matchCount(regex, "(unclosed anything") == 0) - #expect(matchCount(regex, "") == 0) - } - - @Test - func productionPatternsCompileAndMatchTheirTargets() { - // A pattern that failed to compile would have degraded to - // never-matching, so each positive match proves the literal compiled. - #expect(matchCount(MessageFormattingEngine.Patterns.hashtag, "see #mesh") == 1) - #expect(matchCount(MessageFormattingEngine.Patterns.mention, "hi @alice#ab12") == 1) - - let cashuToken = "cashuA" + String(repeating: "x", count: 45) - #expect(matchCount(MessageFormattingEngine.Patterns.cashu, cashuToken) == 1) - #expect(matchCount(MessageFormattingEngine.Patterns.quickCashuPresence, cashuToken) == 1) - - let bolt11 = "lnbc1" + String(repeating: "q", count: 55) - #expect(matchCount(MessageFormattingEngine.Patterns.bolt11, bolt11) == 1) - - let lnurl = "lnurl1" + String(repeating: "q", count: 25) - #expect(matchCount(MessageFormattingEngine.Patterns.lnurl, lnurl) == 1) - - #expect(matchCount(MessageFormattingEngine.Patterns.lightningScheme, "pay lightning:abc123") == 1) - } - - @Test - func contentNormalizerStillSimplifiesURLs() { - // Exercises ContentNormalizer's regex through its public entry point: - // same URL with different query strings must normalize identically. - let a = ContentNormalizer.normalizedKey("check https://example.com/page?q=1") - let b = ContentNormalizer.normalizedKey("check https://example.com/page?q=2") - #expect(a == b) - } -} diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index aae65dab..6f40558f 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -79,100 +79,6 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey) } - func test_upsertCryptographicIdentity_refusesToReplacePinnedSigningKey() async { - let manager = SecureIdentityStateManager(MockKeychain()) - let noisePublicKey = Data(repeating: 0x11, count: 32) - let fingerprint = noisePublicKey.sha256Fingerprint() - let peerID = PeerID(publicKey: noisePublicKey) - let victimSigningKey = Data(repeating: 0x22, count: 32) - let attackerSigningKey = Data(repeating: 0x66, count: 32) - - manager.upsertCryptographicIdentity( - fingerprint: fingerprint, - noisePublicKey: noisePublicKey, - signingPublicKey: victimSigningKey, - claimedNickname: "victim" - ) - let pinned = await waitUntil { - manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey - } - XCTAssertTrue(pinned) - - // Attacker upsert with a different signing key must be refused in - // full — signing key AND claimed nickname stay the victim's. - manager.upsertCryptographicIdentity( - fingerprint: fingerprint, - noisePublicKey: noisePublicKey, - signingPublicKey: attackerSigningKey, - claimedNickname: "attacker" - ) - - // Synchronous reads fence the manager's pending barrier writes. - XCTAssertEqual( - manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, - victimSigningKey - ) - XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim") - - // The legitimate peer (same signing key) can still update. - manager.upsertCryptographicIdentity( - fingerprint: fingerprint, - noisePublicKey: noisePublicKey, - signingPublicKey: victimSigningKey, - claimedNickname: "victim-renamed" - ) - let renamed = await waitUntil { - manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "victim-renamed" - } - XCTAssertTrue(renamed) - XCTAssertEqual( - manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, - victimSigningKey - ) - } - - func test_cryptographicIdentity_persistsAcrossReinitAndKeepsSigningKeyPin() async { - let keychain = MockKeychain() - let manager = SecureIdentityStateManager(keychain) - let noisePublicKey = Data(repeating: 0x13, count: 32) - let fingerprint = noisePublicKey.sha256Fingerprint() - let peerID = PeerID(publicKey: noisePublicKey) - let victimSigningKey = Data(repeating: 0x24, count: 32) - let attackerSigningKey = Data(repeating: 0x77, count: 32) - - manager.upsertCryptographicIdentity( - fingerprint: fingerprint, - noisePublicKey: noisePublicKey, - signingPublicKey: victimSigningKey, - claimedNickname: "victim" - ) - let pinned = await waitUntil { - manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey - } - XCTAssertTrue(pinned) - manager.forceSave() - - // Simulated app restart: the pin must survive and still refuse a - // different signing key. - let reloaded = SecureIdentityStateManager(keychain) - XCTAssertEqual( - reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, - victimSigningKey - ) - - reloaded.upsertCryptographicIdentity( - fingerprint: fingerprint, - noisePublicKey: noisePublicKey, - signingPublicKey: attackerSigningKey, - claimedNickname: "attacker" - ) - XCTAssertEqual( - reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, - victimSigningKey - ) - XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim") - } - func test_setBlocked_clearsFavoriteState() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "ab", count: 32) @@ -468,7 +374,6 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertTrue(manager.getFavorites().isEmpty) XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty) XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty) - XCTAssertNil(keychain.getIdentityKey(forKey: "bitchat.identityCache.v2")) } func test_clearAllIdentityData_removesCachedState() async { @@ -493,59 +398,6 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertTrue(cleared) } - func test_privateMediaCapabilityPinPersistsMonotonicallyAndPanicClearRemovesIt() async { - let keychain = MockKeychain() - let fingerprint = Data(repeating: 0x42, count: 32).sha256Fingerprint() - let manager = SecureIdentityStateManager(keychain) - - XCTAssertFalse(manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint)) - manager.markPrivateMediaCapable(fingerprint: fingerprint) - XCTAssertTrue( - manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint), - "pin insertion must be synchronously visible to the next downgrade decision" - ) - - // Re-marking is idempotent, and the encrypted cache carries the pin - // across launches. - manager.markPrivateMediaCapable(fingerprint: fingerprint) - manager.forceSave() - let reloaded = SecureIdentityStateManager(keychain) - XCTAssertTrue(reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint)) - - // ChatViewModel's panic path calls this same wipe after deleting - // keychain data; the in-memory pin must disappear immediately too. - reloaded.clearAllIdentityData() - let cleared = await waitUntil { - !reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint) - } - XCTAssertTrue(cleared) - } - - func test_noiseAuthenticatedSigningKeyBindingPersistsAndPanicClearRemovesIt() async { - let keychain = MockKeychain() - let fingerprint = Data(repeating: 0x31, count: 32).sha256Fingerprint() - let firstKey = Data(repeating: 0x41, count: 32) - let rotatedKey = Data(repeating: 0x42, count: 32) - let manager = SecureIdentityStateManager(keychain) - - manager.bindAuthenticatedSigningPublicKey(firstKey, fingerprint: fingerprint) - XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), firstKey) - // A later authenticated Noise session may legitimately rotate the - // announcement signing key. - manager.bindAuthenticatedSigningPublicKey(rotatedKey, fingerprint: fingerprint) - XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey) - - manager.forceSave() - let reloaded = SecureIdentityStateManager(keychain) - XCTAssertEqual(reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey) - - reloaded.clearAllIdentityData() - let cleared = await waitUntil { - reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint) == nil - } - XCTAssertTrue(cleared) - } - func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async { let keychain = FailingCacheSaveKeychain() let manager = SecureIdentityStateManager(keychain) @@ -562,7 +414,7 @@ final class SecureIdentityStateManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = TestConstants.settleTimeout, + timeout: TimeInterval = 1.0, condition: @escaping () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -643,8 +495,4 @@ private final class FailingCacheSaveKeychain: KeychainManagerProtocol { func delete(key: String, service: String) { serviceStorage[service]?.removeValue(forKey: key) } - - func deleteAll(service: String) { - serviceStorage.removeValue(forKey: service) - } } diff --git a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift deleted file mode 100644 index ae71c91a..00000000 --- a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift +++ /dev/null @@ -1,335 +0,0 @@ -import Foundation -import Testing - -@testable import bitchat - -/// Vouch storage, accept-policy gates, derived trust levels, and persistence -/// compatibility for `SecureIdentityStateManager`. -/// -/// Ordering note: mutations use barrier blocks on the manager's concurrent -/// queue and reads use `queue.sync`, so a read submitted after a mutation -/// always observes it — no polling needed. -/// -/// `@MainActor` matches production (the manager's vouch API is driven by the -/// main-actor `ChatVouchCoordinator`) and keeps the blocking `queue.sync` -/// reads off the Swift Concurrency cooperative pool. Left nonisolated, Swift -/// Testing runs these tests in parallel on that pool, and on CI's few-core -/// runners every pool thread ended up parked in `queue.sync` behind a pending -/// `queue.async(.barrier)` write that never got a dispatch worker — a -/// process-wide deadlock (watchdog SIGKILL, exit 137). -@MainActor -struct SecureIdentityStateManagerVouchTests { - private let voucher = String(repeating: "0a", count: 32) - private let vouchee = String(repeating: "0b", count: 32) - - private func makeManager() -> SecureIdentityStateManager { - SecureIdentityStateManager(MockKeychain()) - } - - // MARK: - Accept-policy gates - - @Test - func recordVouch_rejectsUnverifiedVoucher() { - let manager = makeManager() - - #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) - #expect(manager.validVouchers(for: vouchee).isEmpty) - - manager.setVerified(fingerprint: voucher, verified: true) - #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) - #expect(manager.validVouchers(for: vouchee).count == 1) - } - - @Test - func recordVouch_ignoresSelfVouch() { - let manager = makeManager() - manager.setVerified(fingerprint: voucher, verified: true) - - #expect(!manager.recordVouch(voucheeFingerprint: voucher, voucherFingerprint: voucher, timestamp: Date())) - #expect(manager.validVouchers(for: voucher).isEmpty) - } - - @Test - func recordVouch_ignoresAlreadyVerifiedVouchee() { - let manager = makeManager() - manager.setVerified(fingerprint: voucher, verified: true) - manager.setVerified(fingerprint: vouchee, verified: true) - - #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) - #expect(!manager.isVouched(fingerprint: vouchee)) - } - - @Test - func recordVouch_rejectsStaleAndFarFutureTimestamps() { - let manager = makeManager() - manager.setVerified(fingerprint: voucher, verified: true) - - let stale = Date().addingTimeInterval(-31 * 24 * 60 * 60) - #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: stale)) - - let farFuture = Date().addingTimeInterval(2 * 60 * 60) - #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: farFuture)) - - #expect(manager.validVouchers(for: vouchee).isEmpty) - } - - @Test - func recordVouch_capsVouchersPerVoucheeKeepingMostRecent() { - let manager = makeManager() - let base = Date() - - // 9 verified vouchers vouch with strictly increasing timestamps. - let vouchers = (0..<9).map { String(format: "%02x", $0 + 0x10) + String(repeating: "00", count: 31) } - for (index, voucherFingerprint) in vouchers.enumerated() { - manager.setVerified(fingerprint: voucherFingerprint, verified: true) - let stored = manager.recordVouch( - voucheeFingerprint: vouchee, - voucherFingerprint: voucherFingerprint, - timestamp: base.addingTimeInterval(TimeInterval(index)), - now: base.addingTimeInterval(TimeInterval(index)) - ) - #expect(stored) - } - - let records = manager.validVouchers(for: vouchee) - #expect(records.count == SecureIdentityStateManager.maxVouchersPerVouchee) - // The oldest voucher fell off the end. - #expect(!records.contains { $0.voucherFingerprint == vouchers[0] }) - #expect(records.contains { $0.voucherFingerprint == vouchers[8] }) - - // An attestation older than everything retained is not stored. - let older = String(repeating: "0c", count: 32) - manager.setVerified(fingerprint: older, verified: true) - #expect(!manager.recordVouch( - voucheeFingerprint: vouchee, - voucherFingerprint: older, - timestamp: base.addingTimeInterval(-1), - now: base - )) - - // A repeat vouch from a retained voucher refreshes, not duplicates. - #expect(manager.recordVouch( - voucheeFingerprint: vouchee, - voucherFingerprint: vouchers[8], - timestamp: base.addingTimeInterval(100), - now: base.addingTimeInterval(100) - )) - #expect(manager.validVouchers(for: vouchee).count == SecureIdentityStateManager.maxVouchersPerVouchee) - } - - // MARK: - Derived trust & invalidation - - @Test - func unverifyingVoucher_invalidatesTheirVouchesWithoutDeletingThem() { - let manager = makeManager() - manager.setVerified(fingerprint: voucher, verified: true) - manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) - #expect(manager.isVouched(fingerprint: vouchee)) - - // Removing my verification of the voucher retires their vouches… - manager.setVerified(fingerprint: voucher, verified: false) - #expect(!manager.isVouched(fingerprint: vouchee)) - #expect(manager.validVouchers(for: vouchee).isEmpty) - - // …but the records survive: re-verifying the voucher restores them - // (recompute on read, no cascade delete). - manager.setVerified(fingerprint: voucher, verified: true) - #expect(manager.isVouched(fingerprint: vouchee)) - } - - @Test - func validVouchers_expireAtReadTime() { - let manager = makeManager() - manager.setVerified(fingerprint: voucher, verified: true) - - let now = Date() - let timestamp = now.addingTimeInterval(-29 * 24 * 60 * 60) - #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: timestamp, now: now)) - #expect(manager.isVouched(fingerprint: vouchee, now: now)) - - let twoDaysLater = now.addingTimeInterval(2 * 24 * 60 * 60) - #expect(manager.validVouchers(for: vouchee, now: twoDaysLater).isEmpty) - #expect(!manager.isVouched(fingerprint: vouchee, now: twoDaysLater)) - } - - @Test - func effectiveTrustLevel_slotsVouchedBetweenCasualAndTrusted() { - let manager = makeManager() - manager.setVerified(fingerprint: voucher, verified: true) - - // Unknown peer with a valid vouch reads as vouched. - #expect(manager.effectiveTrustLevel(for: vouchee) == .unknown) - manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) - #expect(manager.effectiveTrustLevel(for: vouchee) == .vouched) - - // Explicit trust outranks a vouch. - manager.updateSocialIdentity(SocialIdentity( - fingerprint: vouchee, - localPetname: nil, - claimedNickname: "bob", - trustLevel: .trusted, - isFavorite: false, - isBlocked: false, - notes: nil - )) - #expect(manager.effectiveTrustLevel(for: vouchee) == .trusted) - - // Explicit verification outranks everything. - manager.setVerified(fingerprint: vouchee, verified: true) - #expect(manager.effectiveTrustLevel(for: vouchee) == .verified) - #expect(!manager.isVouched(fingerprint: vouchee)) - - // Losing the voucher downgrades vouched back to the stored level. - manager.setVerified(fingerprint: vouchee, verified: false) - manager.setVerified(fingerprint: voucher, verified: false) - #expect(manager.effectiveTrustLevel(for: vouchee) == .casual) - } - - // MARK: - Exchange-policy state - - @Test - func mostRecentlyVerifiedFingerprints_ordersAndExcludes() { - let manager = makeManager() - let first = String(repeating: "01", count: 32) - let second = String(repeating: "02", count: 32) - let third = String(repeating: "03", count: 32) - manager.setVerified(fingerprint: first, verified: true) - manager.setVerified(fingerprint: second, verified: true) - manager.setVerified(fingerprint: third, verified: true) - - let ordered = manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: third) - #expect(ordered == [second, first]) - - let limited = manager.mostRecentlyVerifiedFingerprints(limit: 1, excluding: third) - #expect(limited == [second]) - } - - @Test - func vouchBatchSentAt_roundTrips() { - let manager = makeManager() - #expect(manager.lastVouchBatchSent(to: voucher) == nil) - - let sentAt = Date(timeIntervalSince1970: 1_700_000_000) - manager.markVouchBatchSent(to: voucher, at: sentAt) - #expect(manager.lastVouchBatchSent(to: voucher) == sentAt) - } - - @Test - func signingPublicKey_returnsAnnounceBoundKeyByFingerprint() async { - let manager = makeManager() - let signingKey = Data(repeating: 0x22, count: 32) - manager.upsertCryptographicIdentity( - fingerprint: voucher, - noisePublicKey: Data(repeating: 0x11, count: 32), - signingPublicKey: signingKey, - claimedNickname: nil - ) - - let stored = await waitUntil { manager.signingPublicKey(forFingerprint: voucher) == signingKey } - #expect(stored) - #expect(manager.signingPublicKey(forFingerprint: vouchee) == nil) - } - - // MARK: - Panic wipe - - @Test - func clearAllIdentityData_wipesVouchState() async { - let manager = makeManager() - manager.setVerified(fingerprint: voucher, verified: true) - manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) - manager.markVouchBatchSent(to: voucher, at: Date()) - #expect(manager.isVouched(fingerprint: vouchee)) - - manager.clearAllIdentityData() - - let wiped = await waitUntil { !manager.isVouched(fingerprint: vouchee) } - #expect(wiped) - #expect(manager.validVouchers(for: vouchee).isEmpty) - #expect(manager.lastVouchBatchSent(to: voucher) == nil) - #expect(manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: "").isEmpty) - } - - // MARK: - Persistence compatibility - - @Test - func trustLevelRawValuesAreStable() throws { - // Raw values are what's persisted; they must never change when cases - // are added mid-ladder. - #expect(TrustLevel.unknown.rawValue == "unknown") - #expect(TrustLevel.casual.rawValue == "casual") - #expect(TrustLevel.vouched.rawValue == "vouched") - #expect(TrustLevel.trusted.rawValue == "trusted") - #expect(TrustLevel.verified.rawValue == "verified") - - let legacy = Data(#"["unknown","casual","trusted","verified"]"#.utf8) - let decoded = try JSONDecoder().decode([TrustLevel].self, from: legacy) - #expect(decoded == [.unknown, .casual, .trusted, .verified]) - } - - @Test - func identityCachePersistedBeforeVouchingDecodesCleanly() throws { - // A cache captured before the vouch fields existed must decode without - // tripping the "unreadable cache" recovery path. - let legacyJSON = Data(""" - { - "socialIdentities": {}, - "nicknameIndex": {}, - "verifiedFingerprints": ["\(voucher)"], - "lastInteractions": {}, - "blockedNostrPubkeys": [], - "version": 1 - } - """.utf8) - - let decoded = try JSONDecoder().decode(IdentityCache.self, from: legacyJSON) - #expect(decoded.vouchesByVouchee == nil) - #expect(decoded.vouchBatchSentAt == nil) - #expect(decoded.verifiedAt == nil) - #expect(decoded.verifiedFingerprints == [voucher]) - } - - @Test - func identityCacheRoundTripsVouchState() throws { - var cache = IdentityCache() - cache.verifiedFingerprints = [voucher] - cache.vouchesByVouchee = [vouchee: [VouchRecord(voucherFingerprint: voucher, timestamp: Date(timeIntervalSince1970: 1_700_000_000))]] - cache.vouchBatchSentAt = [voucher: Date(timeIntervalSince1970: 1_700_000_001)] - cache.verifiedAt = [voucher: Date(timeIntervalSince1970: 1_700_000_002)] - - let decoded = try JSONDecoder().decode(IdentityCache.self, from: JSONEncoder().encode(cache)) - #expect(decoded.vouchesByVouchee == cache.vouchesByVouchee) - #expect(decoded.vouchBatchSentAt == cache.vouchBatchSentAt) - #expect(decoded.verifiedAt == cache.verifiedAt) - } - - @Test - func vouchStateSurvivesReload() async { - let keychain = MockKeychain() - let manager = SecureIdentityStateManager(keychain) - manager.setVerified(fingerprint: voucher, verified: true) - manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) - let saved = await waitUntil { manager.isVouched(fingerprint: self.vouchee) } - #expect(saved) - manager.forceSave() - - let reloaded = SecureIdentityStateManager(keychain) - #expect(reloaded.isVouched(fingerprint: vouchee)) - #expect(reloaded.validVouchers(for: vouchee).count == 1) - } - - // MARK: - Helpers - - private func waitUntil( - timeout: TimeInterval = TestConstants.settleTimeout, - condition: @escaping () -> Bool - ) async -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if condition() { - return true - } - try? await Task.sleep(nanoseconds: 10_000_000) - } - return condition() - } -} diff --git a/bitchatTests/Services/TorPreferenceReadTests.swift b/bitchatTests/Services/TorPreferenceReadTests.swift deleted file mode 100644 index b9f198e3..00000000 --- a/bitchatTests/Services/TorPreferenceReadTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation -import Testing -@testable import bitchat - -/// The geo-relay directory refresh runs off the main actor and has to decide -/// whether waiting for Tor is meaningful. It previously waited unconditionally, -/// so with Tor switched off — and `TorManager` therefore shut down — every -/// refresh spent the full bootstrap timeout and the directory froze on its -/// cached copy. -struct TorPreferenceReadTests { - private func makeDefaults() -> UserDefaults { - UserDefaults(suiteName: "bitchat.tests.tor.\(UUID().uuidString)")! - } - - @Test func defaultsToOnWhenNothingHasBeenStored() { - // Fail safe: an unwritten preference must not read as "Tor off", which - // would let a fetch go direct. - #expect(NetworkActivationService.persistedTorPreference(in: makeDefaults())) - } - - @Test func reflectsTheStoredPreference() { - let defaults = makeDefaults() - - defaults.set(false, forKey: NetworkActivationService.torPreferenceKey) - #expect(!NetworkActivationService.persistedTorPreference(in: defaults)) - - defaults.set(true, forKey: NetworkActivationService.torPreferenceKey) - #expect(NetworkActivationService.persistedTorPreference(in: defaults)) - } - - @Test func nonBooleanStoredValueReadsAsOn() { - let defaults = makeDefaults() - defaults.set("nonsense", forKey: NetworkActivationService.torPreferenceKey) - - // Same fail-safe direction: anything unrecognized means keep using Tor. - #expect(NetworkActivationService.persistedTorPreference(in: defaults)) - } -} diff --git a/bitchatTests/Services/TransferProgressManagerTests.swift b/bitchatTests/Services/TransferProgressManagerTests.swift index 9023c7f5..9ed6374a 100644 --- a/bitchatTests/Services/TransferProgressManagerTests.swift +++ b/bitchatTests/Services/TransferProgressManagerTests.swift @@ -24,7 +24,7 @@ struct TransferProgressManagerTests { let didReceive = await TestHelpers.waitUntil({ recorder.values == ["started:\(transferID):3"] - }, timeout: 5.0) + }, timeout: 0.5) #expect(didReceive) #expect(recorder.values == ["started:\(transferID):3"]) @@ -49,7 +49,7 @@ struct TransferProgressManagerTests { recorder.append("updated:\(id):\(sent):\(total)") case .completed(let id, let total): recorder.append("completed:\(id):\(total)") - case .cancelled, .rejected: + case .cancelled: break } } @@ -59,7 +59,7 @@ struct TransferProgressManagerTests { let didReceive = await TestHelpers.waitUntil({ recorder.values.count == 3 - }, timeout: 5.0) + }, timeout: 0.5) #expect(didReceive) #expect(recorder.values == [ @@ -85,7 +85,7 @@ struct TransferProgressManagerTests { recorder.append("started:\(id):\(total)") case .cancelled(let id, let sent, let total): recorder.append("cancelled:\(id):\(sent):\(total)") - case .updated, .completed, .rejected: + case .updated, .completed: break } } @@ -97,7 +97,7 @@ struct TransferProgressManagerTests { let didReceive = await TestHelpers.waitUntil({ recorder.values.contains("started:\(transferID):4") && recorder.values.contains("cancelled:\(transferID):1:4") - }, timeout: 5.0) + }, timeout: 0.5) #expect(didReceive) #expect(recorder.values.contains("started:\(transferID):4")) @@ -105,28 +105,6 @@ struct TransferProgressManagerTests { #expect(manager.snapshot(id: transferID) == nil) _ = cancellable } - - @Test("Preflight policy rejection publishes a visible failure reason") - @MainActor - func rejectBeforeStartPublishesReason() async { - let manager = TransferProgressManager() - let transferID = "transfer-visible-reject" - let recorder = EventRecorder() - let cancellable = manager.publisher.sink { event in - if case .rejected(let id, let reason) = event { - recorder.append("rejected:\(id):\(reason)") - } - } - - manager.rejectBeforeStart(id: transferID, reason: "upgrade required") - - let didReceive = await TestHelpers.waitUntil({ - recorder.values == ["rejected:\(transferID):upgrade required"] - }, timeout: 5.0) - #expect(didReceive) - #expect(manager.snapshot(id: transferID) == nil) - _ = cancellable - } } private final class EventRecorder: @unchecked Sendable { diff --git a/bitchatTests/Services/UnifiedNoticesTests.swift b/bitchatTests/Services/UnifiedNoticesTests.swift deleted file mode 100644 index 3dd369b4..00000000 --- a/bitchatTests/Services/UnifiedNoticesTests.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// UnifiedNoticesTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -@testable import bitchat - -struct UnifiedNoticesTests { - - private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) - private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } - - private func makePost( - content: String, - nickname: String = "alice", - createdAt: UInt64? = nil, - urgent: Bool = false - ) -> BoardPostPacket { - BoardPostPacket( - postID: Data((0..<16).map { _ in UInt8.random(in: 0...255) }), - geohash: "9q8yy", - content: content, - authorSigningKey: Data(repeating: 1, count: 32), - authorNickname: nickname, - createdAt: createdAt ?? baseMs, - expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000, - flags: urgent ? BoardPostPacket.urgentFlag : 0, - signature: Data(repeating: 2, count: 64) - ) - } - - private func makeNote( - content: String, - nickname: String? = "alice", - createdAt: Date? = nil, - geohash: String = "9q8yy" - ) -> LocationNotesManager.Note { - LocationNotesManager.Note( - id: UUID().uuidString, - pubkey: "ab" + UUID().uuidString.replacingOccurrences(of: "-", with: ""), - content: content, - createdAt: createdAt ?? baseDate, - nickname: nickname, - geohash: geohash - ) - } - - @Test - func merge_dropsBridgedCopyOfBoardPost() { - let post = makePost(content: "free couch on 5th") - let bridged = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30)) - - let merged = UnifiedNotices.merge(posts: [post], notes: [bridged]) - - #expect(merged.count == 1) - #expect(merged[0].isBoardPost) - } - - @Test - func merge_keepsNoteWithSameContentOutsideWindow() { - let post = makePost(content: "water station here") - let oldNote = makeNote( - content: "water station here", - createdAt: baseDate.addingTimeInterval(-UnifiedNotices.bridgeDedupeWindow - 60) - ) - - let merged = UnifiedNotices.merge(posts: [post], notes: [oldNote]) - - #expect(merged.count == 2) - } - - @Test - func merge_keepsSameTextNoteFromNeighborCell() { - // The notes subscription covers the center cell plus 8 neighbors; a - // matching note posted to a *neighbor* is not the bridged copy. - let post = makePost(content: "free couch on 5th") - let neighborNote = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30), geohash: "9q8yz") - - let merged = UnifiedNotices.merge(posts: [post], notes: [neighborNote]) - - #expect(merged.count == 2) - } - - @Test - func merge_keepsNoteFromDifferentAuthor() { - let post = makePost(content: "meetup at 6", nickname: "alice") - let note = makeNote(content: "meetup at 6", nickname: "bob") - - let merged = UnifiedNotices.merge(posts: [post], notes: [note]) - - #expect(merged.count == 2) - } - - @Test - func merge_sortsUrgentFirstThenNewest() { - let urgent = makePost(content: "road closed", createdAt: baseMs - 60_000, urgent: true) - let newerPost = makePost(content: "later post", createdAt: baseMs) - let note = makeNote(content: "a note", nickname: "carol", createdAt: baseDate.addingTimeInterval(30)) - - let merged = UnifiedNotices.merge(posts: [newerPost, urgent], notes: [note]) - - #expect(merged.map(\.content) == ["road closed", "a note", "later post"]) - #expect(merged[0].isUrgent) - } - - @Test - func merge_anonNicknamesMatchForDedupe() { - // Bridged posts from an empty nickname arrive as anon notes with no - // "n" tag; they must still dedupe against the anon board copy. - let post = makePost(content: "hello", nickname: "") - let bridged = makeNote(content: "hello", nickname: nil) - - let merged = UnifiedNotices.merge(posts: [post], notes: [bridged]) - - #expect(merged.count == 1) - #expect(merged[0].isBoardPost) - #expect(merged[0].author == "anon") - } - - @Test - func noticeItem_normalizesNoteDisplayName() { - let note = LocationNotesManager.Note( - id: "e1", - pubkey: "deadbeef", - content: "hi", - createdAt: baseDate, - nickname: "dave", - geohash: "9q8yy" - ) - - let item = NoticeItem(note: note) - - #expect(item.author == "dave") - #expect(!item.isBoardPost) - #expect(!item.isUrgent) - } -} diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index 4bc6f775..c910af9b 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -41,160 +41,6 @@ struct UnifiedPeerServiceTests { #expect(service.isBlocked(peerID)) } - - @Test @MainActor - func setBlocked_persistsByFingerprintAndToggles() async { - let transport = MockTransport() - let identity = TestIdentityManager() - let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity) - - let peerID = PeerID(str: "00000000000000EE") - let fingerprint = "fp-target" - transport.peerFingerprints[peerID] = fingerprint - - // Blocking resolves and persists by the peer's fingerprint, and - // scrubs the peer's carried public messages from the gossip archive - // while the fingerprint↔peerID mapping is still known (the - // archived-echo seed filter can't resolve offline strangers). - let resolved = service.setBlocked(peerID, blocked: true) - #expect(resolved == fingerprint) - #expect(identity.isBlocked(fingerprint: fingerprint)) - #expect(service.isBlocked(peerID)) - #expect(transport.purgedArchivePeers == [peerID]) - - // Unblocking clears it against the same identity, without purging. - let unresolved = service.setBlocked(peerID, blocked: false) - #expect(unresolved == fingerprint) - #expect(!identity.isBlocked(fingerprint: fingerprint)) - #expect(!service.isBlocked(peerID)) - #expect(transport.purgedArchivePeers == [peerID]) - } - - // MARK: - Offline-favorite dedup (updatePeers phase 2) - - /// A mutual favorite that is also on the mesh must collapse to a single - /// row keyed by the short mesh ID — even when the announced nickname no - /// longer matches the one stored with the favorite. - @Test @MainActor - func updatePeers_mutualFavoriteOnMeshYieldsSingleRow() async { - let favoritesService = FavoritesPersistenceService.shared - - let transport = MockTransport() - let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager()) - - let noiseKey = Data(repeating: 0xAB, count: 32) - favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "alice") - favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) - defer { - favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) - favoritesService.removeFavorite(peerNoisePublicKey: noiseKey) - } - - let meshID = PeerID(publicKey: noiseKey) - let snapshots = [TransportPeerSnapshot( - peerID: meshID, - nickname: "alice-renamed", - isConnected: true, - noisePublicKey: noiseKey, - lastSeen: Date() - )] - transport.updatePeerSnapshots(snapshots) - service.didUpdatePeerSnapshots(snapshots) - - let rows = service.peers.filter { $0.noisePublicKey == noiseKey } - #expect(rows.count == 1) - #expect(rows.first?.peerID == meshID) - #expect(rows.first?.isMutualFavorite == true) - #expect(service.favorites.filter { $0.noisePublicKey == noiseKey }.count == 1) - } - - /// Same collapse must hold for a reachable-but-not-connected favorite - /// (relayed peers linger as "reachable" after their link drops). - @Test @MainActor - func updatePeers_reachableMutualFavoriteYieldsSingleRow() async { - let favoritesService = FavoritesPersistenceService.shared - - let transport = MockTransport() - let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager()) - - let noiseKey = Data(repeating: 0xCD, count: 32) - favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "bob") - favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) - defer { - favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) - favoritesService.removeFavorite(peerNoisePublicKey: noiseKey) - } - - let otherKey = Data(repeating: 0x11, count: 32) - let snapshots = [ - // A live link is required for anyone to count as reachable. - TransportPeerSnapshot( - peerID: PeerID(publicKey: otherKey), - nickname: "carol", - isConnected: true, - noisePublicKey: otherKey, - lastSeen: Date() - ), - TransportPeerSnapshot( - peerID: PeerID(publicKey: noiseKey), - nickname: "bob", - isConnected: false, - noisePublicKey: noiseKey, - lastSeen: Date() - ) - ] - transport.updatePeerSnapshots(snapshots) - service.didUpdatePeerSnapshots(snapshots) - - let bobRows = service.peers.filter { $0.noisePublicKey == noiseKey } - #expect(bobRows.count == 1) - #expect(bobRows.first?.peerID == PeerID(publicKey: noiseKey)) - #expect(bobRows.first?.isReachable == true) - } - - /// A mutual favorite with no mesh presence still gets its offline row, - /// keyed by the full noise-key PeerID. - @Test @MainActor - func updatePeers_offlineMutualFavoriteGetsOfflineRow() async { - let favoritesService = FavoritesPersistenceService.shared - - let transport = MockTransport() - let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager()) - - let noiseKey = Data(repeating: 0xEF, count: 32) - favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "dave") - favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) - defer { - favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) - favoritesService.removeFavorite(peerNoisePublicKey: noiseKey) - } - - transport.updatePeerSnapshots([]) - service.didUpdatePeerSnapshots([]) - - let rows = service.peers.filter { $0.noisePublicKey == noiseKey } - #expect(rows.count == 1) - #expect(rows.first?.peerID == PeerID(hexData: noiseKey)) - #expect(rows.first?.isMutualFavorite == true) - } - - @Test @MainActor - func setBlocked_unknownIdentityReturnsNil() async { - let transport = MockTransport() - let identity = TestIdentityManager() - let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity) - - // No fingerprint resolvable for this peer (offline & unknown). - let peerID = PeerID(str: "00000000000000FF") - - #expect(service.setBlocked(peerID, blocked: true) == nil) - #expect(!service.isBlocked(peerID)) - } } private final class TestIdentityManager: SecureIdentityStateManagerProtocol { @@ -219,6 +65,18 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { socialIdentities[identity.fingerprint] = identity } + func getFavorites() -> Set { + favorites + } + + func setFavorite(_ fingerprint: String, isFavorite: Bool) { + if isFavorite { + favorites.insert(fingerprint) + } else { + favorites.remove(fingerprint) + } + } + func isFavorite(fingerprint: String) -> Bool { favorites.contains(fingerprint) } @@ -259,6 +117,8 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} + func updateHandshakeState(peerID: PeerID, state: HandshakeState) {} + func clearAllIdentityData() { socialIdentities.removeAll() favorites.removeAll() @@ -266,11 +126,6 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { verified.removeAll() } - func markPrivateMediaCapable(fingerprint: String) {} - func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false } - func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {} - func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { nil } - func removeEphemeralSession(peerID: PeerID) {} func setVerified(fingerprint: String, verified: Bool) { @@ -288,33 +143,4 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { func getVerifiedFingerprints() -> Set { verified } - - // MARK: Vouching (unused by these tests) - - @discardableResult - func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { - false - } - - func validVouchers(for fingerprint: String) -> [VouchRecord] { - [] - } - - func isVouched(fingerprint: String) -> Bool { - false - } - - func lastVouchBatchSent(to fingerprint: String) -> Date? { - nil - } - - func markVouchBatchSent(to fingerprint: String, at date: Date) {} - - func signingPublicKey(forFingerprint fingerprint: String) -> Data? { - nil - } - - func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { - [] - } } diff --git a/bitchatTests/Services/VerificationServiceTests.swift b/bitchatTests/Services/VerificationServiceTests.swift index ce854a58..575b1781 100644 --- a/bitchatTests/Services/VerificationServiceTests.swift +++ b/bitchatTests/Services/VerificationServiceTests.swift @@ -39,19 +39,6 @@ final class VerificationServiceTests: XCTestCase { XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60)) } - func test_verifyScannedQR_rejectsFutureDatedPayload() throws { - let (service, noise) = makeService() - let futureTimestamp = Int64(Date().addingTimeInterval(3600).timeIntervalSince1970) - let qrString = try makeSignedQR( - noise: noise, - nickname: "future-\(UUID().uuidString)", - npub: nil, - ts: futureTimestamp - ) - - XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60)) - } - func test_verifyScannedQR_rejectsTamperedSignature() throws { let (service, noise) = makeService() let badSignature = Data(repeating: 0xAA, count: 64) @@ -111,13 +98,10 @@ final class VerificationServiceTests: XCTestCase { } private func makeService() -> (VerificationService, NoiseEncryptionService) { - // The service consumes Noise identity operations through the - // Transport's narrow noise* wrappers; the mock transport's backing - // encryption service is returned for direct assertions. - let transport = MockTransport() + let noise = NoiseEncryptionService(keychain: MockKeychain()) let service = VerificationService() - service.configure(with: transport) - return (service, transport.mockNoiseService) + service.configure(with: noise) + return (service, noise) } private func makeSignedQR( diff --git a/bitchatTests/SharedContentHandoffTests.swift b/bitchatTests/SharedContentHandoffTests.swift deleted file mode 100644 index ff205ad6..00000000 --- a/bitchatTests/SharedContentHandoffTests.swift +++ /dev/null @@ -1,173 +0,0 @@ -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -@Suite("Share extension handoff", .serialized) -struct SharedContentHandoffTests { - private func makeStore() -> (suite: String, defaults: UserDefaults, store: SharedContentStore) { - let suite = "SharedContentHandoffTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - return (suite, defaults, SharedContentStore(defaults: defaults)) - } - - @Test("A staged share survives an inactive app and a late open") - func stagedShareSurvivesLateOpen() throws { - let context = makeStore() - defer { context.defaults.removePersistentDomain(forName: context.suite) } - let stagedAt = Date(timeIntervalSince1970: 1_000_000) - let payload = SharedContentPayload.text("review me later", createdAt: stagedAt) - - try context.store.stage(payload, now: stagedAt) - - #expect(context.store.pending(now: stagedAt.addingTimeInterval(60 * 60)) == payload) - #expect(context.defaults.data(forKey: SharedContentStore.storageKey) != nil) - } - - @Test("Malformed, oversized, unsupported, and expired payloads are rejected and cleared") - func invalidPayloadsAreRejectedAndCleared() throws { - let context = makeStore() - defer { context.defaults.removePersistentDomain(forName: context.suite) } - let now = Date(timeIntervalSince1970: 2_000_000) - - context.defaults.set(Data("not-json".utf8), forKey: SharedContentStore.storageKey) - #expect(context.store.pending(now: now) == nil) - #expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil) - - context.defaults.set( - Data(repeating: 0x41, count: SharedContentPayload.maxEnvelopeBytes + 1), - forKey: SharedContentStore.storageKey - ) - #expect(context.store.pending(now: now) == nil) - #expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil) - - let oversized = SharedContentPayload.text( - String(repeating: "x", count: SharedContentPayload.maxContentBytes + 1), - createdAt: now - ) - #expect(throws: SharedContentHandoffError.contentTooLarge) { - try context.store.stage(oversized, now: now) - } - #expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil) - - let unsupportedURL = SharedContentPayload( - kind: .url, - content: "file:///private/tmp/secret.txt", - createdAt: now - ) - #expect(throws: SharedContentHandoffError.unsupportedURL) { - try context.store.stage(unsupportedURL, now: now) - } - - let misleadingControl = SharedContentPayload.text("safe\u{202E}txt", createdAt: now) - #expect(throws: SharedContentHandoffError.invalidCharacters) { - try context.store.stage(misleadingControl, now: now) - } - - let expired = SharedContentPayload.text( - "too old", - createdAt: now.addingTimeInterval(-SharedContentPayload.retentionSeconds - 1) - ) - context.defaults.set(try JSONEncoder().encode(expired), forKey: SharedContentStore.storageKey) - #expect(context.store.pending(now: now) == nil) - #expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil) - } - - @Test("Mesh, geohash, and stale private selections resolve to explicit destinations") - func destinationsAreExplicit() { - let geohashChannel = ChannelID.location( - GeohashChannel(level: .city, geohash: "9Q8YY") - ) - let stalePeer = PeerID(str: "0011223344556677") - - #expect(SharedContentDestination.resolve( - selectedPrivatePeerID: nil, - privateDisplayName: nil, - activeChannel: .mesh - ) == .mesh) - #expect(SharedContentDestination.resolve( - selectedPrivatePeerID: nil, - privateDisplayName: nil, - activeChannel: geohashChannel - ) == .geohash("9q8yy")) - #expect(SharedContentDestination.resolve( - selectedPrivatePeerID: stalePeer, - privateDisplayName: "alice", - activeChannel: geohashChannel - ) == .privateConversation(peerID: stalePeer, displayName: "alice")) - } - - @Test("A destination change requires a new confirmation and never consumes on the stale tap") - @MainActor - func staleDestinationCannotBeConfirmed() throws { - let context = makeStore() - defer { context.defaults.removePersistentDomain(forName: context.suite) } - let now = Date(timeIntervalSince1970: 3_000_000) - let payload = SharedContentPayload.text("do not auto-send", createdAt: now) - let peer = PeerID(str: "8899aabbccddeeff") - let privateDestination = SharedContentDestination.privateConversation( - peerID: peer, - displayName: "alice" - ) - let model = SharedContentImportModel(store: context.store) - try context.store.stage(payload, now: now) - model.refresh(destination: privateDestination, now: now) - - #expect(model.confirm(destination: .mesh, now: now) == nil) - #expect(model.offer?.destination == .mesh) - #expect(context.store.pending(now: now) == payload) - - #expect(model.confirm(destination: .mesh, now: now) == payload.content) - #expect(model.offer == nil) - #expect(context.store.pending(now: now) == nil) - } - - @Test("Confirmation consumes once and cancellation explicitly clears without producing composer text") - @MainActor - func oneTimeConfirmationAndCancellation() throws { - let context = makeStore() - defer { context.defaults.removePersistentDomain(forName: context.suite) } - let now = Date(timeIntervalSince1970: 4_000_000) - let model = SharedContentImportModel(store: context.store) - - let first = SharedContentPayload.text("confirmed", createdAt: now) - try context.store.stage(first, now: now) - model.refresh(destination: .geohash("u4pruy"), now: now) - #expect(model.confirm(destination: .geohash("u4pruy"), now: now) == "confirmed") - #expect(model.confirm(destination: .geohash("u4pruy"), now: now) == nil) - - let second = SharedContentPayload.text("cancelled", createdAt: now) - try context.store.stage(second, now: now) - model.refresh(destination: .mesh, now: now) - model.cancel(destination: .mesh, now: now) - #expect(model.offer == nil) - #expect(context.store.pending(now: now) == nil) - - let third = SharedContentPayload.text("panic-wiped", createdAt: now) - try context.store.stage(third, now: now) - model.refresh(destination: .mesh, now: now) - model.discardAll() - #expect(model.offer == nil) - #expect(context.store.pending(now: now) == nil) - } - - @Test("Cancelling an old review never deletes a newer staged share") - @MainActor - func cancellationPreservesNewerShare() throws { - let context = makeStore() - defer { context.defaults.removePersistentDomain(forName: context.suite) } - let now = Date(timeIntervalSince1970: 5_000_000) - let model = SharedContentImportModel(store: context.store) - let old = SharedContentPayload.text("old", createdAt: now) - let newer = SharedContentPayload.text("new", createdAt: now) - - try context.store.stage(old, now: now) - model.refresh(destination: .mesh, now: now) - try context.store.stage(newer, now: now) - model.cancel(destination: .mesh, now: now) - - #expect(model.offer?.payload == newer) - #expect(context.store.pending(now: now) == newer) - } -} diff --git a/bitchatTests/Simulation/SimulatedMesh.swift b/bitchatTests/Simulation/SimulatedMesh.swift deleted file mode 100644 index 6caf379d..00000000 --- a/bitchatTests/Simulation/SimulatedMesh.swift +++ /dev/null @@ -1,216 +0,0 @@ -import BitFoundation -import Foundation -@testable import bitchat - -/// A deterministic multi-node mesh over real `BLEService` engines and no -/// CoreBluetooth: nodes are wired edge-to-edge through the outbound packet -/// tap and the production ingress-attribution path (`_test_ingestFrame`), -/// so announces bind links, signatures verify, Noise handshakes complete, -/// and rotation rebinds run exactly the engine code a radio would drive. -/// -/// Determinism model: outbound packets are buffered under a lock (the tap -/// fires on each sender's engine); the test thread pumps deliveries and -/// fences every engine between rounds. Timer-driven work (relay jitter, -/// deferred flushes) is released explicitly through each node's -/// `BLEEngineManualScheduler` via `advanceTime`. -/// -/// Fidelity boundary: there are no physical links, so per-link fanout -/// planning always reports failure to the sender (directed packets spool) -/// — every capture happens at the pre-planning tap. Protocol-level -/// behavior (attribution, binding, dedup, TTL, relay decisions, sessions) -/// is faithful; link-selection and backpressure behavior is not exercised. -final class SimulatedMesh { - struct Node { - let service: BLEService - let scheduler: BLEEngineManualScheduler - } - - private let lock = NSLock() - private var pendingDeliveries: [(from: Int, packet: BitchatPacket)] = [] - /// Total (packet, receiving-node) deliveries pumped — the storm bound. - private(set) var deliveredFrameCount = 0 - - private(set) var nodes: [Node] = [] - private var neighbors: [Set] = [] - private var duplicateLinkEdges: Set = [] - private var emitted: [[BitchatPacket]] = [] - - /// Every packet a node has put on the wire — the attacker's capture - /// buffer for replay tests. - func emittedPackets(from index: Int) -> [BitchatPacket] { - lock.lock() - defer { lock.unlock() } - return emitted[index] - } - - @discardableResult - func addNode(nickname: String) -> Node { - let keychain = MockKeychain() - let identityManager = MockIdentityManager(keychain) - let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - let scheduler = BLEEngineManualScheduler() - let service = BLEService( - keychain: keychain, - idBridge: idBridge, - identityManager: identityManager, - initializeBluetoothManagers: false, - engineScheduler: scheduler - ) - let index = nodes.count - let node = Node(service: service, scheduler: scheduler) - // An earlier node's engine can fire its tap (which reads `emitted` - // under the lock) while this append reallocates the array. - lock.lock() - nodes.append(node) - neighbors.append([]) - emitted.append([]) - lock.unlock() - // The tap must be live before `setNickname` below: setNickname - // force-announces asynchronously on the engine, and if that slot - // ran in the gap before a later tap install, the announce was - // emitted invisibly while still stamping the wall-clock announce - // throttle — swallowing `announceAll`'s forced announce on a - // starved runner (the CI flake this ordering fixes). - service._test_onOutboundPacket = { [weak self] packet in - // Runs on the sender's engine; only buffer here — delivering - // inline would nest one engine inside another. - guard let self else { return } - self.lock.lock() - self.pendingDeliveries.append((from: index, packet: packet)) - self.emitted[index].append(packet) - self.lock.unlock() - } - service.setNickname(nickname) - return node - } - - func connect(_ a: Int, _ b: Int) { - neighbors[a].insert(b) - neighbors[b].insert(a) - } - - /// Radio silence: stops delivering between two nodes without reporting - /// any link event, so existing bindings persist exactly as they do when - /// a peer walks out of range before its link times out. Lets a test - /// capture a packet the far side never received. - func silence(_ a: Int, _ b: Int) { - neighbors[a].remove(b) - neighbors[b].remove(a) - } - - /// Models two live links to the same phone (issue #1538): every frame - /// from the neighbour arrives twice, on two link IDs that both bind to - /// the sender. - /// - /// Both are central links — the remote's connections to our peripheral - /// role. That is deliberate and faithful to the defect: central links - /// are the ones we cannot cancel (they belong to the remote), so they - /// are exactly the links the peripheral-cancel path cannot reach after - /// a rotation. Peripheral-role bindings additionally require physical - /// link state keyed by a real CBPeripheral, which no CB-free harness - /// can fabricate. - func connectDuplicateLinks(_ a: Int, _ b: Int) { - connect(a, b) - duplicateLinkEdges.insert(Self.edgeKey(a, b)) - } - - /// The synthetic central link a frame from `sender` arrives on at - /// `receiver`. Stable per directed edge, like a CoreBluetooth central - /// UUID. - func linkUUID(from sender: Int, at receiver: Int) -> String { - "SIM-\(sender)-TO-\(receiver)" - } - - /// Order-independent edge key. - private static func edgeKey(_ a: Int, _ b: Int) -> String { - "\(min(a, b))-\(max(a, b))" - } - - /// The second link of a duplicate-link edge. - func duplicateLinkUUID(from sender: Int, at receiver: Int) -> String { - "SIM-DUP-\(sender)-TO-\(receiver)" - } - - private func links(from sender: Int, at receiver: Int) -> [BLEIngressLinkID] { - var links: [BLEIngressLinkID] = [.central(linkUUID(from: sender, at: receiver))] - if duplicateLinkEdges.contains(Self.edgeKey(sender, receiver)) { - links.append(.central(duplicateLinkUUID(from: sender, at: receiver))) - } - return links - } - - func forceAnnounce(from index: Int) { - nodes[index].service._test_forceAnnounce() - pump() - } - - /// Pumps buffered deliveries until the mesh is quiescent: no pending - /// frames and every engine drained. Timer-deferred work stays pending - /// until `advanceTime`. - func pump(maxRounds: Int = 64) { - for _ in 0.. Bool) { - for _ in 0.. Int { - lock.lock() - defer { lock.unlock() } - return publicMessages.filter { $0 == content }.count - } - - func drainedPublicMessageCount(content: String, drains: Int = 50) async -> Int { - for _ in 0.. 0 { break } - await MainActor.run {} - } - return count(content: content) - } -} diff --git a/bitchatTests/Sync/GossipSyncBoardTests.swift b/bitchatTests/Sync/GossipSyncBoardTests.swift deleted file mode 100644 index 6e5b5357..00000000 --- a/bitchatTests/Sync/GossipSyncBoardTests.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// GossipSyncBoardTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -/// Board posts ride gossip sync through a provider that queries the board -/// store, so retention (expiry, tombstones, caps) has a single owner. -struct GossipSyncBoardTests { - - private let myPeerID = PeerID(str: "0102030405060708") - - private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket { - BitchatPacket( - type: MessageType.boardPost.rawValue, - senderID: try #require(Data(hexString: "aabbccddeeff0011")), - recipientID: nil, - timestamp: timestamp, - payload: Data([0x42]), - signature: nil, - ttl: 7 - ) - } - - private func quietConfig() -> GossipSyncManager.Config { - var config = GossipSyncManager.Config() - config.messageSyncIntervalSeconds = 0 - config.fragmentSyncIntervalSeconds = 0 - config.fileTransferSyncIntervalSeconds = 0 - config.prekeyBundleSyncIntervalSeconds = 0 - return config - } - - @Test func boardRequestIsServedFromProvider() async throws { - let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager()) - let delegate = RecordingBoardDelegate() - manager.delegate = delegate - let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000)) - manager.boardPacketsProvider = { return [boardPacket] } - - let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) - manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) - - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) - let sent = try #require(delegate.packets.first) - #expect(sent.type == MessageType.boardPost.rawValue) - #expect(sent.isRSR) - } - - @Test func nonBoardRequestDoesNotServeBoardPackets() async throws { - let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager()) - let delegate = RecordingBoardDelegate() - manager.delegate = delegate - let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000)) - manager.boardPacketsProvider = { return [boardPacket] } - - let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages) - manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) - - // Follow with a board request; only its response should arrive, which - // also proves the first request produced nothing. - let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) - manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest) - - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) - #expect(delegate.packets.count == 1) - #expect(delegate.packets.first?.type == MessageType.boardPost.rawValue) - } - - @Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws { - var config = quietConfig() - config.boardSyncIntervalSeconds = 1 - - // Without a provider the board schedule stays silent. - let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) - let unwiredDelegate = RecordingBoardDelegate() - unwired.delegate = unwiredDelegate - unwired._performMaintenanceSynchronously(now: Date()) - #expect(unwiredDelegate.packets.isEmpty) - - // With a provider, maintenance sends a board-typed request. - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) - let delegate = RecordingBoardDelegate() - manager.delegate = delegate - manager.boardPacketsProvider = { return [] } - manager._performMaintenanceSynchronously(now: Date()) - - #expect(delegate.packets.count == 1) - let payload = try #require(delegate.packets.first?.payload) - let request = try #require(RequestSyncPacket.decode(from: payload)) - #expect(request.types == .board) - } -} - -private final class RecordingBoardDelegate: GossipSyncManager.Delegate { - private let lock = NSLock() - private var _packets: [BitchatPacket] = [] - - var packets: [BitchatPacket] { - lock.lock() - defer { lock.unlock() } - return _packets - } - - func sendPacket(_ packet: BitchatPacket) { - lock.lock() - _packets.append(packet) - lock.unlock() - } - - func sendPacket(to peerID: PeerID, packet: BitchatPacket) { - lock.lock() - _packets.append(packet) - lock.unlock() - } - - func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { - packet - } - - func getConnectedPeers() -> [PeerID] { - [] - } -} diff --git a/bitchatTests/Sync/RequestSyncManagerTests.swift b/bitchatTests/Sync/RequestSyncManagerTests.swift index 497271e6..aada4bfc 100644 --- a/bitchatTests/Sync/RequestSyncManagerTests.swift +++ b/bitchatTests/Sync/RequestSyncManagerTests.swift @@ -63,7 +63,7 @@ final class RequestSyncManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = TestConstants.settleTimeout, + timeout: TimeInterval = 1.0, condition: @escaping () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift b/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift deleted file mode 100644 index d055bde6..00000000 --- a/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// RequestSyncPacketFragmentFilterTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -import BitFoundation -@testable import bitchat - -struct RequestSyncPacketFragmentFilterTests { - - @Test func fragmentIdFilterRoundTripsThroughWireEncoding() throws { - let id1 = try #require(Data(hexString: "00112233445566aa")) - let id2 = try #require(Data(hexString: "ffeeddccbbaa9988")) - let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([id1, id2])) - - let packet = RequestSyncPacket(p: 7, m: 128, data: Data([0x01]), types: .fragment, fragmentIdFilter: filter) - let decoded = try #require(RequestSyncPacket.decode(from: packet.encode())) - - #expect(decoded.fragmentIdFilter == filter) - let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(decoded.fragmentIdFilter)) - #expect(ids == Set([id1, id2])) - } - - @Test func encodeCapsFilterAtMaxCountWithinDecoderBudget() throws { - let ids = (0..<100).map { i -> Data in - var id = Data(repeating: 0, count: 8) - id[7] = UInt8(i) - return id - } - let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter(ids)) - - let tokens = filter.split(separator: ",") - #expect(tokens.count == RequestSyncPacket.maxFragmentIdFilterCount) - // 60 IDs * 17 bytes ("<16 hex>,") - 1 = 1019 ≤ the 1024-byte cap. - #expect(filter.utf8.count == 1019) - #expect(filter.utf8.count <= 1024) - } - - @Test func encodeDropsMalformedIDs() throws { - let good = try #require(Data(hexString: "0011223344556677")) - let short = Data([0x01, 0x02]) - let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([short, good])) - #expect(filter == good.hexEncodedString()) - #expect(RequestSyncPacket.encodeFragmentIdFilter([short]) == nil) - #expect(RequestSyncPacket.encodeFragmentIdFilter([]) == nil) - } - - @Test func decodeIgnoresMalformedTokens() throws { - let good = try #require(Data(hexString: "0011223344556677")) - let ids = try #require( - RequestSyncPacket.decodeFragmentIdFilter("zzzz,0011,0011223344556677,") - ) - #expect(ids == Set([good])) - #expect(RequestSyncPacket.decodeFragmentIdFilter(nil) == nil) - #expect(RequestSyncPacket.decodeFragmentIdFilter("not-hex") == nil) - } - - @Test func decoderIgnoresOversizedFilterValue() throws { - // Hand-roll a payload whose 0x06 TLV exceeds the acceptance cap; the - // request must still decode, with the filter dropped. - var payload = RequestSyncPacket(p: 7, m: 128, data: Data([0x01])).encode() - let oversized = Data(repeating: UInt8(ascii: "a"), count: 1025) - payload.append(0x06) - payload.append(UInt8((oversized.count >> 8) & 0xFF)) - payload.append(UInt8(oversized.count & 0xFF)) - payload.append(oversized) - - let decoded = try #require(RequestSyncPacket.decode(from: payload)) - #expect(decoded.fragmentIdFilter == nil) - } -} diff --git a/bitchatTests/Sync/SyncResponseRateLimiterTests.swift b/bitchatTests/Sync/SyncResponseRateLimiterTests.swift deleted file mode 100644 index 63f5329a..00000000 --- a/bitchatTests/Sync/SyncResponseRateLimiterTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation -import Testing -import BitFoundation -@testable import bitchat - -struct SyncResponseRateLimiterTests { - - private let peer = PeerID(str: "1122334455667788") - private let otherPeer = PeerID(str: "8899aabbccddeeff") - - @Test func allowsResponsesUpToBudgetThenBlocks() { - var limiter = SyncResponseRateLimiter(maxResponses: 2, window: 30) - let now = Date() - - let first = limiter.shouldRespond(to: peer, now: now) - let second = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(1)) - let third = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(2)) - - #expect(first) - #expect(second) - #expect(!third) - } - - @Test func budgetIsPerPeer() { - var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) - let now = Date() - - let first = limiter.shouldRespond(to: peer, now: now) - let repeated = limiter.shouldRespond(to: peer, now: now) - let other = limiter.shouldRespond(to: otherPeer, now: now) - - #expect(first) - #expect(!repeated) - #expect(other) - } - - @Test func allowsAgainAfterWindowSlides() { - var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) - let now = Date() - - let first = limiter.shouldRespond(to: peer, now: now) - let insideWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(29)) - let afterWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(31)) - - #expect(first) - #expect(!insideWindow) - #expect(afterWindow) - } - - @Test func pruneDropsExpiredHistory() { - var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) - let now = Date() - - let first = limiter.shouldRespond(to: peer, now: now) - limiter.prune(now: now.addingTimeInterval(31)) - let afterPrune = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(32)) - - #expect(first) - #expect(afterPrune) - } -} diff --git a/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift deleted file mode 100644 index 829bfd4d..00000000 --- a/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// SyncTypeFlagsBoardTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import Foundation -import Testing -@testable import bitchat - -/// The board sync flag is the first bit outside the original single byte of -/// type flags. These tests pin down the wire compatibility contract: the -/// types TLV has been a variable-length (1-8 byte) little-endian bitfield -/// since type-aware sync, so widening to two bytes must decode everywhere -/// and unknown bits must be ignored, not rejected. -struct SyncTypeFlagsBoardTests { - - @Test func boardFlagEncodesIntoSecondByte() throws { - let data = try #require(SyncTypeFlags.board.toData()) - // Little-endian: low byte first, board bit (bit 8) in byte 2. - #expect(data == Data([0x00, 0x01])) - } - - @Test func boardFlagRoundTrips() throws { - let flags = SyncTypeFlags(messageTypes: [.message, .boardPost]) - let data = try #require(flags.toData()) - let decoded = try #require(SyncTypeFlags.decode(data)) - #expect(decoded.contains(.message)) - #expect(decoded.contains(.boardPost)) - #expect(!decoded.contains(.fragment)) - #expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost])) - } - - /// An old decoder is modeled by bits it has no mapping for: the shared - /// decode path accepts the bytes and simply maps unknown bits to no - /// message type, so a board-only request reads as "nothing I can serve". - @Test func unknownBitsDecodeToNoTypes() throws { - // Bits 11-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle, - // bit 10 = groupMessage); a future (or unknown) two-byte bitfield must - // decode without error and yield no known types. - let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8]))) - #expect(decoded.toMessageTypes().isEmpty) - for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle, .groupMessage] { - #expect(!decoded.contains(type)) - } - } - - @Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws { - // Known low-byte flags survive alongside unknown high bits (11-15). - let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xF8]))) - #expect(decoded.contains(.announce)) - #expect(decoded.contains(.message)) - #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message])) - } - - @Test func requestSyncPacketRoundTripsBoardFlag() throws { - let request = RequestSyncPacket( - p: 4, - m: 128, - data: Data([0xAB, 0xCD]), - types: SyncTypeFlags(messageTypes: [.boardPost]) - ) - let decoded = try #require(RequestSyncPacket.decode(from: request.encode())) - let types = try #require(decoded.types) - #expect(types.contains(.boardPost)) - #expect(!types.contains(.message)) - } - - @Test func singleByteLegacyEncodingStillDecodes() throws { - // Requests from old clients keep the one-byte bitfield. - let decoded = try #require(SyncTypeFlags.decode(Data([0x03]))) - #expect(decoded.contains(.announce)) - #expect(decoded.contains(.message)) - #expect(!decoded.contains(.boardPost)) - } -} diff --git a/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift deleted file mode 100644 index 2bd9d53b..00000000 --- a/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// SyncTypeFlagsGroupTests.swift -// bitchat -// -// Wire-compat proof for the groupMessage sync bit (bit 10): the types -// bitfield widens from 1 to 2 bytes, and clients that don't know the bit -// simply ignore it. -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -import BitFoundation -@testable import bitchat - -struct SyncTypeFlagsGroupTests { - - @Test func groupMessageOccupiesBitTen() { - #expect(SyncTypeFlags.groupMessage.rawValue == 1 << 10) - #expect(SyncTypeFlags.groupMessage.contains(.groupMessage)) - #expect(!SyncTypeFlags.publicMessages.contains(.groupMessage)) - } - - @Test func extendedBitfieldWidensToTwoBytes() throws { - // Legacy flags fit one byte… - #expect(SyncTypeFlags.publicMessages.toData() == Data([0x03])) - - // …the group bit widens the little-endian encoding to two bytes. - let combined = SyncTypeFlags.publicMessages.union(.groupMessage) - let encoded = try #require(combined.toData()) - #expect(encoded == Data([0x03, 0x04])) - - let decoded = try #require(SyncTypeFlags.decode(encoded)) - #expect(decoded == combined) - #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message, .groupMessage])) - } - - @Test func unknownBitsAreIgnoredNotRejected() throws { - // An "old client" reading a 2-byte field keeps the raw bits but maps - // unknown bit indices to no message type — it answers with the types - // it knows instead of dropping the request. - let futuristic = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC]))) - #expect(Set(futuristic.toMessageTypes()) == Set([.announce, .message, .groupMessage])) - #expect(futuristic.contains(.announce)) - #expect(futuristic.contains(.message)) - } - - @Test func requestSyncPacketRoundTripsGroupFlag() throws { - let types = SyncTypeFlags.publicMessages.union(.groupMessage) - let packet = RequestSyncPacket(p: 8, m: 1024, data: Data([0xAB, 0xCD]), types: types) - let encoded = packet.encode() - - let decoded = try #require(RequestSyncPacket.decode(from: encoded)) - #expect(decoded.types == types) - #expect(decoded.types?.contains(.groupMessage) == true) - #expect(decoded.p == 8) - #expect(decoded.m == 1024) - #expect(decoded.data == Data([0xAB, 0xCD])) - } -} diff --git a/bitchatTests/Sync/SyncTypeFlagsTests.swift b/bitchatTests/Sync/SyncTypeFlagsTests.swift deleted file mode 100644 index 8c400119..00000000 --- a/bitchatTests/Sync/SyncTypeFlagsTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation -import Testing -import BitFoundation -@testable import bitchat - -struct SyncTypeFlagsTests { - - @Test func knownTypesRoundTripThroughData() throws { - let flags: SyncTypeFlags = [.announce, .message, .fragment, .fileTransfer] - let data = try #require(flags.toData()) - let decoded = try #require(SyncTypeFlags.decode(data)) - #expect(decoded == flags) - } - - @Test func decodeDropsPhantomBits() { - // Bits 11+ map to no message type (bit 8 = boardPost, bit 9 = - // prekeyBundle, bit 10 = groupMessage). They must not survive decode - // as phantom membership. - let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type - let decoded = SyncTypeFlags.decode(phantom) - #expect(decoded?.rawValue == 0) - #expect(decoded?.toMessageTypes().isEmpty == true) - } - - @Test func boardBitSurvivesDecode() { - // Bit 8 maps to boardPost and spills the field into a second byte; - // it must survive decode while the phantom high bits (11+) are - // stripped. Bits 9 (prekeyBundle) and 10 (groupMessage) are cleared - // to isolate the board bit. - let mixed = Data([0x00, 0xF9]) // bit 8 (board) known, bits 11..15 phantom - let decoded = SyncTypeFlags.decode(mixed) - #expect(decoded?.contains(.board) == true) - #expect(decoded?.rawValue == 0b1_0000_0000) - } - - @Test func phantomBitsAreStrippedButKnownBitsSurvive() { - // Low byte = announce(0) + message(1); high byte bits 11+ are phantom. - let mixed = Data([0b0000_0011, 0xF8]) - let decoded = SyncTypeFlags.decode(mixed) - #expect(decoded?.contains(.announce) == true) - #expect(decoded?.contains(.message) == true) - // Only the two known bits remain; phantom high bits are gone. - #expect(decoded?.rawValue == 0b0000_0011) - } - - @Test func rawValueInitNormalizesPhantomBits() { - let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF) - // Every known type bit is set; nothing above them survives. boardPost - // occupies bit 8, so the known set spills into a second byte. - #expect(flags.contains(.announce)) - #expect(flags.contains(.fileTransfer)) - #expect(flags.contains(.board)) - let data = flags.toData() - #expect(data?.count == 2) - } -} diff --git a/bitchatTests/TestUtilities/TestConstants.swift b/bitchatTests/TestUtilities/TestConstants.swift index fb0cb65c..25437d9f 100644 --- a/bitchatTests/TestUtilities/TestConstants.swift +++ b/bitchatTests/TestUtilities/TestConstants.swift @@ -11,59 +11,18 @@ import Foundation struct TestConstants { static let defaultTimeout: TimeInterval = 5.0 - /// For positive waits on work that hops through `Task.detached` or - /// background queues: those contend with every parallel test worker for - /// the global executor, so a loaded CI runner can exceed - /// `defaultTimeout`. `waitUntil` returns as soon as the condition holds, - /// so passing runs never pay the longer timeout. + static let shortTimeout: TimeInterval = 1.0 static let longTimeout: TimeInterval = 10.0 - - /// **Default deadline for any "wait until this async thing settles" helper.** - /// - /// Four separate tests flaked on CI during July 2026 with the same root - /// cause, and it is worth stating the rule rather than re-learning it a - /// fifth time: *a wait deadline is not a latency budget.* It exists so a - /// genuine hang eventually fails the suite. Size it for the worst-case - /// scheduler, never for how long the operation "should" take. - /// - /// A CI runner executes many suites at once. Work behind `@MainActor`, - /// `Task.detached(priority: .utility)`, or a `DispatchQueue.asyncAfter` can - /// be starved for seconds — one observed run took 3.75 s for a 1 s - /// operation. Deadlines sized to the operation (the old 1 s defaults) turn - /// that starvation into a red build that reads like a product bug. - /// - /// This costs nothing when tests pass, because every helper returns as soon - /// as its condition holds. It only extends the genuine-failure case. - /// - /// `TestTimingHygieneTests` enforces that wait helpers default to at least - /// `minimumSettleTimeout`. - static let settleTimeout: TimeInterval = 30.0 - - /// Floor enforced by `TestTimingHygieneTests`. Anything below this is a - /// latency assumption in disguise. - static let minimumSettleTimeout: TimeInterval = 10.0 - - /// For waits whose **expected outcome is `false`** — "prove this does not - /// happen". - /// - /// The floor above is wrong for these, and inverted: a negative wait always - /// runs its deadline out, so `settleTimeout` would spend 30 s per case - /// proving nothing extra. Starvation cannot cause a false failure here - /// either — a starved runner only makes the thing *less* likely to happen, - /// so the assertion still holds. Short is correct, and naming it says the - /// polarity out loud instead of leaving a bare literal that reads like the - /// mistake this file exists to prevent. - /// - /// `TestTimingHygieneTests` accepts this by name. Using it for a wait you - /// expect to succeed reintroduces exactly the flake class it sits next to. - static let negativeWaitWindow: TimeInterval = 1.0 - - + static let testNickname1 = "Alice" static let testNickname2 = "Bob" static let testNickname3 = "Charlie" static let testNickname4 = "David" static let testMessage1 = "Hello, World!" + static let testMessage2 = "How are you?" + static let testMessage3 = "This is a test message" static let testLongMessage = String(repeating: "This is a long message. ", count: 100) + + static let testSignature = Data(repeating: 0xAB, count: 64) } diff --git a/bitchatTests/TestUtilities/TestHelpers.swift b/bitchatTests/TestUtilities/TestHelpers.swift index 45b432cb..c76f57af 100644 --- a/bitchatTests/TestUtilities/TestHelpers.swift +++ b/bitchatTests/TestUtilities/TestHelpers.swift @@ -12,7 +12,20 @@ import BitFoundation @testable import bitchat final class TestHelpers { - + + // MARK: - Key Generation + + static func generateTestKeyPair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) { + let privateKey = Curve25519.KeyAgreement.PrivateKey() + let publicKey = privateKey.publicKey + return (privateKey, publicKey) + } + + static func generateTestIdentity(peerID: String, nickname: String) -> (peerID: String, nickname: String, privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) { + let (privateKey, publicKey) = generateTestKeyPair() + return (peerID: peerID, nickname: nickname, privateKey: privateKey, publicKey: publicKey) + } + // MARK: - Message Creation static func createTestMessage( @@ -41,13 +54,13 @@ final class TestHelpers { type: UInt8 = 0x01, senderID: PeerID = PeerID(str: UUID().uuidString), recipientID: PeerID? = nil, - payload: Data = Data("test payload".utf8), + payload: Data = "test payload".data(using: .utf8)!, signature: Data? = nil, ttl: UInt8 = 3 ) -> BitchatPacket { return BitchatPacket( type: type, - senderID: Data(senderID.id.utf8), + senderID: senderID.id.data(using: .utf8)!, recipientID: recipientID?.id.data(using: .utf8), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, @@ -65,7 +78,11 @@ final class TestHelpers { } return data } - + + static func generateTestPeerID() -> String { + return "PEER" + UUID().uuidString.prefix(8) + } + // MARK: - Async Helpers static func waitFor(_ condition: @escaping () -> Bool, timeout: TimeInterval = TestConstants.defaultTimeout) async throws { @@ -93,38 +110,32 @@ final class TestHelpers { } return true } + + static func expectAsync( + timeout: TimeInterval = TestConstants.defaultTimeout, + operation: @escaping () async throws -> T + ) async throws -> T { + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + return try await operation() + } + + group.addTask { + try await sleep(1) + throw TestError.timeout + } + + let result = try await group.next()! + group.cancelAll() + return result + } + } } enum TestError: Error { case timeout -} - -// MARK: - Private chat seeding (ConversationStore migration) - -extension ChatViewModel { - /// Test-only replacement for the deleted `privateChats` setter: seeds a - /// peer's chat through the single-writer `ConversationStore` intents - /// (upsert keeps re-seeding with updated copies working the way the old - /// dictionary assignment did). - @MainActor - func seedPrivateChat(_ messages: [BitchatMessage], for peerID: PeerID) { - _ = conversations.conversation(for: .directPeer(peerID)) - for message in messages { - conversations.upsertByID(message, in: .directPeer(peerID)) - } - } - - /// Test-only replacement for the deleted `messages` setter: seeds a - /// public channel's conversation through the single-writer - /// `ConversationStore` intents (upsert keeps re-seeding with updated - /// copies working the way the old array assignment did). - @MainActor - func seedPublicMessages(_ messages: [BitchatMessage], for channel: ChannelID = .mesh) { - for message in messages { - conversations.upsertByID(message, in: ConversationID(channelID: channel)) - } - } - + case unexpectedValue + case testFailure(String) } func sleep(_ seconds: TimeInterval) async throws { diff --git a/bitchatTests/TestUtilities/TestTimingHygieneTests.swift b/bitchatTests/TestUtilities/TestTimingHygieneTests.swift deleted file mode 100644 index c6a15147..00000000 --- a/bitchatTests/TestUtilities/TestTimingHygieneTests.swift +++ /dev/null @@ -1,177 +0,0 @@ -import Foundation -import Testing - -/// Guards the test suite against the flake class that produced four separate -/// red builds in July 2026: **treating a wait deadline as a latency budget.** -/// -/// A CI runner executes many suites at once, so work behind `@MainActor`, -/// `Task.detached(priority: .utility)`, or `DispatchQueue.asyncAfter` can be -/// starved for seconds. One observed run took 3.75 s for a 1 s operation. -/// Deadlines sized to how long the operation "should" take turn that starvation -/// into a red build that reads like a product bug, and the debugging cost lands -/// on whoever opened an unrelated PR. -/// -/// Two rules, both enforced below: -/// -/// 1. A wait helper's default deadline must be at least -/// `TestConstants.minimumSettleTimeout`. Waits return as soon as their -/// condition holds, so a generous deadline is free in the passing case. -/// 2. No test asserts an *upper bound* on elapsed wall-clock time. Such an -/// assertion cannot distinguish the behaviour under test from a slow -/// machine, so it can only be flaky. Assert the property somewhere it is -/// computable — with an injected clock, on the pure logic — instead. -/// -/// Both rules can be waived per line with `\(Self.waiver)` plus a reason, for -/// the rare case where the timing itself is genuinely the thing under test. -struct TestTimingHygieneTests { - /// Opt-out marker. Reviewers should expect a reason next to it. - static let waiver = "test-timing-ok:" - - private static let testsRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // TestUtilities - .deletingLastPathComponent() // bitchatTests - - private struct Line { - let file: String - let number: Int - let text: String - /// True when the waiver appears on this line or in the comment block - /// immediately above it, so a reason can be written at readable length - /// rather than crammed onto the end of the code line. - let waived: Bool - } - - private static func swiftLines() throws -> [Line] { - let enumerator = FileManager.default.enumerator( - at: testsRoot, - includingPropertiesForKeys: nil - ) - var out: [Line] = [] - while let url = enumerator?.nextObject() as? URL { - guard url.pathExtension == "swift" else { continue } - // This file necessarily contains the patterns it bans. - guard url.lastPathComponent != "TestTimingHygieneTests.swift" else { continue } - let name = url.lastPathComponent - let texts = try String(contentsOf: url, encoding: .utf8) - .components(separatedBy: .newlines) - for (index, text) in texts.enumerated() { - // Scan back over an unbroken run of comment lines. - var waived = text.contains(waiver) - var back = index - 1 - while !waived, back >= 0 { - let above = texts[back].trimmingCharacters(in: .whitespaces) - guard above.hasPrefix("//") else { break } - waived = above.contains(waiver) - back -= 1 - } - out.append(Line(file: name, number: index + 1, text: text, waived: waived)) - } - } - return out - } - - private static func isWaived(_ line: Line) -> Bool { - line.waived - } - - /// Rule 1: no wait helper may default to a deadline below the floor. - @Test func waitHelpersDoNotDefaultToShortDeadlines() throws { - let lines = try Self.swiftLines() - #expect(!lines.isEmpty, "hygiene scan found no test sources — check the path") - - // Two shapes, both of which have flaked here: - // a declaration default — `timeout: TimeInterval = 2.5` - // a wait call site — `wait(for:…, timeout: 1.0)`, `waitUntil(timeout: 5.0)` - // - // Deliberately NOT matched: a bare `timeout:` label on something that is - // not a wait, such as the injected production handshake timeouts in the - // Noise tests. Those are the behaviour under test, and a short value is - // correct there. - let patterns = [ - #"(?:timeout|deadline)\s*:\s*TimeInterval\s*=\s*([0-9]+(?:\.[0-9]+)?)"#, - #"(?:wait|waitUntil|waitFor|fulfillment)\s*\([^)]*\btimeout:\s*([0-9]+(?:\.[0-9]+)?)"# - ].map { try? NSRegularExpression(pattern: $0) }.compactMap { $0 } - #expect(patterns.count == 2, "hygiene regexes failed to compile") - - // Named constants hide the same mistake behind a symbol, and did: the - // fifth flake of the session was `timeout: TestConstants.shortTimeout` - // (1 s) on a positive wait, which a literals-only scan cannot see. - // `shortTimeout` itself is deleted (Periphery flagged it dead once its - // last wait site converted); the ban stays so it cannot come back. - // `negativeWaitWindow` is deliberately absent — short is correct there. - let bannedConstants = ["shortTimeout", "defaultTimeout"] - - var offenders: [String] = [] - for line in lines where !Self.isWaived(line) { - let range = NSRange(line.text.startIndex..., in: line.text) - var flagged = false - for pattern in patterns { - guard let match = pattern.firstMatch(in: line.text, range: range), - let valueRange = Range(match.range(at: 1), in: line.text), - let value = TimeInterval(line.text[valueRange]), - value < TestConstants.minimumSettleTimeout else { continue } - offenders.append("\(line.file):\(line.number) — \(value)s: \(line.text.trimmingCharacters(in: .whitespaces))") - flagged = true - break - } - guard !flagged else { continue } - for name in bannedConstants - where line.text.contains("timeout: TestConstants.\(name)") { - offenders.append("\(line.file):\(line.number) — TestConstants.\(name): \(line.text.trimmingCharacters(in: .whitespaces))") - break - } - } - - #expect( - offenders.isEmpty, - """ - Wait deadlines below \(TestConstants.minimumSettleTimeout)s are latency \ - assumptions and will flake on a loaded runner. Use \ - TestConstants.settleTimeout, or add "\(Self.waiver) " if the \ - timing really is what the test asserts. - - \(offenders.joined(separator: "\n")) - """ - ) - } - - /// Rule 2: no test bounds elapsed wall-clock time from above. - /// - /// This is the assertion that started it all — `XCTAssertLessThan( - /// Date().timeIntervalSince(start), 1.4)` proving a debounce deadline was - /// not restarted. It cannot separate "behaved correctly" from "runner was - /// busy", so it only ever fails for the wrong reason. - @Test func testsDoNotAssertUpperBoundsOnElapsedTime() throws { - let lines = try Self.swiftLines() - - let elapsedAssertion = try NSRegularExpression( - pattern: #"(?:XCTAssertLessThan|XCTAssertLessThanOrEqual)\s*\(\s*(?:Date\(\)\.timeIntervalSince|[A-Za-z_][A-Za-z0-9_]*\.timeIntervalSince|ContinuousClock)"# - ) - - var offenders: [String] = [] - for line in lines where !Self.isWaived(line) { - let range = NSRange(line.text.startIndex..., in: line.text) - guard elapsedAssertion.firstMatch(in: line.text, range: range) != nil else { continue } - offenders.append("\(line.file):\(line.number) — \(line.text.trimmingCharacters(in: .whitespaces))") - } - - #expect( - offenders.isEmpty, - """ - An upper bound on elapsed wall-clock time cannot distinguish the \ - behaviour under test from a slow machine. Assert the property where \ - it is computable — inject a clock, or test the pure logic — or add \ - "\(Self.waiver) ". - - \(offenders.joined(separator: "\n")) - """ - ) - } - - /// The floor must stay meaningfully above the operations being waited on, - /// and the default must satisfy the rule this file enforces. - @Test func settleTimeoutsAreSelfConsistent() { - #expect(TestConstants.settleTimeout >= TestConstants.minimumSettleTimeout) - #expect(TestConstants.minimumSettleTimeout > TestConstants.defaultTimeout) - } -} diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 6ab5290e..cdf9a8df 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -1,4 +1,3 @@ -import Combine import Testing import Foundation import SwiftUI @@ -30,159 +29,6 @@ private func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockT return (viewModel, transport, identityManager) } -@MainActor -private struct SmokeFeatureModels { - let publicChatModel: PublicChatModel - let appChromeModel: AppChromeModel - let locationChannelsModel: LocationChannelsModel - let privateInboxModel: PrivateInboxModel - let privateConversationModel: PrivateConversationModel - let verificationModel: VerificationModel - let conversationUIModel: ConversationUIModel - let peerListModel: PeerListModel - let boardAlertsModel: BoardAlertsModel - let sharedContentImportModel: SharedContentImportModel -} - -@MainActor -private func makeSmokeLocationManager() -> LocationChannelManager { - let suiteName = "ViewSmokeTests.\(UUID().uuidString)" - let storage = UserDefaults(suiteName: suiteName) ?? .standard - storage.removePersistentDomain(forName: suiteName) - return LocationChannelManager(storage: storage) -} - -@MainActor -private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels { - let locationManager = makeSmokeLocationManager() - let conversations = viewModel.conversations - let publicChatModel = PublicChatModel(conversations: conversations) - let locationChannelsModel = LocationChannelsModel(manager: locationManager) - let privateInboxModel = PrivateInboxModel(conversations: conversations) - let appChromeModel = AppChromeModel( - chatViewModel: viewModel, - privateInboxModel: privateInboxModel - ) - let privateConversationModel = PrivateConversationModel( - chatViewModel: viewModel, - conversations: conversations, - locationChannelsModel: locationChannelsModel - ) - let verificationModel = VerificationModel( - chatViewModel: viewModel, - privateConversationModel: privateConversationModel - ) - let conversationUIModel = ConversationUIModel( - chatViewModel: viewModel, - privateConversationModel: privateConversationModel, - conversations: conversations - ) - let peerListModel = PeerListModel( - chatViewModel: viewModel, - conversations: conversations, - locationChannelsModel: locationChannelsModel - ) - - let boardAlertsModel = BoardAlertsModel( - arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(), - dependencies: BoardAlertsModel.Dependencies( - isOwnPost: { _ in false }, - emitSystemLine: { _, _ in } - ) - ) - - return SmokeFeatureModels( - publicChatModel: publicChatModel, - appChromeModel: appChromeModel, - locationChannelsModel: locationChannelsModel, - privateInboxModel: privateInboxModel, - privateConversationModel: privateConversationModel, - verificationModel: verificationModel, - conversationUIModel: conversationUIModel, - peerListModel: peerListModel, - boardAlertsModel: boardAlertsModel, - sharedContentImportModel: SharedContentImportModel(store: nil) - ) -} - -@MainActor -private func installSmokeEnvironment( - _ view: V, - featureModels: SmokeFeatureModels -) -> some View { - view - .environmentObject(featureModels.publicChatModel) - .environmentObject(featureModels.appChromeModel) - .environmentObject(featureModels.locationChannelsModel) - .environmentObject(featureModels.privateInboxModel) - .environmentObject(featureModels.privateConversationModel) - .environmentObject(featureModels.verificationModel) - .environmentObject(featureModels.conversationUIModel) - .environmentObject(featureModels.peerListModel) - .environmentObject(featureModels.boardAlertsModel) - .environmentObject(featureModels.sharedContentImportModel) -} - -@MainActor -private struct ContentPeopleSheetHarness: View { - @State private var showSidebar = true - @State private var messageText = "" - @State private var selectedMessageSender: String? - @State private var selectedMessageSenderID: PeerID? - @State private var imagePreviewURL: URL? - @State private var windowCountPublic = 300 - @State private var windowCountPrivate: [PeerID: Int] = [:] - @State private var isAtBottomPrivate = true - @State private var autocompleteDebounceTimer: Timer? - @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() - @FocusState private var isTextFieldFocused: Bool - #if os(iOS) - @State private var showImagePicker = false - @State private var imagePickerSourceType: UIImagePickerController.SourceType = .photoLibrary - #else - @State private var showMacImagePicker = false - #endif - - var body: some View { - #if os(iOS) - ContentPeopleSheetView( - showSidebar: $showSidebar, - messageText: $messageText, - selectedMessageSender: $selectedMessageSender, - selectedMessageSenderID: $selectedMessageSenderID, - imagePreviewURL: $imagePreviewURL, - windowCountPublic: $windowCountPublic, - windowCountPrivate: $windowCountPrivate, - isAtBottomPrivate: $isAtBottomPrivate, - isTextFieldFocused: $isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - headerHeight: 44, - onSendMessage: {}, - showImagePicker: $showImagePicker, - imagePickerSourceType: $imagePickerSourceType - ) - #else - ContentPeopleSheetView( - showSidebar: $showSidebar, - messageText: $messageText, - selectedMessageSender: $selectedMessageSender, - selectedMessageSenderID: $selectedMessageSenderID, - imagePreviewURL: $imagePreviewURL, - windowCountPublic: $windowCountPublic, - windowCountPrivate: $windowCountPrivate, - isAtBottomPrivate: $isAtBottomPrivate, - isTextFieldFocused: $isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - headerHeight: 44, - onSendMessage: {}, - showMacImagePicker: $showMacImagePicker - ) - #endif - } -} - @MainActor @discardableResult private func mount(_ view: V) -> AnyObject { @@ -266,13 +112,11 @@ private func makeTemporaryImageURL() throws -> URL { return url } -@Suite("View Smoke Tests", .serialized) @MainActor struct ViewSmokeTests { @Test func fingerprintView_renders_verifiedAndPendingStates() async { let (viewModel, transport, _) = makeSmokeViewModel() - let featureModels = makeSmokeFeatureModels(for: viewModel) let verifiedPeer = PeerID(str: "0102030405060708") let pendingPeer = PeerID(str: "1112131415161718") let verifiedFingerprint = String(repeating: "ab", count: 32) @@ -287,11 +131,11 @@ struct ViewSmokeTests { viewModel.verifiedFingerprints.insert(verifiedFingerprint) - let verifiedView = FingerprintView(peerID: verifiedPeer) - .environmentObject(featureModels.verificationModel) - let pendingView = FingerprintView(peerID: pendingPeer) - .environmentObject(featureModels.verificationModel) + let verifiedView = FingerprintView(viewModel: viewModel, peerID: verifiedPeer) + let pendingView = FingerprintView(viewModel: viewModel, peerID: pendingPeer) + _ = verifiedView.body + _ = pendingView.body _ = mount(verifiedView) _ = mount(pendingView) @@ -301,14 +145,13 @@ struct ViewSmokeTests { @Test func verificationViews_renderCoreBranches() throws { let (viewModel, transport, _) = makeSmokeViewModel() - let featureModels = makeSmokeFeatureModels(for: viewModel) let peerID = PeerID(str: "2122232425262728") let fingerprint = String(repeating: "cd", count: 32) var isPresented = true transport.peerFingerprints[peerID] = fingerprint transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: "Verifier", noiseByte: 0x33)]) - featureModels.privateConversationModel.startConversation(with: peerID) + viewModel.selectedPrivateChatPeer = peerID viewModel.verifiedFingerprints.insert(fingerprint) let image = try makeCGImage() @@ -330,32 +173,35 @@ struct ViewSmokeTests { set: { isPresented = $0 } ) ) - .environmentObject(featureModels.verificationModel) + .environmentObject(viewModel) ) } @Test func meshPeerList_renders_emptyAndPopulatedStates() async { let (viewModel, transport, identityManager) = makeSmokeViewModel() - let featureModels = makeSmokeFeatureModels(for: viewModel) let connectedPeer = PeerID(str: "3132333435363738") let blockedPeer = PeerID(str: "4142434445464748") let blockedFingerprint = String(repeating: "ef", count: 32) _ = mount( MeshPeerList( + viewModel: viewModel, + textColor: .green, + secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } ) - .environmentObject(featureModels.peerListModel) ) _ = MeshPeerList( + viewModel: viewModel, + textColor: .green, + secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } - ) - .environmentObject(featureModels.peerListModel) + ).body transport.peerFingerprints[blockedPeer] = blockedFingerprint identityManager.setBlocked(blockedFingerprint, isBlocked: true) @@ -364,15 +210,17 @@ struct ViewSmokeTests { makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55) ]) try? await Task.sleep(nanoseconds: 50_000_000) - viewModel.markPrivateChatUnread(blockedPeer) + viewModel.unreadPrivateMessages.insert(blockedPeer) _ = mount( MeshPeerList( + viewModel: viewModel, + textColor: .green, + secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } ) - .environmentObject(featureModels.peerListModel) ) #expect(viewModel.hasUnreadMessages(for: blockedPeer)) @@ -381,46 +229,37 @@ struct ViewSmokeTests { @Test func commandSuggestionsAndLocationViews_render() { let (viewModel, _, _) = makeSmokeViewModel() - let featureModels = makeSmokeFeatureModels(for: viewModel) let channel = GeohashChannel(level: .city, geohash: "u4pruy") var messageText = "/f" - featureModels.locationChannelsModel.select(.location(channel)) + LocationChannelManager.shared.select(.location(channel)) _ = mount( CommandSuggestionsView( messageText: Binding( get: { messageText }, set: { messageText = $0 } - ) + ), + textColor: .green, + backgroundColor: .black, + secondaryTextColor: .gray ) - .environmentObject(featureModels.privateConversationModel) - .environmentObject(featureModels.locationChannelsModel) + .environmentObject(viewModel) ) _ = mount( LocationChannelsSheet(isPresented: .constant(true)) - .environmentObject(featureModels.locationChannelsModel) - .environmentObject(featureModels.peerListModel) + .environmentObject(viewModel) ) #expect(messageText == "/f") - featureModels.locationChannelsModel.select(.mesh) - featureModels.locationChannelsModel.endLiveRefresh() + LocationChannelManager.shared.select(.mesh) + LocationChannelManager.shared.endLiveRefresh() } @Test - func noticesView_rendersNoRelayAndLoadedStates() throws { - let (viewModel, transport, _) = makeSmokeViewModel() - let featureModels = makeSmokeFeatureModels(for: viewModel) - featureModels.locationChannelsModel.select(.location(GeohashChannel(level: .building, geohash: "u4pruydq"))) - defer { featureModels.locationChannelsModel.select(.mesh) } - let board = BoardManager( - transport: transport, - store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }), - publishToNostr: { _, _, _, _, _ in nil }, - deleteFromNostr: { _, _ in } - ) + func locationNotesView_rendersNoRelayAndLoadedStates() throws { + let (viewModel, _, _) = makeSmokeViewModel() let noRelayManager = LocationNotesManager( geohash: "u4pruydq", @@ -463,30 +302,12 @@ struct ViewSmokeTests { eose?() _ = mount( - NoticesView( - senderNickname: viewModel.nickname, - board: board, - initialTab: .geo, - notesManager: noRelayManager - ) - .environmentObject(featureModels.locationChannelsModel) + LocationNotesView(geohash: "u4pruydq", manager: noRelayManager) + .environmentObject(viewModel) ) _ = mount( - NoticesView( - senderNickname: viewModel.nickname, - board: board, - initialTab: .geo, - notesManager: loadedManager - ) - .environmentObject(featureModels.locationChannelsModel) - ) - _ = mount( - NoticesView( - senderNickname: viewModel.nickname, - board: board, - initialTab: .mesh - ) - .environmentObject(featureModels.locationChannelsModel) + LocationNotesView(geohash: "u4pruydq", manager: loadedManager) + .environmentObject(viewModel) ) #expect(loadedManager.notes.count == 1) @@ -501,15 +322,13 @@ struct ViewSmokeTests { description: "app_info.features.encryption.description" ) - // AppInfoView's settings pane reads LocationChannelsModel from the - // environment, so it can only render mounted with one installed. let appInfo = AppInfoView() - .environmentObject(LocationChannelsModel(manager: makeSmokeLocationManager())) let header = SectionHeader("app_info.features.title") let featureRow = FeatureRow(info: feature) let paymentCashu = PaymentChipView(paymentType: .cashu("cashuA_test-token")) let paymentLightning = PaymentChipView(paymentType: .lightning("lightning:lnbc1test")) + _ = appInfo.body _ = header.body _ = featureRow.body _ = paymentCashu.body @@ -531,134 +350,13 @@ struct ViewSmokeTests { #expect(PaymentChipView.PaymentType.lightning("lightning:lnbc1test").url?.scheme == "lightning") } - @Test - func contentShellViews_renderPublicAndPrivateBranches() async { - let (viewModel, transport, _) = makeSmokeViewModel() - let featureModels = makeSmokeFeatureModels(for: viewModel) - let peerID = PeerID(str: "5152535455565758") - - transport.updatePeerSnapshots([ - makeSnapshot(peerID: peerID, nickname: "Alice", noiseByte: 0x66) - ]) - try? await Task.sleep(nanoseconds: 50_000_000) - - // ContentView + people sheet must mount with the full feature-model - // set (peerList / publicChat / privateInbox included). Missing any of - // those crashes the NavigationStack sheet on some iOS versions (#1558). - _ = mount(installSmokeEnvironment(ContentView(), featureModels: featureModels)) - _ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels)) - - featureModels.privateConversationModel.startConversation(with: peerID) - try? await Task.sleep(nanoseconds: 50_000_000) - - _ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels)) - - #expect(featureModels.privateConversationModel.selectedPeerID == peerID) - #expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID) - } - - @Test("Root Bluetooth alert waits for location and notices sheets") - func rootBluetoothAlertGuard_includesHeaderSheets() { - #expect(!ContentRootModalPresentationState().hasPresentation) - #expect( - ContentRootModalPresentationState( - isLocationChannelsSheetPresented: true - ).hasPresentation - ) - #expect( - ContentRootModalPresentationState( - isNoticesSheetPresented: true - ).hasPresentation - ) - } - - @Test("People-sheet Bluetooth alert waits for local verification sheet") - func peopleSheetBluetoothAlertGuard_includesVerificationSheet() { - #expect(!ContentPeopleSheetModalPresentationState().hasPresentation) - #expect( - ContentPeopleSheetModalPresentationState( - isVerificationSheetPresented: true - ).hasPresentation - ) - } - - @Test("Bluetooth alerts wait for the voice recording error alert") - func bluetoothAlertGuards_includeVoiceAlert() { - #expect( - ContentRootModalPresentationState( - isVoiceAlertPresented: true - ).hasPresentation - ) - #expect( - ContentPeopleSheetModalPresentationState( - isVoiceAlertPresented: true - ).hasPresentation - ) - } - - @Test("Root Bluetooth alert waits for screenshot privacy alert") - @MainActor - func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() { - let (viewModel, _, _) = makeSmokeViewModel() - let featureModels = makeSmokeFeatureModels(for: viewModel) - - #expect( - !ContentRootModalPresentationState( - appChromeModel: featureModels.appChromeModel - ).hasPresentation - ) - - featureModels.appChromeModel.showScreenshotPrivacyWarning = true - - #expect( - ContentRootModalPresentationState( - appChromeModel: featureModels.appChromeModel - ).hasPresentation - ) - } - - @Test("People-sheet Bluetooth alert waits for legacy media consent") - @MainActor - func peopleSheetBluetoothAlertGuard_tracksLegacyConsentState() async { - let (viewModel, _, _) = makeSmokeViewModel() - let featureModels = makeSmokeFeatureModels(for: viewModel) - - #expect( - !ContentPeopleSheetModalPresentationState( - legacyPrivateMediaConsentRequest: - featureModels.conversationUIModel - .legacyPrivateMediaConsentRequest - ).hasPresentation - ) - - viewModel.enqueueLegacyPrivateMediaConsent( - for: PeerID(str: "5152535455565758"), - transferId: "legacy-consent-transfer", - messageID: "legacy-consent-message" - ) { _ in } - defer { viewModel.cancelAllLegacyPrivateMediaConsents() } - - let consentPropagated = await TestHelpers.waitUntil { - featureModels.conversationUIModel - .legacyPrivateMediaConsentRequest != nil - } - #expect( - consentPropagated - ) - #expect( - ContentPeopleSheetModalPresentationState( - legacyPrivateMediaConsentRequest: - featureModels.conversationUIModel - .legacyPrivateMediaConsentRequest - ).hasPresentation - ) - } - @Test func geohashAndTextMessageViews_renderCoreBranches() { let (viewModel, _, _) = makeSmokeViewModel() - let featureModels = makeSmokeFeatureModels(for: viewModel) let geohashPeopleList = GeohashPeopleList( + viewModel: viewModel, + textColor: .green, + secondaryTextColor: .gray, onTapPerson: {} ) let truncatableMessage = BitchatMessage( @@ -679,10 +377,10 @@ struct ViewSmokeTests { deliveryStatus: .partiallyDelivered(reached: 1, total: 2) ) - _ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel)) - _ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel)) - _ = mount(TextMessageView(message: truncatableMessage).environmentObject(featureModels.conversationUIModel)) - _ = mount(TextMessageView(message: paymentMessage).environmentObject(featureModels.conversationUIModel)) + _ = geohashPeopleList.body + _ = mount(geohashPeopleList) + _ = mount(TextMessageView(message: truncatableMessage).environmentObject(viewModel)) + _ = mount(TextMessageView(message: paymentMessage).environmentObject(viewModel)) #expect(truncatableMessage.content.count > TransportConfig.uiLongMessageLengthThreshold) #expect(paymentMessage.content.contains("lightning:") && paymentMessage.content.contains("cashu")) @@ -691,19 +389,11 @@ struct ViewSmokeTests { @Test func voiceAndMediaViews_renderAndWarmCaches() async throws { let audioURL = try makeTemporaryAudioURL() - // Probed directly below. Deliberately a separate file from `audioURL`: - // `WaveformCache.shared` is process-wide and the mounted - // `VoiceNoteView` warms it for `audioURL` at the view's default bin - // width concurrently, so asserting an exact bin count for that URL - // races with the view's own cache write. - let waveformProbeURL = try makeTemporaryAudioURL() let imageURL = try makeTemporaryImageURL() defer { try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: waveformProbeURL) try? FileManager.default.removeItem(at: imageURL) WaveformCache.shared.purge(url: audioURL) - WaveformCache.shared.purge(url: waveformProbeURL) } let waveformView = WaveformView( @@ -737,95 +427,36 @@ struct ViewSmokeTests { _ = mount(voiceNoteView) let bins = await withCheckedContinuation { continuation in - WaveformCache.shared.waveform(for: waveformProbeURL, bins: 16) { values in + WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in continuation.resume(returning: values) } } playback.loadDuration() - // loadDuration hops through a background queue and back to main; poll - // instead of a fixed sleep so a loaded runner can't outlast the wait. - _ = await TestHelpers.waitUntil({ playback.duration > 0 }) + try? await Task.sleep(nanoseconds: 250_000_000) playback.seek(to: 1.25) playback.stop() VoiceNotePlaybackCoordinator.shared.activate(playback) VoiceNotePlaybackCoordinator.shared.deactivate(playback) - await VoiceRecorder.shared.cancelRecording(owner: VoiceRecorder.RecordingOwner()) + await VoiceRecorder.shared.cancelRecording() #expect(bins.count == 16) - #expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16) + #expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16) #expect(playback.duration > 0) #expect(playback.progress == 0) } - @Test - func messageRows_snapshotDeliveryStatusForSwiftUIDiffing() { - // Regression: `BitchatMessage` is a reference type mutated in place - // by `ConversationStore.applyDeliveryStatus`, and SwiftUI compares - // reference-typed view fields by identity — so a status-only change - // (delivered → read) on the SAME instance is invisible to the row's - // structural diff and its body gets skipped even when the list - // re-renders. The row views must therefore snapshot the status as a - // value-typed stored property at init, so a rebuilt row value - // compares different and re-renders. - func deliveryStatusSnapshot(of row: Any) -> DeliveryStatus? { - Mirror(reflecting: row).children - .first { $0.label == "deliveryStatus" }? - .value as? DeliveryStatus - } - - let delivered = DeliveryStatus.delivered(to: "builder", at: Date(timeIntervalSince1970: 50)) - let message = BitchatMessage( - id: "dm-status-1", - sender: "anon", - content: "hello", - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: "builder", - senderPeerID: PeerID(str: "abcdef1234567890"), - deliveryStatus: delivered - ) - - #expect(deliveryStatusSnapshot(of: TextMessageView(message: message)) == delivered) - - // In-place mutation of the shared instance (what the store does on a - // READ ack); a freshly built row must carry the new status value. - let read = DeliveryStatus.read(by: "builder", at: Date(timeIntervalSince1970: 100)) - message.deliveryStatus = read - - #expect(deliveryStatusSnapshot(of: TextMessageView(message: message)) == read) - - let mediaRow = MediaMessageView( - message: message, - media: .image(URL(fileURLWithPath: "/tmp/never-loaded.jpg")), - imagePreviewURL: .constant(nil) - ) - #expect(deliveryStatusSnapshot(of: mediaRow) == read) - } - + #if os(iOS) @Test func cameraScannerView_previewAndCoordinatorSmoke() { - #if os(iOS) || os(macOS) - // Avoid constructing AVCaptureDeviceInput (and the TCC prompt it can - // trigger) unless the host process already has camera authorization — - // same class of isolation as keeping tests off the login keychain. - let status = AVCaptureDevice.authorizationStatus(for: .video) let preview = CameraScannerView.PreviewView(frame: .zero) - let coordinator = CameraScannerCoordinator() + let coordinator = CameraScannerView.Coordinator() - #if os(iOS) _ = CameraScannerView.PreviewView.layerClass - #elseif os(macOS) - preview.layout() - #endif _ = preview.videoPreviewLayer - - if status == .authorized { - coordinator.setup(previewLayer: preview.videoPreviewLayer) { _ in } - coordinator.setActive(false) - } + coordinator.setup(sessionOwner: preview) { _ in } + coordinator.setActive(false) #expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill) - #endif } + #endif } diff --git a/bitchatTests/VoiceBurstPacketTests.swift b/bitchatTests/VoiceBurstPacketTests.swift deleted file mode 100644 index ea7b657f..00000000 --- a/bitchatTests/VoiceBurstPacketTests.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// VoiceBurstPacketTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -@testable import bitchat - -struct VoiceBurstPacketTests { - private let burstID = Data((0..<8).map { UInt8($0 + 1) }) - - // MARK: - Round trips - - @Test func startRoundTrip() throws { - let packet = try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))) - let decoded = try #require(VoiceBurstPacket.decode(packet.encode())) - #expect(decoded == packet) - #expect(decoded.seq == 0) - } - - @Test func framesRoundTrip() throws { - let frames = [Data([0xDE, 0xAD]), Data(repeating: 0x42, count: 130)] - let packet = try #require(VoiceBurstPacket(burstID: burstID, seq: 7, kind: .frames(frames))) - let decoded = try #require(VoiceBurstPacket.decode(packet.encode())) - #expect(decoded == packet) - guard case .frames(let decodedFrames) = decoded.kind else { - Issue.record("expected frames") - return - } - #expect(decodedFrames == frames) - } - - @Test func endRoundTrip() throws { - let packet = try #require(VoiceBurstPacket(burstID: burstID, seq: 42, kind: .end(totalDataPackets: 41, durationMs: 2_688))) - let decoded = try #require(VoiceBurstPacket.decode(packet.encode())) - #expect(decoded == packet) - } - - @Test func canceledRoundTrip() throws { - let packet = try #require(VoiceBurstPacket(burstID: burstID, seq: 3, kind: .canceled)) - let decoded = try #require(VoiceBurstPacket.decode(packet.encode())) - #expect(decoded == packet) - } - - @Test func decodeSurvivesReslicedData() throws { - // Simulates the payload arriving as a slice with a non-zero start index. - let packet = try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data([1, 2, 3])]))) - var padded = Data([0xAA, 0xBB]) - padded.append(packet.encode()) - let slice = padded.dropFirst(2) - #expect(VoiceBurstPacket.decode(slice) == packet) - } - - // MARK: - Validation - - @Test func rejectsMalformedInput() { - #expect(VoiceBurstPacket.decode(Data()) == nil) - #expect(VoiceBurstPacket.decode(Data(repeating: 0, count: 10)) == nil) - // Unknown flags byte. - var unknownFlags = burstID - unknownFlags.append(contentsOf: [0, 1, 0xFF]) - #expect(VoiceBurstPacket.decode(unknownFlags) == nil) - // Data packet with zero frames. - var empty = burstID - empty.append(contentsOf: [0, 1, 0]) - #expect(VoiceBurstPacket.decode(empty) == nil) - // Truncated frame length. - var truncated = burstID - truncated.append(contentsOf: [0, 1, 0, 0x00, 0x10, 0xAB]) - #expect(VoiceBurstPacket.decode(truncated) == nil) - // Unknown codec in START. - var badCodec = burstID - badCodec.append(contentsOf: [0, 0, 0x01, 0x7F]) - #expect(VoiceBurstPacket.decode(badCodec) == nil) - } - - @Test func rejectsInvalidConstruction() { - #expect(VoiceBurstPacket(burstID: Data([1, 2]), seq: 0, kind: .canceled) == nil) - #expect(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([])) == nil) - #expect(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data()])) == nil) - let tooMany = Array(repeating: Data([0x01]), count: VoiceBurstPacket.maxFramesPerPacket + 1) - #expect(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames(tooMany)) == nil) - } - - @Test func makeBurstIDProducesUniqueEightBytes() { - let a = VoiceBurstPacket.makeBurstID() - let b = VoiceBurstPacket.makeBurstID() - #expect(a.count == VoiceBurstPacket.burstIDSize) - #expect(a != b) - } - - // MARK: - Packetizer - - @Test func packetizerRespectsBudgetAndCounts() throws { - var packetizer = VoiceBurstPacketizer(burstID: burstID, budget: 210) - let frame = Data(repeating: 0x55, count: 130) // realistic 16 kbps AAC frame - - // First frame buffers, second forces a flush of the first. - #expect(packetizer.add(frame).isEmpty) - let flushed = packetizer.add(frame) - #expect(flushed.count == 1) - let first = try #require(VoiceBurstPacket.decode(flushed[0])) - #expect(first.seq == 1) - guard case .frames(let frames) = first.kind else { - Issue.record("expected frames") - return - } - #expect(frames == [frame]) - - // Remaining frame flushes on demand; counters advance. - let final = packetizer.flush() - #expect(final.count == 1) - #expect(try #require(VoiceBurstPacket.decode(final[0])).seq == 2) - #expect(packetizer.dataPacketCount == 2) - #expect(packetizer.nextSeq == 3) - #expect(packetizer.flush().isEmpty) - } - - @Test func packetizerBatchesSmallFrames() throws { - var packetizer = VoiceBurstPacketizer(burstID: burstID, budget: 210) - let small = Data(repeating: 0x11, count: 40) - for _ in 0..<4 { - #expect(packetizer.add(small).isEmpty) // 4 * 42 + 11 = 179 <= 210 - } - let packets = packetizer.flush() - #expect(packets.count == 1) - guard case .frames(let frames) = try #require(VoiceBurstPacket.decode(packets[0])).kind else { - Issue.record("expected frames") - return - } - #expect(frames.count == 4) - } - - @Test func packetizerDropsOversizedFrame() { - var packetizer = VoiceBurstPacketizer(burstID: burstID, budget: 210) - #expect(packetizer.add(Data(repeating: 0, count: 500)).isEmpty) - #expect(packetizer.flush().isEmpty) - #expect(packetizer.dataPacketCount == 0) - } - - @Test func encodedPacketStaysWithinNoisePaddingBucket() throws { - // The whole point of the budget: burst content + 1 type byte + - // 16-byte Noise tag must stay within MessagePadding's 256 bucket. - var packetizer = VoiceBurstPacketizer(burstID: burstID) - var largest = 0 - for _ in 0..<3 { - for packet in packetizer.add(Data(repeating: 0xAB, count: 160)) { - largest = max(largest, packet.count) - } - } - for packet in packetizer.flush() { - largest = max(largest, packet.count) - } - #expect(largest > 0) - #expect(largest + 1 + 16 <= 256) - } -} diff --git a/bitchatTests/VoiceCaptureSessionTests.swift b/bitchatTests/VoiceCaptureSessionTests.swift deleted file mode 100644 index bfa0ac96..00000000 --- a/bitchatTests/VoiceCaptureSessionTests.swift +++ /dev/null @@ -1,265 +0,0 @@ -// -// VoiceCaptureSessionTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -@testable import bitchat - -@MainActor -private final class StubPTTCapture: PTTCapturing { - var onFrames: (([Data]) -> Void)? - var stopResult: (url: URL?, encodedFrames: Int) - var startError: Error? - private(set) var startCount = 0 - private(set) var cancelCount = 0 - - init( - stopResult: (url: URL?, encodedFrames: Int), - startError: Error? = nil - ) { - self.stopResult = stopResult - self.startError = startError - } - - func start(outputURL: URL) async throws { - startCount += 1 - if let startError { - throw startError - } - } - - func stop() -> (url: URL?, encodedFrames: Int) { - stopResult - } - - func cancel() { - cancelCount += 1 - } -} - -private final class CaptureLeaseSession: SessionApplying, @unchecked Sendable { - private let lock = NSLock() - private var _activationCalls: [Bool] = [] - - var activationCalls: [Bool] { lock.withLock { _activationCalls } } - - func setCategory(_ category: AudioSessionCoordinator.Category) throws {} - - func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { - lock.withLock { _activationCalls.append(active) } - } -} - -@MainActor -private final class GatedVoiceCaptureSession: VoiceCaptureSession { - let isLive = false - private let startError: Error? - private(set) var finishStarted = false - private(set) var cancelCount = 0 - private(set) var panicCancelCount = 0 - private var finishContinuation: CheckedContinuation? - - init(startError: Error? = nil) { - self.startError = startError - } - - func requestPermission() async -> Bool { true } - func start() async throws { - if let startError { throw startError } - } - - func finish() async -> URL? { - finishStarted = true - return await withCheckedContinuation { continuation in - finishContinuation = continuation - } - } - - func cancel() async { - cancelCount += 1 - } - - func panicCancelSynchronously() { - panicCancelCount += 1 - } - - func resolveFinish(with url: URL?) { - let continuation = finishContinuation - finishContinuation = nil - continuation?.resume(returning: url) - } -} - -@MainActor -struct VoiceCaptureSessionTests { - private func waitUntil( - _ condition: () -> Bool, - sourceLocation: SourceLocation = #_sourceLocation - ) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout)) - while !condition(), ContinuousClock.now < deadline { - await Task.yield() - try? await Task.sleep(nanoseconds: 1_000_000) - } - #expect(condition(), sourceLocation: sourceLocation) - } - - private func isRecording(_ state: VoiceRecordingViewModel.State) -> Bool { - if case .recording = state { return true } - return false - } - - @Test func staleCaptureCallbackCannotInvalidateNewGeneration() { - let generations = PTTCaptureGeneration() - let old = generations.begin() - generations.invalidate() - let current = generations.begin() - - #expect(!generations.invalidate(ifCurrent: old)) - #expect(generations.isCurrent(current)) - #expect(generations.invalidate(ifCurrent: current)) - #expect(!generations.isCurrent(current)) - } - - @Test func coordinatorCancellationIsNotReportedAsAStartedCapture() async { - let capture = StubPTTCapture( - stopResult: (nil, 0), - startError: CancellationError() - ) - let session = PTTLiveVoiceSession( - sendPacket: { _ in }, - capture: capture - ) - - await #expect(throws: CancellationError.self) { - try await session.start() - } - } - - @Test func interruptedShortCaptureIsCanceledEvenAfterLongHold() async throws { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("ptt-interrupted-test-\(UUID().uuidString).m4a") - _ = FileManager.default.createFile(atPath: url.path, contents: Data([0x00])) - defer { try? FileManager.default.removeItem(at: url) } - - let capture = StubPTTCapture(stopResult: (url, 1)) - var sentPackets: [Data] = [] - var now = Date() - let session = PTTLiveVoiceSession( - sendPacket: { sentPackets.append($0) }, - capture: capture, - now: { now }, - burstID: Data(repeating: 0xA5, count: 8) - ) - - try await session.start() - now = now.addingTimeInterval(2) - let result = await session.finish() - - #expect(result == nil) - #expect(!FileManager.default.fileExists(atPath: url.path)) - let packet = try #require(sentPackets.last.flatMap(VoiceBurstPacket.decode)) - guard case .canceled = packet.kind else { - Issue.record("Expected a canceled control packet for a subsecond interrupted capture") - return - } - } - - @Test func droppingCaptureLeaseReturnsItsCoordinatorToken() async throws { - let rawSession = CaptureLeaseSession() - let coordinator = AudioSessionCoordinator(session: rawSession) - let token = try await coordinator.acquire(.capture) {} - var lease: PTTCaptureSessionLease? = PTTCaptureSessionLease(coordinator: coordinator) - lease?.install(token) - - lease = nil - await coordinator.drain() - - #expect(rawSession.activationCalls == [true, false]) - } - - @Test func rejectedNewHoldAndStaleFinalizeLeaveNewerGenerationIdle() async { - let oldSession = GatedVoiceCaptureSession() - let newSession = GatedVoiceCaptureSession( - startError: VoiceRecorder.RecorderError.recordingInProgress - ) - var sessions: [GatedVoiceCaptureSession] = [oldSession, newSession] - let viewModel = VoiceRecordingViewModel() - viewModel.sessionProvider = { sessions.removeFirst() } - - viewModel.start(shouldShow: true) - await waitUntil { self.isRecording(viewModel.state) } - viewModel.finish(completion: { _ in }) - await waitUntil { oldSession.finishStarted } - - viewModel.start(shouldShow: true) - await waitUntil { newSession.cancelCount == 1 && viewModel.state == .idle } - #expect(viewModel.state == .idle) - - // The older finalize now fails after the newer press has completed. - // It must not replace the newer generation's idle state with an alert. - oldSession.resolveFinish(with: nil) - for _ in 0..<20 { - await Task.yield() - } - #expect(viewModel.state == .idle) - } - - @Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async { - let session = GatedVoiceCaptureSession() - let viewModel = VoiceRecordingViewModel() - viewModel.sessionProvider = { session } - - viewModel.start(shouldShow: true) - await waitUntil { self.isRecording(viewModel.state) } - - viewModel.panicWipe() - - #expect(session.panicCancelCount == 1) - #expect(viewModel.state == .idle) - #expect(!viewModel.isLiveStreaming) - } - - @Test func panicInvalidatesARecordingAlreadyFinalizing() async throws { - let session = GatedVoiceCaptureSession() - let viewModel = VoiceRecordingViewModel() - viewModel.sessionProvider = { session } - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("voice-panic-\(UUID().uuidString).m4a") - try Data([0x01]).write(to: url) - var delivered = false - - viewModel.start(shouldShow: true) - await waitUntil { self.isRecording(viewModel.state) } - viewModel.finish { _ in delivered = true } - await waitUntil { session.finishStarted } - - viewModel.panicWipe() - session.resolveFinish(with: url) - await waitUntil { - !FileManager.default.fileExists(atPath: url.path) - } - - #expect(!delivered) - #expect(viewModel.state == .idle) - } - - @Test func liveSessionPanicStopsCaptureWithoutSendingControl() { - let capture = StubPTTCapture(stopResult: (nil, 0)) - var sentPackets: [Data] = [] - let session = PTTLiveVoiceSession( - sendPacket: { sentPackets.append($0) }, - capture: capture - ) - - session.panicCancelSynchronously() - - #expect(capture.cancelCount == 1) - #expect(sentPackets.isEmpty) - } -} diff --git a/bitchatTests/VoiceNotePlaybackControllerTests.swift b/bitchatTests/VoiceNotePlaybackControllerTests.swift deleted file mode 100644 index 29991d0e..00000000 --- a/bitchatTests/VoiceNotePlaybackControllerTests.swift +++ /dev/null @@ -1,158 +0,0 @@ -// -// VoiceNotePlaybackControllerTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import AVFoundation -import Foundation -@testable import bitchat - -/// Thread-safe: the coordinator invokes it on its private serial queue (the -/// blocking session IPC runs off the main thread) while the test reads from -/// the main actor. -private final class RecordingAudioSession: SessionApplying, @unchecked Sendable { - private let lock = NSLock() - private var _activationCalls: [Bool] = [] - private var _categoryCallCount = 0 - private var _categoryError: Error? - - var activationCalls: [Bool] { lock.withLock { _activationCalls } } - var categoryCallCount: Int { lock.withLock { _categoryCallCount } } - var categoryError: Error? { - get { lock.withLock { _categoryError } } - set { lock.withLock { _categoryError = newValue } } - } - - func setCategory(_ category: AudioSessionCoordinator.Category) throws { - try lock.withLock { - _categoryCallCount += 1 - if let error = _categoryError { - _categoryError = nil - throw error - } - } - } - - func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { - lock.withLock { _activationCalls.append(active) } - } -} - -private struct PlaybackSessionError: Error {} - -@MainActor -struct VoiceNotePlaybackControllerTests { - /// A short silent PCM file `AVAudioPlayer` can open on the test host. - private func makeTempVoiceNote(seconds: Double = 0.2) throws -> URL { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("voice-note-test-\(UUID().uuidString).caf") - let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)) - let file = try AVAudioFile(forWriting: url, settings: format.settings) - let frames = AVAudioFrameCount(seconds * 16_000) - let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames)) - buffer.frameLength = frames - try file.write(from: buffer) - return url - } - - /// Waits for an async settle, then asserts. - /// - /// The deadline is deliberately far larger than the work it waits on. Every - /// condition here depends on a `@MainActor` Task that playback schedules - /// (the session acquire and its failure path), and on a CI runner executing - /// many suites in parallel that Task can simply not be scheduled for - /// seconds. At five seconds this timed out on CI and reported *two* - /// failures — the wait itself, and the `!isPlaying` that the un-run failure - /// path had not yet reset — which reads like a playback bug rather than a - /// starved scheduler. - /// - /// A generous deadline costs nothing when the condition holds, since this - /// returns as soon as it does; it only extends the genuine-failure case. - private func waitUntil( - _ condition: () -> Bool, - sourceLocation: SourceLocation = #_sourceLocation - ) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout)) - while !condition(), ContinuousClock.now < deadline { - await Task.yield() - try? await Task.sleep(nanoseconds: 1_000_000) - } - #expect(condition(), sourceLocation: sourceLocation) - } - - @Test func seekWhilePausedDoesNotAcquireSession() throws { - let session = RecordingAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - let url = try makeTempVoiceNote() - defer { try? FileManager.default.removeItem(at: url) } - - let controller = VoiceNotePlaybackController( - url: url, - sessionCoordinator: coordinator, - exclusivity: VoiceNotePlaybackCoordinator() - ) - controller.seek(to: 0.5) - - // The scrub position moved (the player is real and ready)... - #expect(controller.progress > 0.25) - // ...but nothing is audible, so the session must not be held: an - // acquired-while-paused token on a discarded row would pin the - // session (and any escalated category) forever. - #expect(session.activationCalls.isEmpty) - #expect(!controller.isPlaying) - } - - @Test func deinitReleasesSessionAndStopsPlayback() async throws { - let session = RecordingAudioSession() - let coordinator = AudioSessionCoordinator(session: session) - let url = try makeTempVoiceNote() - defer { try? FileManager.default.removeItem(at: url) } - - // Fresh exclusivity slot: a parallel test's play() must not pause - // this controller while its session acquire is in flight. - var controller: VoiceNotePlaybackController? = VoiceNotePlaybackController( - url: url, - sessionCoordinator: coordinator, - exclusivity: VoiceNotePlaybackCoordinator() - ) - controller?.play() - // The session acquire is asynchronous now (its blocking IPC runs off - // the main thread), so await the activation instead of asserting - // right after play(). - await waitUntil { session.activationCalls == [true] } - - // Navigating away discards the row's @StateObject mid-playback: - // deinit must release the session hold (a fire-and-forget hop onto - // the coordinator's queue). - controller = nil - - await waitUntil { session.activationCalls == [true, false] } - } - - @Test func activationFailureDoesNotStartUnregisteredPlayback() async throws { - let session = RecordingAudioSession() - session.categoryError = PlaybackSessionError() - let coordinator = AudioSessionCoordinator(session: session) - let url = try makeTempVoiceNote(seconds: 30) - defer { try? FileManager.default.removeItem(at: url) } - - let controller = VoiceNotePlaybackController( - url: url, - sessionCoordinator: coordinator, - exclusivity: VoiceNotePlaybackCoordinator() - ) - controller.play() - - await waitUntil { - session.categoryCallCount == 1 && !controller.isPlaybackStartPending - } - - #expect(session.activationCalls.isEmpty) - #expect(!controller.isPlaying) - #expect(controller.currentTime == 0) - } -} diff --git a/bitchatTests/VoiceRecorderTests.swift b/bitchatTests/VoiceRecorderTests.swift deleted file mode 100644 index 68069f11..00000000 --- a/bitchatTests/VoiceRecorderTests.swift +++ /dev/null @@ -1,455 +0,0 @@ -// -// VoiceRecorderTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -@testable import bitchat - -/// One-shot event that bridges synchronous production seams to async tests -/// without blocking a shared dispatch worker while waiting for the seam. -private final class VoiceRecorderAsyncEvent: @unchecked Sendable { - private let lock = NSLock() - private var isSignaled = false - private var waiters: [CheckedContinuation] = [] - - func wait() async { - await withCheckedContinuation { continuation in - let resumeImmediately = lock.withLock { () -> Bool in - guard !isSignaled else { return true } - waiters.append(continuation) - return false - } - if resumeImmediately { - continuation.resume() - } - } - } - - func signal() { - let continuations = lock.withLock { () -> [CheckedContinuation] in - guard !isSignaled else { return [] } - isSignaled = true - defer { waiters.removeAll() } - return waiters - } - continuations.forEach { $0.resume() } - } -} - -private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable { - private let lock = NSLock() - private let activationGate = DispatchSemaphore(value: 0) - private let activationBegan = VoiceRecorderAsyncEvent() - private let shouldGateFirstActivation: Bool - private var gatedFirstActivation = false - private var _activationCalls: [Bool] = [] - - init(gateFirstActivation: Bool = false) { - self.shouldGateFirstActivation = gateFirstActivation - } - - var activationCalls: [Bool] { lock.withLock { _activationCalls } } - - func setCategory(_ category: AudioSessionCoordinator.Category) throws {} - - func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws { - let shouldWait = lock.withLock { () -> Bool in - _activationCalls.append(active) - guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false } - gatedFirstActivation = true - return true - } - if shouldWait { - activationBegan.signal() - activationGate.wait() - } - } - - func waitUntilActivationBegan() async { - await activationBegan.wait() - } - - func resumeActivation() { - activationGate.signal() - } -} - -private final class TestVoiceAudioRecorder: VoiceAudioRecording { - let prepareResult: Bool - let recordResult: Bool - - private let lock = NSLock() - private var _isRecording = false - private var _isMeteringEnabled = false - private var _prepareCallCount = 0 - private var _recordedDurations: [TimeInterval] = [] - private var _stopCallCount = 0 - - init(prepareResult: Bool, recordResult: Bool) { - self.prepareResult = prepareResult - self.recordResult = recordResult - } - - var isRecording: Bool { lock.withLock { _isRecording } } - var isMeteringEnabled: Bool { - get { lock.withLock { _isMeteringEnabled } } - set { lock.withLock { _isMeteringEnabled = newValue } } - } - var prepareCallCount: Int { lock.withLock { _prepareCallCount } } - var recordedDurations: [TimeInterval] { lock.withLock { _recordedDurations } } - var stopCallCount: Int { lock.withLock { _stopCallCount } } - - func prepareToRecord() -> Bool { - lock.withLock { _prepareCallCount += 1 } - return prepareResult - } - - func record(forDuration duration: TimeInterval) -> Bool { - lock.withLock { - _recordedDurations.append(duration) - if recordResult { - _isRecording = true - } - } - return recordResult - } - - func stop() { - lock.withLock { - _stopCallCount += 1 - _isRecording = false - } - } - - /// Models `record(forDuration:)` reaching its duration cap before the - /// caller invokes `VoiceRecorder.stopRecording(owner:)`. - func simulateAutomaticStop() { - lock.withLock { _isRecording = false } - } -} - -private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating { - struct Plan { - let prepareResult: Bool - let recordResult: Bool - - static let success = Plan(prepareResult: true, recordResult: true) - } - - private let lock = NSLock() - private var plans: [Plan] - private var _recorders: [TestVoiceAudioRecorder] = [] - private var _urls: [URL] = [] - - init(plans: [Plan]) { - self.plans = plans - } - - var recorders: [TestVoiceAudioRecorder] { lock.withLock { _recorders } } - var urls: [URL] { lock.withLock { _urls } } - - func makeRecorder(url: URL) throws -> any VoiceAudioRecording { - let plan = lock.withLock { plans.isEmpty ? .success : plans.removeFirst() } - // AVAudioRecorder creates its output during initialization. A real - // byte on disk lets the tests distinguish preserve from delete. - try Data([0x01]).write(to: url) - let recorder = TestVoiceAudioRecorder( - prepareResult: plan.prepareResult, - recordResult: plan.recordResult - ) - lock.withLock { - _recorders.append(recorder) - _urls.append(url) - } - return recorder - } -} - -/// One-shot async gate that proves `VoiceRecorder.stopRecording` has reached -/// its actor-reentrant padding boundary, then holds it there until the test has -/// exercised a competing owner. Unlike `Task.yield()` plus a short real sleep, -/// this remains deterministic when the full test suite saturates the executor. -private final class VoiceRecorderPaddingGate: @unchecked Sendable { - private let lock = NSLock() - private let entered = VoiceRecorderAsyncEvent() - private var isOpen = false - private var openWaiters: [CheckedContinuation] = [] - - func wait() async { - await withCheckedContinuation { continuation in - let resumeImmediately = lock.withLock { () -> Bool in - guard !isOpen else { return true } - openWaiters.append(continuation) - return false - } - entered.signal() - if resumeImmediately { - continuation.resume() - } - } - } - - func waitUntilEntered() async { - await entered.wait() - } - - func open() { - let waiters = lock.withLock { () -> [CheckedContinuation] in - isOpen = true - defer { openWaiters.removeAll() } - return openWaiters - } - waiters.forEach { $0.resume() } - } -} - -@MainActor -struct VoiceRecorderTests { - private func makeTemporaryDirectory() throws -> URL { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("voice-recorder-tests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url - } - - @Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws { - let directory = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let session = VoiceRecorderTestSession(gateFirstActivation: true) - let coordinator = AudioSessionCoordinator(session: session) - let factory = TestVoiceAudioRecorderFactory(plans: [.success]) - let voiceRecorder = VoiceRecorder( - sessionCoordinator: coordinator, - recorderFactory: factory, - permissionGranted: { true }, - paddingInterval: 0, - outputDirectory: directory - ) - let owner = VoiceRecorder.RecordingOwner() - - let startTask = Task { try await voiceRecorder.startRecording(owner: owner) } - await session.waitUntilActivationBegan() - - await voiceRecorder.cancelRecording(owner: owner) - session.resumeActivation() - - do { - _ = try await startTask.value - Issue.record("The canceled session acquire unexpectedly started recording") - } catch { - #expect(error is CancellationError) - } - await coordinator.drain() - - #expect(factory.recorders.isEmpty) - #expect(session.activationCalls == [true, false]) - } - - @Test func prepareFailureCleansUpAndAllowsTheNextRecording() async throws { - try await verifyFailedStart( - firstPlan: .init(prepareResult: false, recordResult: true), - expectedPrepareCalls: 1, - expectedRecordCalls: 0 - ) - } - - @Test func recordFailureCleansUpAndAllowsTheNextRecording() async throws { - try await verifyFailedStart( - firstPlan: .init(prepareResult: true, recordResult: false), - expectedPrepareCalls: 1, - expectedRecordCalls: 1 - ) - } - - @Test func automaticStopReturnsAndPreservesFileThenNextRecordingWorks() async throws { - let directory = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let session = VoiceRecorderTestSession() - let coordinator = AudioSessionCoordinator(session: session) - let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success]) - let voiceRecorder = VoiceRecorder( - sessionCoordinator: coordinator, - recorderFactory: factory, - permissionGranted: { true }, - paddingInterval: 0, - outputDirectory: directory - ) - let firstOwner = VoiceRecorder.RecordingOwner() - - let firstURL = try await voiceRecorder.startRecording(owner: firstOwner) - let firstRecorder = try #require(factory.recorders.first) - #expect(firstRecorder.recordedDurations == [120]) - firstRecorder.simulateAutomaticStop() - - let finishedURL = await voiceRecorder.stopRecording(owner: firstOwner) - await coordinator.drain() - #expect(finishedURL == firstURL) - #expect(firstRecorder.stopCallCount == 0) - #expect(FileManager.default.fileExists(atPath: firstURL.path)) - #expect(session.activationCalls == [true, false]) - - let secondOwner = VoiceRecorder.RecordingOwner() - let secondURL = try await voiceRecorder.startRecording(owner: secondOwner) - #expect(secondURL != firstURL) - #expect(FileManager.default.fileExists(atPath: firstURL.path)) - #expect(factory.recorders.count == 2) - let secondRecorder = try #require(factory.recorders.last) - - #expect(await voiceRecorder.stopRecording(owner: secondOwner) == secondURL) - await coordinator.drain() - #expect(secondRecorder.stopCallCount == 1) - #expect(session.activationCalls == [true, false, true, false]) - #expect(FileManager.default.fileExists(atPath: firstURL.path)) - #expect(FileManager.default.fileExists(atPath: secondURL.path)) - } - - @Test func rejectedNewHoldCancelCannotDeleteHoldFinishingDuringPadding() async throws { - let directory = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let session = VoiceRecorderTestSession() - let coordinator = AudioSessionCoordinator(session: session) - let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success]) - let paddingGate = VoiceRecorderPaddingGate() - let voiceRecorder = VoiceRecorder( - sessionCoordinator: coordinator, - recorderFactory: factory, - permissionGranted: { true }, - paddingInterval: 0.05, - outputDirectory: directory, - testingHooks: .init(waitForStopPadding: { _ in await paddingGate.wait() }) - ) - let finishingHold = VoiceNoteCaptureSession(recorder: voiceRecorder) - let rejectedHold = VoiceNoteCaptureSession(recorder: voiceRecorder) - - try await finishingHold.start() - let firstURL = try #require(factory.urls.first) - let finishTask = Task { await finishingHold.finish() } - await paddingGate.waitUntilEntered() - - await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) { - try await rejectedHold.start() - } - // This is the view-model error path that used to globally cancel the - // shared recorder and delete `firstURL` during the padding sleep. - await rejectedHold.cancel() - paddingGate.open() - - #expect(await finishTask.value == firstURL) - await coordinator.drain() - #expect(FileManager.default.fileExists(atPath: firstURL.path)) - #expect(factory.recorders[0].stopCallCount == 1) - #expect(session.activationCalls == [true, false]) - } - - @Test func stalePreviousHoldCancelCannotStopNewRecording() async throws { - let directory = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let session = VoiceRecorderTestSession() - let coordinator = AudioSessionCoordinator(session: session) - let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success]) - let voiceRecorder = VoiceRecorder( - sessionCoordinator: coordinator, - recorderFactory: factory, - permissionGranted: { true }, - paddingInterval: 0, - outputDirectory: directory - ) - let previousHold = VoiceNoteCaptureSession(recorder: voiceRecorder) - let currentHold = VoiceNoteCaptureSession(recorder: voiceRecorder) - - try await previousHold.start() - let firstURL = try #require(factory.urls.first) - #expect(await previousHold.finish() == firstURL) - - try await currentHold.start() - let secondURL = try #require(factory.urls.last) - let secondRecorder = try #require(factory.recorders.last) - await previousHold.cancel() - - #expect(secondRecorder.isRecording) - #expect(FileManager.default.fileExists(atPath: secondURL.path)) - #expect(await currentHold.finish() == secondURL) - await coordinator.drain() - #expect(secondRecorder.stopCallCount == 1) - #expect(FileManager.default.fileExists(atPath: firstURL.path)) - #expect(FileManager.default.fileExists(atPath: secondURL.path)) - } - - @Test func classicSessionPanicStopsRecorderAndDeletesFileBeforeReturning() async throws { - let directory = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let rawSession = VoiceRecorderTestSession() - let coordinator = AudioSessionCoordinator(session: rawSession) - let factory = TestVoiceAudioRecorderFactory(plans: [.success]) - let voiceRecorder = VoiceRecorder( - sessionCoordinator: coordinator, - recorderFactory: factory, - permissionGranted: { true }, - paddingInterval: 0, - outputDirectory: directory - ) - let capture = VoiceNoteCaptureSession(recorder: voiceRecorder) - - try await capture.start() - let url = try #require(factory.urls.first) - let recorder = try #require(factory.recorders.first) - - capture.panicCancelSynchronously() - - #expect(recorder.stopCallCount == 1) - #expect(!recorder.isRecording) - #expect(!FileManager.default.fileExists(atPath: url.path)) - await coordinator.drain() - #expect(rawSession.activationCalls == [true, false]) - } - - private func verifyFailedStart( - firstPlan: TestVoiceAudioRecorderFactory.Plan, - expectedPrepareCalls: Int, - expectedRecordCalls: Int - ) async throws { - let directory = try makeTemporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let session = VoiceRecorderTestSession() - let coordinator = AudioSessionCoordinator(session: session) - let factory = TestVoiceAudioRecorderFactory(plans: [firstPlan, .success]) - let voiceRecorder = VoiceRecorder( - sessionCoordinator: coordinator, - recorderFactory: factory, - permissionGranted: { true }, - paddingInterval: 0, - outputDirectory: directory - ) - let failedOwner = VoiceRecorder.RecordingOwner() - - await #expect(throws: VoiceRecorder.RecorderError.failedToStartRecording) { - try await voiceRecorder.startRecording(owner: failedOwner) - } - await coordinator.drain() - - let failedRecorder = try #require(factory.recorders.first) - let failedURL = try #require(factory.urls.first) - #expect(failedRecorder.prepareCallCount == expectedPrepareCalls) - #expect(failedRecorder.recordedDurations.count == expectedRecordCalls) - #expect(!FileManager.default.fileExists(atPath: failedURL.path)) - #expect(session.activationCalls == [true, false]) - - let nextOwner = VoiceRecorder.RecordingOwner() - let nextURL = try await voiceRecorder.startRecording(owner: nextOwner) - #expect(FileManager.default.fileExists(atPath: nextURL.path)) - #expect(await voiceRecorder.stopRecording(owner: nextOwner) == nextURL) - await coordinator.drain() - #expect(session.activationCalls == [true, false, true, false]) - } -} diff --git a/bitchatTests/XChaCha20Poly1305CompatTests.swift b/bitchatTests/XChaCha20Poly1305CompatTests.swift index a90e1de0..9b607a9b 100644 --- a/bitchatTests/XChaCha20Poly1305CompatTests.swift +++ b/bitchatTests/XChaCha20Poly1305CompatTests.swift @@ -13,7 +13,7 @@ import struct Foundation.Data struct XChaCha20Poly1305CompatTests { @Test func sealAndOpenRoundtrip() throws { - let plaintext = Data("Hello, XChaCha20-Poly1305!".utf8) + let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let nonce = Data(repeating: 0x24, count: 24) @@ -29,10 +29,10 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealAndOpenWithAAD() throws { - let plaintext = Data("Secret message".utf8) + let plaintext = "Secret message".data(using: .utf8)! let key = Data(repeating: 0xAB, count: 32) let nonce = Data(repeating: 0xCD, count: 24) - let aad = Data("additional authenticated data".utf8) + let aad = "additional authenticated data".data(using: .utf8)! let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad) let decrypted = try XChaCha20Poly1305Compat.open( @@ -47,7 +47,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealProducesDifferentCiphertextWithDifferentNonces() throws { - let plaintext = Data("Same plaintext".utf8) + let plaintext = "Same plaintext".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let nonce1 = Data(repeating: 0x01, count: 24) let nonce2 = Data(repeating: 0x02, count: 24) @@ -59,7 +59,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnShortKey() { - let plaintext = Data("Test".utf8) + let plaintext = "Test".data(using: .utf8)! let shortKey = Data(repeating: 0x42, count: 16) let nonce = Data(repeating: 0x24, count: 24) @@ -73,7 +73,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnLongKey() { - let plaintext = Data("Test".utf8) + let plaintext = "Test".data(using: .utf8)! let longKey = Data(repeating: 0x42, count: 64) let nonce = Data(repeating: 0x24, count: 24) @@ -87,7 +87,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnEmptyKey() { - let plaintext = Data("Test".utf8) + let plaintext = "Test".data(using: .utf8)! let emptyKey = Data() let nonce = Data(repeating: 0x24, count: 24) @@ -116,7 +116,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnShortNonce() { - let plaintext = Data("Test".utf8) + let plaintext = "Test".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let shortNonce = Data(repeating: 0x24, count: 12) @@ -130,7 +130,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnLongNonce() { - let plaintext = Data("Test".utf8) + let plaintext = "Test".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let longNonce = Data(repeating: 0x24, count: 32) @@ -144,7 +144,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func sealThrowsOnEmptyNonce() { - let plaintext = Data("Test".utf8) + let plaintext = "Test".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let emptyNonce = Data() @@ -173,7 +173,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func openFailsWithWrongKey() throws { - let plaintext = Data("Secret".utf8) + let plaintext = "Secret".data(using: .utf8)! let correctKey = Data(repeating: 0x42, count: 32) let wrongKey = Data(repeating: 0x43, count: 32) let nonce = Data(repeating: 0x24, count: 24) @@ -195,7 +195,7 @@ struct XChaCha20Poly1305CompatTests { } @Test func openFailsWithTamperedCiphertext() throws { - let plaintext = Data("Secret".utf8) + let plaintext = "Secret".data(using: .utf8)! let key = Data(repeating: 0x42, count: 32) let nonce = Data(repeating: 0x24, count: 24) diff --git a/docs/ARCHITECTURE_V2.md b/docs/ARCHITECTURE_V2.md deleted file mode 100644 index 0284e830..00000000 --- a/docs/ARCHITECTURE_V2.md +++ /dev/null @@ -1,48 +0,0 @@ -# Architecture V2 - -This branch starts a larger simplification effort focused on performance, reliability, and maintainability without rewriting the transport and protocol stack in one pass. - -## What Landed - -1. `AppRuntime` is now the composition root for startup, lifecycle, notification routing, and shared-content intake. -2. `BitchatApp` is reduced to app wiring and no longer owns operational startup logic. -3. `AppEventStream` introduces a typed async event surface for app-level events. -4. `PeerHandle` and `ConversationID` establish canonical identity/conversation keys above the existing transport-specific IDs. -5. `IdentityResolver` mirrors the current peer graph into canonical handles. -6. `ConversationStore` now owns app-layer conversation context for the active public channel and selected private conversation, while also mirroring the current public/private message timelines under canonical IDs. -7. `AppRuntime` now owns Tor, screenshot, and Nostr reconnect observer wiring instead of leaving that app-lifecycle graph inside `ChatViewModel`. -8. `PublicChatModel`, `PrivateInboxModel`, `PrivateConversationModel`, `ConversationUIModel`, `LocationChannelsModel`, and `PeerListModel` provide focused view-facing models for public chat, private inbox state, selected-DM state, composer/message interactions, location-channel state, and peer counts. `PrivateInboxModel` now reads its message, unread, and selection state directly from `ConversationStore` instead of merging through `PrivateChatManager`. -9. `AppChromeModel` now owns app-chrome presentation state for nickname editing, fingerprint routing, app-info/location sheet visibility, unread-private-chat affordances, and screenshot/privacy UI. -10. `ContentView`, `MessageListView`, `LocationChannelsSheet`, `LocationNotesView`, `CommandSuggestionsView`, `FingerprintView`, and `VerificationSheetView` no longer talk directly to `ChatViewModel` or location singletons for their primary app-layer state; they consume feature-scoped models injected from `AppRuntime`. -11. `PeerListModel` now consumes the runtime-owned `LocationChannelsModel` instead of reading the location manager directly, and QR scan parsing is funneled through `VerificationModel` rather than the SwiftUI views. -12. `ChatViewModel` is thinner on the app side: startup wiring now lives in `ChatViewModelBootstrapper`, send/lifecycle/transport seams live in focused coordinators, and peer-list notification/timer state now lives in `ChatPeerListCoordinator` instead of the main view model. - -## Why This Shape - -- `ChatViewModel` remains the active domain/UI bridge so the app can keep working while we migrate feature-by-feature. -- The new runtime/store layer gives us a safe place to move lifecycle, routing, and cross-cutting state before we split the UI into smaller feature models. -- `ConversationStore` now owns the selected conversation context and private-inbox read state, which lets feature models read one runtime-managed source of truth for focus/navigation and DM timelines even while legacy mutation paths still flow through `ChatViewModel`. -- The new chrome model lets us remove presentation-only state from `ChatViewModel` without forcing a big rewrite of the domain/message paths. -- The private-conversation model pulls selected-DM header identity, availability, encryption, and favorite state out of `ContentView`, which keeps the view from re-implementing peer lookup rules. -- The conversation UI model centralizes composer state, autocomplete, message formatting, and row-level actions so the main views no longer proxy most conversation interactions directly to `ChatViewModel`. -- The location channels model gives the remaining location-heavy views a focused adapter over the location/bookmark/network singletons, which reduces direct global reads in the view layer and makes smoke/architecture tests more reliable. -- Reusing that same location model inside the peer-list feature keeps geohash/mesh presence presentation on one runtime-owned source of truth instead of mixing view models with singleton reads. -- `BitchatApp` now injects only feature-scoped models into the view tree, which removes the old pattern of keeping `ChatViewModel` as a global environment object for the entire app shell. -- Routing QR scan parsing through the verification model keeps the verification UI as a passive renderer and gives us a clearer seam for future camera/manual-scan behavior changes. -- The new `ChatViewModel` coordinators are intentionally transitional: they shrink the main file and isolate composition, lifecycle, transport, and peer-list responsibilities without forcing a risky rewrite of the transport/BLE core in the same pass. - -## Next Steps - -1. Migrate view composition from one global `ChatViewModel` to focused feature models backed by `ConversationStore`. -2. Route more inbound transport events through typed app/domain events instead of delegate plus notification fan-out. -3. Move private/public conversation mutation behind the new store instead of still mirroring legacy message writes from `ChatViewModel`. -4. Replace remaining singleton-heavy seams with injected runtime services where practical. -5. Revisit actor isolation for identity and conversation state once the remaining message/peer models are safe to move off the main actor. - -## Transport Follow-Up - -The Bluetooth architecture branch begins step 2 by adding a typed `TransportEvent` boundary while preserving the legacy `BitchatDelegate` bridge. New transport code should emit typed events first, with delegate forwarding used only as a compatibility adapter during migration. - -The branch also starts carving performance-sensitive BLE scheduling state out of `BLEService`: pending write backpressure now lives in `BLEOutboundWriteBuffer`, giving the outbound hot path a focused, unit-tested component before deeper fragmentation and link-scheduler work. - -The next transport slice continues that path by extracting ingress link memory and outbound fanout selection. `BLEIngressLinkRegistry` now owns duplicate/last-hop tracking, ingress peer memory, and direct-link sender binding decisions, while `BLEFanoutSelector` owns deterministic broadcast subsetting and ingress-peer/link exclusion. `BLEService` still coordinates CoreBluetooth callbacks, but these hot-path decisions are now pure, covered units instead of inline dictionary logic. diff --git a/docs/ARTI-BINARY-PROVENANCE.md b/docs/ARTI-BINARY-PROVENANCE.md deleted file mode 100644 index 9f44db3c..00000000 --- a/docs/ARTI-BINARY-PROVENANCE.md +++ /dev/null @@ -1,94 +0,0 @@ -# Arti Binary Provenance - -This repo vendors a prebuilt Arti static-library xcframework at: - -`localPackages/Arti/Frameworks/arti.xcframework` - -SwiftPM links it through `localPackages/Arti/Package.swift` as the binary target named `arti`. Treat changes to this artifact like dependency updates: review the Rust sources, lockfile, build script, produced headers, and artifact hashes together. - -## Source Inputs - -- Rust workspace: `localPackages/Arti/Cargo.toml` -- Crate: `localPackages/Arti/arti-bitchat` -- Dependency lockfile: `localPackages/Arti/Cargo.lock` -- Build script: `localPackages/Arti/build-ios.sh` -- Exported C header: `localPackages/Arti/Frameworks/include/arti.h` - -The crate declares `rust-version = "1.90"` and uses `arti-client` / `tor-rtcompat` `0.38` with minimal Tokio/Rustls features. The current lockfile requires Rust 1.90 or newer. The build script currently targets: - -- `aarch64-apple-ios` -- `aarch64-apple-ios-sim` -- `x86_64-apple-ios` -- `aarch64-apple-darwin` -- `x86_64-apple-darwin` - -It builds release static libraries with size-oriented flags (`opt-level=z`, fat LTO, one codegen unit, `panic=abort`, stripped symbols), normalizes static-archive metadata with `xcrun libtool -static -D`, then packages them with `xcodebuild -create-xcframework`. - -## Regenerating The Artifact - -From the repo root: - -```sh -cd localPackages/Arti -rustup toolchain install 1.96.0 -rustup target add --toolchain 1.96.0 aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios aarch64-apple-darwin x86_64-apple-darwin -rustup run 1.96.0 cargo install cbindgen --version 0.29.4 --locked -./build-ios.sh -``` - -`build-ios.sh` defaults to the audited `1.96.0` toolchain and refuses a rustc -version other than `1.96.0`; it likewise requires cbindgen `0.29.4`. Set -`RUST_TOOLCHAIN`, `RUSTC_VERSION`, or `CBINDGEN_VERSION` explicitly only when -intentionally updating the binary provenance and hashes below. - -After rebuilding, verify that: - -- `Cargo.lock` changes are intentional and reviewed. -- `Frameworks/include/arti.h` still matches the exported FFI functions used by `TorManager`. -- `Frameworks/arti.xcframework` contains iOS device arm64, universal iOS simulator arm64+x86_64, and universal macOS arm64+x86_64 slices. -- The main app still passes iOS tests and the macOS build. - -## Audited Rebuild - -The July 2026 artifact below was rebuilt from source on this host with: - -```text -rustc 1.96.0 (ac68faa20 2026-05-25) -cargo 1.96.0 (30a34c682 2026-05-25) -rustup 1.29.0 (28d1352db 2026-03-05) -cbindgen 0.29.4 -Xcode 26.6 -Build version 17F113 -``` - -Rust 1.86.0 was also checked during the audit and no longer builds this lockfile because `typed-index-collections@3.4.0` requires Rust 1.90.0 or newer. - -The build script now normalizes static-archive metadata and writes a stable xcframework `Info.plist`. Two consecutive no-source-change rebuilds on this host produced the same hashes below. - -## Current Artifact Hashes - -Run this from the repo root to verify the checked-in artifact: - -```sh -find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 | sort -z | xargs -0 shasum -a 256 -``` - -Current hashes: - -```text -cac99db408280bbef15cae8ce64c8ccdbf2e8863c205168d59f83fe8ab680f94 localPackages/Arti/Frameworks/arti.xcframework/Info.plist -551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/Headers/arti.h -5461a231a786812e91e7965290031ea3479fdc5c6459553e46988ecafbbc2a3d localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a -551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/Headers/arti.h -af8f5f636eb6affb309b3e44f13e48498eb2540c77af44ddcd7fdf9241b1e317 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/libarti_bitchat.a -551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/Headers/arti.h -7c9afe98227f1767567ddcd4e35d9dfffe70309c302c4dbc9a6c9d6aeefab007 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/libarti_bitchat.a -``` - -## Review Checklist - -- Record `rustc --version`, `cargo --version`, `cbindgen --version`, and `xcodebuild -version` in the PR when refreshing the binary. -- Include the hash output above after any binary change. -- If a rebuild changes only xcframework/library bytes, record the new hashes and app validation evidence in the PR. -- Keep `target/`, `.build/`, and `.swiftpm/` out of source control. -- Do not accept an xcframework-only update without matching source, lockfile, or build-script evidence explaining where it came from. diff --git a/docs/BLE-ARCHITECTURE-V3.md b/docs/BLE-ARCHITECTURE-V3.md deleted file mode 100644 index 9f607cb3..00000000 --- a/docs/BLE-ARCHITECTURE-V3.md +++ /dev/null @@ -1,248 +0,0 @@ -# BLE Transport Architecture V3 - -The plan of record for restructuring `BLEService` from an 8.3k-line god -object into a layered mesh stack. ARCHITECTURE_V2 rebuilt the app layer -above the transport and deliberately deferred the transport itself; this -document covers that remainder: what already landed, the target shape, and -the order for the rest. - -## Why the satellite strategy stalled - -V2's transport approach was to peel pure policies and closure-driven -handlers out of `BLEService` while the class kept coordinating. The ~30 -pure policy structs were a clear win. The five big handler extractions -were not: each needed an "environment" of 20–30 closures that weakly -capture the service and hop queues back into its state. Logic left, but -state ownership and synchronization never moved, so extraction paid a -plumbing tax that grew as fast as the logic shrank — the five -`make*HandlerEnvironment()` factories alone were ~1.5k lines. The file -held ~60 mutable fields across four concurrency domains whose ownership -lived in comments, and every new feature added Transport requirements, -state maps, and switch cases to the same class. - -Two chronic costs came straight from that structure: queue-order -deadlocks (the July 9 main↔bleQueue ABBA freeze), and timing-dependent -tests (correctness only observable through real queues and real time). - -## Target shape - -A packet-radio stack with one rule per layer about state and threads: - -1. **`BLELinkLayer`** — the only CoreBluetooth import. Owns both managers, - scanning/advertising, duty cycle, connection scheduling, MTU, write - and notification backpressure buffers, state restoration. Speaks - `LinkEvent` up (link up/down, bytes in, writable) and `LinkCommand` - down (send bytes on link, scan/advertise policy). Knows nothing about - packets, peers, or Noise. bleQueue-confined. A `SimulatedLinkLayer` - implementing the same port gives multi-node tests real topologies with - no radios and no wall-clock waits. -2. **Mesh engine** — one serial queue owning all protocol state: wire - codec, fragmentation, dedup, relay policy, peer registry, topology, - gossip sync, Noise orchestration. Synchronous single-writer logic; the - pure policy satellites slot in unchanged. Endgame: the engine core - becomes `handle(event, now) -> [Effect]` (sans-I/O), which makes the - whole mesh property-testable and fuzzable in simulation. -3. **Feature modules** — courier, board, prekeys, private media, file - transfer, voice, diagnostics, groups, verify/vouch each own their - state and register for their message types. A new feature is a new - module, not edits to the engine. -4. **App boundary** — a small `Transport` core both transports genuinely - implement, plus capability protocols discovered with `as?` - (`MeshBridgingTransport` etc.), replacing the ~90-requirement - god-protocol and its inert defaults. - -### Concurrency contract - -State is owned one of three ways: - -- **Engine-confined** — mutated only on the serial engine queue - (`mesh.message`). Cross-thread callers use `onEngine`. -- **bleQueue-confined** — link-layer state next to CoreBluetooth objects - (link store, write/notification buffers, link-auth maps). -- **Lock-backed store** — state with legitimate cross-domain readers - (peer registry, local identity/capabilities, traffic monitor). Writes - still come from one domain; the lock exists so readers never block on - a queue. Every mutation is a single whole-transition method, so - readers never observe torn state. - -Sync-edge order (deadlock freedom by construction, debug-enforced in -`onEngine`): - -``` -main / test threads ──sync──▶ engine ──sync──▶ bleQueue - └──sync──▶ noise / identity queues (leaves) -``` - -Nothing may sync-wait in the reverse direction: bleQueue and the crypto -queues reach the engine only via `async`, and nothing sync-dispatches to -main. Two subtleties worth knowing: - -- A closure executed inside a noise-manager critical section entered - *from* an engine slot may touch engine state directly (the blocked - slot makes it exclusive) but must never sync-re-enter the engine — - that is a self-deadlock. -- bleQueue critical sections (e.g. the verified-announce link rebind) - must receive engine-derived values as arguments rather than fetching - them through `onEngine`. - -## What landed in this pass - -- **Lock-backed peer state** (`BLEPeerRegistryStore`): every main-actor - Transport read (`isPeerConnected`, nicknames, snapshots, capability - queries) reads a lock, not a queue. Runtime capability bits moved into - `BLELocalIdentityStateStore` beside the identity they ride announces - with. -- **bleQueue owns the link buffers**: `pendingPeripheralWrites`, - `pendingNotifications`, `pendingWriteBuffers` are bleQueue-confined - (their producers and drains already ran there); the notification drain - no longer invokes CoreBluetooth from a transport queue. -- **One serial engine queue**: the concurrent message queue and the - collections queue it guarded state with are one serial domain; every - barrier flag and per-field ownership comment deleted; ~98 cross-queue - hops removed. `onEngine` documents and debug-enforces the sync-edge - order — and its trap caught two latent inversions during migration - (the announce-rebind path and the noise session-generation closures). -- **Capability ports**: gateway/bridge/courier wiring, the panic - lifecycle, and radio-state reads go through `MeshBridgingTransport`, - `PanicResettingTransport`, and `BluetoothStateReporting`; no app code - casts to `BLEService` anymore. -- **Feature-owned state**: `BLEMeshPingTracker` (the /ping probe map and - per-link response budget) and `BLEPrivateMediaSessionStore` (the six - generation-keyed private-media maps plus the convergence-deferral set, - as whole-transition methods under a leaf lock), both with direct unit - tests. The private-media store also took the last routine main-actor - sync reads off the engine and turned the noise-critical-section - transitions into ordinary leaf-lock calls. Remaining feature state - (courier, board, prekeys) already lives in injected stores. -- **Transport split**: the mesh-only surface left the god-protocol. - `Transport` is core only (lifecycle, identity, snapshots, basic - messaging, noise wrappers); files/private media, voice, courier, - groups, board, diagnostics, verification, and the public archive are - eight capability protocols discovered with `as?`, alongside the - bridging/panic/radio-state ports. The inert-defaults extension is - gone; consumers that relied on a default keep its safe floor - explicitly at the call site. -- **Contract pinning**: `BLEQueueContractTests` greps the transport - sources — only `onEngine` may sync-enter the engine, transport code - never sync-dispatches to main, and the collections queue stays - deleted (waivable per line with `queue-contract-ok:` plus a reason). - -Full suite green throughout (1,964 tests), identical wall-clock — BLE -throughput is nowhere near what one serial queue sustains. - -## Remaining roadmap (in order) - -1. **Link-layer extraction.** Move the CB delegates, scheduling, duty - cycle, and buffers behind `LinkEvent`/`LinkCommand` ports. - - **Link-auth boundary (decided): bindings become engine-owned.** - Today `noiseAuthenticatedLinkOwners`, the rebind containment rules, - and the peer↔link binding maps live on bleQueue so that "check - binding + auth, then act" is one critical section (the rebind path - and the authenticated-send commit point in - `notifyOrEnqueueIfAccepted`). That atomicity exists to stop a - binding from changing between a security check and its action — and - the engine's serial slot provides exactly the same guarantee once - every rebind is an engine operation. The residual stolen-link risk - is unchanged: directed payloads are Noise ciphertext, useless on a - link that changed hands after the decision. Making bindings engine - state also puts the receive path in its sans-I/O shape: the link - layer reports `received(bytes, linkID)` and the engine resolves the - sender binding, instead of the CB delegate resolving peers before - handoff. The link layer keeps only physical link state (CB objects, - connect/subscribe lifecycles, backpressure buffers) keyed by opaque - link IDs. - - Extraction order: (a) the binding-free radio half — scanning, - advertising, duty cycle, connection budget/scheduling — moves first - (it makes no peer decisions); (b) bindings + link-auth migrate to - the engine, converting `readLinkState` callers; (c) the delegates - shrink to event emission and move behind the port. - - **(a) and (b) are done.** (a) landed as `BLERadioController` - (#1539). (b) landed in two steps: #1540 cohered the loose maps into - `BLELinkAuthState` + `BLELinkBindings` (still bleQueue-owned, - behavior-identical), and the option-B flip then moved ownership to - the engine. Since the flip: - - - `linkAuth`/`linkBindings` are engine-owned behind a DEBUG - `dispatchPrecondition` trap; bleQueue code cannot touch them. - - The receive path is in its sans-I/O shape: bleQueue decodes - frames and hands `(packet, linkID)` up through - `ingestDecodedPacket` (which captures the panic lifecycle at the - handoff); `attributeAndHandlePacket` resolves the sender binding, - admits or rejects the claimed sender, applies raw-announce - binding, and records ingress — all on the engine. Per-link frame - order is preserved end to end (both queues are serial), which - supersedes the old batch-local TOCTOU binding. - - The rotation rebind is one engine slot - (`rebindLinkAfterVerifiedDirectAnnounce`): containment checks, - proof retirement, binding flip, reconnect decision, and - rotated-identity retirement, with only CoreBluetooth cancels - hopping to bleQueue. - - Authenticated-send eligibility (`notifyOrEnqueueIfAccepted`, - `writeOrEnqueueIfAccepted`) is checked on the engine — serialized - against rebinds by construction — and only the physical admission - (updateValue / write / backpressure queues) runs on bleQueue. - - Teardown splits: bleQueue delegates do physical work inline - (`discardPeripheralLinkPhysical`) and queue the identity half - (`retirePeripheralLinkIdentity`, binding survivor repair) to the - engine. A binding can briefly outlive its physical link; queries - that need liveness join against the physical store via - `readLinkState` (the engine→bleQueue sync direction), and the - queued retirement converges the two. -2. **Sans-I/O engine core + simulator.** Make the engine formally - `handle(event) -> [Effect]`, feed it from a `SimulatedLinkLayer`, and - move the multi-node E2E suite onto deterministic simulation (no - `waitUntil`, no timing hygiene battles). Property tests become - possible: relay-storm bounds, partition-heal convergence, dedup - soundness under duplicate floods. The remaining feature *code* moves - (courier, board, prekey, voice, file, group handlers out of the - packet switch) ride this seam as handler-registered modules instead - of getting closure-environment extractions now. - - **The simulator half is done — simulator-first.** Because the B2 - receive path already hands `(packet, linkID)` up through one choke - point, `SimulatedMesh` (bitchatTests/Simulation/) wires real - CB-free `BLEService` engines edge-to-edge through the outbound tap - and `_test_ingestFrame` (the production attribution path), with - per-edge synthetic link IDs and manual-scheduler time. Five - deterministic multi-node tests run in ~40ms: announce/bind - convergence, end-to-end Noise establishment, line-topology relay - within a TTL/frame budget, duplicate-flood dedup, and the panic - rotation single-slot rebind + containment — the scenario that - previously required two phones. Fidelity boundary: no physical - links, so fanout planning/backpressure is not exercised; protocol - behavior is. On its first day the simulator found a real bug: the - forced-announce throttle survived panic, so a rotation within - `bleForceAnnounceMinIntervalSeconds` of the last announce left the - new identity invisible until the next maintenance cycle - (`BLEAnnounceThrottle.reset()` now runs in the panic slot). - **The upward port is named and the delegates live behind it.** - `BLELinkEvent` (frameDecoded + the four physical lifecycle - transitions) is the enumerable bleQueue→engine surface; every - crossing goes through `emitLinkEvent` into one engine consumer - (`handleLinkEvent`), and the simulated mesh drives lifecycle events - through the identical enum a radio does (see - `linkDropEventRetiresBindingAndReconnectHeals`). The CoreBluetooth - delegate extensions moved to their own files — - `BLEService+LinkLayerCentralRole.swift` / - `BLEService+LinkLayerPeripheralRole.swift` — as physical - bookkeeping plus event emission; the physical-domain members they - share are `internal` with the queue contract enforced by the - existing traps and grep guards rather than access control. - - **Deliberately not done:** a formal `handle(event) -> [Effect]` - effect system, and splitting the engine-domain feature handlers - into more files. Both would flip the engine's private state - (noiseService, peerRegistry, the identity domain) to internal for - purely cosmetic file counts — the domains are already uniform - (one queue, one rule set) and mechanically guarded. The effect - formalization should ride actual feature-module extractions when a - feature earns its own module, not precede them. - -## What this is not - -No wire changes: packet formats, signing (padding is signed), the -peerID identity binding, and courier tag construction are untouched — -see the wire-landmines notes before assuming any of that is local. diff --git a/docs/CONVERSATION-STORE-DESIGN.md b/docs/CONVERSATION-STORE-DESIGN.md deleted file mode 100644 index 5aee3e46..00000000 --- a/docs/CONVERSATION-STORE-DESIGN.md +++ /dev/null @@ -1,169 +0,0 @@ -# Conversation Store: Single Source of Truth - -**Status: migration complete (steps 1–5).** `ConversationStore` is the sole -holder of message state; the feature models (`PublicChatModel`, -`PrivateInboxModel`, `PrivateConversationModel`, `PeerListModel`, -`ConversationUIModel`) observe it directly — `PublicChatModel` observes the -active `Conversation` object, so background appends never invalidate it. -`LegacyConversationStore`, `LegacyConversationStoreBridge`, and -`PublicTimelineStore` are deleted. Baselines recorded in -`bitchatTests/Performance/PerformanceBaselineTests.swift` -(`pipeline.privateIngest`, `pipeline.publicIngest`, `store.append`, -`delivery.incrementalUpdate`, `delivery.storeUpdate`). - -Deviations from the plan below, chosen at cutover: - -- **No `IdentityResolver` canonicalization layer.** Direct conversations stay - keyed by raw routing `PeerID` (`ConversationID.directPeer`). The - coordinators' ephemeral↔stable mirroring/consolidation guarantees the - selected peer's key always holds the full timeline, and no view enumerates - direct conversations as a list — the legacy resolver-keyed dedup only ever - fed `isEmpty`-style badge checks (identity-aware unread resolution lives in - `ChatUnreadStateResolver`, which works on raw keys). `IdentityResolver` was - deleted with the legacy store; `PeerHandle` slimmed to `id` + - `routingPeerID`. -- **Selection state lives in the store.** The legacy store also carried the - two UI selection axes (`activeChannel`, `selectedPrivatePeerID`); they - moved into `ConversationStore` (`setActiveChannel` / - `setSelectedPrivatePeer`, deriving `selectedConversationID`), and - `migrateConversation` hands the private-peer selection off with the - conversation. -- **`ChatViewModel.messages` / `privateChats` / `unreadPrivateMessages` - survive as derived read-only views** (not stored properties): coordinators, - commands, and tests read them; the dict shape is only rebuilt where a - coordinator genuinely needs the whole dictionary (migration scans, unread - resolution). Simple per-peer reads dispatch through the store-direct - `privateMessages(for:)` context witness instead. - ---- - -## 1. Problem - -Message state is replicated across four stores, kept eventually consistent by three -async bridges. One inbound private message today: - -1. `ChatPrivateConversationCoordinator.handlePrivateMessage` writes through - `ChatViewModel.privateChats` — a passthrough computed property - (`ChatViewModel.swift:167-173`) into `PrivateChatManager`'s - `@Published var privateChats` (`PrivateChatManager.swift:16`). -2. **Bridge 1:** the bootstrapper subscribes `privateChatManager.$privateChats` and - `$unreadMessages` with `.receive(on: DispatchQueue.main)` sinks - (`ChatViewModelBootstrapper.swift:92-108`). -3. **Bridge 2:** each sink calls `schedulePrivateConversationStoreSynchronization`, - a `Task.yield`-debounced task (`ChatViewModel.swift:1084-1092`) that eventually runs - `synchronizePrivateConversationStore` (`ChatViewModel.swift:1095-1101`). -4. That calls `ConversationStore.synchronizePrivateChats` — a **full-dict replace**: every - conversation is re-normalized (dedup + `O(n log n)` sort) and diff-compared on every - sync (`AppArchitecture.swift:304-346`, `normalized` at `AppArchitecture.swift:359-372`). -5. **Bridge 3:** `PrivateInboxModel` subscribes `conversationStore.$messagesByConversation` - (again `.receive(on: DispatchQueue.main)`) and rebuilds its entire - `messagesByPeerID` dictionary via `refreshMessages` - (`PrivateConversationModels.swift:43-48`, `54-68`). -6. SwiftUI finally observes the feature model. - -Costs and hazards: - -- **O(total messages) × 3 layers per single message.** One append re-sorts, re-compares, - and re-publishes every conversation through store and feature-model layers. The ingest - path itself is also quadratic: `isDuplicateMessage` linearly scans *all* private chats - per inbound message (`ChatPrivateConversationCoordinator.swift:622-630`) and - `sanitizeChat` re-sorts the whole chat per append (`PrivateChatManager.swift:213-234`). -- **Delivery status mutates two copies.** `ChatDeliveryCoordinator` patches both - `context.messages` and a value-copied `context.privateChats` - (`ChatDeliveryCoordinator.swift:105-139`), navigating with a positional - `messageLocationIndex` (`ChatDeliveryCoordinator.swift:40`) that any non-append - mutation invalidates, forcing a full rebuild over every message location - (`ChatDeliveryCoordinator.swift:298-320`). -- **Transient disagreement.** Between the `@Published` write and the debounced sync, - `privateChatManager.privateChats` and `ConversationStore.messagesByConversation` - disagree; anything reading the store mid-flight sees stale data. - -The public path has the same shape: `@Published var messages` -(`ChatViewModel.swift:122`) is the render copy, `PublicTimelineStore` -(`ChatViewModel.swift:342-345`) is the backing copy, and `handlePublicMessage` appends to -the timeline, then full-replaces the conversation store **per message** -(`ChatPublicConversationCoordinator.swift:504-545` calling -`synchronizePublicConversationStore` at `:358-364`, which funnels into -`ConversationStore.replaceMessages`'s whole-array compare at `AppArchitecture.swift:249-253`), -while a timer-batched `PublicMessagePipeline` mutates `messages` ~80 ms later -(`PublicMessagePipeline.swift`, `TransportConfig.basePublicFlushInterval`). -`PublicChatModel` then mirrors the store again (`PublicChatModel.swift`). - -## 2. Design - -`ConversationStore` (already `@MainActor` and owned by `AppRuntime`, -`AppRuntime.swift:46`) becomes the **sole writer and sole holder** of message state. - -- **`Conversation` is a reference-type `ObservableObject`**, one instance per - `ConversationID` (`.mesh` / `.geohash` / `.direct`), with `@Published private(set)` - `messages` and unread state. Each conversation maintains its message-ID index - **incrementally** (insert on append, never rebuilt from scratch) and owns its cap - policy: `TransportConfig.meshTimelineCap` / `geoTimelineCap` / `privateChatCap` - (`TransportConfig.swift:17-19`) fold into the store; `PublicTimelineStore`'s trim logic - and `PrivateChatManager`'s cap disappear. -- **Publishing granularity is per conversation.** Views observe ONE `Conversation` - object. An append to chat A never invalidates observers of chat B — unlike today, - where any write republishes the entire `messagesByConversation` dictionary - (`AppArchitecture.swift:205`) and every bound feature model rebuilds. -- **Store-level `changes: PassthroughSubject`** for non-UI - consumers (delivery tracking, notifications, gossip/sync) that need "a message was - appended / status changed in conversation X" without subscribing to message arrays. -- **Mutations go through an intent API only**, mirroring the codebase's existing - single-writer intent ops (`ChatViewModel.swift:421-424`, the `private(set)` + - dedicated-mutator pattern): - - `append(_:to:)` — incremental, dedup via the ID index - - `upsertByID(_:in:)` — replace-or-append (media progress, edits) - - `setDeliveryStatus(_:for:in:)` — keyed by message ID, no positional index - - `markRead(_:)` / `markUnread(_:)` - - `migrateConversation(from:to:)` — the ephemeral↔stable peer-ID handoff that today - is hand-rolled dictionary surgery in three places - - `clear(_:)` - Backing collections are `private(set)`; coordinators receive the intent surface, not - the dictionaries. -- **Reads are synchronous.** Because writers and readers share the main actor and there - is one copy, "await the sync" disappears: after `append` returns, every observer of - that `Conversation` sees the message. - -## 3. Deleted at end state (done) - -- `PublicTimelineStore` (`bitchat/ViewModels/PublicTimelineStore.swift`) — folded into - `Conversation` cap/dedup policy. -- `PrivateChatManager`'s message dict and trim/sanitize logic — the manager shrinks to - read-receipt policy (`markAsRead`, `syncReadReceiptsForSentMessages`). -- `ChatDeliveryCoordinator.messageLocationIndex` and its growth/rebuild machinery - (`ChatDeliveryCoordinator.swift:40-45`, `221-320`) — replaced by - `setDeliveryStatus(for:in:)` against the per-conversation ID index. -- Both bootstrapper sync bridges (`ChatViewModelBootstrapper.swift:92-108`). -- `schedulePrivateConversationStoreSynchronization` / - `synchronizePrivateConversationStore` and the public equivalents - (`ChatViewModel.swift:1084-1101`, `ChatPublicConversationCoordinator.swift:351-386`). -- Feature-model mirror collections: `PrivateInboxModel.messagesByPeerID`, - `PublicChatModel.messages` (they observe `Conversation` objects directly). -- `ChatViewModel.messages` / `ChatViewModel.privateChats` as stored/owning properties. - -## 4. Migration plan (complete) - -Each step lands green against the full suite plus the `PerformanceBaselineTests` -numbers (no pipeline throughput regression at any step). - -1. **Additive store.** Introduce `Conversation` objects and the intent API inside - `ConversationStore` alongside the existing replace-based API. Nothing reads them yet. -2. **Private cutover with compat shims.** Inbound/outbound private paths write through - the intent API. `ChatViewModel.privateChats` becomes a derived **read-only** view of - the store; `PrivateChatManager`'s dict and the private sync bridges are bypassed but - the property surface stays so coordinators/tests compile unchanged. -3. **Public cutover.** `handlePublicMessage` and the `PublicMessagePipeline` flush write - to the store; `PublicTimelineStore` folds in; `ChatViewModel.messages` becomes a - derived view of the active conversation. -4. **Delivery via store.** `ChatDeliveryCoordinator` switches to - `setDeliveryStatus(for:in:)`; `messageLocationIndex` is deleted. -5. **View cutover.** Views and feature models observe `Conversation` objects directly; - delete all shims, mirrors, and the replace-based store API. - -## 5. Non-goals - -- **No message persistence.** bitchat is ephemeral by design; the store stays in-memory. -- **`sentReadReceipts` UserDefaults persistence stays put** (`ChatViewModel.swift:394-406`); - it is receipt-protocol state, not conversation state. -- **`MessageRouter`'s outbox remains the SSOT for unsent messages**; the store records - delivery status but never owns retry/resend queues. diff --git a/docs/PEER-ID-ROTATION.md b/docs/PEER-ID-ROTATION.md deleted file mode 100644 index b71de1ed..00000000 --- a/docs/PEER-ID-ROTATION.md +++ /dev/null @@ -1,342 +0,0 @@ -# Peer ID Rotation Specification - -**Status:** Draft for cross-platform review. The derivations and the wire format **are implemented and tested**; nothing is wired into the shipping mesh. -**Audience:** bitchat iOS and bitchat Android maintainers. -**Requires agreement before going further.** This changes the wire protocol, so neither platform can ship it alone. - -**Where the code is:** - -| Piece | File | -|---|---| -| Epochs, ID derivation, recognition tags, tag block, binding message | `localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift` | -| `announceV2 = 0x2C` wire format | `localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift` | -| Executable test vectors | `localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift` | -| Wire-format tests | `localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift` | - -The code is deliberately an **opinionated working base, not a finished feature**. Every number and context string in it is a concrete proposal you can disagree with by changing one function and watching a test vector move. What is *not* implemented is the part that carries risk: nothing emits a v2 announce, and `BLEService` parses the type and explicitly ignores it, because consuming it needs both the replacement identity binding (§4.5) and a decision on how unverified presence appears in the peer list (O4). - -Three policy decisions were forced by the compiler when the new message type was added, and are worth reviewing as part of this: - -- **Not gossip-synced** (`SyncTypeFlags`). Syncing presence would defeat the purpose: a device never in radio range could collect tag blocks, turning a local beacon into a network-wide one. -- **Not padded** (`BLEOutboundPacketPolicy`). At ~75 bytes the smallest bucket would triple the airtime of the most frequent packet in the protocol. The format is already near-constant width; making the capability and geohash fields fixed-width would be cheaper than padding. Open for argument. -- **Parsed but ignored** on receive (`BLEService`), as above. - ---- - -## 1. The problem - -Today a passive listener with a BLE dongle, standing in a crowd, can do the following with no cryptographic attack and no active participation: - -1. **Detect that a phone is running bitchat.** The service UUID is a fixed constant. -2. **Assign that phone a permanent identifier.** The 8-byte sender ID in every packet header is `SHA-256(noiseStaticPublicKey)[0..8]`, and the Noise static key is generated once and kept in the keychain. It does not rotate. Same phone, same bytes, next week, next city. -3. **Learn the phone's long-term public keys and its self-chosen nickname.** The announce carries the 32-byte Noise static key, the 32-byte Ed25519 signing key, and the nickname, all in cleartext, re-broadcast every 4–30 seconds and on demand to anything that connects and subscribes. -4. **Reconstruct who was standing near whom.** The announce also carries up to ten neighbour IDs, so one receiver gets the local adjacency graph without needing several receivers or signal-strength trilateration. - -For the people this app is explicitly built for, (2) and (4) are the dangerous ones. A protest attendee's phone announces a stable pseudonym and its social graph to anyone within radio range. - -iOS BLE address randomization does not help. It randomizes the link-layer address underneath an application layer that publishes a stable identifier above it. - -**The correction that matters most:** rotating the peer ID *alone* accomplishes nothing. As long as the announce carries the static keys in cleartext, a rotated ID is re-linked to the same device on its first announce. Rotation and announce confidentiality have to land together or not at all. - -## 2. Goals and non-goals - -**Goals** - -- **G1.** A passive listener cannot link two observations of the same device across rotation periods. -- **G2.** A passive listener cannot learn a device's long-term identity keys or nickname. -- **G3.** Peers who already know each other (mutual favourites) still recognise each other automatically, without an interactive handshake, so existing UX does not regress. -- **G4.** Strangers can still discover and handshake, so the mesh still forms among people who have never met. -- **G5.** Old and new clients interoperate. A mixed mesh keeps working, in both directions, with no flag day. -- **G6.** Rotation does not make identity spoofing easier than it is today. - -**Non-goals, explicitly out of scope here** - -- Hiding *that* bitchat is in use. The service UUID is a separate problem; BLE requires something discoverable. Tracked separately. -- Traffic-analysis resistance in general: padding coverage, send-time jitter, TTL randomization, and the neighbour-list leak each need their own change. Rotation does not fix them and they do not fix rotation. -- Resistance to an active attacker who connects and completes a handshake. Anyone you handshake with learns your identity; that is what a handshake is for. - -## 3. What currently binds an identity, and why rotation breaks it - -This is the part most likely to be underestimated, so it is stated precisely. - -`peerID == SHA-256(noiseStaticPublicKey)[0..8]` is not merely a convention. It is **the mechanism that makes peer IDs unforgeable**, and it is enforced in two places: - -**Announce preflight** — `BLEAnnounceHandlingPolicy.swift:32-35`: - -```swift -let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey) -guard derivedPeerID == peerID else { return .reject(.senderMismatch(derivedPeerID: derivedPeerID)) } -``` - -**Handshake completion** — `NoiseSessionManager.swift:1106-1122`: - -```swift -private func authenticatedRemoteKey(_ remoteKey: Curve25519.KeyAgreement.PublicKey, - matches claimedPeerID: PeerID) -> Bool { - let rawKey = remoteKey.rawRepresentation - if claimedPeerID.isShort { return PeerID(publicKey: rawKey) == claimedPeerID } - … -} -``` - -Failure throws `NoiseSessionError.peerIdentityMismatch`. - -If the peer ID becomes independent of the key, **both checks fail for every peer** and there is nothing left proving that a sender ID belongs to the sender. Any rotation design must therefore ship a *replacement* binding in the same change. This is why the work is a protocol revision and not a patch. - -Note also what the existing announce signature does and does not prove. The packet signature covers the sender ID (`BitchatPacket.toBinaryDataForSigning()` zeroes only TTL and the RSR flag), but it is verified against the Ed25519 key carried *inside the same announce* — a self-signature. The code says so plainly (`BLEAnnounceHandlingPolicy.swift:94-103`): an attacker can replay a victim's peer ID and Noise key with their own signing key and a valid self-signature, and only trust-on-first-use pinning of the signing key stops it. So today's binding is "derived ID + TOFU", and a replacement must be at least that strong. - -## 4. Design - -### 4.1 Epochs - -Rotation is on a wall-clock schedule so that two devices that have never met agree on the current period without negotiation. - -``` -epoch = floor(unixTimeSeconds / ROTATION_PERIOD) -ROTATION_PERIOD = 3600 (1 hour, proposed — see open question O1) -``` - -`epoch` is a `UInt32`, big-endian wherever it is hashed. Implementations MUST accept `epoch-1`, `epoch`, and `epoch+1` when matching (the ±1 window absorbs clock skew and boundary crossings), following the precedent already set by courier recipient tags (`CourierEnvelope.candidateTags`). - -### 4.2 The rotating peer ID - -``` -K_rot = HKDF-SHA256(ikm: noiseStaticPrivateKey, - salt: "", - info: "bitchat-peer-rotation-v1", - length: 32) - -peerID_e = HMAC-SHA256(key: K_rot, - message: "bitchat-peer-id-v2" || uint32be(epoch))[0..8] -``` - -Properties: - -- Derived from the **private** key, so no observer can compute or predict it, and two epochs' IDs are unlinkable. -- Deterministic, so the device recomputes the same ID after a restart within the same epoch. -- Still 8 bytes, so the packet header layout is unchanged. - -**It must be derived from private key material.** Deriving from the *public* key would let anyone who has ever seen that key compute every past and future ID, which is worse than doing nothing because it would look like protection. This mistake already exists in the codebase: `CourierEnvelope.recipientTag` is `HMAC(key: recipient's **public** static key, epochDay)`, and since that public key is broadcast in cleartext today, any observer in radio range can compute a peer's courier tags for any day. The whitepaper's claim that couriers "cannot link it across days" does not currently hold. Fixing that is out of scope here but should be tracked; do not copy the pattern. - -### 4.3 Recognising peers you already know - -With the static keys off the air, mutual favourites need another way to spot each other. Each announce carries a set of **pairwise recognition tags**. For a device A announcing under `peerID_e` to mutual favourite B: - -``` -S_AB = X25519(A_noiseStaticPrivate, B_noiseStaticPublic) // == X25519(B_priv, A_pub) -K_AB = HKDF-SHA256(ikm: S_AB, salt: "", info: "bitchat-recognition-v1", length: 32) - -tag_A→B = HMAC-SHA256(key: K_AB, - message: uint32be(epoch) - || A_noiseStaticPublic (32) - || B_noiseStaticPublic (32) - || peerID_e (8))[0..8] -``` - -A includes `tag_A→B` in its announce. B computes the same value independently — it holds the same shared secret and both public keys — and matches it against inbound announces. Only A and B can compute it, because it needs one of the two private keys. - -Two properties of that MAC input are load-bearing, and an earlier draft of this document got both wrong. They were caught in review of #1487, which is the argument for shipping the code alongside the prose. - -**Ordered keys make the tag directional.** The earlier form was `HMAC(K_AB, epoch)`, which is symmetric: A and B would broadcast the *identical* 8 bytes. An observer who saw one value appear in two different announces would learn that those two devices are mutual favourites, and could link their two rotating IDs to each other — handing over precisely the social graph this design exists to hide, and providing a cross-epoch correlation handle. Ordering the keys yields distinct A→B and B→A values, and both parties can still compute both directions because both hold both public keys. - -**`peerID_e` binds the tag to the announce carrying it.** Without it a tag depends only on (pair, epoch), so an attacker could lift A's tag out of a recorded announce and replay it in a fresh announce under an ID of their own choosing; B would match and treat that ID as A. Because `epoch-1` is also accepted, the spoof would stay usable into the following period. Binding to the ID reduces this from impersonation-as-any-ID to replaying A's own presence. - -**Residual risk, unfixable while announces are unsigned:** an attacker can rebroadcast A's exact announce within the epoch window, making A appear present when absent. Recognition is therefore a **hint only**. A match may populate presence, but anything consequential — routing a DM, showing a verified badge — MUST wait for a completed handshake whose static key equals the favourite that produced the match. See O4. - -Rules: - -- Tags are **unordered**. Implementations MUST NOT infer anything from position. -- The tag list MUST be padded with uniform random 8-byte values to a fixed count `TAG_SLOTS = 8`, so the number of tags does not disclose how many mutual favourites a device has. Random padding is indistinguishable from a real tag to anyone who cannot compute it. -- With more than `TAG_SLOTS` mutual favourites, a device MUST rotate which favourites occupy the slots across successive announces so all of them eventually see a tag. (Selection strategy is an implementation detail; convergence is not — see O2.) -- A device MUST NOT include a tag for a one-directional favourite, since that would disclose interest to someone who has not reciprocated. - -### 4.4 Strangers - -Nothing identifying is broadcast for strangers. Discovery still works: - -1. A hears an announce from unknown `peerID_e` advertising the rotation capability. -2. A initiates Noise **XX** to that ID. -3. In XX, the responder's static key is sent in message 2 *after* `ee`, and the initiator's in message 3 — both encrypted. A passive observer learns neither. -4. On completion, both sides learn the peer's real static key and fingerprint, exactly as they do today (`handleSessionEstablished`), and the existing `AuthenticatedPeerStatePacket` (Noise payload `0x21`) carries the Ed25519 signing key and capability claims *inside* the session, where they are proven rather than asserted. - -So the model becomes **handshake first, identify second**, for anyone who is not already a mutual favourite. - -### 4.5 The replacement binding - -Inside the completed handshake, each side proves that the rotating ID it was using belongs to its static key: - -``` -proof = Ed25519-Sign(signingPrivateKey, - "bitchat-peerid-binding-v1" - || uint32be(epoch) - || peerID_e (8 bytes) - || noiseStaticPublicKey (32 bytes)) -``` - -Sent as a new TLV in `AuthenticatedPeerStatePacket`, whose existing structure already carries a version byte, a canonicality-checked capability TLV, and the 32-byte signing key. The receiver verifies: - -- **that the `noiseStaticPublicKey` inside the proof is byte-equal to the remote static key the Noise session actually established** — see below, this one is load-bearing, and -- the signature against the signing key in the same packet, **and** -- that the signing key matches whatever it has already pinned for this fingerprint, using the existing trust ladder (authenticated key, then TOFU pin), and -- that `peerID_e` equals the ID the session was actually conducted under, and -- that `epoch` is within the ±1 window. - -An earlier draft of this list omitted the first check, which left a hole worth spelling out because it is the kind that survives review. The proof is a self-contained signed blob: nothing in the signature ties it to *the session it arrives on*. So a peer M who has observed A's proof — it travels inside a session, but M can be a peer A legitimately talked to — could replay A's proof verbatim inside M's own session with B. Without the static-key check, B verifies A's signature successfully, sees a well-formed binding, and on **first contact** TOFU-pins A's signing key against M's fingerprint. From then on B attributes M's identity to A's key. Comparing the proof's static key against the key the handshake actually produced closes it: M cannot substitute A's key without also being A. - -This replaces `authenticatedRemoteKey`'s derivation check with an explicit signed statement. With the static-key check present it is strictly stronger than today's self-signed announce, because the signing key is checked against a pin rather than taken from the same message. Without it, it is weaker — a reminder that "signed" and "bound to this conversation" are different properties. - -Note the canonical-bytes helper for this already half-exists: `NoiseEncryptionService.buildAnnounceSignature` / `verifyAnnounceSignature` / `canonicalAnnounceBytes`, with context `"bitchat-announce-v1"`, are present but unreferenced in production (only tests call them). They sign `context‖peerID(8)‖noiseKey(32)‖ed25519Key(32)‖nickname‖timestampMs`. The binding above is deliberately a **different context string** and a different field set, so the two can never be confused; the dead code should be deleted or repurposed explicitly rather than silently reused. - -### 4.6 The announce, before and after - -**Today** (`AnnouncementPacket`, TLVs in `Packets.swift:33-40`), all cleartext: - -| T | Field | Width | -|---|---|---| -| `0x01` | nickname | var | -| `0x02` | Noise static public key | 32 | -| `0x03` | Ed25519 signing public key | 32 | -| `0x04` | direct neighbours | N × 8, max 10 | -| `0x05` | capabilities | 1–8 | -| `0x06` | bridge geohash | var | - -`0x01`, `0x02`, `0x03` are **required** by the decoder (`Packets.swift:147`). - -**Proposed v2 announce.** Because the existing decoder hard-requires the three identity TLVs, a v2 announce cannot simply omit them — that is a parse failure, not a graceful degrade. It therefore needs a distinct message type: **`announceV2 = 0x2C`**. - -An earlier draft proposed `0x05` on the grounds that it is unassigned today and sits next to `announce = 0x01`. That was wrong. `0x05` has already been recycled twice — `announce`, then `bulkTransferResponse`, then `fragmentStart` until #446 — so a sufficiently old peer may still map it to a fragment header and misparse presence as a partial message. Values above `voiceFrame = 0x29` have only ever been allocated forward, which is the safe direction; `0x2A`/`0x2B` are spoken for by the courier spray-ack work, leaving `0x2C`. Verified never used anywhere in this repository's history (see O3). - -TLVs, all cleartext but none identifying: - -| T | Field | Width | Notes | -|---|---|---|---| -| `0x01` | epoch | 4 | `uint32be`; lets a receiver match without guessing | -| `0x02` | recognition tags | `TAG_SLOTS` × 8 = 64 | unordered, random-padded | -| `0x03` | capabilities | 1–8 | same minimal-LE encoding as today | -| `0x04` | bridge geohash | ≤12 | unchanged semantics | - -Deliberately absent: nickname, both public keys, neighbour list. - -Worth noting because it is counter-intuitive: **the v2 announce is smaller than the v1 announce**, despite carrying 64 bytes of tags. A v1 announce with a 10-byte nickname and a full neighbour list is roughly 165 payload bytes plus a 64-byte signature; a v2 announce is roughly 75 bytes and unsigned. Dropping two 32-byte keys, the neighbour list, and the signature more than pays for the tag block, so this reduces airtime rather than adding to it. - -- **Nickname** moves inside the session (`AuthenticatedPeerStatePacket`). A nickname is a self-chosen, often reused human label; broadcasting it in cleartext is a linkage vector on its own. -- **Neighbour list** is dropped entirely. It exists to seed source routing, and its documented fallback is flooding. Publishing the adjacency graph of a crowd is not a reasonable price for routing efficiency. (Dropping it is independently backward compatible — the TLV is optional on decode — and can ship ahead of this spec.) - -**The v2 announce is unsigned.** This is a real trade-off and needs review (O4). There is no key to verify a signature against without disclosing one, so a v2 announce asserts nothing except "somebody is here, and here are some tags". Consequences: - -- An attacker can emit v2 announces with arbitrary IDs and random tags — cheap peer-list noise. This is bounded by the existing announce rate limiting, per-central subscription limiting, and connection rate limits, but it is weaker than today. -- An attacker **cannot** impersonate a specific known peer, because it cannot compute that peer's recognition tags without one of the two private keys. -- An attacker cannot get a Noise session, so it cannot send messages, only occupy a peer-list slot. - -Mitigation for review: treat a v2 announce as *unverified presence* only, and do not surface it in the peer list until either a recognition tag matches or a handshake completes. That preserves today's property that the peer list reflects authenticated peers. - -## 5. Compatibility and rollout - -The repo already has the two mechanisms this needs, both proven in production. - -**Capability bit.** `PeerCapabilities` is a `UInt64` `OptionSet` with minimal little-endian wire encoding, at least one byte, so "no TLV" and "empty set" stay distinguishable. Crucially `BLEPeerRegistry.capabilitiesWereExplicitlyAdvertised(for:)` distinguishes *old client that sent no TLV* from *new client with the bit off*. Add `peerIDRotation` at the next free bit — **bit 14** at the time of writing: bit 10 is burned and MUST NOT be reused, bit 11 is claimed by the Nostr double-ratchet work (#1107), bit 12 by courier spray receipts (#1438), and bit 13 is reserved for stickers (#1544). Re-check the claim table in `PeerCapabilities.swift` before assigning; whichever platform implements first pins the number in a shared test vector. - -**Observed-version gating.** `MeshTopologyTracker.recordObservedVersion(_:for:)` records the highest protocol version seen from each node, and `computeRoute(…, requiringVersion:)` refuses paths through nodes not observed at that version. `docs/SOURCE_ROUTING.md` records this as the shipped pattern for a compatible rollout. The same shape applies here. - -**Phased plan.** - -| Phase | Behaviour | -|---|---| -| 1 | Both platforms ship the ability to **parse** v2 announces and advertise the capability, while still sending v1. Purely additive; a v2 announce from a test build is understood rather than dropped. | -| 2 | Send v1 **and** v2 announces, alternating. New clients prefer v2 and ignore the v1 from a peer they have recognised via v2; old clients see only the v1. Costs airtime, buys a no-flag-day transition. | -| 3 | Once telemetry-free judgement says adoption is sufficient, a setting (default on) suppresses v1 announces. A device that suppresses v1 becomes invisible to old clients — that is the intended cost of unlinkability, and it must be stated in the UI, not buried. | - -During phases 2–3 a device runs **both** a stable v1 ID and a rotating v2 ID. They must never appear as two peers; a peer recognised by both paths has to collapse to one entry. The repo has the beginnings of this in `MessageRouter.peerIDAliases` and `ChatPeerIdentityCoordinator.migrateChatState`, but they were built for panic-reset rotation, not steady-state rotation. - -## 6. Impact inventory - -This is what an implementer must handle. Every item below was verified against the iOS source; Android should expect its own equivalents. - -### 6.1 Must be fixed or the feature is broken - -| Area | Why | iOS reference | -|---|---|---| -| **Handshake identity check** | `authenticatedRemoteKey` re-derives the ID from the static key and fails for every peer once IDs are independent. Replace with §4.5. | `NoiseSessionManager.swift:1106-1122`, enforced `:714-718` | -| **Announce preflight** | Same derivation check rejects any announce whose ID is not the key's hash. | `BLEAnnounceHandlingPolicy.swift:32-35` | -| **Sealed message outbox** | Queued DM plaintext is keyed by peer ID on disk and survives app kill. A recipient's rotation orphans their queue. Needs re-keying by **fingerprint** (stable) with the peer ID as a lookup hint. This is the single worst offender. | `MessageOutboxStore.swift:66`, `:704-707`, `:746` | -| **Private-media durable IDs** | `stableID` hashes sender and recipient short IDs, and the durable receipt ledger keys accept/tombstone records on it. Rotation silently breaks dedup **and user deletion tombstones**, so deleted media could be re-accepted. | `BitchatFilePacket.swift:183-231`, `BLEPrivateMediaReceiptStore.swift` | -| **Initiator tie-break** | Crossed-initiation resolution compares `localPeerID < peerID`. Both sides must reach the same verdict; a rotation mid-negotiation flips it asymmetrically. Needs a rotation-stable comparison key (fingerprint). | `NoiseSessionManager.swift:83`, `:569`, `:582`, `:603` | -| **Fingerprint-prefix lookups** | Several paths recover a peer from `fingerprint.hasPrefix(peerID)`. These silently return empty, and one of them is what lets a public message from a not-yet-registered peer be accepted at all. | `SecureIdentityStateManager.swift:437-444`; `ChatGroupCoordinator.swift:98-102`, `:432`; `FavoritesPersistenceService.swift:188-195`; `BLEService.swift:2552`, `:2823` | -| **`PeerID.routingData`** | Falls back to `toShort()`, i.e. fingerprint-derived routing bytes. | `PeerID.swift:190-202` | - -### 6.2 Degrades gracefully but needs handling - -| Area | Effect | iOS reference | -|---|---|---| -| **Noise sessions** | A rotation mid-session leaves an established session under the old ID. Rotation should either be deferred while sessions are live or migrate them explicitly. | `NoiseEncryptionService.swift:1010-1019` | -| **Fragment reassembly** | The reassembly key mixes the 8-byte sender ID, so a rotation mid-transfer strands every in-flight assembly until the 30 s timeout. Defer rotation while fragments are in flight. | `BLEFragmentAssemblyBuffer.swift:4-47` | -| **Dedup LRU** | Keys embed the sender ID, so the same packet crossing a rotation boundary can be reprocessed once. Bounded and probably acceptable. | `BLEReceivePipeline.swift:21` | -| **Source routes / topology** | A remote rotation invalidates cached adjacency, and a rotated relay no longer finds itself in an in-flight v2 route, falling back to flooding. Already the documented fallback. | `MeshTopologyTracker.swift`, `BLERouteForwardingPolicy.swift:62` | -| **Gossip archive** | Archived raw packets keep the old sender ID forever, and packet IDs are sender-derived, so attribution and purge-by-peer break for pre-rotation history. | `GossipMessageArchive.swift`, `PacketIdUtil.swift:8-17` | -| **Read receipts** | The wire receipt carries an 8-byte `readerID`; one sent before and matched after a rotation will not correlate. | `ReadReceipt.swift:47-64` | - -### 6.3 Already safe — no work needed - -Keyed by fingerprint, Noise key, or Ed25519 key rather than peer ID: the identity cache and every map in it (social identities, verified fingerprints, vouches, blocks), favourites (keyed by Noise static key), courier envelopes and recipient tags, prekey bundles, board posts, bridge drop dedup, group rosters, vouch attestations, and all geohash/location state (keyed by Nostr pubkey). Peer registry, link state, and all Noise session maps are in-memory and session-scoped. - -## 7. Test vectors - -These live as assertions in `PeerIDRotationTests.swift`, so they run on every build rather than rotting in a table. - -All three were **cross-checked against an independent implementation written from this document alone** — Python `hmac`/`hashlib`, HKDF as extract-then-expand with an empty salt — and matched byte for byte. That is the property that matters: the spec text is sufficient to reproduce the numbers without reading the Swift. - -With `noiseStaticPrivateKey = 0102…20` (bytes 1 through 32): - -``` -rotationSecret = HKDF-SHA256(ikm: 0102…20, salt: , - info: "bitchat-peer-rotation-v1", len: 32) - = fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09 - -peerID(epoch=100) = HMAC-SHA256(rotationSecret, - "bitchat-peer-id-v2" || uint32be(100))[0..8] - = f7c08c528506a374 -``` - -With a recognition key derived from a shared secret of 32 × `0x42`, sender key -32 × `0x0A`, recipient key 32 × `0x0B`, and announced ID 8 × `0xA1`: - -``` -recognitionKey = HKDF-SHA256(ikm: 42×32, salt: , - info: "bitchat-recognition-v1", len: 32) - -tag_A→B(epoch=100) = HMAC-SHA256(recognitionKey, - uint32be(100) || 0A×32 || 0B×32 || A1×8)[0..8] - = 4568f61d61d6cbfb - -tag_B→A(epoch=100) (same key, keys swapped) - = 5313c7731f629959 -``` - -Both directions are given because their *difference* is the security property: if -an implementation produces the same value for both, it has reintroduced the -symmetric-tag flaw. - -Also asserted, and worth reproducing on Android because they are the properties rather than the numbers: both sides of a real X25519 pair derive the identical tag from opposite key halves; consecutive epochs produce unrelated IDs; the ±1 epoch window matches across a boundary but two epochs out does not; the tag block is always 64 bytes regardless of how many tags it carries; a match is found regardless of slot position; and the binding message is fixed-width so a short input cannot shift a later field into an earlier field's position. - -Still to be written jointly: a full `announceV2` packet as a hex blob, and the §4.5 signature over a fixed key. Whichever platform writes a vector, the other MUST reproduce it from this document rather than from the first platform's code. - -## 8. Open questions for review - -- **O1 — Rotation period.** One hour is a guess balancing unlinkability against churn. Shorter means less linkable and more session/route disruption; longer the reverse. Is there a period that is clearly right, or should it be a build constant both platforms pin? -- **O2 — More than `TAG_SLOTS` favourites.** What is the required convergence guarantee — "every mutual favourite sees a tag within N announces"? Should the slot rotation be deterministic from the epoch so it is testable? -- **O3 — New message type vs. announce version byte.** A distinct `MessageType` is cleanest given the decoder's required TLVs, but it consumes a type value and means two announce paths. Would a version TLV inside the existing type, with the identity TLVs made optional on both platforms first, be preferable? -- **O4 — Unsigned v2 announces.** Binding tags to the announced peer ID removes impersonation-as-any-ID, but a recorded announce can still be rebroadcast verbatim within the epoch window, so a peer can be made to look present when absent. Is "presence is a hint; nothing consequential until a handshake whose static key matches the favourite that produced the match" acceptable? The alternatives are an ephemeral per-epoch signing key with a proof-of-continuity, or a freshness nonce echoed by the recipient — both more machinery and more bytes. -- **O5 — Rotation while a session is live.** Defer rotation until sessions are idle, or rotate and migrate? Deferring is simpler and safer, but a long-lived session pins the ID for its lifetime, which weakens G1 for exactly the people who talk most. -- **O6 — Nickname timing.** Moving the nickname into the session means a stranger's name appears only after a handshake. Is that acceptable UX on both platforms, or does the peer list need a "someone nearby" placeholder state? -- **O7 — Padding is a coordinated change, not a local one.** This started as a question about decoder tolerance and turned into something firmer. `BitchatPacket.toBinaryDataForSigning()` encodes with padding enabled, so **the padding bytes are inside the signed material for every signed packet**. Changing the padding algorithm therefore changes the signed byte stream, and signatures stop verifying against any peer that has not made the identical change. Both outstanding padding fixes are affected: extending coverage beyond `noiseEncrypted`/`noiseHandshake`, and closing the gap where a frame needing more than 255 bytes of padding is emitted unpadded (encoded *frames* of 241–256, 497–768 and 1009–1792 bytes ship at exact length today — the arithmetic is over the whole encoded packet that `pad` receives, not the payload alone). Two things to settle: whether Android's decoder also tolerates trailing bytes the way iOS's does (`guard offset <= buf.count`, plus an unpad retry), and whether padding changes ride this protocol revision or get their own capability-gated one. - -- **O9 — A seized device recomputes every past peer ID.** `K_rot` is a long-lived secret, so `peerID_e = HMAC(K_rot, epoch)` is computable for *any* epoch by whoever holds it. Someone who seizes a phone, or extracts the Noise static key from a backup, can therefore take historical radio captures and identify which of them were this device — retroactively defeating the unlinkability for every past epoch. Rotation protects against the passive observer, not against later key compromise. A hash ratchet (`K_{e+1} = HKDF(K_e)`, discarding `K_e`) would give forward secrecy for the ID stream, at the cost of state that must survive restarts, tolerate clock jumps, and resynchronise after a gap — none of which is free, and all of which interacts with the ±1 window. Worth deciding deliberately rather than inheriting. - -## 9. Relationship to other work - -Rotation is the largest item in the radio-layer metadata cluster but not the only one, and the others are cheaper: - -- **Drop the neighbour list** and **randomize origin TTL** — both landed separately, since neither needs agreement: see the radio-metadata PR. -- **Extend padding beyond Noise frames, and fix the length-marker gap** — only `noiseEncrypted` and `noiseHandshake` are padded, and `pad` silently declines when the required padding exceeds the single-byte marker, so frames well below their bucket ship unpadded. **Not unilateral**: padding is inside the signed bytes, so this needs both platforms. See O7. - -None of these substitute for rotation, and rotation does not substitute for them: a device with a rotating ID that still publishes its neighbour list, or that still marks its own originated packets by TTL, remains linkable. diff --git a/docs/PRIVATE-MEDIA-MIGRATION.md b/docs/PRIVATE-MEDIA-MIGRATION.md deleted file mode 100644 index dd90e162..00000000 --- a/docs/PRIVATE-MEDIA-MIGRATION.md +++ /dev/null @@ -1,122 +0,0 @@ -# Private-media wire migration - -Private files use the `BitchatFilePacket` TLV shared by iOS and Android. The -preferred direct-message wire form encrypts that complete TLV inside the -peer's Noise session before BLE fragmentation. - -## Wire values and capability - -- `NoisePayloadType.privateFile` is `0x20`, the value already deployed by the - Android client. New sends must use this value. -- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the - private-media change. Decoders canonicalize it to `privateFile`; they never - emit it. -- `NoisePayloadType.authenticatedPeerState` is permanently assigned `0x21`. - It is emitted after every completed/rekeyed Noise XX session and echoed at - most once when the remote state arrives, so message-3/proof reordering over - different mesh links converges. This type is part of the protocol security - boundary and is not removed when the media migration ends. -- The `0x21` payload starts with version `0x01`, followed by one-byte - type/length/value fields. Version 1 requires canonical TLV `0x01` (the - minimal little-endian `PeerCapabilities` bitfield, 1-8 bytes) and TLV `0x02` - (the 32-byte Ed25519 announcement signing key). Duplicate required fields, - non-minimal capabilities, malformed lengths, missing fields, and unknown - versions are ignored without changing state. Unknown TLVs are skipped. -- The public `PeerCapabilities.privateMedia` announce bit is a discovery hint: - it starts a Noise handshake, but never selects encrypted sending or creates - a pin. A private transfer waits boundedly for the exact session's encrypted - `0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a - no-proof timeout reaches the explicit legacy-consent path for an unpinned - peer. No timeout automatically sends raw bytes. -- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session - authenticated proof enables bounded sender-side automatic retry; a public - announce never does. It does not replace bit 8: encrypted `0x20` media from - bit-8-only prior iOS clients keeps the same deterministic stable ID and - delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so - a lost proof followed by a later bit-9 retry cannot create a second, - random-ID bubble. -- An unpinned peer with a stable Noise key but without that capability is - eligible for one signed, directed - `fileTransfer`, matching the pre-migration wire form used by older iOS and - accepted by current Android clients, only after the sender confirms a - per-send warning that the file is not end-to-end encrypted and mesh relays - can see it. The - consent is consumed by that invocation and is never remembered. -- A signed announce never creates a pin by itself: an attacker can copy a - victim's public Noise key, supply its own Ed25519 key and capability bits, - and self-sign an internally consistent announce. Only successfully - decrypted `0x21` state pins the authenticated Noise fingerprint and binds - the Ed25519 key used by later announces/public messages. A later valid - no-bit `0x21` is treated as a downgrade, and raw fallback is blocked even if - a caller presents legacy consent. Public no-bit announces cannot overwrite - current session-authenticated state. -- During migration, both an absent capabilities TLV and an explicit TLV - without `privateMedia` are legacy-eligible when that stable fingerprint is - not pinned. This supports clients that added capability advertisement before - encrypted media. Neither shape bypasses a previously authenticated pin. - -Older clients decrypt and ignore unknown inner type `0x21`; they do not need to -understand it to continue using text or the warned legacy media path. They are -never inferred capable merely because the handshake succeeded. - -Removal gates are independent and must not share an arbitrary calendar date: - -- Remove the `0x09` receive alias only after every TestFlight/internal build - that emitted it has expired and minimum-supported-client policy excludes it. -- Remove the signed directed raw `0x22` fallback only after minimum-supported - iOS and Android clients emit authenticated bit-8 `0x21` state and the legacy - population has aged out. -- Nostr kind `1059` compatibility is a separate envelope migration. Its dual - publish/removal gate is not evidence that either BLE compatibility shape can - be removed. - -## Security boundary - -The encrypted form provides Noise confidentiality and peer authentication. -The fallback is signed and its signature is required on receive, so relays -cannot forge its sender or contents. It is not confidential: relays can see -the raw file TLV. The UI says this explicitly and asks on every send. A peer -without a stable Noise key from a verified registry entry cannot use the -fallback. Keep it only for the mixed-version migration, and remove it only -after minimum-supported Android and iOS releases emit authenticated bit-8 -`0x21` state and the legacy population has aged out. Never replace it with an -unsigned fallback, persist blanket consent, or send both forms. - -Incoming clients accept all three migration-era shapes: - -| Sender | Inbound form | Result | -| --- | --- | --- | -| Current Android | Noise `0x20` | Decrypt and deliver | -| Prerelease iOS | Noise `0x09` | Decrypt, canonicalize, and deliver | -| Older client | Signed directed `fileTransfer` | Verify signature and deliver | -| Forged/unsigned raw sender | Directed `fileTransfer` | Reject | - -Panic wipe clears the persistent capability pins together with the rest of -the encrypted identity cache. - -This migration path is mesh-Noise-only (BLE and compatible direct mesh links). -Nostr private-media transport is unchanged and remains a follow-up. Nostr -inbound paths explicitly ignore `0x21`; do not infer the mesh consent fallback -or capability-pin semantics for Nostr delivery. - -## Size interoperability - -iOS bounds inbound file content at 1 MiB and applies the expanded allocation -budget only after a large Noise ciphertext authenticates to `0x20` or the -temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit. - -Current Android builds cap each reassembly at 256 fragments. Depending on the -negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB, -well below iOS's absolute inbound ceiling. That cap only applies to those -receivers, which take private media exclusively over the directed raw-file -migration fallback (they do not implement the encrypted `0x20` path). -Private-media v1 therefore runs the actual route-aware BLE fragment planner -before a consented legacy send and rejects any plan above 256 fragments with a -visible failure. Encrypted sends go only to peers that advertised the -`privateMedia` capability — modern clients that reassemble up to the full -receiver ceiling (10,000 fragments) — so they are not held to Android's cap and -iOS→iOS photos in the ~120-512 KiB range keep working. This fragment-count -contract, rather than a guessed byte threshold, stays correct as route overhead -changes. A future Android client that adopts `0x20` but still caps its -reassembler would need to negotiate an explicit per-peer fragment limit -(tracked as a #1434 follow-up). diff --git a/docs/PUSH-TO-TALK-DESIGN.md b/docs/PUSH-TO-TALK-DESIGN.md deleted file mode 100644 index fc3a69fd..00000000 --- a/docs/PUSH-TO-TALK-DESIGN.md +++ /dev/null @@ -1,162 +0,0 @@ -# Smart Push-to-Talk (PTT) — Design - -**Status:** Draft for review — no code yet -**Scope:** Live voice bursts over the BLE mesh, in public chat and Noise DMs, with graceful degradation to the existing voice-note pipeline. - -## 1. Goal and core idea - -Today, holding the mic button records an AAC `.m4a` and ships it as a `fileTransfer` (0x22) after you release — the receiver hears nothing until the whole file arrives. PTT makes the same gesture *live*: encoded audio frames stream over the mesh while you speak, and nearby receivers hear you with sub-second delay, walkie-talkie style. - -The "smart" part is that PTT is not a separate mode the user must choose. It is a **delivery strategy**, picked automatically per conversation: - -| Context | Delivery | -|---|---| -| DM, peer connected/reachable on mesh | **Live stream** (Noise-encrypted frames) + finalized voice note for reliability | -| DM, peer only reachable via Nostr | Existing voice-note recording only (no live; media doesn't ride Nostr today) | -| Public mesh chat | **Live broadcast stream** (signed) + finalized voice note, same dedup as DMs | -| Geohash (Nostr) channels | PTT unavailable (matches existing `canSendMediaInCurrentContext` media policy) | - -*(Public originally speced as live-only to avoid doubling bandwidth, but dropping the note broadcast would regress mixed-version meshes — old clients that can't decode live bursts would stop receiving public voice entirely — and late joiners/out-of-range peers would get nothing. The note stays; live receivers absorb it silently.)* - -One gesture (hold mic), one mental model ("talk"), and the system degrades from live → reliable-note → unavailable based on what the transport can actually do. - -**Bandwidth reality check:** the mesh moves ~15 KB/s per link (469 B fragments at 30 ms spacing); our voice codec needs ~2 KB/s. Live voice fits with a wide margin, even relayed. - -## 2. What already exists (reused, not rebuilt) - -- **Capture UX:** `ContentComposerView.micButtonView` already implements hold-to-record / release-to-send via `DragGesture`, backed by `VoiceRecordingViewModel`'s state machine and `VoiceRecorder` (AAC-LC, 16 kHz mono, 16 kbps). Mic permission string is already in Info.plist. -- **Reliable delivery:** `BitchatFilePacket` TLV + `fileTransfer` (0x22) + fragmentation + transfer progress + `ChatMediaTransferCoordinator`. -- **Playback:** `VoiceNotePlaybackController` + `VoiceNotePlaybackCoordinator` (single active playback), `WaveformView`, `VoiceNoteView`. -- **Transport:** signed public packets, Noise sessions with typed inner payloads, `RelayController` flood control, `MessageDeduplicator`, `MessageRouter.canDeliverPromptly`. - -New work is the **streaming path**: a frame encoder/packetizer, a wire format for bursts, a jitter-buffered receiver/player, relay policy, and the live-bubble UI. - -## 3. Wire protocol - -### 3.1 Burst framing (shared inner format) - -A talk burst is a sequence of packets sharing an 8-byte random `burstID`: - -``` -[burstID: 8][seq: UInt16 BE][flags: 1][payload…] -``` - -`flags` bits: -- `0x01 START` — payload is a header TLV: codec (1 B enum, 0x01 = AAC-LC/16kHz/mono), frame duration ms, batch size. Sent as seq 0 and re-attached (piggybacked TLV) every ~2 s so mid-burst joiners can sync. -- `0x02 END` — payload: total data-packet count (UInt16), duration ms (UInt32). Lets receivers detect tail loss. -- `0x04 CANCELED` — sender slid-to-cancel; receivers stop playback, discard buffered audio, and drop the bubble. -- `0x00` (data) — payload is N length-prefixed AAC frames: `[len: UInt16 BE][ADTS-less raw AAC frame]…` - -Audio math: AAC-LC at 16 kHz has 1024-sample frames = **64 ms/frame ≈ 130 B at 16 kbps**. A greedy packetizer batches frames up to a **210-byte burst-content budget** (`pttMaxBurstContentBytes`), chosen so the Noise ciphertext (content + 1 inner-type byte + 16-byte tag) stays inside `MessagePadding`'s 256-byte bucket — the whole directed packet is 288 bytes and voice **never enters the fragment scheduler** (which caps concurrent transfers at 2 and would starve file sends). At 16 kbps that works out to **1 frame/packet, ~15.6 pkt/s, ~5.3 KB/s wire** for DMs; public bursts (unpadded, planned for phase 2) can batch 3 frames. - -### 3.2 Public mesh: new `MessageType.voiceFrame = 0x29` - -- Broadcast (no recipient), **signed** like public messages; unsigned or signature-mismatched frames are dropped on receive. -- **No padding** (add to `BLEOutboundPacketPolicy` alongside `fileTransfer`) — padding to the 512 block would push every packet over MTU into fragmentation. -- TTL: `messageTTL` (7) at origin; `RelayController` gets a `voiceFrame` case mirroring the fragment policy — dense clamp to 5, jitter 8–25 ms — so live audio floods like fragments, not like announces. Per-hop cost is ~10–40 ms; 3 hops stays comfortably inside the jitter buffer. -- Dedup: existing `MessageDeduplicator` (timestamp ms + seq make each packet unique). -- 0x29 is the next free code after `nostrCarrier = 0x28`; unknown types are ignored by older clients (iOS and Android), so rollout is compatible — old clients simply don't hear live bursts. - -### 3.3 DM: new `NoisePayloadType.voiceFrame = 0x08` - -- Inner payload = the same burst framing, wrapped in `noiseEncrypted` (0x11) directed packets — wire-indistinguishable from other DM traffic by size/type (0x04/0x05 are reserved per the comment in `BitchatProtocol.swift`; 0x08 is the first free slot after `groupKeyUpdate = 0x07`). -- Directed encrypted packets are already always-relayed by `RelayController`, so multi-hop DMs work unchanged. -- Requires an established session; PTT-live is only offered when `noiseService.hasEstablishedSession` (otherwise first hold triggers the normal handshake + falls back to voice-note for that burst). -- Fire-and-forget: **no delivery acks, no retransmit** for frames. Late audio is worthless; reliability comes from the finalized note (§5). - -### 3.4 Known traffic-analysis tradeoff - -A steady ~8 pkt/s cadence for the duration of a burst is a timing fingerprint even under Noise (observers can infer "someone is speaking to someone", not content). This is inherent to live voice; the doc records it as accepted for v1. Block padding is skipped for voiceFrame (size classes would leak little beyond what cadence already does). - -## 4. Sender pipeline - -`VoiceRecorder` (AVAudioRecorder → file) can't stream, so PTT capture uses a parallel path in a new `PTTStreamEncoder` actor: - -``` -AVAudioEngine input tap (native format) - → PTTInputResampler → 16 kHz mono PCM - → PTTFrameEncoder (AVAudioConverter) → AAC-LC frames → packetizer → BLEService.sendVoiceFrame - → the same PCM is simultaneously written to an AVAudioFile .m4a (identical settings - to VoiceRecorder), so finalization needs no remux step -``` - -- On release: emit `END`, then hand the finalized `.m4a` to the **existing** `sendVoiceNote` flow. The note's `fileName` embeds the burst ID (`voice_.m4a`) — that links note↔burst with **zero changes** to `BitchatFilePacket`/Android interop. -- On slide-to-cancel: stop tap, emit `CANCELED`, delete local file, skip finalization. UX note: unlike voice notes, live audio already played on the far side cannot be unsent — the recording UI must make "you are live" unmistakable (see §7). -- Caps: max burst 120 s (matching the voice-note recorder's cap), exactly one outbound burst at a time. -- Audio session: reuse `VoiceRecorder`'s `.playAndRecord` config; add `.duckOthers`. - -## 5. Receiver pipeline - -New `VoiceBurstAssembler` (keyed by sender + burstID) feeding a `PTTBurstPlayer`: - -- **Jitter buffer:** start playback after 350 ms of buffered audio or 500 ms wall-clock, whichever first. Frames decode via `AVAudioConverter` → `AVAudioPlayerNode`. -- **Loss handling:** gap in seq → insert silence for the missing frames (64 ms each) and keep going. No PLC in v1; at these frame sizes brief dropouts are acceptable. -- **Burst end:** on `END`, or 3 s with no frames (talker walked out of range). -- **Persistence:** frames append to an incoming ADTS `.aac` file (already an allowed `MimeType`), so every burst becomes a replayable voice-note bubble containing whatever was captured — even a partial one. -- **Dedup with the finalized note:** when a `fileTransfer` arrives whose fileName carries a burstID we already assembled (DM or public), it silently *replaces* the partial file behind the existing bubble (no new message row, no second notification). Receivers that heard everything live just get a lossless copy. -- **Resource caps:** ≤ 8 concurrent assemblies, ≤ 256 KB per burst (60 s × 2 KB/s + slack), 30 s stale cleanup, and drop inbound frames beyond ~2× realtime per sender (spam/flood guard). - -## 6. Playback policy — when does it actually make sound? - -Auto-playing strangers' audio is the fastest way to make this feature hated. Rules: - -1. **Live autoplay** only when *all* hold: app foregrounded, the burst's conversation is the one currently on screen, and the **"live voice messages"** toggle is on (app-level, in the app-info sheet, default on; per-conversation overrides are a v2 refinement). The same toggle gates live *sending* — off means voice behaves exactly like classic notes in both directions. -2. Otherwise the burst appears as a **live bubble**: pulsing waveform + `LIVE` badge + sender name; tapping it joins playback at the live edge. When the burst ends it becomes a normal voice-note bubble. -3. **One voice at a time:** route through `VoiceNotePlaybackCoordinator`. If two people talk simultaneously, the first burst holds the floor; the second shows as a tappable live bubble. No mixing in v1. -4. Notifications: a live burst in a non-focused DM fires the normal message notification once (at START), not per-frame. - -**Floor courtesy (public mesh):** while someone else's burst is live in the current channel, the mic button tints "busy" with the talker's name. Holding it still works — a decentralized mesh has no floor arbiter, and rejecting sends would desync under partitions — but the UI discourages talk-over. Hard floor control (token passing) is explicitly out of scope. - -## 7. UX - -- **Same gesture:** hold mic = talk. When the live path is active, the recording HUD shows a red pulsing **LIVE** treatment (vs. the current neutral recording UI) so the sender knows audio is leaving in real time, not on release. When live isn't available (Nostr-only peer, no session yet), the HUD looks like today's and behavior is unchanged. -- Slide-to-cancel keeps working in both modes, with the §4 caveat surfaced simply: cancel stops and discards; it can't unplay what was heard. -- VoiceOver: mirror the existing `accessibilityAction` toggle-record pattern on the mic button. -- Foreground-only in v1: no `audio` UIBackgroundMode (App Store review + battery implications). If the app backgrounds mid-burst, capture stops cleanly with END. Background listen/talk is a v2 candidate. - -## 8. Latency budget (1 hop, DM) - -| Stage | Cost | -|---|---| -| Frame accumulation (1 × 64 ms) | 64 ms | -| Encode + packetize | ~5 ms | -| BLE write + delivery | 30–60 ms | -| Jitter buffer | 350 ms | -| **Mouth-to-ear** | **~470 ms** | - -Each relay hop adds ~10–40 ms jitter + radio time. 2–3 hops stays under ~800 ms — solidly in walkie-talkie territory (commercial PoC apps run 300 ms–1 s+). - -## 9. Security & privacy summary - -- Public frames signed with the existing packet signature; verified against the sender's announce before decode. Unsigned → dropped. -- DM frames ride inside Noise; content confidentiality/integrity as any DM. -- Codec input is validated by frame-length prefixes and total-size caps before touching `AVAudioConverter` (malformed frames dropped, assembly aborted over cap). -- Timing fingerprint accepted per §3.4. No PTT in geohash channels (would put voice on public Nostr relays). -- Mic capture only while the button is held; recording state is always visible. - -## 10. New components & touch points - -| Piece | Where | -|---|---| -| `PTTStreamEncoder` (tap → AAC → packets) | `bitchat/Features/voice/` | -| `PTTBurstPlayer` (jitter buffer → decode → engine) | `bitchat/Features/voice/` | -| `VoiceBurstFramer` / `VoiceBurstAssembler` (wire encode/decode, caps) | `bitchat/Protocols/` + `bitchat/Services/PTT/` | -| `MessageType.voiceFrame = 0x29` | `localPackages/BitFoundation/.../MessageType.swift` + `BLEReceivePipeline` / `BLEService.handleReceivedPacket` | -| `NoisePayloadType.voiceFrame = 0x08` | `BitchatProtocol.swift` + `ChatTransportEventCoordinator` dispatch | -| Relay policy case | `RelayController` (fragment-like clamp/jitter) | -| No-padding rule | `BLEOutboundPacketPolicy` | -| Pacing/cap constants | `TransportConfig` | -| Live bubble + LIVE HUD + busy mic tint + settings toggle | `VoiceNoteView`/`MediaMessageView`, `ContentComposerView`, conversation settings | -| Delivery-mode selection | `ChatViewModel` / `ChatMediaTransferCoordinator` (reuse `isPeerConnected` / `canDeliverPromptly` / `hasEstablishedSession`) | - -## 11. Phasing - -1. **Phase 1 — DM live (highest value, lowest blast radius):** encoder, framer, Noise inner type, assembler/player, live bubble, finalize-as-note dedup. Single-hop DMs are the dominant real-world case. -2. **Phase 2 — public mesh:** `voiceFrame` 0x29, signing/verification, relay policy, floor-courtesy UI, autoplay defaults. -3. **Phase 3 (v2 candidates):** background audio mode, mid-burst repair requests (piggyback on REQUEST_SYNC), talk-over mixing, AAC-ELD low-delay profile, dedicated walkie-mode screen, media-over-Nostr (unlocks live-ish PTT for internet peers), Android protocol spec sync. - -## 12. Testing - -- Unit: framer round-trip (START/data/END/CANCELED, seq gaps, oversize frames), assembler caps + stale cleanup + burstID note dedup, packetizer batch sizing vs. MTU (assert no fragmentation), relay-policy clamps. -- Integration: two-simulator loopback via the existing mesh test harness; loss injection (drop every Nth frame) → verify silence-fill + partial-file persistence. -- Device: two-phone live DM (latency measurement vs. §8 budget), three-phone relay chain, talk-over behavior, cancel semantics, mic-permission-denied path. diff --git a/docs/REQUEST_SYNC_MANAGER.md b/docs/REQUEST_SYNC_MANAGER.md index a885f159..b304508e 100644 --- a/docs/REQUEST_SYNC_MANAGER.md +++ b/docs/REQUEST_SYNC_MANAGER.md @@ -20,11 +20,9 @@ The new implementation introduces a **RequestSyncManager** to track outgoing syn ### Request Sync Payload The `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include: -* `sinceTimestamp` (Type 0x05): filter-coverage cursor (UInt64 big-endian). The requester's GCS filter only covers packets at or after this timestamp; the responder skips older packets instead of re-sending them every round. -* `fragmentIdFilter` (Type 0x06): targeted fragment resync (UTF-8 string). Comma-separated 16-hex-char (8-byte) fragment **stream IDs** — the ID that prefixes every fragment payload. - * **Requester**: when a broadcast reassembly stalls (no new fragment for 5 s), the fragment assembler reports the stream ID and a `REQUEST_SYNC` with `types = fragment` and this filter goes to each connected peer (re-requested at most every 10 s per stream). Directed reassemblies are excluded — peers only archive broadcast fragments for sync. - * **Responder**: when the filter is present, the fragment diff is restricted to exactly the named streams and the `sinceTimestamp` cursor is bypassed for them; the GCS filter still excludes pieces the requester already holds. Responses keep RSR marking, TTL 0, per-peer response rate limiting (8/30 s), and `REQUEST_SYNC` itself remains link-local (TTL 0, never relayed). - * **Bounds**: at most 60 IDs per request. Each ID encodes as 16 hex chars plus a comma separator, so the largest value is 60 × 17 − 1 = 1019 bytes, within the decoder's 1024-byte acceptance cap; oversized filter values are ignored (the rest of the request still decodes). +* **Future Filters**: + * `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian). + * `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string). ## Architecture diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index 55d00232..6832a16e 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -2,7 +2,7 @@ This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays. -**Status:** Implemented in Android and iOS: both decode routed packets, forward along routes, and originate routes. iOS origination is policy-gated (see §8). Backward compatible (v1 clients never receive routed frames from iOS: routes are only originated when every node on the path has been observed speaking v2). +**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data). --- @@ -144,44 +144,3 @@ When a node receives a packet **not** addressed to itself: * **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery. 3. **If NO (Standard):** * Flood the packet to all connected neighbors (subject to TTL and probability rules). - ---- - -## 8. iOS Origination Policy - -iOS attaches a route (upgrading the packet to v2 and re-signing it) only when -**all** of the following hold at send time (`BLESourceRouteOriginationPolicy`): - -1. **Authored locally.** The packet's `SenderID` is our own peer ID. Relays - never rewrite someone else's packet — adding a route would force a - re-sign under the wrong key. Relays only *follow* existing routes - (`BLERouteForwardingPolicy`). -2. **Directed.** The packet has a single-peer `RecipientID` (not the - broadcast ID). In practice this covers Noise-encrypted private traffic, - private file transfers, and their fragments (fragments inherit the - parent's route and version, per §5). -3. **TTL headroom.** `TTL > 1`. Link-local packets (e.g. `REQUEST_SYNC`, - always TTL 0) never carry routes. -4. **Recipient not directly connected.** A direct write already delivers in - one hop; a route would only add bytes. -5. **Complete v2 path exists.** BFS over the confirmed-edge mesh graph - (`MeshTopologyTracker`, built from verified announce `directNeighbors` - claims, entries expiring after 60 s) finds a path with **at most 4 - intermediate hops** where every intermediate hop **and the recipient** - has been observed originating or relaying a v2 packet. Nodes never seen - speaking v2 are assumed v1-only and are excluded — a v1 client cannot - decode a v2 frame, so routing through it would silently drop the packet. -6. **No recent route failure.** See below. - -If any gate fails, behavior is exactly the pre-routing flood/direct-write -path — v1 peers observe no change. - -### Failure Fallback - -A routed unicast rides one path; a broken hop loses the packet where a flood -would heal around it. iOS keeps a small per-recipient health cache -(`BLESourceRouteFailureCache`): a routed send that sees no inbound packet -authored by the recipient within 10 s counts as a route failure, and directed -sends to that recipient fall back to flooding for the next 60 s before -routing is attempted again. Retransmission of the payload itself stays where -it always was (MessageRouter and higher layers). diff --git a/docs/TOR-INTEGRATION.md b/docs/TOR-INTEGRATION.md index 46f80951..0d714834 100644 --- a/docs/TOR-INTEGRATION.md +++ b/docs/TOR-INTEGRATION.md @@ -1,53 +1,66 @@ -# Tor integration +Tor-by-default integration (scaffold) -## Overview +Overview +- All network traffic is routed via a local Tor SOCKS5 proxy by default, with fail-closed behavior when Tor isn’t ready. There are no user-visible settings. +- This repo now includes a minimal TorManager and TorURLSession to make dropping in an embedded Tor framework straightforward. -Internet traffic — Nostr relay sockets and the geo-relay directory fetch — is routed through Tor by default, fail-closed: when Tor is wanted but not ready, requests queue rather than falling back to clearnet. +Key pieces +- TorManager + - Boots Tor, manages a DataDirectory under Application Support, exposes SOCKS at 127.0.0.1:39050, and provides awaitReady(). + - Fails closed by default until Tor is bootstrapped. For local development only, define BITCHAT_DEV_ALLOW_CLEARNET to bypass Tor. +- TorURLSession + - Provides a shared URLSession configured with a SOCKS5 proxy when Tor is enforced/ready. + - NostrRelayManager and GeoRelayDirectory now use this session and await Tor readiness before starting network activity. -Tor is provided by **Arti, in-process**, vendored as a Swift package under `localPackages/Arti` wrapping a Rust static-library xcframework. There is no `tor` binary, no `torrc`, and no control port. A SOCKS5 listener on `127.0.0.1:39050` is the only interface. +Drop‑in steps +1) Build or obtain a small Tor framework + - Recommended: Tor C (client-only) with static linking and dead-strip. + - Configure Tor with a minimal feature set: + ./configure \ + --enable-static \ + --disable-asciidoc --disable-unittests --disable-manpage \ + --disable-zstd --disable-lzma --enable-zlib \ + --disable-systemd --disable-ptrace --disable-seccomp + CFLAGS="-Os -fdata-sections -ffunction-sections" \ + LDFLAGS="-Wl,-dead_strip" + - Build a tiny OpenSSL/LibreSSL (no engines, strip symbols) or reuse system crypto where permitted on macOS. -## Key pieces +2) Add the framework to Xcode targets + - Drop your xcframework into `Frameworks/`. The project is prewired in `project.yml` to link/embed `Frameworks/tor-nolzma.xcframework` (rename yours to match, or update the path). + - Ensure the binary includes the slices you need (iOS device/simulator and/or macOS). If your xcframework lacks simulator slices, you can still build/run on device or macOS arm64; simulator will fail to link. + - On iOS, it will be embedded and signed automatically. -- **`TorManager`** — owns the Arti client and its data directory under Application Support, exposes the SOCKS port, and provides `awaitReady()`. - - `torEnforced` is compile-time: true unless `BITCHAT_DEV_ALLOW_CLEARNET` is defined. It is not set anywhere in `Configs/` or the project file, so release builds enforce. - - `isStarting`, `bootstrapProgress`, and `bootstrapSummary` describe an attempt in flight. - - `bootstrapDidStall` becomes true when an attempt spends its whole 75-second deadline without completing, and posts `.TorBootstrapDidStall`. This is the state a network that blocks Tor produces, and it is deliberately distinct from `isStarting`: without it the UI says "starting tor…" indefinitely. It is cleared on each new start or restart. -- **`TorURLSession`** — a shared `URLSession` with the SOCKS proxy configured when proxying is on, and an unproxied session when it is off. `setProxyMode(useTor:)` is the switch, driven by `NetworkActivationService`. -- **`NetworkActivationService`** — decides whether Tor may run at all. Tor starts when the activation policy permits it *and* the Tor preference is on. `persistedTorPreference(in:)` is a `nonisolated` read of that preference for callers off the main actor. +3) Wire Tor bootstrap in TorManager.startTor() + - Two paths are already implemented: + - If a module named `Tor` is present (iCepa API), it starts `TORThread` directly. + - Otherwise, it attempts a dynamic load (`dlopen`) of a bundled framework binary named `tor-nolzma.framework/tor-nolzma` (or `Tor.framework/Tor`), resolves `tor_run_main`, and launches Tor on a background thread. + - `TorManager` writes a torrc and then probes `127.0.0.1:39050` until ready. -Both network call sites go through `TorURLSession`: `NostrRelayManager` (relay websockets) and `GeoRelayDirectory` (directory CSV refresh). There is no other outbound network in the app or the share extension. +4) Verify networking + - On app launch, TorManager.startIfNeeded() is called implicitly by awaitReady(). + - NostrRelayManager.connect() awaits readiness, then creates WebSocket tasks via TorURLSession.shared. + - GeoRelayDirectory.fetchRemote() awaits readiness, then fetches via TorURLSession.shared. -## The Tor preference is user-visible +5) Optional macOS optimization + - Detect a system Tor binary (e.g., /opt/homebrew/bin/tor) and run it as a subprocess to avoid bundling. Keep the embedded fallback for portability. -The earlier version of this document said there are no user-visible settings. There is one: a **tor routing** toggle in settings, persisted under `networkActivationService.userTorEnabled`, defaulting to on. +torrc template +The generated torrc (under Application Support/bitchat/tor/torrc) is: -Turning it off is a real change in exposure, not a performance tweak. Every fail-closed guard is conditioned on the preference, so with it off: + DataDirectory /bitchat/tor + ClientOnly 1 + SOCKSPort 127.0.0.1:39050 + ControlPort 127.0.0.1:39051 + CookieAuthentication 1 + AvoidDiskWrites 1 + MaxClientCircuitsPending 8 -- relay websockets connect directly, and every relay operator sees the device IP — including relays carrying private messages; -- the geo-relay directory fetch also goes direct. +Dev bypass (local only) +- To temporarily allow direct network without Tor for local development: + - Add Swift compiler flag: BITCHAT_DEV_ALLOW_CLEARNET + - This enables a clearnet session in TorURLSession when Tor isn’t present. + - Never enable this in release builds. -The settings UI states this while the toggle is off. - -`GeoRelayDirectory` keys its Tor wait on the *preference*, not on live readiness, and this distinction is load-bearing. With Tor off, waiting for a client that has been shut down would spend the full bootstrap timeout on every refresh and freeze the directory on its cached copy. With Tor on but not ready, the wait must still fail so the fetch is skipped rather than silently leaking the IP. - -## Relays - -Private messages target the built-in relay set plus any relays added by hand (`NostrRelaySettings`, capped at 8, `.onion` addresses accepted). The built-in set is four well-known clearnet hostnames, so a filter blocking four names would otherwise end internet-delivered private messages until a new build shipped. - -## Artifact maintenance - -- Binary provenance, rebuild steps, and current hashes: `docs/ARTI-BINARY-PROVENANCE.md`, enforced by `.github/workflows/arti-provenance.yml`. -- The xcframework must include iOS device, iOS simulator, and macOS arm64 slices. -- Any refresh reviews the Rust source, `Cargo.lock`, generated header, build script, and new hashes together. A binary-only update is not acceptable. - -## Known gap: no bridges or pluggable transports - -`arti-client` is built with `default-features = false` and features `["tokio", "rustls"]` only — no `pt-client`, no `bridge-client` — and `arti-bitchat/src/lib.rs` bootstraps from stock configuration with no bridge lines and no configurable directory authorities. - -So in a country that blocks Tor by blocking the public relays and directory authorities, bootstrap never completes. The app reports that clearly now instead of appearing to start forever, and the BLE mesh is unaffected, but there is no circumvention path: obfs4, snowflake, and meek are all unavailable. - -Closing this means enabling the pluggable-transport features, plumbing bridge configuration through the FFI and a settings surface, and rebuilding the xcframework under the pinned toolchain with a provenance-manifest update. That is the single largest remaining gap in censorship resilience for the internet transport. - -## Dev bypass (local only) - -Define the Swift compiler flag `BITCHAT_DEV_ALLOW_CLEARNET` to allow direct network access without Tor while developing. Never enable it in release builds. +Notes +- We intentionally do not change any app-level APIs: consumers simply use TorURLSession via existing code paths. +- When Tor is missing in release builds, the app will not connect (fail-closed), logging a clear reason. diff --git a/docs/VERIFYING-A-BUILD.md b/docs/VERIFYING-A-BUILD.md deleted file mode 100644 index 23b5181b..00000000 --- a/docs/VERIFYING-A-BUILD.md +++ /dev/null @@ -1,94 +0,0 @@ -# Verifying bitchat - -This document is about a specific risk: getting a copy of bitchat that someone has modified. - -It matters because the repository has been the target of takedown demands. When a repository or a releases page becomes unavailable, mirrors appear, and people who need the app during a shutdown install whatever they can find. That is exactly the moment a trojaned build reaches the people with the most to lose. A modified bitchat can log plaintext, ship keys off the device, or weaken the mesh, and it will look and behave normally while doing it. - -The honest summary is short. **Source can be verified. Compiled apps cannot, unless they come from the App Store.** Everything below elaborates on that. - -## Getting the app - -In order of how much verification is possible: - -1. **The App Store.** Apple verifies the developer signature, and the binary cannot be altered without breaking it. This is the only channel where a compiled build is verifiable end to end, and it is the right recommendation for almost everyone. -2. **Build it yourself from verified source.** See below. Requires a Mac and Xcode, and gives you the strongest guarantee if you can do it. -3. **A compiled build from anywhere else.** Not verifiable. See "Builds from other sources". - -## Verifying source - -Every tagged release has a `SOURCE-MANIFEST.txt` produced by `.github/workflows/source-manifest.yml`. It records the tag, the commit, the git tree hash, and a SHA-256 for every tracked file. - -Keep the downloaded manifest *outside* the source tree (say, `/tmp`) — a stray copy inside the checkout would itself trip the completeness checks below. Then check a copy of the source against it: - -```sh -# From the root of the source you obtained, with the manifest at /tmp -grep -v '^#' /tmp/SOURCE-MANIFEST.txt > /tmp/files.sha256 -shasum -a 256 -c /tmp/files.sha256 -``` - -Any `FAILED` line means that file differs from the released source. Investigate before building. - -That check alone is not enough. `shasum -c` verifies the files the manifest lists and says nothing about files it does not list — and the Xcode project compiles every source file present in the tree automatically, so a hostile mirror can pass the hash check by leaving every listed file intact and *adding* one. Confirm nothing extra is present: - -```sh -# The manifest's path list must match the tree exactly — no missing files, no extras -grep -v '^#' /tmp/SOURCE-MANIFEST.txt | sed 's/^[0-9a-f]* //' | LC_ALL=C sort > /tmp/manifest-paths -find . -type f ! -path './.git/*' | sed 's|^\./||' | LC_ALL=C sort > /tmp/actual-paths -diff /tmp/manifest-paths /tmp/actual-paths # must print nothing -``` - -In a git checkout the same assurance is one command — it also catches extra files, because they show as untracked. `--ignored` matters: `.gitignore` covers paths like `build/`, plain `git status` would not report a planted file there, and Xcode compiles it all the same: - -```sh -git status --porcelain --ignored # must print nothing before you build -``` - -The single value that covers the whole tree is the git tree hash in the manifest header: - -```sh -git rev-parse HEAD^{tree} # must equal the "tree:" line in the manifest -``` - -Note the tree hash covers tracked content only; it does not see untracked files sitting in the working directory, which is why the emptiness checks above come first. - -The manifest itself carries a provenance attestation tying it to the workflow run that produced it, so a manifest handed to you along with a mirror is checkable too: - -```sh -gh attestation verify /tmp/SOURCE-MANIFEST.txt --repo permissionlesstech/bitchat -``` - -That last step is what makes this resistant to a hostile mirror. Without it, whoever gives you the source can give you a matching manifest. - -### If the manifest is unavailable - -Compare against a commit instead. Git object hashes cover content and history, so if you can obtain the expected commit hash through any channel you trust — a second mirror, a maintainer's post elsewhere, someone who cloned earlier — then: - -```sh -git fetch --tags -git rev-parse v1.2.3 # compare against the hash you trust -git verify-tag v1.2.3 # if the tag is signed -``` - -A mirror whose history matches a commit hash you trust from elsewhere is a faithful mirror. - -## Builds from other sources - -If you have an `.ipa`, an `.apk`, or a Mac app from a forum, a chat group, a file locker, or any mirror, you cannot verify it, and this project cannot help you verify it. There is no published signing key for compiled builds and no reproducible-build pipeline, so there is nothing to compare a binary against. - -What to do instead, in order of preference: install from the App Store; build from verified source; or, if neither is possible, treat that build as untrusted — assume anything you type into it may be disclosed, do not use it for anything sensitive, and do not carry it somewhere it being on your phone is itself a risk. - -Do not rely on the app looking right. A modified build has no reason to look different. - -## For maintainers - -Cutting a release: - -- Push the tag. `source-manifest.yml` runs and attaches `SOURCE-MANIFEST.txt` to the release; if the release does not exist yet, collect the manifest from the workflow artifact and attach it when you publish. -- Sign the tag (`git tag -s`). A signed tag lets anyone verify the release came from a key you control, independent of GitHub. This needs a published key fingerprint to be useful — see the gap below. -- Note the commit hash somewhere outside this repository. If the repository is taken down, a hash recorded elsewhere is what lets people verify a mirror. - -Known gaps, so nobody assumes more protection than exists: - -- **No published signing key.** Tags are not currently verifiable against a known key. Publishing a fingerprint through channels independent of GitHub, and signing tags with it from then on, is the missing piece. -- **No verifiable compiled builds outside the App Store.** Closing this needs either a signed-and-notarized release pipeline or a reproducible build, and until one exists the guidance above stands. -- **No non-GitHub source mirror.** Every remote for this project is on the platform the takedown demands were served to. A mirror on independent infrastructure, published before it is needed, would mean a takedown does not remove the ability to verify. diff --git a/docs/privacy-assessment.md b/docs/privacy-assessment.md index 92598407..cba3aa2e 100644 --- a/docs/privacy-assessment.md +++ b/docs/privacy-assessment.md @@ -1,121 +1,64 @@ -# bitchat Privacy Assessment +BitChat Privacy Assessment +========================== -Last reviewed: July 2026 +Scope +- Mesh transport (BLE) behavior and metadata minimization +- Nostr-based private message fallback (gift-wrapped, end-to-end encrypted) +- Read receipts and delivery acknowledgments +- Logging/telemetry posture and controls -## Scope +Summary +- No accounts, no servers for mesh; Nostr used only for mutual favorites, with end-to-end Noise encryption encapsulated in gift wraps. +- BLE announces contain only nickname and Noise pubkey. No device name, no plaintext identity beyond what the user broadcasts. +- Discovery and flooding incorporate jitter and TTL caps to reduce linkability and propagation radius of encrypted payloads. +- UI and storage remain ephemeral; message content is not persisted to disk. Minimal state (e.g., read-receipt IDs) is stored for UX and is bounded/cleaned. +- Logging defaults to conservative levels; debug verbosity is suppressed for release builds. A single env var can raise/lower threshold when needed. -- BLE discovery, mesh routing, gossip sync, private delivery, and courier behavior -- Nostr private fallback, bridge courier drops, mesh bridging, geohash channels, and notices -- CoreLocation and reverse geocoding -- Local persistence, panic wipe, logging, and App Store privacy manifests +BLE Privacy Considerations +- Announce content: Unchanged — nickname + Noise public key only. +- Local Name: Not used (explicitly disabled). Avoids leaking device/OS identity. +- Address: iOS uses BLE MAC randomization; BitChat does not attempt to set static addresses. +- Announce jitter: Each announce is delayed by a small random jitter to avoid synchronization-based correlation. +- Scanning: Foreground scanning uses “allow duplicates” briefly to improve discovery latency; background uses standard scanning parameters. +- RSSI gating: The acceptance threshold adapts to nearby density (approx. -95 to -80 dBm) to reduce long-distance observations in dense areas and improve connectivity in sparse ones. +- Fragmentation: Fragments use write-with-response for reliability (less re-broadcast churn = fewer repeated signals). +- GATT permissions: Private characteristic disallows .read; we use notify/write/writeWithoutResponse to avoid exposing plaintext attributes over GATT. -The user-facing contract is `PRIVACY_POLICY.md`. This document records implementation-level behavior and residual risks that should be re-audited when storage or transport semantics change. +Mesh Routing and Multi-hop Limits +- Encrypted relays permitted with random per-hop delay (small jitter) to smooth floods. +- TTL cap: Encrypted payloads are capped at 2 hops, limiting metadata spread and path reconstruction risk while enabling close-range relays. -## Current Posture +Nostr Private Messaging Fallback +- Usage criteria: Only attempted for mutual favorites or where a Nostr key has been exchanged (stored in favorites). +- Payload confidentiality: Messages embed a BitChat Noise-encrypted packet inside a NIP-17 gift wrap; relays see only random-looking ciphertext. +- Timestamp handling: Gift wraps add small randomized offsets to reduce exact timing correlation. +- Read/delivery acks: Also encapsulated in gift wraps, preserving content secrecy and minimizing metadata. +- Relay policy variance: Some relays apply “web-of-trust” policies and may reject events; BitChat tolerates partial delivery and still prefers mesh when available. -- The project operates no account system, analytics pipeline, advertising SDK, or project-owned messaging backend. -- Mesh transport is peer-to-peer. Optional internet features use third-party Nostr relays and can expose public content, coarse geohashes, timing, relay, and network metadata. -- Private payloads are end-to-end encrypted, but public mesh, board, bridge, and geohash content is intentionally visible to its participants. -- Local storage is bounded where practical and included in panic wipe, but it is not wholly ephemeral. The app persists the stores listed below. -- The app and share extension each bundle a privacy manifest declaring their actual required-reason API use. -- A locked device discloses less than an unlocked one, but not nothing. Notification previews are hidden by default and the app-switcher snapshot is covered; see "Locked and Seized Devices". +Read Receipts and Delivery Acks +- Routing policy: Prefer mesh if Noise session established; otherwise use Nostr when mapping exists. +- Throttling: Nostr READ acks are queued and rate-limited (~3/s) to prevent relay rate limits during backlogs. +- Coalescing (optional future): When entering a chat with many unread, only send READ for the latest message, marking older as read locally to reduce metadata. -## BLE Discovery and Metadata +Data Retention and State +- Messages: Ephemeral in-memory only; history is bounded per chat and trimmed. +- Read-receipt IDs: Stored in `UserDefaults` for UX continuity; periodically pruned to IDs present in memory. +- Favorites: Noise and optional Nostr keys with petnames; can be wiped via panic action. +- Panic: Triple-tap clears keys, sessions, cached state, and disconnects transports. -Signed announces can expose: +Logging and Telemetry +- Centralized `SecureLogger` filters potential secrets and uses OSLog privacy markers. +- Default level: `info`; release builds suppress debug. Developers can set `BITCHAT_LOG_LEVEL=debug|info|warning|error|fault`. +- Transport routing, ACK sends, subscribe/connect noise were downgraded from info→debug. +- OS/system errors (e.g., transient WebSocket disconnects) may still appear in system logs; BitChat avoids re-logging those unless actionable. -- Nickname, persistent Noise public key, and Ed25519 signing public key -- Capability flags -- A bounded set of short direct-neighbor identifiers -- A coarse rendezvous geohash when the bridge capability is enabled +Residual Risks and Mitigations +- RF fingerprinting: BLE presence is observable at the RF layer; mitigated by minimal announce content and platform MAC randomization. +- Timing correlation: Announce/relay jitter reduces but does not eliminate timing analysis. Avoids synchronized bursts. +- Relay metadata: Nostr relays can see that an account posts gift wraps; content remains end-to-end encrypted. Favor mesh path when in range. -The app does not advertise the device's assigned name. iOS manages BLE address randomization; bitchat does not attempt to create a stable MAC address. +Recommendations (Next) +- Add optional coalesced READ behavior for large backlogs. +- Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts. +- Allow user-configurable Nostr relay set with a “private relays only” toggle. -That randomization does not deliver the unlinkability it might suggest, because the application layer publishes stable identifiers above it. The 8-byte peer ID in every packet header is the first 8 bytes of the Noise static key fingerprint, so it does not rotate; announces carry the static keys themselves; and the fixed service UUID makes any bitchat device detectable as such by a passive scanner. A receiver in radio range can therefore recognise a specific device across sessions and locations, and detect that the app is in use at all. RSSI, timing, traffic volume, and radio fingerprints remain observable as well. - -Ingress validates announce structure, sender binding, signatures, payload sizes, and freshness. Current-link Noise authentication is required before destructive courier handoff or strict directed delivery. Floods, queues, fragments, ingress work, and per-peer state are bounded. - -## Private Messaging and Courier Delivery - -- Direct mesh sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256. -- Undelivered outgoing private messages remain in a bounded, ChaChaPoly-sealed outbox for at most 24 hours. Its key is stored in the keychain. -- Physical couriers store opaque Noise-sealed envelopes, not plaintext. Deposits have trust-tier quotas, per-depositor caps, a global cap, and at most a 24-hour lifetime. -- Spray-and-wait copies are bounded and progress cannot be replenished by replaying a deposit. -- Delivery status advances only after real transport admission or explicit relay acceptance; late failure cannot downgrade a delivered/read message. -- Panic wipe deletes the outbox, courier mail, keys, dedup state, and active transport state. - -Residual risk: private-message metadata such as timing, radio adjacency, ciphertext size, rotating recipient tags, and relay connections remains observable. A compromised recipient device can disclose plaintext. - -## Public Gossip, Boards, and Media - -- Recent signed public mesh messages are archived in Application Support for up to 6 hours so gossip sync survives a relaunch and can cross mesh partitions. -- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas. -- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe. -- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota, and all managed media — incoming and outgoing — is additionally bounded by age: a launch-time sweep deletes anything older than seven days. In-flight live captures and files reserved by a delivery or deletion in progress are exempt regardless of age. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning. - -Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away. - -## Nostr and Mesh Bridge - -- BitChat's proprietary private-envelope fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. It is not NIP-17, NIP-44, or NIP-59 compatible and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. Relays still see the recipient public-key tag, event timing and size, and network metadata. -- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags. -- Relay publication is considered successful only after an explicit NIP-01 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable. -- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants. -- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event. - -- Relays added by hand persist in local preferences (`nostr.customRelays`, at most 8, normalized on read) and are wiped on panic. An added relay names an operator someone chose to route through, so it is treated as sensitive local state rather than inert configuration. `.onion` addresses are accepted, which is the point: the four built-in relays are well-known clearnet hostnames and a filter blocking four names would otherwise end internet-delivered private messages until a new build shipped. -- Turning the Tor preference off routes relay sockets and the relay-directory fetch directly, disclosing the device IP to every relay operator including those carrying private messages. The settings UI states this while the preference is off. - -Residual risk: Nostr relay retention and logging are outside project control. Public events may be copied indefinitely. Timing, coarse location, and participation can be correlated even when content is encrypted or per-cell identities are used. - -## Location - -- When-in-use CoreLocation access computes geohash choices and bridge cells. Permission revocation stops live sampling and releases subscriptions. -- Exact coordinates are not persisted by bitchat or placed into mesh/Nostr payloads. -- Selected/bookmarked geohashes, teleport flags, and display names persist in local preferences; a fine geohash can identify a small area. -- Friendly place names use `CLGeocoder.reverseGeocodeLocation`. Apple may process the supplied location under its own privacy terms, so this operation is not accurately described as wholly on-device. -- Automatic presence is limited to lower-precision geohashes; precise posts occur through user-selected channels, notes, notices, or the bridge behavior presented in the UI. - -## Logging and Telemetry - -- `SecureLogger` uses OSLog privacy markers and filters likely secrets. Release builds suppress debug verbosity. -- No project analytics or telemetry endpoint exists. -- Apple system logs, Nostr relays, network providers, and nearby radios can still observe operational metadata outside the project's logging layer. - -## Privacy Manifests - -`bitchat/PrivacyInfo.xcprivacy` declares: - -- UserDefaults: `CA92.1` for app-only preferences and `1C8F.1` for the shared app group -- File timestamps/metadata: `C617.1` for app-container files and `3B52.1` for user-granted files -- System boot time: `35F9.1` for elapsed-time deadlines and timers - -`bitchatShareExtension/PrivacyInfo.xcprivacy` declares app-group UserDefaults reason `1C8F.1`. Both manifests declare no tracking domains and no data collection by the app developer. They must remain bundled in their respective executable bundles. - -## Locked and Seized Devices - -The realistic compromise for many of the people this app is built for is not interception but a phone taken and, often, unlocked under coercion. - -- Notification content is rendered by the system on the lock screen, so it is readable without unlocking. Message previews are therefore hidden by default: alerts state that a direct message, mention, or location-channel activity arrived, and withhold the message body, the sender's nickname, and the geohash until the app is opened. `userInfo` still carries the routing peer ID and deep link, neither of which the system displays. The preference is `notifications.hideMessagePreviews`; turning it off restores full previews. -- The window is covered on `willResignActive`, so the snapshot iOS stores for the app switcher shows a placeholder rather than an open conversation. The cover is opaque rather than blurred, and is added synchronously because the capture follows shortly after that notification. Panic wipe separately deletes snapshots already on disk. -- Clearing a mesh timeline erases the on-disk gossip archive behind it, so cleared public history is deleted rather than hidden. The echo watermark still suppresses pre-clear messages this device hears again from peers. -- Managed media is bounded by age as well as size, so a received photo does not outlive its conversation indefinitely. - -Not addressed, and deliberately out of scope here: - -- **No duress mechanism.** There is no decoy passphrase, no wipe-on-failed-authentication, and no biometric or passcode lock on the app itself. A coerced unlock discloses everything the device still holds. Adding one is a product decision as much as an engineering one: in some jurisdictions destroying data on demand is itself an offence, so a mode that *hides* may protect someone better than one that *destroys*, and the choice should be made deliberately rather than by default. -- **macOS gets no file-protection classes.** Every `FileProtectionType` application is inside `#if os(iOS)`; Data Protection on macOS additionally requires an entitlement. The Mac app also has no app-switcher equivalent. -- **Media is not sealed at the app layer.** It relies on the platform default protection class, which is readable once the device has been unlocked since boot. Sealing under a key with the same accessibility would not change that; only a key gated on user authentication would, and that conflicts with receiving media while locked. - -## Panic Wipe Coverage - -The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, hand-added relays, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test. - -## Release Review Checklist - -- Reconcile every new Application Support, UserDefaults, keychain, cache, or relay write with this assessment and `PRIVACY_POLICY.md`. -- Re-scan required-reason APIs and validate both bundled `PrivacyInfo.xcprivacy` files before archive submission. -- Verify panic wipe reaches any newly added persistent store. -- Treat geohash precision, bridge-cell changes, new relay tags, and announce fields as privacy-surface changes. -- Re-run real-device Bluetooth, background/locked-device recovery, location revocation, and audio-route checks; simulators cannot validate the physical side of those behaviors. -- Check what a new notification discloses on a locked screen, and that the app-switcher snapshot is covered, whenever notification or scene-lifecycle code changes. diff --git a/localPackages/Arti/Frameworks/arti.xcframework/Info.plist b/localPackages/Arti/Frameworks/arti.xcframework/Info.plist index 42143638..5e9b24d6 100644 --- a/localPackages/Arti/Frameworks/arti.xcframework/Info.plist +++ b/localPackages/Arti/Frameworks/arti.xcframework/Info.plist @@ -4,6 +4,22 @@ AvailableLibraries + + BinaryPath + libarti_bitchat.a + HeadersPath + Headers + LibraryIdentifier + macos-arm64 + LibraryPath + libarti_bitchat.a + SupportedArchitectures + + arm64 + + SupportedPlatform + macos + BinaryPath libarti_bitchat.a @@ -26,36 +42,18 @@ HeadersPath Headers LibraryIdentifier - ios-arm64_x86_64-simulator + ios-arm64-simulator LibraryPath libarti_bitchat.a SupportedArchitectures arm64 - x86_64 SupportedPlatform ios SupportedPlatformVariant simulator - - BinaryPath - libarti_bitchat.a - HeadersPath - Headers - LibraryIdentifier - macos-arm64_x86_64 - LibraryPath - libarti_bitchat.a - SupportedArchitectures - - arm64 - x86_64 - - SupportedPlatform - macos - CFBundlePackageType XFWK diff --git a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/Headers/arti.h b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/Headers/arti.h similarity index 100% rename from localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/Headers/arti.h rename to localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/Headers/arti.h diff --git a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a new file mode 100644 index 00000000..3b5d7176 Binary files /dev/null and b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a differ diff --git a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a index ba2f3583..35bf4c67 100644 Binary files a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a and b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a differ diff --git a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/libarti_bitchat.a deleted file mode 100644 index 18c0372b..00000000 Binary files a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64_x86_64-simulator/libarti_bitchat.a and /dev/null differ diff --git a/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/Headers/arti.h b/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/Headers/arti.h similarity index 100% rename from localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/Headers/arti.h rename to localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/Headers/arti.h diff --git a/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a new file mode 100644 index 00000000..3884360b Binary files /dev/null and b/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a differ diff --git a/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/libarti_bitchat.a deleted file mode 100644 index f43f8a76..00000000 Binary files a/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64_x86_64/libarti_bitchat.a and /dev/null differ diff --git a/localPackages/Arti/Package.swift b/localPackages/Arti/Package.swift index c0e0ff5f..c32ad6cf 100644 --- a/localPackages/Arti/Package.swift +++ b/localPackages/Arti/Package.swift @@ -5,16 +5,16 @@ let package = Package( name: "Tor", // Keep name "Tor" for drop-in compatibility platforms: [ .iOS(.v16), - .macOS(.v13) + .macOS(.v13), ], products: [ .library( name: "Tor", targets: ["Tor"] - ) + ), ], dependencies: [ - .package(path: "../BitLogger") + .package(path: "../BitLogger"), ], targets: [ // Main Swift target @@ -22,26 +22,25 @@ let package = Package( name: "Tor", dependencies: [ "arti", - .product(name: "BitLogger", package: "BitLogger") + .product(name: "BitLogger", package: "BitLogger"), ], path: "Sources", exclude: ["C"], sources: [ "TorManager.swift", "TorURLSession.swift", - "TorNotifications.swift" + "TorNotifications.swift", ], linkerSettings: [ .linkedLibrary("resolv"), .linkedLibrary("z"), - .linkedLibrary("sqlite3") + .linkedLibrary("sqlite3"), ] ), - // Binary framework containing the Rust static library. - // Provenance and rebuild steps: repo-root docs/ARTI-BINARY-PROVENANCE.md + // Binary framework containing the Rust static library .binaryTarget( name: "arti", path: "Frameworks/arti.xcframework" - ) + ), ] ) diff --git a/localPackages/Arti/Sources/TorManager.swift b/localPackages/Arti/Sources/TorManager.swift index 1e9f2eb8..45958b59 100644 --- a/localPackages/Arti/Sources/TorManager.swift +++ b/localPackages/Arti/Sources/TorManager.swift @@ -30,6 +30,12 @@ private func arti_bootstrap_progress() -> Int32 @_silgen_name("arti_bootstrap_summary") private func arti_bootstrap_summary(_ buf: UnsafeMutablePointer, _ len: Int32) -> Int32 +@_silgen_name("arti_go_dormant") +private func arti_go_dormant() -> Int32 + +@_silgen_name("arti_wake") +private func arti_wake() -> Int32 + /// Arti-based Tor integration for BitChat. /// - Boots a local Arti client and exposes a SOCKS5 proxy /// on 127.0.0.1:socksPort. All app networking should await readiness and @@ -48,15 +54,6 @@ public final class TorManager: ObservableObject { @Published private(set) var lastError: Error? @Published private(set) var bootstrapProgress: Int = 0 @Published private(set) var bootstrapSummary: String = "" - /// True once a bootstrap attempt has spent its whole deadline without - /// completing. - /// - /// This separates "still starting" from "not getting through", which are - /// indistinguishable from `isStarting` alone. The second is what a network - /// that blocks Tor looks like from inside the app, and without it the UI - /// says "starting tor…" indefinitely while nothing is happening. Cleared on - /// each new start attempt. - @Published private(set) public var bootstrapDidStall: Bool = false // Internal readiness trackers private var socksReady: Bool = false { didSet { recomputeReady() } } @@ -78,18 +75,10 @@ public final class TorManager: ObservableObject { } private var didStart = false - // shutdownCompletely() resets `didStart` asynchronously (after Arti has - // actually stopped). A startIfNeeded() arriving in that window must not be - // dropped — it is recorded here and honored when the shutdown finishes. - private var shutdownsInFlight = 0 - private var startPendingAfterShutdown = false private var bootstrapMonitorStarted = false - // Fences the detached poll loop: shutdown, dormancy, and restart each bump - // this, so a loop from a previous attempt cannot run out its deadline and - // report a stall over state that a newer lifecycle event already owns. - private var bootstrapGeneration = 0 private var pathMonitor: NWPathMonitor? private var isAppForeground: Bool = true + private var isDormant: Bool = false private var lastRestartAt: Date? = nil private var startedAt: Date? = nil // Tracks initial startup time for grace period private(set) var allowAutoStart: Bool = false @@ -101,15 +90,10 @@ public final class TorManager: ObservableObject { public func startIfNeeded() { guard allowAutoStart else { return } guard isAppForeground else { return } - if shutdownsInFlight > 0 { - SecureLogger.debug("TorManager: startIfNeeded() deferred - shutdown in flight", category: .session) - startPendingAfterShutdown = true - return - } guard !didStart else { return } didStart = true + isDormant = false isStarting = true - bootstrapDidStall = false startedAt = Date() // Track startup time for grace period SecureLogger.debug("TorManager: startIfNeeded() - startedAt set", category: .session) lastError = nil @@ -126,9 +110,7 @@ public final class TorManager: ObservableObject { public func isForeground() -> Bool { isAppForeground } nonisolated - // Default matches the bootstrap monitor deadline (75s); a shorter wait here - // reports "not ready" while Arti is still legitimately bootstrapping. - public func awaitReady(timeout: TimeInterval = 75.0) async -> Bool { + public func awaitReady(timeout: TimeInterval = 25.0) async -> Bool { await MainActor.run { if self.isAppForeground { self.startIfNeeded() } } @@ -272,46 +254,26 @@ public final class TorManager: ObservableObject { private func startBootstrapMonitor() { guard !bootstrapMonitorStarted else { return } bootstrapMonitorStarted = true - bootstrapGeneration += 1 - let generation = bootstrapGeneration Task.detached(priority: .utility) { [weak self] in - await self?.bootstrapPollLoop(generation: generation) + await self?.bootstrapPollLoop() } } - private func bootstrapPollLoop(generation: Int) async { + private func bootstrapPollLoop() async { let deadline = Date().addingTimeInterval(75) - var didComplete = false while Date() < deadline { - guard generation == bootstrapGeneration else { return } let progress = Int(arti_bootstrap_progress()) let summary = getBootstrapSummary() - self.bootstrapProgress = progress - self.bootstrapSummary = summary - if progress >= 100 { self.isStarting = false } - self.recomputeReady() - - if progress >= 100 { - didComplete = true - break + await MainActor.run { + self.bootstrapProgress = progress + self.bootstrapSummary = summary + if progress >= 100 { self.isStarting = false } + self.recomputeReady() } - try? await Task.sleep(nanoseconds: 1_000_000_000) - } - // Running out the deadline is a reportable outcome, not silence. The - // loop previously just ended, leaving `isStarting` true forever, so a - // blocked network was indistinguishable from a slow one. A deliberate - // shutdown mid-bootstrap is not a stall, hence the generation check. - if !didComplete { - guard generation == bootstrapGeneration else { return } - self.isStarting = false - self.bootstrapDidStall = true - SecureLogger.warning( - "TorManager: bootstrap did not complete within its deadline (progress=\(self.bootstrapProgress)); network may be blocking Tor", - category: .session - ) - NotificationCenter.default.post(name: .TorBootstrapDidStall, object: nil) + if progress >= 100 { break } + try? await Task.sleep(nanoseconds: 1_000_000_000) } } @@ -357,7 +319,6 @@ public final class TorManager: ObservableObject { // Clear isStarting so foreground recovery can proceed if bootstrap was interrupted. SecureLogger.debug("TorManager: goDormantOnBackground() called", category: .session) Task { @MainActor in - self.bootstrapGeneration += 1 self.isReady = false self.socksReady = false self.isStarting = false @@ -366,9 +327,6 @@ public final class TorManager: ObservableObject { public func shutdownCompletely() { SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session) - startPendingAfterShutdown = false - bootstrapGeneration += 1 - shutdownsInFlight += 1 Task.detached { [weak self] in guard let self = self else { return } _ = arti_stop() @@ -381,6 +339,7 @@ public final class TorManager: ObservableObject { } await MainActor.run { + self.isDormant = false self.isReady = false self.socksReady = false self.bootstrapProgress = 0 @@ -391,12 +350,6 @@ public final class TorManager: ObservableObject { self.bootstrapMonitorStarted = false // Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded() // Clearing it here races with startup and defeats the grace period - self.shutdownsInFlight -= 1 - if self.shutdownsInFlight == 0 && self.startPendingAfterShutdown { - self.startPendingAfterShutdown = false - SecureLogger.debug("TorManager: honoring start deferred during shutdown", category: .session) - self.startIfNeeded() - } } } } @@ -405,13 +358,12 @@ public final class TorManager: ObservableObject { SecureLogger.debug("TorManager: restartArti() starting", category: .session) await MainActor.run { NotificationCenter.default.post(name: .TorWillRestart, object: nil) - self.bootstrapGeneration += 1 self.isReady = false self.socksReady = false self.bootstrapProgress = 0 self.bootstrapSummary = "" self.isStarting = true - self.bootstrapDidStall = false + self.isDormant = false self.lastRestartAt = Date() } diff --git a/localPackages/Arti/Sources/TorNotifications.swift b/localPackages/Arti/Sources/TorNotifications.swift index 569bf404..e96bce3b 100644 --- a/localPackages/Arti/Sources/TorNotifications.swift +++ b/localPackages/Arti/Sources/TorNotifications.swift @@ -5,7 +5,4 @@ public extension Notification.Name { static let TorWillRestart = Notification.Name("TorWillRestart") static let TorWillStart = Notification.Name("TorWillStart") static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged") - /// A bootstrap attempt ran out its deadline without completing — the - /// signature of a network that blocks Tor. - static let TorBootstrapDidStall = Notification.Name("TorBootstrapDidStall") } diff --git a/localPackages/Arti/arti-bitchat/Cargo.toml b/localPackages/Arti/arti-bitchat/Cargo.toml index 5a835194..fcb8abf5 100644 --- a/localPackages/Arti/arti-bitchat/Cargo.toml +++ b/localPackages/Arti/arti-bitchat/Cargo.toml @@ -2,7 +2,7 @@ name = "arti-bitchat" version = "0.1.0" edition = "2021" -rust-version = "1.90" +rust-version = "1.86" [lib] crate-type = ["staticlib"] diff --git a/localPackages/Arti/build-ios.sh b/localPackages/Arti/build-ios.sh index 97278a48..3b16b52e 100755 --- a/localPackages/Arti/build-ios.sh +++ b/localPackages/Arti/build-ios.sh @@ -5,20 +5,11 @@ # Output: Frameworks/arti.xcframework containing static libraries for: # - aarch64-apple-ios (iOS device) # - aarch64-apple-ios-sim (iOS simulator, Apple Silicon) -# - x86_64-apple-ios (iOS simulator, Intel) -# - aarch64-apple-darwin (macOS, Apple Silicon) -# - x86_64-apple-darwin (macOS, Intel) +# - x86_64-apple-ios (iOS simulator, Intel - optional) +# - aarch64-apple-darwin (macOS) # set -e -# rustup/cargo install tools here on standard setups; GUI/Xcode shells often -# omit it from PATH even though `cargo` itself is available elsewhere. -export PATH="$HOME/.cargo/bin:$PATH" -RUST_TOOLCHAIN="${RUST_TOOLCHAIN:-1.96.0}" -RUSTC_VERSION="${RUSTC_VERSION:-1.96.0}" -CBINDGEN_VERSION="${CBINDGEN_VERSION:-0.29.4}" -export RUSTC="$(rustup which --toolchain "$RUST_TOOLCHAIN" rustc)" - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" @@ -32,9 +23,7 @@ OUTPUT_DIR="$SCRIPT_DIR/Frameworks" TARGETS=( "aarch64-apple-ios" # iOS device "aarch64-apple-ios-sim" # iOS simulator (Apple Silicon) - "x86_64-apple-ios" # iOS simulator (Intel) - "aarch64-apple-darwin" # macOS (Apple Silicon) - "x86_64-apple-darwin" # macOS (Intel) + "aarch64-apple-darwin" # macOS ) # Colors for output @@ -56,35 +45,23 @@ check_prerequisites() { exit 1 fi - if ! rustup run "$RUST_TOOLCHAIN" cargo --version &> /dev/null; then - log_error "Cargo is not installed in rustup toolchain $RUST_TOOLCHAIN." - exit 1 - fi - - local actual_rustc - actual_rustc="$(rustup run "$RUST_TOOLCHAIN" rustc --version)" - if [[ "$actual_rustc" != "rustc $RUSTC_VERSION "* ]]; then - log_error "Expected rustc $RUSTC_VERSION in $RUST_TOOLCHAIN; found $actual_rustc." + if ! command -v cargo &> /dev/null; then + log_error "Cargo is not installed. Please install via rustup." exit 1 fi # Check/install targets for target in "${TARGETS[@]}"; do - if ! rustup target list --toolchain "$RUST_TOOLCHAIN" --installed | grep -q "$target"; then + if ! rustup target list --installed | grep -q "$target"; then log_info "Installing target: $target" - rustup target add --toolchain "$RUST_TOOLCHAIN" "$target" + rustup target add "$target" fi done # Install cbindgen if needed if ! command -v cbindgen &> /dev/null; then - log_info "Installing cbindgen $CBINDGEN_VERSION..." - rustup run "$RUST_TOOLCHAIN" cargo install cbindgen --version "$CBINDGEN_VERSION" --locked - fi - - if [[ "$(cbindgen --version)" != "cbindgen $CBINDGEN_VERSION" ]]; then - log_error "Expected cbindgen $CBINDGEN_VERSION; found $(cbindgen --version)." - exit 1 + log_info "Installing cbindgen..." + cargo install cbindgen fi log_info "Prerequisites OK" @@ -123,7 +100,7 @@ build_target() { setup_rustflags "$target" # Build release - rustup run "$RUST_TOOLCHAIN" cargo build --release --target "$target" -p "$CRATE_NAME" + cargo build --release --target "$target" -p "$CRATE_NAME" # Check output local lib_path="target/$target/release/$LIB_NAME" @@ -146,7 +123,9 @@ create_xcframework() { rm -rf "$xcframework_path" mkdir -p "$OUTPUT_DIR" - # Normalize each architecture before combining the universal slices. + # Build the xcodebuild command + local cmd="xcodebuild -create-xcframework" + for target in "${TARGETS[@]}"; do local lib_path="$SCRIPT_DIR/target/$target/release/$LIB_NAME" if [[ -f "$lib_path" ]]; then @@ -154,121 +133,24 @@ create_xcframework() { log_info "Stripping $target library..." strip -x "$lib_path" 2>/dev/null || true - # Normalize archive metadata so repeated rebuilds are byte-stable. - local normalized_path="$lib_path.normalized" - xcrun libtool -static -D -no_warning_for_no_symbols "$lib_path" -o "$normalized_path" - mv "$normalized_path" "$lib_path" + cmd="$cmd -library $lib_path" + # Add headers if they exist + local header_dir="$OUTPUT_DIR/include" + if [[ -d "$header_dir" ]]; then + cmd="$cmd -headers $header_dir" + fi else - log_error "Missing required library: $lib_path" - exit 1 + log_warn "Skipping missing library: $lib_path" fi done - local simulator_dir="$SCRIPT_DIR/target/ios-universal-simulator/release" - local simulator_lib="$simulator_dir/$LIB_NAME" - mkdir -p "$simulator_dir" - xcrun lipo -create \ - "$SCRIPT_DIR/target/aarch64-apple-ios-sim/release/$LIB_NAME" \ - "$SCRIPT_DIR/target/x86_64-apple-ios/release/$LIB_NAME" \ - -output "$simulator_lib" - - # Normalize the universal archive after lipo so repeated rebuilds remain - # byte-stable just like the single-architecture inputs. - local normalized_simulator="$simulator_lib.normalized" - xcrun libtool -static -D -no_warning_for_no_symbols "$simulator_lib" -o "$normalized_simulator" - mv "$normalized_simulator" "$simulator_lib" - - local macos_dir="$SCRIPT_DIR/target/macos-universal/release" - local macos_lib="$macos_dir/$LIB_NAME" - mkdir -p "$macos_dir" - xcrun lipo -create \ - "$SCRIPT_DIR/target/aarch64-apple-darwin/release/$LIB_NAME" \ - "$SCRIPT_DIR/target/x86_64-apple-darwin/release/$LIB_NAME" \ - -output "$macos_lib" - - local normalized_macos="$macos_lib.normalized" - xcrun libtool -static -D -no_warning_for_no_symbols "$macos_lib" -o "$normalized_macos" - mv "$normalized_macos" "$macos_lib" - - local header_dir="$OUTPUT_DIR/include" - local cmd="xcodebuild -create-xcframework" - cmd="$cmd -library $SCRIPT_DIR/target/aarch64-apple-ios/release/$LIB_NAME -headers $header_dir" - cmd="$cmd -library $simulator_lib -headers $header_dir" - cmd="$cmd -library $macos_lib -headers $header_dir" cmd="$cmd -output $xcframework_path" log_info "Running: $cmd" eval "$cmd" if [[ -d "$xcframework_path" ]]; then - cat > "$xcframework_path/Info.plist" << 'EOF' - - - - - AvailableLibraries - - - BinaryPath - libarti_bitchat.a - HeadersPath - Headers - LibraryIdentifier - ios-arm64 - LibraryPath - libarti_bitchat.a - SupportedArchitectures - - arm64 - - SupportedPlatform - ios - - - BinaryPath - libarti_bitchat.a - HeadersPath - Headers - LibraryIdentifier - ios-arm64_x86_64-simulator - LibraryPath - libarti_bitchat.a - SupportedArchitectures - - arm64 - x86_64 - - SupportedPlatform - ios - SupportedPlatformVariant - simulator - - - BinaryPath - libarti_bitchat.a - HeadersPath - Headers - LibraryIdentifier - macos-arm64_x86_64 - LibraryPath - libarti_bitchat.a - SupportedArchitectures - - arm64 - x86_64 - - SupportedPlatform - macos - - - CFBundlePackageType - XFWK - XCFrameworkFormatVersion - 1.0 - - -EOF local size=$(du -sh "$xcframework_path" | cut -f1) log_info "Created $xcframework_path ($size)" else diff --git a/localPackages/BitFoundation/Package.swift b/localPackages/BitFoundation/Package.swift index fbcf8e4e..927ce283 100644 --- a/localPackages/BitFoundation/Package.swift +++ b/localPackages/BitFoundation/Package.swift @@ -14,15 +14,9 @@ let package = Package( targets: ["BitFoundation"] ) ], - dependencies: [ - .package(path: "../BitLogger") - ], targets: [ .target( name: "BitFoundation", - dependencies: [ - .product(name: "BitLogger", package: "BitLogger") - ], path: "Sources" ), .testTarget( diff --git a/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift b/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift deleted file mode 100644 index 72b2fd14..00000000 --- a/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift +++ /dev/null @@ -1,158 +0,0 @@ -// -// AnnounceV2Packet.swift -// BitFoundation -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -// periphery:ignore - intentionally unreferenced by production code; nothing -// emits or consumes this type yet, and BLEService parses it only to ignore it. -// Delete this annotation when the mesh starts using it. -/// Identity-free presence announcement for rotating peer IDs. -/// -/// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 4–30 seconds: the -/// nickname, the 32-byte Noise static public key, the 32-byte Ed25519 signing -/// key, and up to ten neighbour IDs. That is a permanent device fingerprint plus -/// the local social graph, free to anyone in radio range. This carries none of -/// it — only an epoch, a fixed-size block of pairwise recognition tags, and -/// capability bits. -/// -/// Deliberately absent, with reasons: -/// - **Public keys**: they are the linkage. Peers learn them inside the Noise XX -/// handshake, where they are already encrypted on the wire. -/// - **Nickname**: a self-chosen, frequently reused human label. It moves into -/// the session (`AuthenticatedPeerStatePacket`). -/// - **Neighbour list**: it seeds source routing, whose documented fallback is -/// flooding. Publishing a crowd's adjacency graph is not a reasonable price -/// for routing efficiency. -/// -/// **Unsigned, on purpose and not without cost.** There is no key to verify a -/// signature against without disclosing one, so this asserts only "somebody is -/// here, and here are some tags". An attacker can therefore emit noise — bounded -/// by existing announce and connection rate limits — but cannot impersonate a -/// specific peer, because forging a recognition tag needs one of the two private -/// keys, and cannot send anything without completing a handshake. The intended -/// posture is to treat a v2 announce as *unverified presence* and not surface it -/// until a tag matches or a handshake completes. See open question O4 in -/// `docs/PEER-ID-ROTATION.md`. -/// -/// Not emitted or consumed by the shipping mesh yet. -public struct AnnounceV2Packet: Equatable, Sendable { - /// Rotation epoch this announce was built for. Carried explicitly so a - /// receiver matches against a stated epoch instead of guessing. - public let epoch: UInt32 - /// Exactly `PeerIDRotation.tagSlots * PeerIDRotation.idLength` bytes. - public let tagBlock: Data - public let capabilities: PeerCapabilities? - /// Coarse rendezvous cell, when bridging. Same semantics as v1. - public let bridgeGeohash: String? - - public init( - epoch: UInt32, - tagBlock: Data, - capabilities: PeerCapabilities? = nil, - bridgeGeohash: String? = nil - ) { - self.epoch = epoch - self.tagBlock = tagBlock - self.capabilities = capabilities - self.bridgeGeohash = bridgeGeohash - } - - private enum TLVType: UInt8 { - case epoch = 0x01 - case tagBlock = 0x02 - case capabilities = 0x03 - case bridgeGeohash = 0x04 - } - - /// Expected tag-block width. A fixed size is load-bearing: it hides how many - /// mutual favourites a device has. - public static var tagBlockLength: Int { - PeerIDRotation.tagSlots * PeerIDRotation.idLength - } - - public func encode() -> Data? { - guard tagBlock.count == Self.tagBlockLength else { return nil } - - var data = Data() - - data.append(TLVType.epoch.rawValue) - data.append(UInt8(4)) - withUnsafeBytes(of: epoch.bigEndian) { data.append(contentsOf: $0) } - - data.append(TLVType.tagBlock.rawValue) - data.append(UInt8(tagBlock.count)) - data.append(tagBlock) - - if let capabilities { - let bytes = capabilities.encoded() - guard bytes.count <= 255 else { return nil } - data.append(TLVType.capabilities.rawValue) - data.append(UInt8(bytes.count)) - data.append(bytes) - } - - if let bridgeGeohash, !bridgeGeohash.isEmpty { - let bytes = Data(bridgeGeohash.utf8) - guard bytes.count <= 12 else { return nil } - data.append(TLVType.bridgeGeohash.rawValue) - data.append(UInt8(bytes.count)) - data.append(bytes) - } - - return data - } - - public static func decode(from data: Data) -> AnnounceV2Packet? { - var epoch: UInt32? - var tagBlock: Data? - var capabilities: PeerCapabilities? - var bridgeGeohash: String? - - var offset = data.startIndex - while offset < data.endIndex { - guard data.distance(from: offset, to: data.endIndex) >= 2 else { return nil } - let rawType = data[offset] - let length = Int(data[data.index(after: offset)]) - let valueStart = data.index(offset, offsetBy: 2) - guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil } - let value = data.subdata(in: valueStart.. -// - -enum Constants { - // Compression - static let compressionThresholdBytes: Int = 100 -} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift deleted file mode 100644 index acba7bf7..00000000 --- a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// CourierEnvelope.swift -// BitFoundation -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -private import CryptoKit - -/// TLV payload for store-and-forward courier envelopes. -/// -/// A courier envelope lets a mutual favorite physically carry an encrypted -/// message to a peer who is currently offline. The envelope is opaque to the -/// courier: the only routing information is a rotating recipient tag derived -/// from the recipient's Noise static public key and the UTC day, so envelopes -/// addressed to the same peer on different days do not correlate for -/// observers who don't already know that peer's public key. -public struct CourierEnvelope: Equatable { - /// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated. - public let recipientTag: Data - /// Milliseconds since epoch after which the envelope must be discarded. - public let expiry: UInt64 - /// Opaque one-way Noise X ciphertext (sender identity rides inside). - public let ciphertext: Data - /// Spray-and-wait copy budget: how many redundant copies of this envelope - /// the holder may still hand to other couriers (binary split on each - /// spray). 1 means carry-only — deliver to the recipient, never re-spray. - public let copies: UInt8 - /// Seal-format discriminator: nil means v1 (ciphertext is one-way Noise X - /// to the recipient's *static* key); a value means v2 (Noise X to the - /// recipient's one-time prekey with this ID, forward secret). Encoded as - /// an optional TLV so v1 decoders skip it as unknown: an old client still - /// carries and hands over v2 envelopes opaquely, and when one is addressed - /// to it the static-key open simply fails and is dropped quietly. - public let prekeyID: UInt32? - - public static let tagLength = 16 - /// Couriered messages are text-sized; media transfers are out of scope. - public static let maxCiphertextBytes = 16 * 1024 - /// Matches the outbox retention policy in MessageRouter. - public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60 - /// Cap on the copy budget a depositor can claim, so a malicious envelope - /// cannot turn the courier network into an amplifier. - public static let maxCopies: UInt8 = 8 - - private enum TLVType: UInt8 { - case recipientTag = 0x01 - case expiry = 0x02 - case ciphertext = 0x03 - case copies = 0x04 - case prekeyID = 0x05 - } - - public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1, prekeyID: UInt32? = nil) { - self.recipientTag = recipientTag - self.expiry = expiry - self.ciphertext = ciphertext - self.copies = min(max(copies, 1), Self.maxCopies) - self.prekeyID = prekeyID - } - - /// The same envelope with a different remaining copy budget. - public func withCopies(_ copies: UInt8) -> CourierEnvelope { - CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) - } - - public var isExpired: Bool { - isExpired(at: Date()) - } - - public func isExpired(at date: Date) -> Bool { - UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry - } - - public func encode() -> Data? { - guard recipientTag.count == Self.tagLength else { return nil } - guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil } - - func appendBE(_ value: T, into data: inout Data) { - var big = value.bigEndian - withUnsafeBytes(of: &big) { data.append(contentsOf: $0) } - } - - var encoded = Data() - encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count) - - encoded.append(TLVType.recipientTag.rawValue) - appendBE(UInt16(recipientTag.count), into: &encoded) - encoded.append(recipientTag) - - encoded.append(TLVType.expiry.rawValue) - appendBE(UInt16(8), into: &encoded) - appendBE(expiry, into: &encoded) - - encoded.append(TLVType.ciphertext.rawValue) - appendBE(UInt16(ciphertext.count), into: &encoded) - encoded.append(ciphertext) - - // Omitted when 1 so carry-only envelopes stay byte-identical to the - // pre-spray wire format (old clients skip the TLV as unknown anyway). - if copies > 1 { - encoded.append(TLVType.copies.rawValue) - appendBE(UInt16(1), into: &encoded) - encoded.append(copies) - } - - // Omitted for v1 static-sealed envelopes so they stay byte-identical - // to the pre-prekey wire format. - if let prekeyID { - encoded.append(TLVType.prekeyID.rawValue) - appendBE(UInt16(4), into: &encoded) - appendBE(prekeyID, into: &encoded) - } - - return encoded - } - - public static func decode(_ data: Data) -> CourierEnvelope? { - var cursor = data.startIndex - let end = data.endIndex - - var recipientTag: Data? - var expiry: UInt64? - var ciphertext: Data? - var copies: UInt8 = 1 - var prekeyID: UInt32? - - while cursor < end { - let typeRaw = data[cursor] - cursor = data.index(after: cursor) - - guard data.distance(from: cursor, to: end) >= 2 else { return nil } - let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)]) - cursor = data.index(cursor, offsetBy: 2) - guard data.distance(from: cursor, to: end) >= length else { return nil } - let value = data[cursor.. 0, length <= maxCiphertextBytes else { return nil } - ciphertext = Data(value) - case .copies: - guard length == 1 else { return nil } - copies = value.first ?? 1 - case .prekeyID: - guard length == 4 else { return nil } - prekeyID = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } - case nil: - // Unknown TLV: skip for forward compatibility. - continue - } - } - - guard let recipientTag, let expiry, let ciphertext else { return nil } - return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) - } - - // MARK: - Recipient Tags - - private static let tagContext = Data("bitchat-courier-tag-v1".utf8) - - /// UTC day number used to rotate recipient tags. - public static func epochDay(for date: Date) -> UInt32 { - UInt32(max(0, date.timeIntervalSince1970) / 86_400) - } - - /// Rotating recipient hint for a given day. Computable only by parties - /// who already know the recipient's Noise static public key. - public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data { - var message = tagContext - withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) } - let mac = HMAC.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey)) - return Data(mac).prefix(tagLength) - } - - /// Tags to test when checking whether an envelope is addressed to a peer. - /// Covers the adjacent days so envelopes sealed near midnight (or across - /// modest clock skew) still match while being carried. - public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] { - let day = epochDay(for: date) - return [day == 0 ? 0 : day - 1, day, day + 1].map { - recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0) - } - } -} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift deleted file mode 100644 index ded2efa9..00000000 --- a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// DeliveryStatus.swift -// BitFoundation -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import struct Foundation.Date - -public enum DeliveryStatus: Codable, Equatable, Hashable { - case notSentYet // Created but not yet handed to any transport - case sending - case sent // Left our device - case carried // Sealed envelope handed to a courier; best-effort physical delivery - case delivered(to: String, at: Date) // Confirmed by recipient - case read(by: String, at: Date) // Seen by recipient - case failed(reason: String) - case partiallyDelivered(reached: Int, total: Int) // For rooms - - public var displayText: String { - switch self { - case .notSentYet: - return "Not sent yet" - case .sending: - return "Sending..." - case .sent: - return "Sent" - case .carried: - return "Carried by a friend" - case .delivered(let nickname, _): - return "Delivered to \(nickname)" - case .read(let nickname, _): - return "Read by \(nickname)" - case .failed(let reason): - return "Failed: \(reason)" - case .partiallyDelivered(let reached, let total): - return "Delivered to \(reached)/\(total)" - } - } -} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/KeychainManagerProtocol.swift b/localPackages/BitFoundation/Sources/BitFoundation/KeychainManagerProtocol.swift deleted file mode 100644 index 575174e4..00000000 --- a/localPackages/BitFoundation/Sources/BitFoundation/KeychainManagerProtocol.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// KeychainManagerProtocol.swift -// BitFoundation -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import struct Foundation.Data -import class CoreFoundation.CFString -import typealias Darwin.OSStatus - -public protocol KeychainManagerProtocol { - func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool - func getIdentityKey(forKey key: String) -> Data? - func deleteIdentityKey(forKey key: String) -> Bool - func deleteAllKeychainData() -> Bool - - func secureClear(_ data: inout Data) - func secureClear(_ string: inout String) - - func verifyIdentityKeyExists() -> Bool - - // BCH-01-009: Methods with proper error classification - /// Get identity key with detailed result for error handling - func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult - /// Save identity key with detailed result for error handling - func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult - - // MARK: - Generic Data Storage (consolidated from KeychainHelper) - /// Save data with a custom service name - func save(key: String, data: Data, service: String, accessible: CFString?) - /// Load data from a custom service - func load(key: String, service: String) -> Data? - /// Load data from a custom service while preserving Keychain status. - /// Callers that own encrypted files need to distinguish a missing key - /// from a temporarily inaccessible key before replacing those files. - func loadWithResult(key: String, service: String) -> KeychainReadResult - /// Delete data from a custom service - func delete(key: String, service: String) - /// Delete every item stored under a custom service - func deleteAll(service: String) -} - -public extension KeychainManagerProtocol { - /// Source-compatible fallback for lightweight/test implementations. The - /// production manager overrides this with the underlying OSStatus. - func loadWithResult(key: String, service: String) -> KeychainReadResult { - if let data = load(key: key, service: service) { - return .success(data) - } - return .itemNotFound - } -} - -// MARK: - Keychain Error Types -// BCH-01-009: Proper error classification to distinguish expected states from critical failures - -/// Result of a keychain read operation with proper error classification -public enum KeychainReadResult { - case success(Data) - case itemNotFound // Expected: key doesn't exist yet - case accessDenied // Critical: app lacks keychain access - case deviceLocked // Recoverable: device is locked - case authenticationFailed // Recoverable: biometric/passcode failed - case otherError(OSStatus) // Unexpected error - - public var isRecoverableError: Bool { - switch self { - case .deviceLocked, .authenticationFailed: - return true - default: - return false - } - } -} - -/// Result of a keychain save operation with proper error classification -public enum KeychainSaveResult { - case success - case duplicateItem // Can retry with update - case accessDenied // Critical: app lacks keychain access - case deviceLocked // Recoverable: device is locked - case storageFull // Critical: no space available - case otherError(OSStatus) - - public var isRecoverableError: Bool { - switch self { - case .duplicateItem, .deviceLocked: - return true - default: - return false - } - } -} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift deleted file mode 100644 index 54265b8c..00000000 --- a/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// MeshPingPayload.swift -// BitFoundation -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import struct Foundation.Data - -/// Wire payload shared by the `ping` (0x26) and `pong` (0x27) message types. -/// -/// Layout (9 bytes): -/// - 8 bytes: random nonce (a pong echoes the nonce of the ping it answers) -/// - 1 byte: origin TTL — the TTL the packet was launched with, so the -/// receiver can compute the hop count as `originTTL - receivedTTL`. -/// -/// Both directions are unencrypted and unsigned: the payload carries no -/// private data, and the unguessable nonce already binds a pong to a probe -/// the local device actually sent. -public struct MeshPingPayload: Equatable { - public static let nonceLength = 8 - private static let encodedLength = nonceLength + 1 - - public let nonce: Data - public let originTTL: UInt8 - - public init?(nonce: Data, originTTL: UInt8) { - guard nonce.count == Self.nonceLength else { return nil } - self.nonce = nonce - self.originTTL = originTTL - } - - public func encode() -> Data { - var data = Data(capacity: Self.encodedLength) - data.append(nonce) - data.append(originTTL) - return data - } - - /// Accepts payloads with trailing bytes so future revisions can extend - /// the format without breaking older clients. - public static func decode(_ data: Data) -> MeshPingPayload? { - guard data.count >= encodedLength else { return nil } - let nonce = Data(data.prefix(nonceLength)) - let originTTL = data[data.index(data.startIndex, offsetBy: nonceLength)] - return MeshPingPayload(nonce: nonce, originTTL: originTTL) - } - - /// Number of links a packet crossed, derived from TTL decrements plus the - /// final delivery link (a directly connected peer is 1 hop away). - /// Returns nil when the TTLs are inconsistent (received above origin). - public static func hopCount(originTTL: UInt8, receivedTTL: UInt8) -> Int? { - guard originTTL >= receivedTTL else { return nil } - return Int(originTTL - receivedTTL) + 1 - } -} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift deleted file mode 100644 index 372720d6..00000000 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// MessageType.swift -// BitFoundation -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -/// Simplified BitChat protocol message types. -/// Consolidated from the original 24 wire types down to the 9 cases below. -/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads. -public enum MessageType: UInt8 { - // Public messages (unencrypted) - case announce = 0x01 // "I'm here" with nickname - case message = 0x02 // Public chat message - case leave = 0x03 // "I'm leaving" - case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer - case requestSync = 0x21 // GCS filter-based sync request (local-only) - - // Noise encryption - case noiseHandshake = 0x10 // Handshake (init or response determined by payload) - case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.) - - // Fragmentation (simplified) - case fragment = 0x20 // Single fragment type for large messages - case fileTransfer = 0x22 // Binary file/audio/image payloads - case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone - case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped) - case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body) - - // Mesh diagnostics - case ping = 0x26 // Directed echo request (nonce + origin TTL) - case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL) - - // Gateway mode: signed Nostr event ferried between a mesh-only peer and - // an internet gateway peer. - case nostrCarrier = 0x28 - - // Live voice: one signed push-to-talk burst packet (ephemeral broadcast, - // never gossip-synced). Private bursts ride noiseEncrypted instead. - case voiceFrame = 0x29 - - /// Identity-free presence for rotating peer IDs. Carries an epoch, a fixed - /// block of pairwise recognition tags, and capabilities — no nickname, no - /// public keys, no neighbour list. A separate type rather than a version of - /// `announce` because that decoder hard-requires the identity TLVs, so - /// omitting them is a parse failure rather than a graceful degrade. - /// - /// `0x2C`, not the seemingly-free `0x05`: that value has been recycled - /// twice already (`announce`, then `bulkTransferResponse`, then - /// `fragmentStart` until #446), and a very old peer that still maps it to a - /// fragment header would misparse presence as a partial message. Values - /// after `voiceFrame` have only ever been allocated forward. `0x2A`/`0x2B` - /// are spoken for by the courier spray-ack work, hence `0x2C`. - /// - /// Not emitted or consumed by the shipping mesh yet; see - /// `docs/PEER-ID-ROTATION.md`. - case announceV2 = 0x2C - - public var description: String { - switch self { - case .announce: return "announce" - case .announceV2: return "announceV2" - case .message: return "message" - case .leave: return "leave" - case .courierEnvelope: return "courierEnvelope" - case .requestSync: return "requestSync" - case .noiseHandshake: return "noiseHandshake" - case .noiseEncrypted: return "noiseEncrypted" - case .fragment: return "fragment" - case .fileTransfer: return "fileTransfer" - case .boardPost: return "boardPost" - case .prekeyBundle: return "prekeyBundle" - case .groupMessage: return "groupMessage" - case .ping: return "ping" - case .pong: return "pong" - case .nostrCarrier: return "nostrCarrier" - case .voiceFrame: return "voiceFrame" - } - } -} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift deleted file mode 100644 index b1308c9b..00000000 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation - -/// Feature capabilities a peer advertises in its announce packet. -/// -/// Encoded as a little-endian bitfield with trailing zero bytes dropped, so the -/// wire form grows only when high bits are assigned. Decoders keep the low 64 -/// bits and ignore any longer field, and unknown bits are preserved verbatim — -/// old clients skip the TLV entirely, new clients degrade per-feature. -public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { - public let rawValue: UInt64 - - public init(rawValue: UInt64) { - self.rawValue = rawValue - } - - public static let prekeys = PeerCapabilities(rawValue: 1 << 0) - public static let wifiBulk = PeerCapabilities(rawValue: 1 << 1) - public static let gateway = PeerCapabilities(rawValue: 1 << 2) - public static let groups = PeerCapabilities(rawValue: 1 << 3) - public static let board = PeerCapabilities(rawValue: 1 << 4) - public static let vouch = PeerCapabilities(rawValue: 1 << 5) - public static let meshDiagnostics = PeerCapabilities(rawValue: 1 << 6) - /// Bridges the local mesh channel to the geohash-cell rendezvous on Nostr - /// (uplink/downlink carriers for mesh-only peers). Advertised alongside - /// a `bridgeGeohash` TLV carrying the rendezvous cell. - public static let bridge = PeerCapabilities(rawValue: 1 << 7) - /// Finalized direct-message media encrypted as Noise payload `0x20` - /// before outer BLE fragmentation. Peers that omit this bit require the - /// signed directed raw-file migration fallback. - public static let privateMedia = PeerCapabilities(rawValue: 1 << 8) - /// Stable private-media IDs are durably deduplicated by the receiver and - /// correlated delivery/read receipts permit bounded automatic resend. - /// - /// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only - /// enables sender-side automatic retry after exact-session proof. - public static let privateMediaReceipts = - PeerCapabilities(rawValue: 1 << 9) - /// Reserved for test builds that briefly advertised non-destructive Noise - /// replacement. Current clients intentionally do not advertise or act on - /// this bit; keep it decodable so the wire assignment is never reused. - public static let nonDestructiveNoiseReplacement = - PeerCapabilities(rawValue: 1 << 10) - - /// Minimal little-endian byte encoding; always at least one byte so an - /// empty set is distinguishable from an absent TLV. - public func encoded() -> Data { - var value = rawValue - var bytes = Data() - repeat { - bytes.append(UInt8(truncatingIfNeeded: value)) - value >>= 8 - } while value != 0 - return bytes - } - - /// Accepts any length; bytes beyond the low 64 bits are ignored for - /// forward compatibility. - public init(encoded data: Data) { - var value: UInt64 = 0 - for (index, byte) in data.prefix(8).enumerated() { - value |= UInt64(byte) << (8 * index) - } - self.init(rawValue: value) - } -} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift index a9a18973..0b2721c2 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift @@ -34,13 +34,6 @@ public struct PeerID: Equatable, Hashable, Sendable { case geoDM = "nostr_" /// `"nostr:"` (+ 8 characters hex) case geoChat = "nostr:" - /// `"group_"` (+ 32 characters hex) — virtual conversation ID for a - /// private group (16-byte group ID). Never routed to a single peer. - case group = "group_" - /// `"bridge:"` (+ 16 characters hex) — a sender reached across a mesh - /// bridge, identified by their rendezvous Nostr pubkey. Not a - /// routable mesh peer. - case bridge = "bridge:" } public let prefix: Prefix @@ -71,12 +64,6 @@ public extension PeerID { self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength)) } - /// Convenience init to create a bridged-sender PeerID by appending - /// `"bridge:"` to the first 16 characters of the rendezvous Nostr pubkey. - init(bridge pubKey: String) { - self.init(prefix: .bridge, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength)) - } - /// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts init(str: any StringProtocol) { if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) { @@ -109,22 +96,6 @@ public extension PeerID { } } -// MARK: - Group Conversation Helpers - -public extension PeerID { - /// Convenience init to create a virtual group conversation PeerID from a - /// 16-byte group ID ("group_" + 32 hex characters). - init(groupID: Data) { - self.init(str: Prefix.group.rawValue + groupID.hexEncodedString()) - } - - /// The 16-byte group ID behind a "group_" PeerID, if this is one. - var groupIDData: Data? { - guard isGroup, bare.count == 32 else { return nil } - return Data(hexString: bare) - } -} - // MARK: - Noise Public Key Helpers public extension PeerID { @@ -172,16 +143,6 @@ public extension PeerID { prefix == .geoDM } - /// Returns true if `id` starts with "`group_`" - var isGroup: Bool { - prefix == .group - } - - /// Returns true if `id` starts with "`bridge:`" - var isBridge: Bool { - prefix == .bridge - } - func toPercentEncoded() -> String { id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift deleted file mode 100644 index 2edfa951..00000000 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift +++ /dev/null @@ -1,315 +0,0 @@ -// -// PeerIDRotation.swift -// BitFoundation -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -private import CryptoKit - -// periphery:ignore - intentionally unreferenced by production code. These are -// the reviewable primitives for a protocol change that cannot ship until both -// platforms agree on it; wiring them into the transport is the next step, not -// this one. Delete this annotation when the mesh starts using them. -/// Derivations for rotating peer IDs and pairwise recognition tags. -/// -/// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the -/// open questions. This type is the executable half of that document: it is -/// deliberately pure (no I/O, no clock of its own, no dependency on the BLE -/// stack) so both platforms can agree on the numbers before anyone wires it -/// into a transport. -/// -/// Nothing here is used by the shipping mesh yet. -/// -/// ## Why the derivations look like this -/// -/// The rotating ID comes from **private** key material. Deriving it from the -/// public key would let anyone who has ever seen that key compute every past -/// and future ID, which is worse than not rotating because it would look like -/// protection. The same mistake is live in `CourierEnvelope.recipientTag`, -/// which is keyed on the recipient's *public* static key — and since that key -/// is broadcast in cleartext in every announce today, any observer in radio -/// range can compute a peer's courier tags for any day. -/// -/// Recognition tags come from the X25519 shared secret between two static -/// keys, so exactly two parties can compute a given tag and an observer can -/// compute none of them. -public enum PeerIDRotation { - // MARK: - Parameters - - /// Seconds per rotation epoch. One hour is a starting position, not a - /// settled one: shorter is less linkable but churns sessions, routes, and - /// in-flight fragment reassembly more often. See open question O1. - public static let rotationPeriod: TimeInterval = 3600 - - /// Bytes of an ID or tag placed on the wire. Matches the existing 8-byte - /// header sender ID, so the packet layout is unchanged. - public static let idLength = 8 - - /// Fixed number of tag slots in an announce. Padding to a constant hides - /// how many mutual favourites a device has, which is itself identifying. - public static let tagSlots = 8 - - // MARK: - Context strings - // - // Distinct per use so a value derived for one purpose can never be - // substituted for another. `bitchat-announce-v1` is deliberately NOT reused: - // it belongs to the production-dead announce-signature helpers in - // NoiseEncryptionService, and confusing the two would be a real bug. - - private static let rotationInfo = Data("bitchat-peer-rotation-v1".utf8) - private static let peerIDContext = Data("bitchat-peer-id-v2".utf8) - private static let recognitionInfo = Data("bitchat-recognition-v1".utf8) - private static let bindingContext = Data("bitchat-peerid-binding-v1".utf8) - - // MARK: - Epochs - - /// Epoch number for a point in time. Wall-clock derived so two devices that - /// have never met agree on the current epoch without negotiating. - public static func epoch(at date: Date) -> UInt32 { - let seconds = max(0, date.timeIntervalSince1970) - return UInt32(truncatingIfNeeded: Int(seconds / rotationPeriod)) - } - - /// Epochs to test when matching, oldest first. - /// - /// The ±1 window absorbs clock skew and the moment either side crosses a - /// boundary, mirroring `CourierEnvelope.candidateTags`. Without it, two - /// devices a few seconds apart across a boundary would fail to recognise - /// each other for no reason a person could understand. - public static func candidateEpochs(around date: Date) -> [UInt32] { - let current = epoch(at: date) - return current == 0 ? [0, 1] : [current - 1, current, current + 1] - } - - // MARK: - Rotating peer ID - - /// Long-lived rotation secret for this device. Derived from the Noise - /// static **private** key, so it never leaves the device and no observer - /// can predict any ID it produces. - public static func rotationSecret(noiseStaticPrivateKey: Data) -> Data { - let derived = HKDF.deriveKey( - inputKeyMaterial: SymmetricKey(data: noiseStaticPrivateKey), - info: rotationInfo, - outputByteCount: 32 - ) - return derived.withUnsafeBytes { Data($0) } - } - - /// This device's peer ID for a given epoch. - public static func peerID(rotationSecret: Data, epoch: UInt32) -> Data { - var message = peerIDContext - message.append(bigEndianBytes(epoch)) - let mac = HMAC.authenticationCode( - for: message, - using: SymmetricKey(data: rotationSecret) - ) - return Data(mac).prefix(idLength) - } - - /// Convenience: the ID this device should be using at `date`. - public static func currentPeerID(noiseStaticPrivateKey: Data, at date: Date) -> Data { - peerID( - rotationSecret: rotationSecret(noiseStaticPrivateKey: noiseStaticPrivateKey), - epoch: epoch(at: date) - ) - } - - // MARK: - Pairwise recognition tags - - /// Symmetric recognition key for a pair, from their X25519 shared secret. - /// - /// Both sides compute the identical value from opposite key halves, which - /// is the whole point: recognition needs no round trip, and no third party - /// can derive it. - public static func recognitionKey(sharedSecret: Data) -> Data { - let derived = HKDF.deriveKey( - inputKeyMaterial: SymmetricKey(data: sharedSecret), - info: recognitionInfo, - outputByteCount: 32 - ) - return derived.withUnsafeBytes { Data($0) } - } - - /// The tag `sender` puts in its announce for `recipient` this epoch. - /// - /// Three inputs beyond the epoch, each load-bearing: - /// - /// - **Ordered keys make the tag directional.** An earlier draft used - /// `HMAC(K_AB, epoch)`, which is symmetric — so A and B broadcast the - /// *same* 8 bytes, and an observer who spots one value in two different - /// announces learns those two devices are mutual favourites and can link - /// their rotating IDs to each other. That hands over exactly the social - /// graph this design exists to hide. Ordering the keys gives A→B and B→A - /// distinct values; both parties can still compute both directions, - /// because both hold both public keys. - /// - **`peerID` binds the tag to the announce carrying it.** Without it the - /// tag depends only on (pair, epoch), so an attacker could lift a tag out - /// of A's announce and replay it under an ID of their choosing; the - /// recipient would match and believe that ID is A. Binding means a lifted - /// tag is only valid alongside A's own ID, which reduces the attack from - /// impersonation-as-any-ID to replaying A's presence. - /// - /// Replaying A's own announce within the epoch window remains possible — - /// unsigned announces cannot prevent it. Recognition is therefore a hint - /// only, and anything consequential must wait for a completed handshake. - /// See open question O4. - public static func recognitionTag( - recognitionKey: Data, - epoch: UInt32, - senderStaticPublicKey: Data, - recipientStaticPublicKey: Data, - peerID: Data - ) -> Data { - var message = bigEndianBytes(epoch) - message.append(fixedWidth(senderStaticPublicKey, 32)) - message.append(fixedWidth(recipientStaticPublicKey, 32)) - message.append(fixedWidth(peerID, idLength)) - let mac = HMAC.authenticationCode( - for: message, - using: SymmetricKey(data: recognitionKey) - ) - return Data(mac).prefix(idLength) - } - - // MARK: - Tag block - - /// Packs tags into the fixed-size announce block, padding with uniform - /// random bytes. - /// - /// Random padding is indistinguishable from a real tag to anyone who cannot - /// compute the real ones, so the block discloses neither how many mutual - /// favourites a device has nor which slot belongs to whom. Tags beyond - /// `tagSlots` are dropped here; choosing *which* to carry across successive - /// announces is the caller's problem (open question O2). - public static func tagBlock( - tags: [Data], - randomBytes: (Int) -> Data = Self.secureRandomBytes - ) -> Data { - var slots = tags.prefix(tagSlots).map { $0.prefix(idLength) } - // Order must carry no information, so shuffle rather than appending - // real tags at the front. - slots.shuffle() - var block = Data() - for slot in slots { - block.append(slot) - if slot.count < idLength { - block.append(Data(repeating: 0, count: idLength - slot.count)) - } - } - let padding = (tagSlots - slots.count) * idLength - if padding > 0 { - block.append(randomBytes(padding)) - } - return block - } - - /// Splits a received block back into candidate tags. - /// - /// Returns nil for a block that is not exactly `tagSlots * idLength`, so a - /// malformed announce is rejected rather than partially interpreted. - public static func tags(fromBlock block: Data) -> [Data]? { - guard block.count == tagSlots * idLength else { return nil } - return stride(from: 0, to: block.count, by: idLength).map { - block.subdata(in: (block.startIndex + $0)..<(block.startIndex + $0 + idLength)) - } - } - - /// Whether any slot in `block` holds the tag we expect a specific peer to - /// have put there, for an announce carrying `peerID`. - /// - /// `senderStaticPublicKey` is the peer we hope sent this (so we compute the - /// direction they would use) and `recipientStaticPublicKey` is our own. - /// Passing them the other way round tests the opposite direction and will - /// not match, which is the point of making tags directional. - /// - /// Comparison is constant-time per candidate, and every slot is examined - /// even after a match, so neither the presence of a match nor its slot - /// index is observable through timing. - public static func blockMatches( - _ block: Data, - recognitionKey: Data, - senderStaticPublicKey: Data, - recipientStaticPublicKey: Data, - peerID: Data, - at date: Date - ) -> Bool { - guard let slots = tags(fromBlock: block) else { return false } - let expected = candidateEpochs(around: date).map { - recognitionTag( - recognitionKey: recognitionKey, - epoch: $0, - senderStaticPublicKey: senderStaticPublicKey, - recipientStaticPublicKey: recipientStaticPublicKey, - peerID: peerID - ) - } - var matched = false - for slot in slots { - for candidate in expected where constantTimeEquals(slot, candidate) { - matched = true - } - } - return matched - } - - // MARK: - Identity binding - - /// Canonical bytes proving a rotating ID belongs to a static key. - /// - /// Signed with the Ed25519 identity key and exchanged **inside** a - /// completed Noise session, this replaces the derivation check that today - /// makes peer IDs unforgeable (`peerID == SHA-256(staticKey)[0..8]`, checked - /// in the announce preflight and again at handshake completion). Once IDs - /// are independent of the key, those checks fail for every peer, so a - /// replacement has to exist before rotation can ship. - /// - /// Fixed-width fields throughout: no length prefixes are needed and no two - /// distinct inputs can produce the same bytes. - public static func bindingMessage( - epoch: UInt32, - peerID: Data, - noiseStaticPublicKey: Data - ) -> Data { - var out = bindingContext - out.append(bigEndianBytes(epoch)) - out.append(fixedWidth(peerID, idLength)) - out.append(fixedWidth(noiseStaticPublicKey, 32)) - return out - } - - // MARK: - Helpers - - /// Padding must be indistinguishable from a real tag, so it comes from the - /// system CSPRNG via key generation rather than a general-purpose RNG. - public static func secureRandomBytes(_ count: Int) -> Data { - guard count > 0 else { return Data() } - let key = SymmetricKey(size: SymmetricKeySize(bitCount: count * 8)) - return key.withUnsafeBytes { Data($0) } - } - - private static func bigEndianBytes(_ value: UInt32) -> Data { - withUnsafeBytes(of: value.bigEndian) { Data($0) } - } - - private static func fixedWidth(_ data: Data, _ width: Int) -> Data { - var out = data.prefix(width) - if out.count < width { - out.append(Data(repeating: 0, count: width - out.count)) - } - return Data(out) - } - - /// Length-independent comparison, so a match cannot be found byte by byte - /// through timing. - private static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool { - guard lhs.count == rhs.count else { return false } - var difference: UInt8 = 0 - for (left, right) in zip(lhs, rhs) { - difference |= left ^ right - } - return difference == 0 - } -} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift b/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift deleted file mode 100644 index 0e73a2c0..00000000 --- a/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// PrekeyBundle.swift -// BitFoundation -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -/// TLV payload for gossiped one-time prekey bundles (MessageType 0x24). -/// -/// A bundle publishes a batch of one-time Curve25519 public prekeys bound to -/// the owner's Noise static key by an Ed25519 signature over domain-prefixed -/// canonical bytes. Anyone holding the owner's announce-verified signing key -/// can verify a bundle offline, which is what lets bundles spread and persist -/// mesh-wide via gossip sync while the owner is away. Senders seal courier -/// mail to one of these prekeys (one-way Noise X) instead of the owner's -/// long-lived static key, restoring forward secrecy for async first contact. -public struct PrekeyBundle: Equatable { - public struct Prekey: Equatable { - public let id: UInt32 - /// Curve25519.KeyAgreement public key (32 bytes). - public let publicKey: Data - - public init(id: UInt32, publicKey: Data) { - self.id = id - self.publicKey = publicKey - } - } - - /// Noise static public key identifying whose prekeys these are (32 bytes). - public let noiseStaticPublicKey: Data - /// One-time prekeys, at most `maxPrekeys` per bundle. - public let prekeys: [Prekey] - /// Milliseconds since epoch when this bundle was generated; newer bundles - /// replace older ones for the same noise key. - public let generatedAt: UInt64 - /// Ed25519 signature over `signableBytes()` by the owner's announce-bound - /// signing key. - public let signature: Data - - public static let keyLength = 32 - public static let signatureLength = 64 - public static let maxPrekeys = 8 - private static let prekeyEntryLength = 4 + keyLength - - /// Domain separation for the bundle signature so it can never be confused - /// with announce or packet signatures. - private static let signingContext = Data("bitchat-prekey-bundle-v1".utf8) - - private enum TLVType: UInt8 { - case noiseStaticPublicKey = 0x01 - case prekeys = 0x02 - case generatedAt = 0x03 - case signature = 0x04 - } - - public init(noiseStaticPublicKey: Data, prekeys: [Prekey], generatedAt: UInt64, signature: Data) { - self.noiseStaticPublicKey = noiseStaticPublicKey - self.prekeys = prekeys - self.generatedAt = generatedAt - self.signature = signature - } - - /// Canonical bytes covered by the Ed25519 signature: domain context, - /// owner key, prekey count, each (id, key) pair, and the generation time. - /// Encoders and verifiers must derive these identically. - public func signableBytes() -> Data { - var out = Data() - out.reserveCapacity(1 + Self.signingContext.count + Self.keyLength + 1 - + prekeys.count * Self.prekeyEntryLength + 8) - out.append(UInt8(min(Self.signingContext.count, 255))) - out.append(Self.signingContext.prefix(255)) - out.append(paddedKey(noiseStaticPublicKey)) - out.append(UInt8(min(prekeys.count, 255))) - for prekey in prekeys.prefix(255) { - appendBE(prekey.id, into: &out) - out.append(paddedKey(prekey.publicKey)) - } - appendBE(generatedAt, into: &out) - return out - } - - public func encode() -> Data? { - guard noiseStaticPublicKey.count == Self.keyLength, - signature.count == Self.signatureLength, - !prekeys.isEmpty, prekeys.count <= Self.maxPrekeys, - prekeys.allSatisfy({ $0.publicKey.count == Self.keyLength }) else { - return nil - } - - var entries = Data() - entries.reserveCapacity(prekeys.count * Self.prekeyEntryLength) - for prekey in prekeys { - appendBE(prekey.id, into: &entries) - entries.append(prekey.publicKey) - } - - var encoded = Data() - encoded.reserveCapacity(4 * 3 + Self.keyLength + entries.count + 8 + Self.signatureLength) - - encoded.append(TLVType.noiseStaticPublicKey.rawValue) - appendBE(UInt16(noiseStaticPublicKey.count), into: &encoded) - encoded.append(noiseStaticPublicKey) - - encoded.append(TLVType.prekeys.rawValue) - appendBE(UInt16(entries.count), into: &encoded) - encoded.append(entries) - - encoded.append(TLVType.generatedAt.rawValue) - appendBE(UInt16(8), into: &encoded) - appendBE(generatedAt, into: &encoded) - - encoded.append(TLVType.signature.rawValue) - appendBE(UInt16(signature.count), into: &encoded) - encoded.append(signature) - - return encoded - } - - public static func decode(_ data: Data) -> PrekeyBundle? { - var cursor = data.startIndex - let end = data.endIndex - - var noiseStaticPublicKey: Data? - var prekeys: [Prekey]? - var generatedAt: UInt64? - var signature: Data? - - while cursor < end { - let typeRaw = data[cursor] - cursor = data.index(after: cursor) - - guard data.distance(from: cursor, to: end) >= 2 else { return nil } - let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)]) - cursor = data.index(cursor, offsetBy: 2) - guard data.distance(from: cursor, to: end) >= length else { return nil } - let value = data[cursor.. 0, length % prekeyEntryLength == 0, - length / prekeyEntryLength <= maxPrekeys else { return nil } - var parsed: [Prekey] = [] - var entryStart = value.startIndex - while entryStart < value.endIndex { - let idEnd = value.index(entryStart, offsetBy: 4) - let id = value[entryStart.. Data { - let fixed = key.prefix(Self.keyLength) - guard fixed.count < Self.keyLength else { return Data(fixed) } - return Data(fixed) + Data(repeating: 0, count: Self.keyLength - fixed.count) - } -} - -private func appendBE(_ value: T, into data: inout Data) { - var big = value.bigEndian - withUnsafeBytes(of: &big) { data.append(contentsOf: $0) } -} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift deleted file mode 100644 index e7653463..00000000 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift +++ /dev/null @@ -1,159 +0,0 @@ -import Foundation -import Testing -@testable import BitFoundation - -/// Wire-format tests for the identity-free announce. These are the second half -/// of the cross-platform contract: Android must encode and decode byte-identical -/// packets, so anything asserted here is a promise, not an implementation detail. -struct AnnounceV2PacketTests { - private var block: Data { - Data(repeating: 0xAB, count: AnnounceV2Packet.tagBlockLength) - } - - @Test func typeValueIsStable() { - // Changing this breaks every deployed decoder. - // - // Deliberately NOT 0x05, which merely looks free: it has been recycled - // twice already (announce, then bulkTransferResponse, then fragmentStart - // until #446), so an old peer could still map it to a fragment header - // and misparse presence as a partial message. Values above - // voiceFrame = 0x29 have only ever been allocated forward; 0x2A/0x2B - // belong to the courier spray-ack work. - #expect(MessageType.announceV2.rawValue == 0x2C) - #expect(MessageType(rawValue: 0x2C) == .announceV2) - #expect(MessageType.announceV2.description == "announceV2") - } - - @Test func tagBlockIsSixtyFourBytes() { - #expect(AnnounceV2Packet.tagBlockLength == 64) - } - - @Test func roundTripsWithEveryField() throws { - let packet = AnnounceV2Packet( - epoch: 495_555, - tagBlock: block, - capabilities: [.bridge, .prekeys], - bridgeGeohash: "u4pruy" - ) - let encoded = try #require(packet.encode()) - let decoded = try #require(AnnounceV2Packet.decode(from: encoded)) - #expect(decoded == packet) - } - - @Test func roundTripsWithOnlyRequiredFields() throws { - let packet = AnnounceV2Packet(epoch: 0, tagBlock: block) - let encoded = try #require(packet.encode()) - let decoded = try #require(AnnounceV2Packet.decode(from: encoded)) - #expect(decoded == packet) - #expect(decoded.capabilities == nil) - #expect(decoded.bridgeGeohash == nil) - } - - @Test func epochIsBigEndianOnTheWire() throws { - let encoded = try #require(AnnounceV2Packet(epoch: 0x0102_0304, tagBlock: block).encode()) - // TLV 0x01, length 4, then the epoch most-significant byte first. - #expect(Array(encoded.prefix(6)) == [0x01, 0x04, 0x01, 0x02, 0x03, 0x04]) - } - - /// The whole point of the format: none of the identifying v1 fields appear. - @Test func encodingCarriesNoIdentity() throws { - let noiseKey = Data(repeating: 0x11, count: 32) - let signingKey = Data(repeating: 0x22, count: 32) - let nickname = Data("alice".utf8) - - let encoded = try #require( - AnnounceV2Packet( - epoch: 100, - tagBlock: block, - capabilities: [.bridge], - bridgeGeohash: "u4pruy" - ).encode() - ) - - #expect(!encoded.contains(noiseKey)) - #expect(!encoded.contains(signingKey)) - #expect(encoded.range(of: nickname) == nil) - } - - @Test func encodingIsSmallerThanAV1Announce() throws { - let v2 = try #require( - AnnounceV2Packet(epoch: 100, tagBlock: block, capabilities: [.bridge]).encode() - ) - // v1 with a 10-byte nickname and a full neighbour list, before its - // 64-byte signature: nickname 12 + noise 34 + signing 34 + neighbours 82 - // + capabilities 3. - let v1PayloadEstimate = 12 + 34 + 34 + 82 + 3 - #expect(v2.count < v1PayloadEstimate) - } - - // MARK: - Rejection - - @Test func encodeRejectsAWrongWidthTagBlock() { - // A short block would disclose the favourite count, so it must never go - // on the wire. - #expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 63)).encode() == nil) - #expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 65)).encode() == nil) - #expect(AnnounceV2Packet(epoch: 1, tagBlock: Data()).encode() == nil) - } - - @Test func encodeRejectsAnOversizedGeohash() { - #expect(AnnounceV2Packet( - epoch: 1, - tagBlock: block, - bridgeGeohash: String(repeating: "u", count: 13) - ).encode() == nil) - } - - @Test func decodeRequiresEpochAndTagBlock() throws { - // Capabilities alone is not a valid announce. - var onlyCapabilities = Data([0x03, 0x01]) - onlyCapabilities.append(PeerCapabilities([.bridge]).encoded()) - #expect(AnnounceV2Packet.decode(from: onlyCapabilities) == nil) - - // Epoch without a tag block is not either. - let onlyEpoch = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64]) - #expect(AnnounceV2Packet.decode(from: onlyEpoch) == nil) - } - - @Test func decodeRejectsTruncatedAndMalformedInput() { - #expect(AnnounceV2Packet.decode(from: Data()) == nil) - // Declares 4 bytes, supplies 2. - #expect(AnnounceV2Packet.decode(from: Data([0x01, 0x04, 0x00, 0x00])) == nil) - // Dangling type byte with no length. - #expect(AnnounceV2Packet.decode(from: Data([0x01])) == nil) - // Wrong epoch width. - #expect(AnnounceV2Packet.decode(from: Data([0x01, 0x02, 0x00, 0x64])) == nil) - } - - @Test func decodeRejectsAWrongWidthTagBlock() { - var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64]) - data.append(0x02) - data.append(UInt8(63)) - data.append(Data(repeating: 0xAB, count: 63)) - #expect(AnnounceV2Packet.decode(from: data) == nil) - } - - @Test func decodeRejectsNonCanonicalCapabilities() throws { - // Same capability set, non-minimal encoding: it must not be accepted, or - // one set could travel as several distinct byte strings. - var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64]) - data.append(0x02) - data.append(UInt8(AnnounceV2Packet.tagBlockLength)) - data.append(block) - data.append(0x03) - data.append(UInt8(3)) - data.append(Data([0x80, 0x00, 0x00])) // trailing zero bytes are non-minimal - #expect(AnnounceV2Packet.decode(from: data) == nil) - } - - @Test func unknownTLVsAreSkippedForForwardCompatibility() throws { - var data = try #require(AnnounceV2Packet(epoch: 100, tagBlock: block).encode()) - data.append(0x7F) // a type this build has never heard of - data.append(UInt8(3)) - data.append(Data([0x01, 0x02, 0x03])) - - let decoded = try #require(AnnounceV2Packet.decode(from: data)) - #expect(decoded.epoch == 100) - #expect(decoded.tagBlock == block) - } -} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/BitchatMessageBridgedFlagTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/BitchatMessageBridgedFlagTests.swift deleted file mode 100644 index c0c8910f..00000000 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/BitchatMessageBridgedFlagTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// BitchatMessageBridgedFlagTests.swift -// BitFoundationTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Testing -@testable import BitFoundation - -@Suite("BitchatMessage bridged flag") -struct BitchatMessageBridgedFlagTests { - @Test func bridgedFlagSurvivesBinaryRoundTrip() throws { - let message = BitchatMessage( - sender: "far-friend", - content: "hello from across the hill", - timestamp: Date(), - isRelay: false, - senderPeerID: PeerID(bridge: "deadbeefcafe0123deadbeefcafe0123"), - isBridged: true - ) - let binary = try #require(message.toBinaryPayload()) - let decoded = try #require(BitchatMessage(binary)) - #expect(decoded.isBridged) - #expect(decoded.senderPeerID?.isBridge == true) - } - - @Test func plainMessageStaysUnbridgedThroughBinary() throws { - let plain = BitchatMessage( - sender: "neighbor", - content: "radio only", - timestamp: Date(), - isRelay: false - ) - let binary = try #require(plain.toBinaryPayload()) - let decoded = try #require(BitchatMessage(binary)) - #expect(!decoded.isBridged) - } - - @Test func legacyBinaryWithoutBridgedBitDecodesUnbridged() throws { - // A pre-bridge encoder never sets flag bit 0x40; decoding such a - // payload must default to unbridged. - let message = BitchatMessage( - sender: "old", - content: "hi", - timestamp: Date(), - isRelay: false, - isBridged: true - ) - var binary = try #require(message.toBinaryPayload()) - binary[0] &= ~UInt8(0x40) // strip the bridged bit like an old client - let decoded = try #require(BitchatMessage(binary)) - #expect(!decoded.isBridged) - } -} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift deleted file mode 100644 index 9631ee81..00000000 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift +++ /dev/null @@ -1,154 +0,0 @@ -// -// CourierEnvelopeTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -@testable import BitFoundation - -struct CourierEnvelopeTests { - - private func makeEnvelope( - tag: Data = Data(repeating: 0xAB, count: CourierEnvelope.tagLength), - expiry: UInt64 = 1_900_000_000_000, - ciphertext: Data = Data(repeating: 0x42, count: 128) - ) -> CourierEnvelope { - CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext) - } - - // MARK: - Spray copies - - @Test func copiesRoundTrip() throws { - let envelope = makeEnvelope().withCopies(4) - let encoded = try #require(envelope.encode()) - let decoded = try #require(CourierEnvelope.decode(encoded)) - #expect(decoded.copies == 4) - #expect(decoded == envelope) - } - - @Test func carryOnlyEnvelopeEncodesIdenticallyToLegacyFormat() throws { - // copies == 1 must be byte-identical to the pre-spray wire format so - // old and new clients dedup the same envelope the same way. - let envelope = makeEnvelope() - #expect(envelope.copies == 1) - let encoded = try #require(envelope.encode()) - let withExplicitOne = try #require(envelope.withCopies(1).encode()) - #expect(encoded == withExplicitOne) - #expect(!encoded.contains(0x04) || CourierEnvelope.decode(encoded)?.copies == 1) - } - - @Test func decodeWithoutCopiesTLVDefaultsToCarryOnly() throws { - let encoded = try #require(makeEnvelope().encode()) - let decoded = try #require(CourierEnvelope.decode(encoded)) - #expect(decoded.copies == 1) - } - - @Test func copiesAreClampedToPolicyBounds() { - #expect(makeEnvelope().withCopies(0).copies == 1) - #expect(makeEnvelope().withCopies(200).copies == CourierEnvelope.maxCopies) - } - - // MARK: - Codec - - @Test func roundTrip() throws { - let envelope = makeEnvelope() - let encoded = try #require(envelope.encode()) - let decoded = try #require(CourierEnvelope.decode(encoded)) - #expect(decoded == envelope) - } - - @Test func roundTripAtMaxCiphertextSize() throws { - let envelope = makeEnvelope(ciphertext: Data(repeating: 0x01, count: CourierEnvelope.maxCiphertextBytes)) - let encoded = try #require(envelope.encode()) - let decoded = try #require(CourierEnvelope.decode(encoded)) - #expect(decoded == envelope) - } - - @Test func encodeRejectsInvalidFields() { - #expect(makeEnvelope(tag: Data(repeating: 0, count: 8)).encode() == nil) - #expect(makeEnvelope(ciphertext: Data()).encode() == nil) - #expect(makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)).encode() == nil) - } - - @Test func decodeRejectsMissingFields() throws { - // Strip the trailing ciphertext TLV: tag(3+16) + expiry(3+8) only. - let encoded = try #require(makeEnvelope().encode()) - let truncated = encoded.prefix(3 + CourierEnvelope.tagLength + 3 + 8) - #expect(CourierEnvelope.decode(Data(truncated)) == nil) - } - - @Test func decodeRejectsTruncatedValue() throws { - let encoded = try #require(makeEnvelope().encode()) - #expect(CourierEnvelope.decode(encoded.dropLast(1)) == nil) - } - - @Test func decodeSkipsUnknownTLVs() throws { - var encoded = try #require(makeEnvelope().encode()) - // Append an unknown TLV (type 0x7F, 2-byte value); decoder must tolerate it. - encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) - let decoded = try #require(CourierEnvelope.decode(encoded)) - #expect(decoded == makeEnvelope()) - } - - @Test func decodeOffsetSlice() throws { - // Decoder must handle slices with non-zero startIndex. - let encoded = try #require(makeEnvelope().encode()) - let padded = Data([0xFF, 0xFF]) + encoded - let slice = padded.dropFirst(2) - #expect(CourierEnvelope.decode(Data(slice)) == makeEnvelope()) - #expect(CourierEnvelope.decode(slice) == makeEnvelope()) - } - - // MARK: - Expiry - - @Test func expiryComparison() { - let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - #expect(!makeEnvelope(expiry: nowMs + 60_000).isExpired) - #expect(makeEnvelope(expiry: nowMs - 60_000).isExpired) - #expect(makeEnvelope(expiry: 0).isExpired) - } - - // MARK: - Recipient Tags - - @Test func tagIsDeterministicPerKeyAndDay() { - let key = Data(repeating: 0x11, count: 32) - let tag1 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000) - let tag2 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000) - #expect(tag1 == tag2) - #expect(tag1.count == CourierEnvelope.tagLength) - } - - @Test func tagRotatesAcrossDaysAndKeys() { - let key = Data(repeating: 0x11, count: 32) - let otherKey = Data(repeating: 0x22, count: 32) - let day: UInt32 = 20_000 - #expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day) - != CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1)) - #expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day) - != CourierEnvelope.recipientTag(noiseStaticKey: otherKey, epochDay: day)) - } - - @Test func candidateTagsCoverAdjacentDays() { - let key = Data(repeating: 0x33, count: 32) - let date = Date(timeIntervalSince1970: 1_750_000_000) - let day = CourierEnvelope.epochDay(for: date) - let candidates = CourierEnvelope.candidateTags(noiseStaticKey: key, around: date) - #expect(candidates.count == 3) - #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day - 1))) - #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day))) - #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1))) - } - - @Test func sealedYesterdayMatchesToday() { - // An envelope sealed late on day D must still match the recipient on day D+1. - let key = Data(repeating: 0x44, count: 32) - let sealedAt = Date(timeIntervalSince1970: 1_750_000_000) - let deliveredAt = sealedAt.addingTimeInterval(20 * 60 * 60) - let tag = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: CourierEnvelope.epochDay(for: sealedAt)) - #expect(CourierEnvelope.candidateTags(noiseStaticKey: key, around: deliveredAt).contains(tag)) - } -} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/DeliveryStatusNotSentYetTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/DeliveryStatusNotSentYetTests.swift deleted file mode 100644 index 71a7d36a..00000000 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/DeliveryStatusNotSentYetTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// DeliveryStatusNotSentYetTests.swift -// bitchatTests -// -// DeliveryStatus is a total state machine: every message carries a concrete -// status from creation. Public messages start .notSentYet, private messages -// keep their historical .sending default, and archives persisted while the -// field was optional decode with the absent field mapped to .notSentYet. -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -@testable import BitFoundation - -struct DeliveryStatusNotSentYetTests { - - private func makeMessage(isPrivate: Bool, deliveryStatus: DeliveryStatus? = nil) -> BitchatMessage { - BitchatMessage( - sender: "alice", - content: "hello", - timestamp: Date(timeIntervalSince1970: 1_000), - isRelay: false, - isPrivate: isPrivate, - deliveryStatus: deliveryStatus - ) - } - - @Test - func publicMessagesStartNotSentYetAndPrivateStartSending() { - #expect(makeMessage(isPrivate: false).deliveryStatus == .notSentYet) - #expect(makeMessage(isPrivate: true).deliveryStatus == .sending) - // An explicit status always wins over the defaults. - #expect(makeMessage(isPrivate: false, deliveryStatus: .sent).deliveryStatus == .sent) - } - - @Test - func decodingLegacyArchiveWithoutStatusYieldsNotSentYet() throws { - // Pre-existing archives omitted the key for public messages while the - // field was optional; absent must map to .notSentYet, not fail. - let encoded = try JSONEncoder().encode(makeMessage(isPrivate: false)) - var json = try #require( - JSONSerialization.jsonObject(with: encoded) as? [String: Any] - ) - json.removeValue(forKey: "deliveryStatus") - let legacyData = try JSONSerialization.data(withJSONObject: json) - - let decoded = try JSONDecoder().decode(BitchatMessage.self, from: legacyData) - #expect(decoded.deliveryStatus == .notSentYet) - } - - @Test - func decodingRoundTripPreservesConcreteStatus() throws { - let message = makeMessage(isPrivate: true, deliveryStatus: .delivered(to: "bob", at: Date(timeIntervalSince1970: 2_000))) - let decoded = try JSONDecoder().decode(BitchatMessage.self, from: JSONEncoder().encode(message)) - #expect(decoded.deliveryStatus == .delivered(to: "bob", at: Date(timeIntervalSince1970: 2_000))) - } -} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift deleted file mode 100644 index 8032bd9c..00000000 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// MeshPingPayloadTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -@testable import BitFoundation - -struct MeshPingPayloadTests { - - @Test func encodeDecodeRoundTrip() throws { - let nonce = Data([0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xFF]) - let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7)) - - let encoded = payload.encode() - #expect(encoded.count == 9) - #expect(encoded.prefix(8) == nonce) - #expect(encoded.last == 7) - - let decoded = try #require(MeshPingPayload.decode(encoded)) - #expect(decoded == payload) - } - - @Test func decodeToleratesTrailingBytes() throws { - let nonce = Data(repeating: 0x42, count: 8) - let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 3)) - var extended = payload.encode() - extended.append(contentsOf: [0xDE, 0xAD]) - - let decoded = try #require(MeshPingPayload.decode(extended)) - #expect(decoded == payload) - } - - @Test func decodeRespectsSliceIndices() throws { - // Data slices keep their parent's indices; decoding must not assume - // startIndex == 0. - let nonce = Data(repeating: 0x11, count: 8) - let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 5)) - let framed = Data([0x00, 0x00]) + payload.encode() - let slice = framed.dropFirst(2) - - let decoded = try #require(MeshPingPayload.decode(slice)) - #expect(decoded == payload) - } - - @Test func rejectsTruncatedPayload() { - #expect(MeshPingPayload.decode(Data(repeating: 0x01, count: 8)) == nil) - #expect(MeshPingPayload.decode(Data()) == nil) - } - - @Test func rejectsWrongNonceLength() { - #expect(MeshPingPayload(nonce: Data(repeating: 0, count: 7), originTTL: 7) == nil) - #expect(MeshPingPayload(nonce: Data(repeating: 0, count: 9), originTTL: 7) == nil) - } - - @Test func hopCountMath() { - // Direct link: no TTL decrement, one hop. - #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 7) == 1) - // One relay in between: two hops. - #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 6) == 2) - // Full TTL consumed. - #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 1) == 7) - // Inconsistent TTLs (received above origin) are rejected. - #expect(MeshPingPayload.hopCount(originTTL: 3, receivedTTL: 7) == nil) - } -} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/MockKeychain.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/MockKeychain.swift deleted file mode 100644 index 3a86759a..00000000 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/MockKeychain.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// MockKeychain.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import BitFoundation - -// Kept local until the test-helper module is split out. -final class MockKeychain: KeychainManagerProtocol { - private var storage: [String: Data] = [:] - private var serviceStorage: [String: [String: Data]] = [:] - - // BCH-01-009: Configurable error simulation for testing - var simulatedReadError: KeychainReadResult? - var simulatedSaveError: KeychainSaveResult? - - func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { - storage[key] = keyData - return true - } - - func getIdentityKey(forKey key: String) -> Data? { - storage[key] - } - - func deleteIdentityKey(forKey key: String) -> Bool { - storage.removeValue(forKey: key) - return true - } - - func deleteAllKeychainData() -> Bool { - storage.removeAll() - serviceStorage.removeAll() - return true - } - - func secureClear(_ data: inout Data) { - data = Data() - } - - func secureClear(_ string: inout String) { - string = "" - } - - func verifyIdentityKeyExists() -> Bool { - storage["identity_noiseStaticKey"] != nil - } - - // BCH-01-009: New methods with proper error classification - func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { - if let simulated = simulatedReadError { - return simulated - } - if let data = storage[key] { - return .success(data) - } - return .itemNotFound - } - - func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { - if let simulated = simulatedSaveError { - return simulated - } - storage[key] = keyData - return .success - } - - // MARK: - Generic Data Storage (consolidated from KeychainHelper) - - func save(key: String, data: Data, service: String, accessible: CFString?) { - if serviceStorage[service] == nil { - serviceStorage[service] = [:] - } - serviceStorage[service]?[key] = data - } - - func load(key: String, service: String) -> Data? { - serviceStorage[service]?[key] - } - - func delete(key: String, service: String) { - serviceStorage[service]?.removeValue(forKey: key) - } - - func deleteAll(service: String) { - serviceStorage.removeValue(forKey: service) - } -} - -/// Typealias for backwards compatibility with tests using MockKeychainHelper -typealias MockKeychainHelper = MockKeychain - -/// Mock keychain that tracks secureClear calls for testing DH secret clearing -final class TrackingMockKeychain: KeychainManagerProtocol { - private var storage: [String: Data] = [:] - private var serviceStorage: [String: [String: Data]] = [:] - - /// Thread-safe counter for secureClear calls - private let lock = NSLock() - private var _secureClearDataCallCount = 0 - private var _secureClearStringCallCount = 0 - - // BCH-01-009: Configurable error simulation for testing - var simulatedReadError: KeychainReadResult? - var simulatedSaveError: KeychainSaveResult? - - var secureClearDataCallCount: Int { - lock.lock() - defer { lock.unlock() } - return _secureClearDataCallCount - } - - var secureClearStringCallCount: Int { - lock.lock() - defer { lock.unlock() } - return _secureClearStringCallCount - } - - var totalSecureClearCallCount: Int { - return secureClearDataCallCount + secureClearStringCallCount - } - - func resetCounts() { - lock.lock() - defer { lock.unlock() } - _secureClearDataCallCount = 0 - _secureClearStringCallCount = 0 - } - - func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { - storage[key] = keyData - return true - } - - func getIdentityKey(forKey key: String) -> Data? { - storage[key] - } - - func deleteIdentityKey(forKey key: String) -> Bool { - storage.removeValue(forKey: key) - return true - } - - func deleteAllKeychainData() -> Bool { - storage.removeAll() - serviceStorage.removeAll() - return true - } - - func secureClear(_ data: inout Data) { - lock.lock() - _secureClearDataCallCount += 1 - lock.unlock() - data = Data() - } - - func secureClear(_ string: inout String) { - lock.lock() - _secureClearStringCallCount += 1 - lock.unlock() - string = "" - } - - func verifyIdentityKeyExists() -> Bool { - storage["identity_noiseStaticKey"] != nil - } - - // BCH-01-009: New methods with proper error classification - func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { - if let simulated = simulatedReadError { - return simulated - } - if let data = storage[key] { - return .success(data) - } - return .itemNotFound - } - - func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { - if let simulated = simulatedSaveError { - return simulated - } - storage[key] = keyData - return .success - } - - func save(key: String, data: Data, service: String, accessible: CFString?) { - if serviceStorage[service] == nil { - serviceStorage[service] = [:] - } - serviceStorage[service]?[key] = data - } - - func load(key: String, service: String) -> Data? { - serviceStorage[service]?[key] - } - - func delete(key: String, service: String) { - serviceStorage[service]?.removeValue(forKey: key) - } - - func deleteAll(service: String) { - serviceStorage.removeValue(forKey: service) - } -} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift deleted file mode 100644 index d66f0cdf..00000000 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// PeerCapabilitiesTests.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Testing -import Foundation -@testable import BitFoundation - -struct PeerCapabilitiesTests { - @Test - func encodingIsMinimalAndRoundTrips() { - #expect(PeerCapabilities([]).encoded() == Data([0x00])) - #expect(PeerCapabilities.prekeys.encoded() == Data([0x01])) - #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40])) - #expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01])) - - #expect( - PeerCapabilities.privateMediaReceipts.encoded() - == Data([0x00, 0x02]) - ) - #expect( - PeerCapabilities.nonDestructiveNoiseReplacement.encoded() - == Data([0x00, 0x04]) - ) - - let high = PeerCapabilities(rawValue: 1 << 11) - #expect(high.encoded() == Data([0x00, 0x08])) - - let all: PeerCapabilities = [ - .prekeys, - .wifiBulk, - .gateway, - .groups, - .board, - .vouch, - .meshDiagnostics, - .privateMedia, - .privateMediaReceipts, - .nonDestructiveNoiseReplacement - ] - #expect(PeerCapabilities(encoded: all.encoded()) == all) - #expect(PeerCapabilities(encoded: high.encoded()) == high) - #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == []) - } - - @Test - func decodingToleratesUnknownBitsAndOversizedFields() { - // Unknown bits survive a round-trip untouched. - let unknown = PeerCapabilities(encoded: Data([0xFF, 0xFF])) - #expect(unknown.rawValue == 0xFFFF) - #expect(unknown.contains(.gateway)) - - // Fields longer than 8 bytes keep the low 64 bits and ignore the rest. - let oversized = Data([0x01] + [UInt8](repeating: 0x00, count: 7) + [0xAA, 0xBB]) - #expect(PeerCapabilities(encoded: oversized) == .prekeys) - - // Empty value decodes to no capabilities. - #expect(PeerCapabilities(encoded: Data()) == []) - } -} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift deleted file mode 100644 index 434d08f3..00000000 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift +++ /dev/null @@ -1,408 +0,0 @@ -import Foundation -import Testing -import CryptoKit -@testable import BitFoundation - -/// Executable test vectors for peer ID rotation. -/// -/// These are the numbers the Android implementation must reproduce. Two rules -/// for keeping them useful: -/// -/// 1. **Reproduce them from `docs/PEER-ID-ROTATION.md`, not from this code.** -/// Deriving the expected values by reading the other platform's -/// implementation proves only that both share a bug. -/// 2. **If a derivation changes, the hex here changes too, deliberately.** A -/// vector that gets "fixed" to match new behavior has stopped being a vector. -/// -/// The three `VECTOR:` values below were cross-checked against an independent -/// HKDF/HMAC implementation written from the specification alone (Python -/// `hmac`/`hashlib`, empty salt, extract-then-expand) and matched byte for byte. -/// So the spec text is sufficient to reproduce them without reading this code — -/// which is the property Android needs. -struct PeerIDRotationTests { - // A fixed, obviously-fake private key so the vectors are stable. - private let staticPrivateA = Data((0..<32).map { UInt8($0 + 1) }) // 01..20 - private let staticPrivateB = Data((0..<32).map { UInt8(0xA0 &+ $0) }) // a0..bf - - private func hex(_ data: Data) -> String { - data.map { String(format: "%02x", $0) }.joined() - } - - // MARK: - Epochs - - @Test func epochIsWallClockDivision() { - #expect(PeerIDRotation.rotationPeriod == 3600) - #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 0)) == 0) - #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3599)) == 0) - #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3600)) == 1) - // 2026-07-26T00:00:00Z - #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 1_784_000_000)) == 495_555) - } - - @Test func candidateEpochsCoverTheBoundaryBothWays() { - // Two devices seconds apart across a boundary must still recognise each - // other, so the window spans the neighbouring epochs. - let date = Date(timeIntervalSince1970: 3600 * 100) - #expect(PeerIDRotation.candidateEpochs(around: date) == [99, 100, 101]) - } - - @Test func candidateEpochsDoNotUnderflowAtTheOrigin() { - // UInt32 underflow here would produce 4294967295 and break matching. - #expect(PeerIDRotation.candidateEpochs(around: Date(timeIntervalSince1970: 0)) == [0, 1]) - } - - // MARK: - Rotating peer ID - - @Test func rotationSecretIsStableForAKey() { - let first = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) - let second = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) - #expect(first == second) - #expect(first.count == 32) - // VECTOR: HKDF-SHA256(ikm: 01..20, salt: empty, info: "bitchat-peer-rotation-v1", 32) - #expect(hex(first) == "fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09") - } - - @Test func peerIDIsEightBytesAndEpochDependent() { - let secret = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) - let a = PeerIDRotation.peerID(rotationSecret: secret, epoch: 100) - let b = PeerIDRotation.peerID(rotationSecret: secret, epoch: 101) - - #expect(a.count == PeerIDRotation.idLength) - #expect(b.count == PeerIDRotation.idLength) - // VECTOR: HMAC-SHA256(rotationSecret, "bitchat-peer-id-v2" || uint32be(100))[0..8] - #expect(hex(a) == "f7c08c528506a374") - // The whole point: consecutive epochs are unrelated to an observer. - #expect(a != b) - // Deterministic within an epoch, so a restart keeps the same ID. - #expect(a == PeerIDRotation.peerID(rotationSecret: secret, epoch: 100)) - } - - @Test func peerIDDiffersBetweenDevices() { - let secretA = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) - let secretB = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateB) - #expect(PeerIDRotation.peerID(rotationSecret: secretA, epoch: 100) - != PeerIDRotation.peerID(rotationSecret: secretB, epoch: 100)) - } - - @Test func currentPeerIDMatchesTheExplicitEpochForm() { - let date = Date(timeIntervalSince1970: 3600 * 100 + 17) - let viaConvenience = PeerIDRotation.currentPeerID( - noiseStaticPrivateKey: staticPrivateA, - at: date - ) - let viaParts = PeerIDRotation.peerID( - rotationSecret: PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA), - epoch: 100 - ) - #expect(viaConvenience == viaParts) - } - - // MARK: - Recognition tags - - private var pubA: Data { Data(repeating: 0x0A, count: 32) } - private var pubB: Data { Data(repeating: 0x0B, count: 32) } - private var idA: Data { Data(repeating: 0xA1, count: 8) } - - /// The property that makes handshake-free recognition possible: both sides - /// reach the same tag from opposite halves of the key pair. - @Test func bothSidesDeriveTheSameRecognitionTag() throws { - let privA = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateA) - let privB = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateB) - - let sharedFromA = try privA.sharedSecretFromKeyAgreement(with: privB.publicKey) - let sharedFromB = try privB.sharedSecretFromKeyAgreement(with: privA.publicKey) - let rawA = sharedFromA.withUnsafeBytes { Data($0) } - let rawB = sharedFromB.withUnsafeBytes { Data($0) } - #expect(rawA == rawB) - - let keyA = PeerIDRotation.recognitionKey(sharedSecret: rawA) - let keyB = PeerIDRotation.recognitionKey(sharedSecret: rawB) - #expect(keyA == keyB) - - // A emits its A->B tag; B computes the same value to look for it. - let emitted = PeerIDRotation.recognitionTag( - recognitionKey: keyA, epoch: 100, - senderStaticPublicKey: privA.publicKey.rawRepresentation, - recipientStaticPublicKey: privB.publicKey.rawRepresentation, - peerID: idA - ) - let expected = PeerIDRotation.recognitionTag( - recognitionKey: keyB, epoch: 100, - senderStaticPublicKey: privA.publicKey.rawRepresentation, - recipientStaticPublicKey: privB.publicKey.rawRepresentation, - peerID: idA - ) - #expect(emitted == expected) - #expect(emitted.count == PeerIDRotation.idLength) - } - - /// Regression, Codex #1487 P1: a symmetric tag means A and B broadcast the - /// identical 8 bytes, so an observer who sees one value in two announces - /// learns those two are mutual favourites and can link their rotating IDs. - /// Tags must therefore differ by direction. - @Test func recognitionTagsAreDirectional() { - let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32)) - let aToB = PeerIDRotation.recognitionTag( - recognitionKey: key, epoch: 100, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA - ) - let bToA = PeerIDRotation.recognitionTag( - recognitionKey: key, epoch: 100, - senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA, peerID: idA - ) - #expect(aToB != bToA) - } - - /// Regression, Codex #1487 P1: without the peer ID in the MAC, a tag lifted - /// from someone's announce could be replayed under an attacker-chosen ID and - /// the recipient would accept that ID as the favourite. - @Test func recognitionTagIsBoundToTheAnnouncedPeerID() { - let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32)) - let real = PeerIDRotation.recognitionTag( - recognitionKey: key, epoch: 100, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA - ) - let underAttackerID = PeerIDRotation.recognitionTag( - recognitionKey: key, epoch: 100, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, - peerID: Data(repeating: 0xFF, count: 8) - ) - #expect(real != underAttackerID) - - // And the lifted tag must not verify against the attacker's ID. - let block = PeerIDRotation.tagBlock(tags: [real]) - #expect(!PeerIDRotation.blockMatches( - block, recognitionKey: key, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, - peerID: Data(repeating: 0xFF, count: 8), - at: Date(timeIntervalSince1970: 3600 * 100) - )) - } - - @Test func recognitionTagRotatesWithTheEpoch() { - let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32)) - let now = PeerIDRotation.recognitionTag( - recognitionKey: key, epoch: 100, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA - ) - let next = PeerIDRotation.recognitionTag( - recognitionKey: key, epoch: 101, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA - ) - #expect(now != next) - // VECTOR: HMAC-SHA256(HKDF(ikm: 0x42*32, info: "bitchat-recognition-v1"), - // uint32be(100) || 0x0A*32 || 0x0B*32 || 0xA1*8)[0..8] - #expect(hex(now) == "4568f61d61d6cbfb") - } - - @Test func aThirdPartyCannotDeriveAPairsTag() { - // An observer holding a *different* shared secret gets a different tag, - // which is what stops it from tracking the pair. - let pair = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x01, count: 32)) - let other = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x02, count: 32)) - #expect(PeerIDRotation.recognitionTag( - recognitionKey: pair, epoch: 7, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA - ) != PeerIDRotation.recognitionTag( - recognitionKey: other, epoch: 7, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA - )) - } - - // MARK: - Tag block - - @Test func tagBlockIsAlwaysFullWidth() { - let expected = PeerIDRotation.tagSlots * PeerIDRotation.idLength - for count in 0...PeerIDRotation.tagSlots { - let tags = (0.. (key: Data, tag: Data, date: Date) { - let date = Date(timeIntervalSince1970: 3600 * 100) - let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x77, count: 32)) - let tag = PeerIDRotation.recognitionTag( - recognitionKey: key, - epoch: PeerIDRotation.epoch(at: date), - senderStaticPublicKey: pubA, - recipientStaticPublicKey: pubB, - peerID: idA - ) - return (key, tag, date) - } - - @Test func blockMatchesRecogniseAPeerAnywhereInTheBlock() { - let (key, tag, date) = matchFixture() - // Slot order must not matter, so assert across many shuffles. - for _ in 0..<20 { - let block = PeerIDRotation.tagBlock(tags: [tag]) - #expect(PeerIDRotation.blockMatches( - block, recognitionKey: key, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, - peerID: idA, at: date - )) - } - } - - /// Testing the wrong direction must fail, or the directional fix would be - /// cosmetic. - @Test func blockDoesNotMatchTheOppositeDirection() { - let (key, tag, date) = matchFixture() - let block = PeerIDRotation.tagBlock(tags: [tag]) - #expect(!PeerIDRotation.blockMatches( - block, recognitionKey: key, - senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA, - peerID: idA, at: date - )) - } - - @Test func blockMatchesToleratesTheEpochBoundary() { - let date = Date(timeIntervalSince1970: 3600 * 100) - let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x11, count: 32)) - - func tag(epoch: UInt32) -> Data { - PeerIDRotation.recognitionTag( - recognitionKey: key, epoch: epoch, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA - ) - } - func matches(_ candidate: Data) -> Bool { - PeerIDRotation.blockMatches( - PeerIDRotation.tagBlock(tags: [candidate]), recognitionKey: key, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, - peerID: idA, at: date - ) - } - - // A peer whose clock has already ticked over still matches. - #expect(matches(tag(epoch: 101))) - // Two epochs out is outside the window and must not. - #expect(!matches(tag(epoch: 98))) - } - - @Test func randomBlockDoesNotMatch() { - let (key, _, date) = matchFixture() - #expect(!PeerIDRotation.blockMatches( - PeerIDRotation.tagBlock(tags: []), recognitionKey: key, - senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, - peerID: idA, at: date - )) - } - - // MARK: - Identity binding - - @Test func bindingMessageIsFixedWidthAndContextSeparated() { - let message = PeerIDRotation.bindingMessage( - epoch: 100, - peerID: Data(repeating: 0xAB, count: 8), - noiseStaticPublicKey: Data(repeating: 0xCD, count: 32) - ) - let context = Data("bitchat-peerid-binding-v1".utf8) - #expect(message.count == context.count + 4 + 8 + 32) - #expect(message.starts(with: context)) - // Must not collide with the production-dead announce-signature helpers, - // which use "bitchat-announce-v1". - #expect(!message.starts(with: Data("bitchat-announce-v1".utf8))) - } - - @Test func bindingMessagePadsShortInputsRatherThanShifting() { - // Fixed-width fields mean a short ID cannot shift the key into the ID's - // position and produce a message that verifies for the wrong pairing. - let short = PeerIDRotation.bindingMessage( - epoch: 1, - peerID: Data([0x01]), - noiseStaticPublicKey: Data([0x02]) - ) - let padded = PeerIDRotation.bindingMessage( - epoch: 1, - peerID: Data([0x01]) + Data(repeating: 0, count: 7), - noiseStaticPublicKey: Data([0x02]) + Data(repeating: 0, count: 31) - ) - #expect(short == padded) - } - - @Test func bindingMessageChangesWithEveryField() { - let base = PeerIDRotation.bindingMessage( - epoch: 1, - peerID: Data(repeating: 0x01, count: 8), - noiseStaticPublicKey: Data(repeating: 0x02, count: 32) - ) - #expect(base != PeerIDRotation.bindingMessage( - epoch: 2, - peerID: Data(repeating: 0x01, count: 8), - noiseStaticPublicKey: Data(repeating: 0x02, count: 32) - )) - #expect(base != PeerIDRotation.bindingMessage( - epoch: 1, - peerID: Data(repeating: 0x03, count: 8), - noiseStaticPublicKey: Data(repeating: 0x02, count: 32) - )) - #expect(base != PeerIDRotation.bindingMessage( - epoch: 1, - peerID: Data(repeating: 0x01, count: 8), - noiseStaticPublicKey: Data(repeating: 0x04, count: 32) - )) - } - - @Test func bindingMessageVerifiesUnderTheIdentityKey() throws { - let signing = Curve25519.Signing.PrivateKey() - let message = PeerIDRotation.bindingMessage( - epoch: 100, - peerID: Data(repeating: 0xAB, count: 8), - noiseStaticPublicKey: Data(repeating: 0xCD, count: 32) - ) - let signature = try signing.signature(for: message) - #expect(signing.publicKey.isValidSignature(signature, for: message)) - - // A different epoch must not verify: replaying a binding into a later - // epoch is exactly what this prevents. - let other = PeerIDRotation.bindingMessage( - epoch: 101, - peerID: Data(repeating: 0xAB, count: 8), - noiseStaticPublicKey: Data(repeating: 0xCD, count: 32) - ) - #expect(!signing.publicKey.isValidSignature(signature, for: other)) - } -} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift deleted file mode 100644 index cd0ef876..00000000 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// TestConstants.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -// Kept local until the test-helper module is split out. -struct TestConstants { - static let defaultTimeout: TimeInterval = 5.0 - static let longTimeout: TimeInterval = 10.0 - - static let testNickname1 = "Alice" - static let testNickname2 = "Bob" - static let testNickname3 = "Charlie" - static let testNickname4 = "David" - - static let testMessage1 = "Hello, World!" - static let testMessage2 = "How are you?" - static let testMessage3 = "This is a test message" - static let testLongMessage = String(repeating: "This is a long message. ", count: 100) - - static let testSignature = Data(repeating: 0xAB, count: 64) -} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift deleted file mode 100644 index 7b2ff6a3..00000000 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// TestHelpers.swift -// bitchatTests -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import CryptoKit -@testable import BitFoundation - -// Kept local until the test-helper module is split out. -final class TestHelpers { - - // MARK: - Key Generation - - static func generateTestKeyPair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) { - let privateKey = Curve25519.KeyAgreement.PrivateKey() - let publicKey = privateKey.publicKey - return (privateKey, publicKey) - } - - static func generateTestIdentity(peerID: String, nickname: String) -> (peerID: String, nickname: String, privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) { - let (privateKey, publicKey) = generateTestKeyPair() - return (peerID: peerID, nickname: nickname, privateKey: privateKey, publicKey: publicKey) - } - - // MARK: - Message Creation - - static func createTestMessage( - content: String = TestConstants.testMessage1, - sender: String = TestConstants.testNickname1, - senderPeerID: PeerID = PeerID(str: UUID().uuidString), - isPrivate: Bool = false, - recipientNickname: String? = nil, - mentions: [String]? = nil - ) -> BitchatMessage { - return BitchatMessage( - id: UUID().uuidString, - sender: sender, - content: content, - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: isPrivate, - recipientNickname: recipientNickname, - senderPeerID: senderPeerID, - mentions: mentions - ) - } - - static func createTestPacket( - type: UInt8 = 0x01, - senderID: PeerID = PeerID(str: UUID().uuidString), - recipientID: PeerID? = nil, - payload: Data = Data("test payload".utf8), - signature: Data? = nil, - ttl: UInt8 = 3 - ) -> BitchatPacket { - return BitchatPacket( - type: type, - senderID: Data(senderID.id.utf8), - recipientID: recipientID?.id.data(using: .utf8), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: signature, - ttl: ttl - ) - } - - // MARK: - Data Generation - - static func generateRandomData(length: Int) -> Data { - var data = Data(count: length) - _ = data.withUnsafeMutableBytes { bytes in - SecRandomCopyBytes(kSecRandomDefault, length, bytes.baseAddress!) - } - return data - } - - static func generateTestPeerID() -> String { - return "PEER" + UUID().uuidString.prefix(8) - } - - // MARK: - Async Helpers - - static func waitFor(_ condition: @escaping () -> Bool, timeout: TimeInterval = TestConstants.defaultTimeout) async throws { - let start = Date() - while !condition() { - if Date().timeIntervalSince(start) > timeout { - throw TestError.timeout - } - try await sleep(0.01) - } - } - - @MainActor - static func waitUntil( - _ condition: @escaping () -> Bool, - timeout: TimeInterval = TestConstants.defaultTimeout, - pollInterval: TimeInterval = 0.01 - ) async -> Bool { - let start = Date() - while !condition() { - if Date().timeIntervalSince(start) > timeout { - return condition() - } - try? await sleep(pollInterval) - } - return true - } - - static func expectAsync( - timeout: TimeInterval = TestConstants.defaultTimeout, - operation: @escaping () async throws -> T - ) async throws -> T { - return try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - return try await operation() - } - - group.addTask { - try await sleep(1) - throw TestError.timeout - } - - let result = try await group.next()! - group.cancelAll() - return result - } - } -} - -enum TestError: Error { - case timeout - case unexpectedValue - case testFailure(String) -} - -func sleep(_ seconds: TimeInterval) async throws { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) -} diff --git a/localPackages/BitLogger/Sources/OSLog+Categories.swift b/localPackages/BitLogger/Sources/OSLog+Categories.swift index 4810b2fb..1d74e27e 100644 --- a/localPackages/BitLogger/Sources/OSLog+Categories.swift +++ b/localPackages/BitLogger/Sources/OSLog+Categories.swift @@ -18,5 +18,6 @@ public extension OSLog { static let keychain = OSLog(subsystem: subsystem, category: "keychain") static let session = OSLog(subsystem: subsystem, category: "session") static let security = OSLog(subsystem: subsystem, category: "security") + static let handshake = OSLog(subsystem: subsystem, category: "handshake") static let sync = OSLog(subsystem: subsystem, category: "sync") } diff --git a/localPackages/BitLogger/Sources/SecureLogger.swift b/localPackages/BitLogger/Sources/SecureLogger.swift index 1a82f437..b6645ea1 100644 --- a/localPackages/BitLogger/Sources/SecureLogger.swift +++ b/localPackages/BitLogger/Sources/SecureLogger.swift @@ -101,8 +101,7 @@ public final class SecureLogger { // MARK: - Global Threshold /// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL. - /// Internal-settable so tests can verify level filtering; app code should not mutate it. - internal static var minimumLevel: LogLevel = { + private static let minimumLevel: LogLevel = { let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased() switch env { case "debug": return .debug @@ -122,46 +121,29 @@ public final class SecureLogger { public extension SecureLogger { - // Each wrapper checks the level BEFORE evaluating the autoclosure so - // filtered messages never pay for string interpolation — this matters on - // hot paths that log per packet/event. Debug compiles out of release - // builds entirely (the core log() drops .debug there anyway). static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { - #if DEBUG - guard shouldLog(.debug) else { return } log(message(), category: category, level: .debug, file: file, line: line, function: function) - #endif } - + static func info(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { - #if DEBUG - guard shouldLog(.info) else { return } log(message(), category: category, level: .info, file: file, line: line, function: function) - #endif } - + static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { - #if DEBUG - guard shouldLog(.warning) else { return } log(message(), category: category, level: .warning, file: file, line: line, function: function) - #endif } - + static func error(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { - #if DEBUG - guard shouldLog(.error) else { return } log(message(), category: category, level: .error, file: file, line: line, function: function) - #endif } /// Log errors with context static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { - #if DEBUG let location = formatLocation(file: file, line: line, function: function) let sanitized = context().sanitized() let errorDesc = error.localizedDescription.sanitized() @@ -171,7 +153,6 @@ public extension SecureLogger { #else os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc) #endif - #endif } } @@ -202,6 +183,10 @@ public extension SecureLogger { } } + static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { + logSecurityEvent(event, level: .debug, file: file, line: line, function: function) + } + static func info(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { logSecurityEvent(event, level: .info, file: file, line: line, function: function) } @@ -246,25 +231,32 @@ private extension SecureLogger { /// Log general messages with automatic sensitive data filtering static func log(_ message: @autoclosure () -> String, category: OSLog, level: LogLevel, file: String, line: Int, function: String) { - // All public wrappers are compiled out of release builds; this core - // is gated too so no future call path can reintroduce production - // logging. bitchat is privacy-first: release builds emit nothing. - #if DEBUG guard shouldLog(level) else { return } let location = formatLocation(file: file, line: line, function: function) let sanitized = "\(location) \(message())".sanitized() + + #if DEBUG os_log("%{public}@", log: category, type: level.osLogType, sanitized) + #else + // In release builds, only log non-debug messages + if level != .debug { + os_log("%{private}@", log: category, type: level.osLogType, sanitized) + } #endif } /// Log a security event static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info, file: String, line: Int, function: String) { - #if DEBUG guard shouldLog(level) else { return } let location = formatLocation(file: file, line: line, function: function) let message = "\(location) \(event.message)" + + #if DEBUG os_log("%{public}@", log: .security, type: level.osLogType, message) + #else + // In release, use private logging to prevent sensitive data exposure + os_log("%{private}@", log: .security, type: level.osLogType, message) #endif } @@ -275,3 +267,15 @@ private extension SecureLogger { return "[\(timestamp)] [\(fileName):\(line) \(function)]" } } + +// MARK: - Migration Helper + +/// Helper to migrate from print statements to SecureLogger +/// Usage: Replace print(...) with secureLog(...) +public func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n", + file: String = #file, line: Int = #line, function: String = #function) { + #if DEBUG + let message = items.map { String(describing: $0) }.joined(separator: separator) + SecureLogger.debug(message, file: file, line: line, function: function) + #endif +} diff --git a/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift b/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift deleted file mode 100644 index db04759f..00000000 --- a/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -import XCTest -@testable import BitLogger - -/// The public logging wrappers must not evaluate their message autoclosure -/// when the level is filtered out — hot paths log per packet/event, and -/// building a discarded interpolated string on each call is real overhead. -final class LogLevelFilteringTests: XCTestCase { - private final class EvaluationCounter { - var count = 0 - func message() -> String { - count += 1 - return "expensive interpolation" - } - } - - private var originalLevel: SecureLogger.LogLevel! - - override func setUp() { - super.setUp() - originalLevel = SecureLogger.minimumLevel - } - - override func tearDown() { - SecureLogger.minimumLevel = originalLevel - super.tearDown() - } - - func testFilteredDebugMessageIsNeverEvaluated() { - SecureLogger.minimumLevel = .info - let counter = EvaluationCounter() - - SecureLogger.debug(counter.message()) - - XCTAssertEqual(counter.count, 0, "Filtered debug message should not be constructed") - } - - func testFilteredInfoMessageIsNeverEvaluated() { - SecureLogger.minimumLevel = .error - let counter = EvaluationCounter() - - SecureLogger.info(counter.message()) - - XCTAssertEqual(counter.count, 0, "Filtered info message should not be constructed") - } - - func testEnabledLevelStillEvaluatesMessage() { - SecureLogger.minimumLevel = .debug - let counter = EvaluationCounter() - - SecureLogger.warning(counter.message()) - - XCTAssertEqual(counter.count, 1, "Enabled levels must still log") - } -} diff --git a/relays/online_relays_gps.csv b/relays/online_relays_gps.csv index f83e5ad0..ff98357e 100644 --- a/relays/online_relays_gps.csv +++ b/relays/online_relays_gps.csv @@ -1,442 +1,304 @@ Relay URL,Latitude,Longitude -bitchat.nostr1.com,40.7057,-74.0136 -relay.fundstr.me,42.3601,-71.0589 -nostr.2b9t.xyz,34.0549,-118.243 -armada.sharegap.net,43.6532,-79.3832 -nostr.chaima.info,51.5072,-0.127586 -nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832 -ribo.eu.nostria.app:443,43.6532,-79.3832 -relay.lightning.pub,39.0438,-77.4874 -relay.nostu.be,40.4167,-3.70329 -nostr.whitenode45.ddns.net,40.55,-74.4758 -nostr.carroarmato0.be:443,50.914,3.21378 -cdn.satellite.earth,40.8302,-74.1299 -relay2.veganostr.com,60.1699,24.9384 -relay.layer.systems:443,49.0291,8.35695 -relay0.gfcom.info,13.7653,100.647 -relay.mmwaves.de:443,48.8575,2.35138 -offchain.pub,39.1585,-94.5728 -bcast.girino.org,43.6532,-79.3832 -staging.yabu.me,35.6092,139.73 -nostr.overpay.com,29.7449,-95.5343 -bridge.tagomago.me,42.3601,-71.0589 -nostr-01.yakihonne.com,1.32123,103.695 -strfry.bonsai.com,39.0438,-77.4874 -relay.sharegap.net,43.6532,-79.3832 -nostr.islandarea.net,35.4669,-97.6473 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -treuzkas.branruz.com,48.8575,2.35138 -relay-rpi.edufeed.org:443,49.4521,11.0767 -vault.iris.to:443,43.6532,-79.3832 -node.kommonzenze.de,49.4521,11.0767 -nostr.thalheim.io:443,60.1699,24.9384 -soloco.nl,43.6532,-79.3832 -strfry.shock.network,39.0438,-77.4874 -nostr-relay.zimage.com,34.0549,-118.243 -public.crostr.com:443,43.6532,-79.3832 -nostr.sathoarder.com:443,48.5734,7.75211 -relay.angor.io,48.1046,11.6002 -relay.wellorder.net,45.5201,-122.99 -relay.mwaters.net,50.9871,2.12554 -relay.staging.commonshub.brussels,49.4543,11.0746 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr-pub.wellorder.net,45.5201,-122.99 -nostr-2.21crypto.ch,47.5356,8.73209 -relay.kaleidoswap.com,50.8476,4.35717 -relay.libernet.app:443,43.6532,-79.3832 -relay.homeinhk.xyz,35.694,139.754 -relay.manneken.brussels,49.4543,11.0746 -nostr.spicyz.io:443,43.6532,-79.3832 -relay.lanacoin-eternity.com:443,40.8302,-74.1299 -ribo.us.nostria.app:443,43.6532,-79.3832 -relay.loveisbitcoin.com,43.6532,-79.3832 -relay.angor.io:443,48.1046,11.6002 -relay02.lnfi.network,35.6764,139.65 -relay.cosmicbolt.net:443,37.3986,-121.964 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -nrs-01.darkcloudarcade.com,39.0997,-94.5786 -relay.endfiat.money:443,59.3327,18.0656 -relay.paulstephenborile.com,49.4543,11.0746 -rele.speyhard.fi,51.5072,-0.127586 -relay.froth.zone,60.1699,24.9384 -relay.nostr.blockhenge.com,39.0438,-77.4874 -nrl.ceskar.xyz,50.5145,16.0119 -rilo.nostria.app,43.6532,-79.3832 -nostr.overmind.lol:443,43.6532,-79.3832 -nostr.snowbla.de:443,50.4754,12.3683 -nostrrelay.taylorperron.com,45.5029,-73.5723 -chorus.pjv.me,45.5201,-122.99 -relay.nostr.place,43.6532,-79.3832 -bucket.coracle.social,37.7775,-122.397 -nostr.girino.org:443,43.6532,-79.3832 -relay.aarpia.com,37.3986,-121.964 -nostr.thalheim.io,60.1699,24.9384 -ec2.f7z.io,60.1699,24.9384 -relay.trotters.cc,43.6532,-79.3832 -relay.mccormick.cx:443,52.3563,4.95714 -relay.momostr.pink,43.6532,-79.3832 -relay.nostr.net,43.6532,-79.3832 -conduitl2.fly.dev,37.7648,-122.432 -chat-relay.zap-work.com,43.6532,-79.3832 -relay.ditto.pub,43.6532,-79.3832 -relay.veganostr.com,60.1699,24.9384 -relay.minibolt.info:443,43.6532,-79.3832 -adre.su,59.9311,30.3609 -bitcoinostr.duckdns.org,41.1976,1.11167 -nostr.computingcache.com:443,34.0356,-118.442 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -nostr.hekster.org:443,37.3986,-121.964 -nostr.88mph.life,52.1941,-2.21905 -wot.dergigi.com,64.1476,-21.9392 -nostr.planix.org,43.6532,-79.3832 -relay.satsmarkt.club,52.6907,4.8181 -nostrcity-club.fly.dev:443,37.7648,-122.432 -aeon.libretechsystems.xyz,55.486,9.86577 -testnet.samt.st,43.6532,-79.3832 -nostr.data.haus,50.4754,12.3683 -wot.sudocarlos.com,43.6532,-79.3832 -relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 -shu01.shugur.net,21.4902,39.2246 -relay.gulugulu.moe:443,43.6532,-79.3832 -relay2.angor.io:443,48.1046,11.6002 -relay.libernet.app,43.6532,-79.3832 -directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832 -wot.nostr.party,36.1659,-86.7844 -relay.zone667.com,60.1699,24.9384 -nostr.wild-vibes.ts.net,48.8566,2.35222 -relay.nostr.com,50.1109,8.68213 -nostr.iskarion.ddns.net,43.3076,-2.95421 -relay-dev.satlantis.io,39.0438,-77.4874 -relay.sovereignresonance.org,48.9006,2.25929 -relay.nostrian-conquest.com,41.223,-111.974 -relay.aidatanorge.no,43.6532,-79.3832 -strfry.apps3.slidestr.net,40.4167,-3.70329 -relay.klabo.world,47.2343,-119.853 -nostr.data.haus:443,50.4754,12.3683 -testr.nymble.world,40.8054,-74.0241 -relay.inforsupports.com,43.6532,-79.3832 -relay.nostrmap.net:443,60.1699,24.9384 -nostr.stakey.net:443,52.3676,4.90414 -dev-relay.nostreon.com,60.1699,24.9384 -nostr.islandarea.net:443,35.4669,-97.6473 -nostr.rtvslawenia.com,49.4543,11.0746 -relay.bowlafterbowl.com,32.9483,-96.7299 -nostr.quali.chat:443,60.1699,24.9384 -relay.plebeian.market,50.1109,8.68213 -relay-rpi.edufeed.org,49.4521,11.0767 -r.0kb.io,32.789,-96.7989 -nostr.notribe.net:443,40.8302,-74.1299 -relay.getsafebox.app:443,43.6532,-79.3832 -nostr.dlcdevkit.com:443,40.0992,-83.1141 -nostrelites.org,34.9582,-81.9907 -nostr.hoppe-relay.it.com,42.8864,-78.8784 -nostr.thebiglake.org,32.71,-96.6745 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -relay.nostriot.com,41.5695,-83.9786 -nostr.christiansass.de,51.7634,7.8887 -relay.btcforplebs.com,43.6532,-79.3832 -nostr.tagomago.me,42.3601,-71.0589 -relayone.geektank.ai,39.0997,-94.5786 -relay.dreamith.to:443,43.6532,-79.3832 -nostr.liberty.fans,36.8767,-89.5879 -wot.makenomistakes.ca,43.7064,-79.3986 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -relay.layer.systems,49.0291,8.35695 -relay.paulstephenborile.com:443,49.4543,11.0746 -relay.ohstr.com,43.6532,-79.3832 -nostr-relay.xbytez.io:443,50.6924,3.20113 -nostr.ac,38.958,-77.3592 -ribo.us.nostria.app,43.6532,-79.3832 -nostr.21crypto.ch,47.5356,8.73209 -relay.chorus.community:443,48.5333,10.7 -relay.cypherflow.ai,48.8575,2.35138 -relay.agorist.space:443,52.3734,4.89406 -relay.nostrian-conquest.com:443,41.223,-111.974 -relay.keykeeper.world,40.7824,-74.0711 -relay.getvia.xyz,60.1699,24.9384 -relay.nuts.cash,52.3676,4.90414 -kotukonostr.onrender.com,37.7775,-122.397 -relay.minibolt.info,43.6532,-79.3832 -relay.dwadziesciajeden.pl,52.2297,21.0122 -relay.fountain.fm:443,43.6532,-79.3832 -relay.fountain.fm,43.6532,-79.3832 -nostr-02.uid.ovh,50.9871,2.12554 -relay.lanavault.space:443,60.1699,24.9384 -nostr.carroarmato0.be,50.914,3.21378 -nexus.libernet.app:443,43.6532,-79.3832 -relay.artio.inf.unibe.ch,46.9501,7.43678 -blossom.gnostr.cloud,43.6532,-79.3832 -relay.binaryrobot.com,43.6532,-79.3832 -relay.earthly.city,34.1749,-118.54 -nostr.hifish.org,47.4244,8.57658 -offchain.pub:443,39.1585,-94.5728 -relay.bullishbounty.com:443,43.6532,-79.3832 -strfry.openhoofd.nl:443,51.5717,3.70417 -cs-relay.nostrdev.com:443,50.4754,12.3683 -strfry.ymir.cloud,43.6532,-79.3832 -nostrbtc.com,43.6532,-79.3832 -relay.directsponsor.net,42.8864,-78.8784 -nostr2.girino.org,43.6532,-79.3832 -relay.sigit.io:443,50.4754,12.3683 -relay.getsafebox.app,43.6532,-79.3832 -antiprimal.net,43.6532,-79.3832 -nostr.sathoarder.com,48.5734,7.75211 -inbox.scuba323.com,40.8218,-74.45 -nrs-01.darkcloudarcade.com:443,39.0997,-94.5786 -nostr.tac.lol,47.4748,-122.273 -nostr.davenov.com,50.1109,8.68213 -relay.trotters.cc:443,43.6532,-79.3832 -nostr.plantroon.com:443,50.1013,8.62643 -relay.nostreon.com,60.1699,24.9384 -nostr.easycryptosend.it,43.6532,-79.3832 -nostr-01.yakihonne.com:443,1.32123,103.695 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr.purpura.cloud,43.6532,-79.3832 -insta-relay.apps3.slidestr.net,40.4167,-3.70329 -nostr.mifen.me,43.6532,-79.3832 -testnet-relay.samt.st:443,40.8302,-74.1299 -nostr.2b9t.xyz:443,34.0549,-118.243 -relay.wavlake.com:443,41.2619,-95.8608 -relay.wisp.talk:443,49.4543,11.0746 -relay-dev.satlantis.io:443,39.0438,-77.4874 -relay.satlantis.io,39.0438,-77.4874 -relay.staging.plebeian.market,51.5072,-0.127586 -relay.openfarmtools.org,60.1699,24.9384 -relay.nostrhub.fr,48.1045,11.6004 -nostr-relay.xbytez.io,50.6924,3.20113 -relay.binaryrobot.com:443,43.6532,-79.3832 -relay.samt.st,40.8302,-74.1299 -relay.illuminodes.com,43.6532,-79.3832 -relay.liberbitworld.org,43.6532,-79.3832 -relay.olas.app:443,60.1699,24.9384 -no.str.cr,8.96171,-83.5246 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -wot.rejecttheframe.xyz,43.6532,-79.3832 -relay.nostriot.com:443,41.5695,-83.9786 -nostr.plantroon.com,50.1013,8.62643 -nostr-01.uid.ovh,50.9871,2.12554 -relay.openresist.com:443,43.6532,-79.3832 -nostr.overmind.lol,43.6532,-79.3832 -relay.internationalright-wing.org,-22.5022,-48.7114 -nostr.myshosholoza.co.za:443,52.3676,4.90414 -nostr.pbfs.io:443,50.4754,12.3683 -21milionidinostr.duckdns.org,41.8967,12.4822 -nostr.4rs.nl,49.0291,8.35696 -relay.lanavault.space,60.1699,24.9384 -relay.mostr.pub,43.6532,-79.3832 -relay.nostar.org,43.6532,-79.3832 -nostr.mom,50.4754,12.3683 -relay.decentralia.fr,48.122,11.589 -relay.agentry.com,42.8864,-78.8784 -relay2.angor.io,48.1046,11.6002 -slick.mjex.me,39.0418,-77.4744 -relay-us.zombi.cloudrodion.com,40.7862,-74.0743 -relay.vrtmrz.net:443,43.6532,-79.3832 -relay.beginningend.com,35.2227,-97.4786 -chat-relay.zap-work.com:443,43.6532,-79.3832 -relay.underorion.se,50.1109,8.68213 -relay.mitchelltribe.com,39.0438,-77.4874 -relay.qstr.app,51.5072,-0.127586 -relay.cyberguy.fyi,52.6907,4.8181 -strfry.bonsai.com:443,39.0438,-77.4874 -relayone.soundhsa.com:443,39.0997,-94.5786 -relay.sigit.io,50.4754,12.3683 -relay.npubhaus.com,43.6532,-79.3832 -relayrs.notoshi.win,43.6532,-79.3832 -relay.mitchelltribe.com:443,39.0438,-77.4874 -relay.44billion.net,43.6532,-79.3832 -reraw.pbla2fish.cc,43.6532,-79.3832 -articles.layer3.news:443,37.3387,-121.885 -nostr.sovereignservices.xyz,43.6532,-79.3832 -relay.nostx.io,43.6532,-79.3832 -nostr-relay.amethyst.name,39.0067,-77.4291 -0x-nostr-relay.fly.dev,37.7648,-122.432 -relay.ohstr.com:443,43.6532,-79.3832 -00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.wavlake.com,41.2619,-95.8608 -purplerelay.com:443,43.6532,-79.3832 -nostr-pr02.redscrypt.org,52.3676,4.90414 -fanfares.nostr1.com:443,40.7057,-74.0136 -kasztanowa.bieda.it,43.6532,-79.3832 -relay.flashapp.me,43.6548,-79.3885 -relay.typedcypher.com,51.5072,-0.127586 -nostr.bond,50.1109,8.68213 -nostr.azzamo.net,52.2633,21.0283 -nexus.libernet.app,43.6532,-79.3832 -relay.cosmicbolt.net,37.3986,-121.964 schnorr.me,43.6532,-79.3832 -relay.mostro.network:443,40.8302,-74.1299 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -relay.chorus.community,48.5333,10.7 -blossom.gnostr.cloud:443,43.6532,-79.3832 -syb.lol:443,34.0549,-118.243 -relay.dyne.org,49.0291,8.35705 -btc.klendazu.com,41.2861,1.24993 -wot.nostr.place,43.6532,-79.3832 -relay.openresist.com,43.6532,-79.3832 -rilo.nostria.app:443,43.6532,-79.3832 -no.str.cr:443,8.96171,-83.5246 -relay.mostr.pub:443,43.6532,-79.3832 -relay.edufeed.org:443,49.4521,11.0767 -nostr.debate.report,50.1109,8.68213 -relay.satmaxt.xyz:443,43.6532,-79.3832 -relay.artx.market:443,43.6548,-79.3885 -relay-dev.gulugulu.moe,43.6532,-79.3832 -relay.novospes.com,43.6532,-79.3832 -relay.nostr-check.me,43.6532,-79.3832 nostr.computingcache.com,34.0356,-118.442 nostr.oxtr.dev,50.4754,12.3683 -relay.fckstate.net,59.3293,18.0686 -relay.vrtmrz.net,43.6532,-79.3832 -relay.bornheimer.app,51.5072,-0.127586 -relay.guggero.org,46.5971,9.59652 -relay01.lnfi.network,35.6764,139.65 -wot.shaving.kiwi,43.6532,-79.3832 -nostr.twinkle.lol,51.902,7.6657 -relay.edufeed.org,49.4521,11.0767 -relay.lanacoin-eternity.com,40.8302,-74.1299 -relay.satmaxt.xyz,43.6532,-79.3832 -nostr.hifish.org:443,47.4244,8.57658 -relay.cypherflow.ai:443,48.8575,2.35138 -infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832 -nostr.na.social:443,43.6532,-79.3832 -nostr.rtvslawenia.com:443,49.4543,11.0746 -relay.mypathtofire.de,42.8864,-78.8784 -public.crostr.com,43.6532,-79.3832 -relay.olas.app,60.1699,24.9384 -relay.agora.social,50.7383,15.0648 -ribo.nostria.app,43.6532,-79.3832 -relay.lab.rytswd.com,49.4543,11.0746 -relay.ditto.pub:443,43.6532,-79.3832 -porchlight.social,43.6532,-79.3832 -nostr.notribe.net,40.8302,-74.1299 -relay.endfiat.money,59.3327,18.0656 -nostr.myshosholoza.co.za,52.3676,4.90414 -relay.nearhood.co.uk,51.5134,-0.0890675 -relay.degmods.com,50.4754,12.3683 -nostr.novacisko.cz,52.2026,20.9397 -prl.plus,55.7628,37.5983 -bruh.samt.st,43.6532,-79.3832 -strfry.openhoofd.nl,51.5717,3.70417 -nostr.spicyz.io,43.6532,-79.3832 -nostr.na.social,43.6532,-79.3832 -nip85.nosfabrica.com,39.0997,-94.5786 -premium.primal.net,43.6532,-79.3832 -fanfares.nostr1.com,40.7057,-74.0136 -relay.scuba323.com,40.8218,-74.45 -nostr2.girino.org:443,43.6532,-79.3832 -relay.mmwaves.de,48.8575,2.35138 -nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 -strfry.shock.network:443,39.0438,-77.4874 -nostr.snowbla.de,50.4754,12.3683 -nostr.spaceshell.xyz,43.6532,-79.3832 -nostr.quali.chat,60.1699,24.9384 -wot.utxo.one,43.6532,-79.3832 -relay.mccormick.cx,52.3563,4.95714 -mostro-p2p.tech,50.1109,8.68213 -basspistol.org,49.0291,8.35696 -ribo.nostria.app:443,43.6532,-79.3832 -chorus.mikedilger.com:444,-36.8906,174.794 -nostr.oxtr.dev:443,50.4754,12.3683 -nostr.nodesmap.com,59.3327,18.0656 -offchain.bostr.online,43.6532,-79.3832 -purplerelay.com,43.6532,-79.3832 -relayrs.notoshi.win:443,43.6532,-79.3832 -relay.wavefunc.live,41.8781,-87.6298 -relay.dreamith.to,43.6532,-79.3832 -bendernostur.duckdns.org:8443,50.1109,8.68213 -relay.nmail.li,50.9871,2.12554 -nostr-relay.corb.net,39.6478,-104.988 -relay.staging.plebeian.market:443,51.5072,-0.127586 -spamspamspamspam.rest,43.6532,-79.3832 -relay1.gfcom.info,13.9215,100.538 -schnorr.me:443,43.6532,-79.3832 -relay.lab.rytswd.com:443,49.4543,11.0746 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -relay.nostrmap.net,60.1699,24.9384 -nostr.relay.hedwig.sh,60.1699,24.9384 -relay.veganostr.com:443,60.1699,24.9384 -relay.wavefunc.live:443,41.8781,-87.6298 -nostr.mikoshi.de,52.52,13.405 -syb.lol,34.0549,-118.243 -relay1.nostrchat.io,60.1699,24.9384 -nostr.wecsats.io:443,43.6532,-79.3832 -nostr.chaima.info:443,51.5072,-0.127586 -nostr.azzamo.net:443,52.2633,21.0283 -relay-can.zombi.cloudrodion.com,43.6532,-79.3832 -nostr.unkn0wn.world,46.8499,9.53287 -relayone.soundhsa.com,39.0997,-94.5786 -x.kojira.io,43.6532,-79.3832 -dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 -nostrelay.circum.space,52.6907,4.8181 -relay.primal.net,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -nostr.pbfs.io,50.4754,12.3683 -relay.kalcafe.xyz,37.3986,-121.964 -relay.gulugulu.moe,43.6532,-79.3832 -top.testrelay.top,43.6532,-79.3832 -relay.kilombino.com,43.6532,-79.3832 -nos.lol:443,50.4754,12.3683 -nos.lol,50.4754,12.3683 -relay.nostr.place:443,43.6532,-79.3832 -cache.trustr.ing,43.6548,-79.3885 -relay.internationalright-wing.org:443,-22.5022,-48.7114 -relay.laantungir.net,-19.4692,-42.5315 -relay.lightning.pub:443,39.0438,-77.4874 -nostr.stakey.net,52.3676,4.90414 -articles.layer3.news,37.3387,-121.885 -relay.wisp.talk,49.4543,11.0746 -relay.pyramid.li,47.4093,8.46503 -relay.typedcypher.com:443,51.5072,-0.127586 -dev.relay.stream,43.6532,-79.3832 -relay.bullishbounty.com,43.6532,-79.3832 -nostr.mom:443,50.4754,12.3683 -relay.plebeian.market:443,50.1109,8.68213 -nostr.hekster.org,37.3986,-121.964 -nostrcity-club.fly.dev,37.7648,-122.432 -nostr.vulpem.com,49.4543,11.0746 -relay-dev.gulugulu.moe:443,43.6532,-79.3832 -weboftrust.libretechsystems.xyz,55.4724,9.87335 -nostr-relay.corb.net:443,39.6478,-104.988 -wheat.happytavern.co,43.6532,-79.3832 -relay.mappingbitcoin.com,43.6532,-79.3832 -testnet-relay.samt.st,40.8302,-74.1299 -relay.bitmacro.cloud,43.6532,-79.3832 -dev.relay.edufeed.org,49.4521,11.0767 -myvoiceourstory.org,37.3598,-121.981 -relay.stickeroo.is-cool.dev,37.3387,-121.885 -relay.agorist.space,52.3734,4.89406 -freelay.sovbit.host,60.1699,24.9384 -nostr-dev.wellorder.net,45.5201,-122.99 -nostr.middling.mydns.jp,35.8099,140.12 -cs-relay.nostrdev.com,50.4754,12.3683 -x.kojira.io:443,43.6532,-79.3832 -nostrelay.circum.space:443,52.6907,4.8181 -nostr.janx.com,43.6532,-79.3832 -relay.mrmave.work,43.6532,-79.3832 -espelho.girino.org,43.6532,-79.3832 -hol.is,43.6532,-79.3832 -ribo.eu.nostria.app,43.6532,-79.3832 -nostr.yutakobayashi.com,43.6532,-79.3832 -relay.mostro.network,40.8302,-74.1299 -communities.nos.social,40.8302,-74.1299 -relay.solife.me,43.6532,-79.3832 -yabu.me,35.6092,139.73 -relay.islandbitcoin.com,12.8498,77.6545 -nostr.wecsats.io,43.6532,-79.3832 -nostr.tac.lol:443,47.4748,-122.273 -relay.arx-ccn.com,50.4754,12.3683 -nostrride.io,37.3986,-121.964 -r.0kb.io:443,32.789,-96.7989 -herbstmeister.com,34.0549,-118.243 -relay.artx.market,43.6548,-79.3885 -vault.iris.to,43.6532,-79.3832 -relay.ru.ac.th,13.7607,100.627 -temp.iris.to,43.6532,-79.3832 -social.amanah.eblessing.co,48.1046,11.6002 -nostr-relay.nextblockvending.com,47.2343,-119.853 wot.codingarena.top,50.4754,12.3683 -relay.sincensura.org,43.6532,-79.3832 +relay.orangepill.ovh,49.1689,-0.358841 +offchain.pub,39.1585,-94.5728 +relay.veganostr.com,60.1699,24.9384 +strfry.atlantislabs.space,43.6532,-79.3832 +relay.plebeian.market,50.1109,8.68213 +kotukonostr.onrender.com,37.7775,-122.397 +rilo.nostria.app,43.6532,-79.3832 +nostr.2b9t.xyz,34.0549,-118.243 +nostr.ovia.to,43.6532,-79.3832 +plebchain.club,43.6532,-79.3832 +nostr-relay.zeabur.app,25.0797,121.234 +relay.trustr.ing,49.8667,-125.133 +vault.iris.to,43.6532,-79.3832 +lightning.red,53.3498,-6.26031 +nostr.mikoshi.de,50.1109,8.68213 +syb.lol,34.0549,-118.243 +relay.ngengine.org,43.6532,-79.3832 +strfry.shock.network,39.0438,-77.4874 +relay.openfarmtools.org,60.1699,24.9384 +nostr-dev.wellorder.net,45.5201,-122.99 +nostr.nodesmap.com,59.3327,18.0656 +nostr.stakey.net,52.3676,4.90414 +relay.bithome.site,52.3563,4.95714 +relay.puresignal.news,43.6532,-79.3832 +strfry.elswa-dev.online,50.1109,8.68213 +relay.nyves.nl,51.1792,5.89444 +strfry.apps3.slidestr.net,40.4167,-3.70329 +social.amanah.eblessing.co,48.1046,11.6002 +inbox.mycelium.social,38.627,-90.1994 +relay.zone667.com,60.1699,24.9384 +nostr.rblb.it,43.7094,10.6582 +testrelay.era21.space,43.6532,-79.3832 +relay.agorist.space,52.3734,4.89406 +nostr.mom,50.4754,12.3683 +relay.ohstr.com,43.6532,-79.3832 +nostr.ac,38.958,-77.3592 +relay.toastr.net,40.8054,-74.0241 +nostr.bitcoiner.social,47.6743,-117.112 +nostr.carroarmato0.be,51.0368,3.21186 +nostrbtc.com,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.notoshi.win,13.966,101.513 +relay-dev.gulugulu.moe,43.6532,-79.3832 +nostr.tac.lol,47.4748,-122.273 +nrs-02.darkcloudarcade.com,39.9526,-75.1652 +ephemeral.snowflare.cc,43.6532,-79.3832 +nostr.chrissexton.org,43.6532,-79.3832 +nostr.pbfs.io,50.4754,12.3683 +strfry.bonsai.com,37.8716,-122.273 +nostr-server-production.up.railway.app,45.5019,-73.5674 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 +librerelay.aaroniumii.com,43.6532,-79.3832 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +nostr.rblb.it:7777,43.7094,10.6582 +nostriches.club,43.6532,-79.3832 +purplerelay.com,43.6532,-79.3832 +relay.earthly.city,34.0362,-118.443 +wot.rejecttheframe.xyz,43.6532,-79.3832 +relay.olas.app,50.4754,12.3683 +relay.binaryrobot.com,43.6532,-79.3832 +nostr.huszonegy.world,47.4979,19.0402 +nostr.thalheim.io,60.1699,24.9384 +nostr.islandarea.net,35.4669,-97.6473 +nostr-relay.nextblockvending.com,47.2343,-119.853 +nostr-relay.zimage.com,34.0549,-118.243 +nostr.thebiglake.org,32.71,-96.6745 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +relay.devcsu.fr,48.8575,2.35138 +relay.getsafebox.app,43.6532,-79.3832 +inbox.scuba323.com,40.8218,-74.45 +ribo.us.nostria.app,43.6532,-79.3832 +relay.fundstr.me,42.3601,-71.0589 +ms.chinacounty.com,39.9042,116.407 +relay.0xchat.com,43.6532,-79.3832 +nostr.twinkle.lol,51.902,7.6657 +nostr.spicyz.io,43.6532,-79.3832 +bucket.coracle.social,37.7775,-122.397 +soloco.nl,43.6532,-79.3832 +nostr.sathoarder.com,48.5734,7.75211 +bitcoiner.social,47.6743,-117.112 +reraw.pbla2fish.cc,43.6532,-79.3832 +ribo.nostria.app,43.6532,-79.3832 +nostr.bgbitcoin.club,50.4754,12.3683 +relayrs.notoshi.win,43.6532,-79.3832 +okn.czas.plus,50.1109,8.68213 +nostr-relay.corb.net,38.8353,-104.822 +nostr.robosats.org,64.1476,-21.9392 +relay.lightning.pub,39.0438,-77.4874 +r.bitcoinhold.net,43.6532,-79.3832 +nostr-relay.xbytez.io,50.6924,3.20113 +relay.nostr-check.me,43.6532,-79.3832 +wot.dergigi.com,64.1476,-21.9392 +nostr-01.yakihonne.com,1.32123,103.695 +relay.layer.systems,49.0291,8.35695 +premium.primal.net,43.6532,-79.3832 +nostr-relay.cbrx.io,43.6532,-79.3832 +nostr.spaceshell.xyz,43.6532,-79.3832 +kasztanowa.bieda.it,43.6532,-79.3832 +relay.qstr.app,50.1109,8.68213 +nostr2.girino.org,43.6532,-79.3832 +nostr-verified.wellorder.net,45.5201,-122.99 +relay.paulstephenborile.com,49.4543,11.0746 +herbstmeister.com,34.0549,-118.243 +cache.trustr.ing,45.4473,-73.7533 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +relay.lab.rytswd.com,49.4543,11.0746 +strfry.bitsbytom.com,51.4968,-0.018337 +relay.btcforplebs.com,43.6532,-79.3832 +relay.snotr.nl:49999,52.0195,4.42946 +relay.mostro.network,40.8302,-74.1299 +relay.klabo.world,47.2343,-119.853 +relay.kubo.watch,51.1792,5.89444 +relay.nostr.net,43.6532,-79.3832 +blossom.gnostr.cloud,43.6532,-79.3832 +nostr.myshosholoza.co.za,52.3913,4.66545 +relay.bitmacro.cloud,43.6532,-79.3832 +relay.nostrverse.net,43.6532,-79.3832 +relay.nostrcheck.me,43.6532,-79.3832 +nostr.luisschwab.net,43.6532,-79.3832 +relay.bikel.ink,60.1699,24.9384 +nostr.zoracle.org,45.6018,-121.185 +nostr.bitczat.pl,60.1699,24.9384 +relay.angor.io,48.1046,11.6002 +nostr.girino.org,43.6532,-79.3832 +relay.bullishbounty.com,43.6532,-79.3832 +relay.endfiat.money,59.3327,18.0656 +santo.iguanatech.net,40.8302,-74.1299 +nostr.88mph.life,52.1941,-2.21905 +nostr-03.dorafactory.org,1.35208,103.82 +relay01.lnfi.network,35.6764,139.65 +nostr.data.haus,50.4754,12.3683 +npub1spxdug4m3y24hpx5crm0el4zhkk0wafs8kp6m0xu0wecygqej2xqq8gyhx.fips.network,43.6532,-79.3832 +nrs-01.darkcloudarcade.com,39.1008,-94.5811 +relay.satmaxt.xyz,43.6532,-79.3832 +ynostr.yael.at,60.1699,24.9384 +relay.sigit.io,50.4754,12.3683 +relay.homeinhk.xyz,35.694,139.754 +relay.bnos.space,43.6532,-79.3832 +nittom.nostr1.com,38.6327,-90.1961 +relay.openresist.com,43.6532,-79.3832 +relay.mmwaves.de,48.8575,2.35138 +relay.artx.market,43.6548,-79.3885 +relay.lanavault.space,60.1699,24.9384 +eu.nostr.pikachat.org,49.4543,11.0746 +relay.satnam.pub,43.6532,-79.3832 +relay.keykeeper.world,40.7824,-74.0711 +relay.mccormick.cx,52.3563,4.95714 +nostr.aruku.kro.kr,37.3589,127.115 +x.kojira.io,43.6532,-79.3832 +relay.camelus.app,45.5201,-122.99 +purpura.cloud,43.6532,-79.3832 +relay.malxte.de,52.52,13.405 +relay.bao.network,43.6532,-79.3832 +relay.nostu.be,40.4167,-3.70329 +relay.bitcoindistrict.org,43.6532,-79.3832 +relay5.bitransfer.org,43.6532,-79.3832 +relay.tapestry.ninja,40.8054,-74.0241 +relay.edufeed.org,49.4521,11.0767 +relayone.soundhsa.com,39.1008,-94.5811 +nostr.quali.chat,60.1699,24.9384 +myvoiceourstory.org,37.3598,-121.981 +nostr.4rs.nl,49.0291,8.35696 +fanfares.nostr1.com,38.6327,-90.1961 +freelay.sovbit.host,64.1476,-21.9392 +bcast.girino.org,43.6532,-79.3832 +seed-options-few-cache.trycloudflare.com,43.6532,-79.3832 +relay.dwadziesciajeden.pl,52.2297,21.0122 +satsage.xyz,37.3986,-121.964 +relay.satlantis.io,32.8769,-80.0114 +nostr-pub.wellorder.net,45.5201,-122.99 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +temp.iris.to,43.6532,-79.3832 +relay.wellorder.net,45.5201,-122.99 +relay.nostriot.com,41.5695,-83.9786 +relay.libernet.app,43.6532,-79.3832 +nostr.nadajnik.org,50.1109,8.68213 +nostr.na.social,43.6532,-79.3832 +wot.sudocarlos.com,43.6532,-79.3832 +relay.cypherflow.ai,48.8575,2.35138 +nostr.vulpem.com,49.4543,11.0746 +relay.erybody.com,41.4513,-81.7021 +relay.agora.social,50.7383,15.0648 +nos.lol,50.4754,12.3683 +insta-relay.apps3.slidestr.net,40.4167,-3.70329 +relay.fckstate.net,59.3293,18.0686 +relay.bornheimer.app,51.5072,-0.127586 +chat-relay.zap-work.com,43.6532,-79.3832 +relay.ditto.pub,43.6532,-79.3832 +nostr.snowbla.de,60.1699,24.9384 +relay.jeffg.fyi,43.6532,-79.3832 +nostrcheck.me,43.6532,-79.3832 +bcast.seutoba.com.br,43.6532,-79.3832 +relay2.angor.io,48.1046,11.6002 nostr.dlcdevkit.com,40.0992,-83.1141 +nostr.n7ekb.net,36.1527,-95.9902 +cs-relay.nostrdev.com,50.4754,12.3683 +ribo.eu.nostria.app,43.6532,-79.3832 +nostr.agentcampfire.com,52.3676,4.90414 +wot.nostr.place,32.7767,-96.797 +relay.anmore.me,49.281,-123.117 +relay.mostr.pub,43.6532,-79.3832 +adre.su,59.9311,30.3609 +nostr.self-determined.de,53.495,10.2542 +nostr.wecsats.io,43.6532,-79.3832 +theoutpost.life,64.1476,-21.9392 +relay.lanacoin-eternity.com,40.8302,-74.1299 +nostr.chaima.info,50.1109,8.68213 +r.0kb.io,32.789,-96.7989 +nostrue.com,40.8054,-74.0241 +nostr.hekster.org,37.3986,-121.964 +relay.guggero.org,46.5971,9.59652 +us-east.nostr.pikachat.org,39.0438,-77.4874 +relay.primal.net,43.6532,-79.3832 +nostrride.io,37.3986,-121.964 +relay.lacompagniemaximus.com,45.3147,-73.8785 +nostrelay.circum.space,52.3676,4.90414 +nostr.dpinkerton.com,39.1008,-94.5811 +nexus.libernet.app,43.6532,-79.3832 +dev.relay.edufeed.org,49.4521,11.0767 +nostr.defucc.me,50.1109,8.68213 +relay.npubhaus.com,43.6532,-79.3832 +strfry.openhoofd.nl,51.9229,4.40833 +nostr-relay.online,43.6532,-79.3832 +dev.relay.stream,43.6532,-79.3832 +relay.arx-ccn.com,50.4754,12.3683 +relay-rpi.edufeed.org,49.4521,11.0767 +relay.illuminodes.com,47.6061,-122.333 +relay.nostrhub.fr,48.1045,11.6004 +wot.shaving.kiwi,43.6532,-79.3832 +relay.agentry.com,42.8864,-78.8784 +bitchat.nostr1.com,38.6327,-90.1961 +relay.nostrian-conquest.com,41.223,-111.974 +no.str.cr,10.074,-84.2155 +nostr.blankfors.se,60.1699,24.9384 +bridge.tagomago.me,3.139,101.687 +nostr.ps1829.com,33.8851,130.883 +wot.nostr.party,36.1627,-86.7816 +relay.threenine.services,51.5222,-0.62916 +relay.mitchelltribe.com,39.0438,-77.4874 +relay.trotters.cc,43.6532,-79.3832 +nos.xmark.cc,50.6924,3.20113 +relay2.ngengine.org,43.6532,-79.3832 +relay.samt.st,40.8302,-74.1299 +relay.solife.me,43.6532,-79.3832 +relay.staging.commonshub.brussels,49.4543,11.0746 +orly-relay.imwald.eu,48.8575,2.35138 +prl.plus,55.7628,37.5983 +nostr.tadryanom.me,43.6532,-79.3832 +nstr.a0a1.space,52.3563,4.95714 +nostr.bond,50.1109,8.68213 +relay.islandbitcoin.com,12.8498,77.6545 +yabu.me,35.6092,139.73 +relay.minibolt.info,43.6532,-79.3832 +relay.shadowbip.com,50.1109,8.68213 +slick.mjex.me,39.0418,-77.4744 +articles.layer3.news,37.3387,-121.885 +nostr.night7.space,50.4754,12.3683 +relay2.liberbitworld.org,22.3193,114.169 +testnet-relay.samt.st,40.8302,-74.1299 +relay.wavlake.com,41.2619,-95.8608 +dev.architectcontext.aarpia.net,51.5222,-0.62916 +nostr.aruku.ovh,1.27994,103.849 +relay.nostrdice.com,-33.8688,151.209 +nostr.liberty.fans,36.9104,-89.5875 +testr.nymble.world,40.8054,-74.0241 +relay.gulugulu.moe,43.6532,-79.3832 +nostr-relay.psfoundation.info,39.0438,-77.4874 +relay.flashapp.me,43.6548,-79.3885 +relay.dreamith.to,43.6532,-79.3832 +relay-nl.zombi.cloudrodion.com,50.8943,6.06237 +nostrcity-club.fly.dev,48.8566,2.35222 +srtrelay.c-stellar.net,43.6532,-79.3832 +relay.sharegap.net,43.6532,-79.3832 +relay.nostrzh.org,43.6532,-79.3832 +nostr.overmind.lol,43.6532,-79.3832 +relay.nostriches.club,43.6532,-79.3832 +nostr.red5d.dev,43.6532,-79.3832 +relay.purplefrog.cloud,35.6916,139.768 +relay.cosmicbolt.net,37.3986,-121.964 +relay-dev.satlantis.io,40.8302,-74.1299 +speakeasy.cellar.social,49.4543,11.0746 +relay.seq1.net,43.6532,-79.3832 +relay-freeharmonypeople.space,38.7223,-9.13934 +top.testrelay.top,43.6532,-79.3832 +relay.nostr.place,32.7767,-96.797 +holland-excited-charming-experiencing.trycloudflare.com,43.6532,-79.3832 +relay.cyphernomad.com,60.1699,24.9384 +relay.decentnewsroom.com,50.4754,12.3683 +spookstr2.nostr1.com,38.6327,-90.1961 +relay.wavefunc.live,41.8781,-87.6298 +wot.dtonon.com,43.6532,-79.3832 +nostr.simplex.icu,50.1109,8.68213 +relay.typedcypher.com,51.5072,-0.127586 +relay.bebond.net,43.6532,-79.3832 +nostr.notribe.net,40.8302,-74.1299 +0x-nostr-relay.fly.dev,48.8566,2.35222 +relay.samcorn.synology.me,39.2904,-76.6122 diff --git a/scripts/check-just-clean-safety.sh b/scripts/check-just-clean-safety.sh deleted file mode 100644 index ca241119..00000000 --- a/scripts/check-just-clean-safety.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$repo_root" - -tracked_justfiles="$(git ls-files | awk 'tolower($0) == "justfile"')" -tracked_justfile_count="$(printf '%s\n' "$tracked_justfiles" | awk 'NF { count++ } END { print count + 0 }')" -if [[ $tracked_justfile_count -ne 1 || $tracked_justfiles != "Justfile" ]]; then - echo "Expected exactly one tracked canonical Justfile; found: ${tracked_justfiles:-none}" >&2 - exit 1 -fi - -if ! grep -Fxq 'clean:' Justfile; then - echo "Clean recipe must not depend on another recipe" >&2 - exit 1 -fi - -clean_recipe="$({ - awk ' - /^clean:/ { in_clean = 1; next } - in_clean && /^[^[:space:]]/ { exit } - in_clean { print } - ' Justfile -})" - -if [[ -z ${clean_recipe//[[:space:]]/} ]]; then - echo "Justfile clean recipe is missing or empty" >&2 - exit 1 -fi - -if ! grep -Fxq 'derived_data := ".DerivedData"' Justfile; then - echo "Derived data path must remain the ignored repo-local .DerivedData directory" >&2 - exit 1 -fi - -clean_forbidden='git[[:space:]]+(checkout|restore|reset|clean)|(^|[[:space:]])(cp|mv)([[:space:]]|$)|bitchat\.xcodeproj|project\.pbxproj|Info\.plist|LaunchScreen|project\.yml|Configs/' -if grep -Eiq "$clean_forbidden" <<<"$clean_recipe"; then - echo "Unsafe source/configuration mutation found in the clean recipe:" >&2 - grep -Ein "$clean_forbidden" <<<"$clean_recipe" >&2 - exit 1 -fi - -if ! grep -Fq 'rm -rf -- "{{derived_data}}" ".build"' <<<"$clean_recipe"; then - echo "Clean recipe must remain limited to the declared repo-local artifact paths" >&2 - exit 1 -fi - -clean_rm_count="$(grep -Ec '^[[:space:]]*@?rm[[:space:]]+-rf([[:space:]]|$)' <<<"$clean_recipe" || true)" -if [[ $clean_rm_count -ne 1 ]]; then - echo "Clean recipe must contain exactly one recursive removal command" >&2 - exit 1 -fi - -expected_clean_recipe=' @echo "Cleaning repo-local build artifacts..." - @rm -rf -- "{{derived_data}}" ".build" - @echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"' -if [[ $clean_recipe != "$expected_clean_recipe" ]]; then - echo "Clean recipe contains commands outside the reviewed artifact-only implementation" >&2 - exit 1 -fi - -file_forbidden='git[[:space:]]+(checkout|restore|reset|clean)|rm[[:space:]]+-rf[^#]*(bitchat\.xcodeproj|bitchat/|Configs/)|LaunchScreen\.storyboard\.ios|project\.pbxproj\.backup|Info\.plist\.backup' -if grep -Ein "$file_forbidden" Justfile; then - echo "Unsafe tracked-file recovery/deletion logic found in Justfile" >&2 - exit 1 -fi - -echo "Justfile clean safety check passed" diff --git a/scripts/check-perf-floors.sh b/scripts/check-perf-floors.sh deleted file mode 100755 index b51eb390..00000000 --- a/scripts/check-perf-floors.sh +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env bash -# -# check-perf-floors.sh — order-of-magnitude performance regression gate. -# -# Parses the `PERF[name]: N unit/sec ...` lines that PerformanceBaselineTests -# prints into captured test output and fails if any benchmark's throughput is -# below its floor from bitchatTests/Performance/perf-floors.json. -# -# Floor philosophy (see the floors file): floors sit at ~25% of locally -# measured throughput, so they catch algorithmic regressions (O(n) -> O(n^2)), -# never runner variance. Raise floors deliberately after intentional -# improvements; never tune them to chase noise. -# -# Retry-on-noise: even generous floors can be dipped under by a saturated -# runner (observed: gcs.buildAndDecode at 85% of floor on a loaded GitHub -# macOS runner). When a benchmark lands below its floor, the gate re-runs the -# benchmark suite — appending to the same PERF log — and keeps each -# benchmark's BEST observed value across attempts. Runner noise clears on a -# retry; a real algorithmic regression stays below floor on every attempt and -# still fails. Floors themselves are never lowered by this mechanism. -# -# Usage: scripts/check-perf-floors.sh [floors-file] -# -# Environment: -# BITCHAT_PERF_GATE_ATTEMPTS total measurement attempts (default 3) -# BITCHAT_PERF_REMEASURE_CMD command run to re-measure on a below-floor -# result (default: swift test --quiet -# --filter PerformanceBaselineTests). The -# command runs with BITCHAT_PERF_LOG pointed at -# the output file so new PERF lines append. -# -# Skips gracefully (exit 0) when: -# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or -# - the output contains no PERF lines (e.g. package-only matrix entries). -# -# Fails when: -# - any benchmark reports throughput below its floor on every attempt -# (exit 1), or -# - PERF lines are present but a floored benchmark is missing — a -# silently-dropped benchmark must be an explicit floors-file change and -# is not retried (exit 3). - -set -euo pipefail - -if [[ $# -lt 1 ]]; then - echo "usage: $0 [floors-file]" >&2 - exit 2 -fi - -OUTPUT_FILE="$1" -FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}" -MAX_ATTEMPTS="${BITCHAT_PERF_GATE_ATTEMPTS:-3}" -REMEASURE_CMD="${BITCHAT_PERF_REMEASURE_CMD:-swift test --quiet --filter PerformanceBaselineTests}" - -if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then - echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate." - exit 0 -fi - -if [[ ! -f "$OUTPUT_FILE" ]]; then - echo "perf-floors: output file '$OUTPUT_FILE' not found — skipping gate." >&2 - exit 0 -fi - -if [[ ! -f "$FLOORS_FILE" ]]; then - echo "perf-floors: floors file '$FLOORS_FILE' not found." >&2 - exit 2 -fi - -if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then - echo "perf-floors: no PERF lines in '$OUTPUT_FILE' — skipping gate." - exit 0 -fi - -# Absolute path so re-measurement appends to the same file regardless of the -# working directory the test process runs in. -case "$OUTPUT_FILE" in - /*) ;; - *) OUTPUT_FILE="$(pwd)/$OUTPUT_FILE" ;; -esac - -# Exit codes: 0 = all floors met, 1 = below floor (retryable — noise vs -# regression undecided), 3 = floored benchmark missing (not retryable). -check_floors() { - OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF' -import json -import os -import re -import sys - -output_file = os.environ["OUTPUT_FILE"] -floors_file = os.environ["FLOORS_FILE"] - -with open(floors_file) as f: - floors = json.load(f)["floors"] - -# PERF[delivery.storeUpdate]: 158862 updates/sec (avg 3.147 ms per pass of 500, 10 passes) -pattern = re.compile(r"PERF\[([^\]]+)\]:\s*([0-9]+(?:\.[0-9]+)?)\s*(\S+)/sec") - -measured = {} -with open(output_file, errors="replace") as f: - for line in f: - m = pattern.search(line) - if m: - # Keep the BEST reported value: measurement retries append to the - # same log, and a healthy benchmark only needs to clear its floor - # once — a real regression never does. - name, value, unit = m.group(1), float(m.group(2)), m.group(3) - if name not in measured or value > measured[name][0]: - measured[name] = (value, unit) - -below_floor = [] -missing = [] -print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)") -for name in sorted(set(floors) | set(measured)): - floor = floors.get(name) - if name not in measured: - missing.append( - f" MISSING {name}: floored benchmark reported no PERF line " - f"(removed/renamed? update perf-floors.json in the same change)") - continue - value, unit = measured[name] - if floor is None: - print(f" NO-FLOOR {name}: {value:.0f} {unit}/sec (consider adding a floor)") - continue - status = "OK" if value >= floor else "BELOW" - line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})" - print(line) - if value < floor: - below_floor.append( - f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} " - f"({value / floor * 100:.0f}% of floor)") - -if missing: - print("\nperf-floors: FAILED — floored benchmark(s) missing from the output:") - print("\n".join(missing + below_floor)) - sys.exit(3) - -if below_floor: - print("\nperf-floors: below floor — order-of-magnitude-class regression suspected:") - print("\n".join(below_floor)) - print("\nFloors are ~25% of healthy local throughput; falling below one means an") - print("algorithmic regression, not runner noise. If the change is intentional,") - print("update bitchatTests/Performance/perf-floors.json deliberately.") - sys.exit(1) - -print("perf-floors: all benchmarks at or above their floors.") -PYEOF -} - -attempt=1 -while true; do - gate_status=0 - check_floors || gate_status=$? - - case "$gate_status" in - 0) - exit 0 - ;; - 1) - # Below floor: retry to separate runner noise from regression. - ;; - *) - # Missing benchmark or parse/setup error: re-measuring can't help. - exit "$gate_status" - ;; - esac - - if (( attempt >= MAX_ATTEMPTS )); then - echo "perf-floors: still below floor after $attempt measurement attempt(s) — treating as a real regression." >&2 - exit 1 - fi - - attempt=$((attempt + 1)) - echo "perf-floors: re-measuring (attempt $attempt of $MAX_ATTEMPTS) to separate runner noise from a real regression." - # Word splitting of REMEASURE_CMD is deliberate: it is a command line. - if ! BITCHAT_PERF_LOG="$OUTPUT_FILE" $REMEASURE_CMD; then - echo "perf-floors: re-measurement command failed: $REMEASURE_CMD" >&2 - exit 1 - fi -done diff --git a/scripts/generate-mac-appicon.swift b/scripts/generate-mac-appicon.swift deleted file mode 100644 index fa98934a..00000000 --- a/scripts/generate-mac-appicon.swift +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env swift -// Generates macOS app-icon PNGs from a square 1024x1024 source image. -// -// macOS does not mask icons at render time the way iOS does, so the rounded -// rectangle must be baked into the artwork. This applies Apple's Big Sur icon -// grid: an 824x824 rounded-rect body (corner radius 185.4 @1024) centered on a -// transparent 1024 canvas, with the standard subtle drop shadow. -// -// Usage: swift scripts/generate-mac-appicon.swift -// e.g. swift scripts/generate-mac-appicon.swift icon_1024x1024.png bitchat/Assets.xcassets/AppIcon.appiconset icon - -import AppKit -import UniformTypeIdentifiers - -guard CommandLine.arguments.count == 4 else { - FileHandle.standardError.write(Data("usage: generate-mac-appicon.swift \n".utf8)) - exit(1) -} - -let sourcePath = CommandLine.arguments[1] -let outDir = URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true) -let prefix = CommandLine.arguments[3] - -guard let dataProvider = CGDataProvider(url: URL(fileURLWithPath: sourcePath) as CFURL), - let source = CGImage(pngDataProviderSource: dataProvider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { - FileHandle.standardError.write(Data("error: could not read PNG at \(sourcePath)\n".utf8)) - exit(1) -} - -let sRGB = CGColorSpace(name: CGColorSpace.sRGB)! - -func render(pixels: Int) -> CGImage { - let ctx = CGContext( - data: nil, width: pixels, height: pixels, - bitsPerComponent: 8, bytesPerRow: 0, space: sRGB, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - )! - let side = CGFloat(pixels) - let inset = side * 100.0 / 1024.0 - let body = CGRect(x: inset, y: inset, width: side - 2 * inset, height: side - 2 * inset) - let radius = side * 185.4 / 1024.0 - let path = CGPath(roundedRect: body, cornerWidth: radius, cornerHeight: radius, transform: nil) - - ctx.saveGState() - ctx.setShadow( - offset: CGSize(width: 0, height: -side * 10.0 / 1024.0), - blur: side * 20.0 / 1024.0, - color: CGColor(colorSpace: sRGB, components: [0, 0, 0, 0.3]) - ) - ctx.addPath(path) - ctx.setFillColor(CGColor(colorSpace: sRGB, components: [0, 0, 0, 1])!) - ctx.fillPath() - ctx.restoreGState() - - ctx.saveGState() - ctx.addPath(path) - ctx.clip() - ctx.interpolationQuality = .high - ctx.draw(source, in: body) - ctx.restoreGState() - - return ctx.makeImage()! -} - -func writePNG(_ image: CGImage, to url: URL) { - let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)! - CGImageDestinationAddImage(dest, image, nil) - guard CGImageDestinationFinalize(dest) else { - FileHandle.standardError.write(Data("error: failed to write \(url.path)\n".utf8)) - exit(1) - } -} - -// (point size, scale) for every mac slot in an appiconset -let slots: [(Int, Int)] = [(16, 1), (16, 2), (32, 1), (32, 2), (128, 1), (128, 2), (256, 1), (256, 2), (512, 1), (512, 2)] -for (points, scale) in slots { - let suffix = scale == 1 ? "" : "@\(scale)x" - let url = outDir.appendingPathComponent("\(prefix)_\(points)x\(points)\(suffix).png") - writePNG(render(pixels: points * scale), to: url) - print("wrote \(url.lastPathComponent)") -} diff --git a/scripts/tests/test_fetch_georelays_workflow.py b/scripts/tests/test_fetch_georelays_workflow.py deleted file mode 100644 index 57c5d81d..00000000 --- a/scripts/tests/test_fetch_georelays_workflow.py +++ /dev/null @@ -1,61 +0,0 @@ -import re -from pathlib import Path -import unittest - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/fetch_georelays.yml" - - -class FetchGeoRelaysWorkflowTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.workflow = WORKFLOW_PATH.read_text(encoding="utf-8") - - def test_write_capable_checkout_action_is_immutable(self) -> None: - checkout = re.search(r"uses: actions/checkout@([0-9a-f]+)", self.workflow) - self.assertIsNotNone(checkout) - self.assertRegex(checkout.group(1), r"^[0-9a-f]{40}$") - self.assertIn("persist-credentials: false", self.workflow) - - def test_pr_failure_has_single_issue_fallback_with_review_metadata(self) -> None: - required_fragments = [ - "issues: write", - "TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request", - "gh pr create", - "gh issue create", - "gh issue edit", - "compare/main...${UPDATE_BRANCH}?expand=1", - "Upstream commit: $SOURCE_COMMIT", - "Data rows: $DATA_ROWS", - "Unique normalized relays: $UNIQUE_RELAYS", - "SHA-256: $DATA_SHA256", - '[[ -n "$issue_url" ]]', - ] - for fragment in required_fragments: - with self.subTest(fragment=fragment): - self.assertIn(fragment, self.workflow) - - confirmed = self.workflow.index('[[ -n "$issue_url" ]]') - success_summary = self.workflow.index( - "Published GeoRelay tracking issue fallback: $issue_url" - ) - self.assertLess(confirmed, success_summary) - - def test_obsolete_review_state_is_cleaned_without_pushing_main(self) -> None: - self.assertIn("gh pr close", self.workflow) - self.assertIn("gh issue close", self.workflow) - self.assertIn('git push origin --delete "$UPDATE_BRANCH"', self.workflow) - self.assertIn('git switch -C "$UPDATE_BRANCH"', self.workflow) - self.assertNotIn("git push origin main", self.workflow) - self.assertNotIn("git push --force origin main", self.workflow) - - def test_workflow_runs_all_validator_tests(self) -> None: - self.assertIn( - 'python3 -m unittest discover -s scripts/tests -p "test_*.py" -v', - self.workflow, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/tests/test_validate_georelays.py b/scripts/tests/test_validate_georelays.py deleted file mode 100644 index faf63834..00000000 --- a/scripts/tests/test_validate_georelays.py +++ /dev/null @@ -1,186 +0,0 @@ -import tempfile -from pathlib import Path -import sys -import unittest - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -import validate_georelays as validator - - -def csv_bytes(rows: list[str]) -> bytes: - return ("Relay URL,Latitude,Longitude\n" + "\n".join(rows) + "\n").encode() - - -class ValidateGeoRelaysTests(unittest.TestCase): - def test_validates_and_deduplicates_secure_relay_addresses(self) -> None: - data = csv_bytes( - [ - "relay.example.com,10,20", - "wss://relay.example.com:443/,10,20", - "https://second.example.org,11,21", - ] - ) - - summary = validator.validate_bytes(data, minimum_unique_relays=2) - - self.assertEqual(summary.data_rows, 3) - self.assertEqual(summary.unique_relays, 2) - - def test_rejects_insecure_or_non_host_relay_urls(self) -> None: - bad_addresses = [ - "http://relay.example.com", - "ws://relay.example.com", - "wss://user@relay.example.com", - "wss://relay.example.com/path", - "wss://relay.example.com?", - "wss://relay.example.com#", - "relay.example.com:0", - "relay.example.com:99999", - "localhost", - "127.0.0.1", - "relay_example.com", - "relay\u202e.example.com", - ] - - for address in bad_addresses: - with self.subTest(address=address): - with self.assertRaises(validator.ValidationError): - validator.validate_bytes( - csv_bytes([f"{address},10,20"]), - minimum_unique_relays=1, - ) - - def test_rejects_malformed_rows_and_unsafe_coordinates(self) -> None: - bad_rows = [ - "relay.example.com,10", - "relay.example.com,NaN,20", - "relay.example.com,1_0,20", - "relay.example.com,\u0661\u0660,20", - "relay.example.com,\uff11\uff10,20", - "relay.example.com,91,20", - "relay.example.com,10,-181", - "relay.example.com,10,20,extra", - '"relay.example.com",10,20', - ] - - for row in bad_rows: - with self.subTest(row=row): - with self.assertRaises(validator.ValidationError): - validator.validate_bytes(csv_bytes([row]), minimum_unique_relays=1) - - def test_accepts_ascii_coordinate_forms_supported_by_swift_double(self) -> None: - summary = validator.validate_bytes( - csv_bytes( - [ - "one.example.com,+1,-.5", - "two.example.com,1.e1,2E+1", - "three.example.com,01,20.", - ] - ), - minimum_unique_relays=3, - ) - - self.assertEqual(summary.unique_relays, 3) - - def test_rejects_conflicts_limits_and_large_baseline_deltas(self) -> None: - with self.assertRaises(validator.ValidationError): - validator.validate_bytes( - csv_bytes(["relay.example.com,10,20", "relay.example.com,11,21"]), - minimum_unique_relays=1, - ) - with self.assertRaises(validator.ValidationError): - validator.validate_bytes(b"x" * 20, maximum_bytes=10, minimum_unique_relays=1) - with self.assertRaises(validator.ValidationError): - validator.validate_bytes( - csv_bytes(["one.example.com,1,1", "two.example.com,2,2"]), - minimum_unique_relays=3, - ) - - baseline = csv_bytes( - [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(120)] - ) - shrunken = csv_bytes( - [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(59)] - ) - with self.assertRaises(validator.ValidationError): - validator.validate_update(shrunken, baseline) - - smaller_baseline = csv_bytes( - [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)] - ) - expanded = csv_bytes( - [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(121)] - ) - with self.assertRaises(validator.ValidationError): - validator.validate_update(expanded, smaller_baseline) - - def test_update_requires_exact_normalized_baseline_entry_overlap(self) -> None: - baseline_rows = [ - f"relay-{index}.example.com,{index % 80},{index % 170}" - for index in range(60) - ] - baseline = csv_bytes(baseline_rows) - disjoint = csv_bytes( - [ - f"attacker-{index}.example.com,{index % 80},{index % 170}" - for index in range(60) - ] - ) - rewritten_coordinates = csv_bytes( - [ - f"relay-{index}.example.com,{(index % 80) + 0.5},{index % 170}" - for index in range(60) - ] - ) - - for candidate in (disjoint, rewritten_coordinates): - with self.subTest(candidate=candidate[:80]): - with self.assertRaisesRegex( - validator.ValidationError, - "exact relay-coordinate entries", - ): - validator.validate_update(candidate, baseline) - - half_retained = csv_bytes( - [ - f"wss://relay-{index}.example.com:443/,{index % 80},{index % 170}" - for index in range(30) - ] - + [ - f"replacement-{index}.example.com,{index % 80},{index % 170}" - for index in range(30) - ] - ) - summary = validator.validate_update(half_retained, baseline) - self.assertEqual(summary.unique_relays, 60) - - def test_cli_copies_only_validated_data_and_emits_review_metadata(self) -> None: - rows = [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)] - data = csv_bytes(rows) - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - candidate = root / "candidate.csv" - baseline = root / "baseline.csv" - output = root / "output.csv" - github_output = root / "github-output.txt" - candidate.write_bytes(data) - baseline.write_bytes(data) - - result = validator.main( - [ - "--input", str(candidate), - "--baseline", str(baseline), - "--output", str(output), - "--github-output", str(github_output), - ] - ) - - self.assertEqual(result, 0) - self.assertEqual(output.read_bytes(), data) - metadata = github_output.read_text() - self.assertIn("unique_relays=60", metadata) - self.assertIn("sha256=", metadata) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/validate_georelays.py b/scripts/validate_georelays.py deleted file mode 100644 index 513a4dc0..00000000 --- a/scripts/validate_georelays.py +++ /dev/null @@ -1,271 +0,0 @@ -#!/usr/bin/env python3 -"""Strict validator for the reviewed georelay CSV update workflow.""" - -from __future__ import annotations - -import argparse -import csv -import hashlib -import io -import math -import re -from dataclasses import dataclass -from pathlib import Path -import sys -import unicodedata -from urllib.parse import urlsplit - - -MAX_BYTES = 512 * 1024 -MAX_ROWS = 5_000 -MAX_UNIQUE_RELAYS = 5_000 -MIN_UNIQUE_RELAYS = 50 -MIN_BASELINE_FRACTION = 0.5 -MAX_BASELINE_MULTIPLIER = 2.0 -EXPECTED_HEADER = ("relay url", "latitude", "longitude") -ASCII_DECIMAL_PATTERN = re.compile( - r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z" -) - - -class ValidationError(ValueError): - pass - - -@dataclass(frozen=True) -class ValidationSummary: - data_rows: int - unique_relays: int - sha256: str - - -@dataclass(frozen=True) -class _ValidatedDataset: - summary: ValidationSummary - entries: frozenset[tuple[str, float, float]] - - -def _has_disallowed_control(value: str) -> bool: - return any( - unicodedata.category(character) in {"Cc", "Cf"} - and character not in {"\r", "\n", "\t"} - for character in value - ) - - -def normalize_relay_address(raw_value: str) -> str: - value = raw_value.strip() - if not value or _has_disallowed_control(value): - raise ValidationError("relay address is empty or contains control characters") - # urlsplit cannot distinguish an absent query/fragment from an explicitly - # empty one. Reject the delimiters themselves so this validator matches - # URLComponents in the client and reviewed data cannot fail closed there. - if "?" in value or "#" in value: - raise ValidationError(f"relay query or fragment is not allowed: {value}") - - candidate = value if "://" in value else f"wss://{value}" - try: - parsed = urlsplit(candidate) - port = parsed.port - except ValueError as error: - raise ValidationError(f"invalid relay URL: {value}") from error - - if parsed.scheme.lower() not in {"wss", "https"}: - raise ValidationError(f"relay must use wss/https or a bare hostname: {value}") - if parsed.username is not None or parsed.password is not None: - raise ValidationError(f"relay credentials are not allowed: {value}") - if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: - raise ValidationError(f"relay path, query, or fragment is not allowed: {value}") - - host = (parsed.hostname or "").lower() - if not host or len(host) > 253 or not host.isascii(): - raise ValidationError(f"relay hostname is missing or non-ASCII: {value}") - if host.endswith(".") or host == "localhost" or host.endswith((".localhost", ".local", ".internal")): - raise ValidationError(f"local or absolute relay hostname is not allowed: {value}") - - labels = host.split(".") - if len(labels) < 2 or all(label.isdigit() for label in labels): - raise ValidationError(f"relay must use a public DNS hostname: {value}") - for label in labels: - if not 1 <= len(label) <= 63: - raise ValidationError(f"invalid DNS label length: {value}") - if label[0] == "-" or label[-1] == "-": - raise ValidationError(f"DNS labels cannot start or end with '-': {value}") - if any(character not in "abcdefghijklmnopqrstuvwxyz0123456789-" for character in label): - raise ValidationError(f"invalid DNS hostname character: {value}") - - if port is not None and not 1 <= port <= 65_535: - raise ValidationError(f"invalid relay port: {value}") - if port in {None, 443}: - return host - return f"{host}:{port}" - - -def _validated_dataset( - data: bytes, - *, - minimum_unique_relays: int = MIN_UNIQUE_RELAYS, - maximum_bytes: int = MAX_BYTES, - maximum_rows: int = MAX_ROWS, - maximum_unique_relays: int = MAX_UNIQUE_RELAYS, -) -> _ValidatedDataset: - if not data or len(data) > maximum_bytes: - raise ValidationError(f"CSV must contain 1..{maximum_bytes} bytes") - - try: - text = data.decode("utf-8") - except UnicodeDecodeError as error: - raise ValidationError("CSV is not valid UTF-8") from error - if text.startswith("\ufeff"): - raise ValidationError("UTF-8 BOM is not allowed") - if _has_disallowed_control(text): - raise ValidationError("CSV contains disallowed control characters") - # Runtime intentionally implements the fixed three-field schema without - # general CSV quoting. Reject quoted variants here so reviewed workflow - # output and client-side validation cannot disagree. - if '"' in text: - raise ValidationError("quoted CSV fields are not allowed") - - reader = csv.reader(io.StringIO(text, newline=""), strict=True) - try: - header = next(reader) - except (StopIteration, csv.Error) as error: - raise ValidationError("CSV header is missing") from error - normalized_header = tuple(field.strip().lower() for field in header) - if normalized_header != EXPECTED_HEADER: - raise ValidationError(f"unexpected CSV header: {header!r}") - - data_rows = 0 - relays: dict[str, tuple[float, float]] = {} - try: - for row in reader: - if not row or all(not field.strip() for field in row): - continue - data_rows += 1 - if data_rows > maximum_rows: - raise ValidationError(f"CSV exceeds {maximum_rows} data rows") - if len(row) != 3: - raise ValidationError(f"row {reader.line_num} must contain exactly 3 columns") - - address = normalize_relay_address(row[0]) - latitude_text = row[1].strip() - longitude_text = row[2].strip() - if not ASCII_DECIMAL_PATTERN.fullmatch(latitude_text) or not ASCII_DECIMAL_PATTERN.fullmatch(longitude_text): - raise ValidationError( - f"row {reader.line_num} coordinates must be ASCII decimal numbers" - ) - latitude = float(latitude_text) - longitude = float(longitude_text) - if not math.isfinite(latitude) or not -90 <= latitude <= 90: - raise ValidationError(f"row {reader.line_num} latitude is out of range") - if not math.isfinite(longitude) or not -180 <= longitude <= 180: - raise ValidationError(f"row {reader.line_num} longitude is out of range") - - coordinates = (latitude, longitude) - previous = relays.get(address) - if previous is not None and previous != coordinates: - raise ValidationError(f"relay {address} has conflicting coordinates") - relays[address] = coordinates - if len(relays) > maximum_unique_relays: - raise ValidationError(f"CSV exceeds {maximum_unique_relays} unique relays") - except csv.Error as error: - raise ValidationError(f"malformed CSV near line {reader.line_num}") from error - - if len(relays) < minimum_unique_relays: - raise ValidationError( - f"CSV has {len(relays)} unique relays; minimum is {minimum_unique_relays}" - ) - - return _ValidatedDataset( - summary=ValidationSummary( - data_rows=data_rows, - unique_relays=len(relays), - sha256=hashlib.sha256(data).hexdigest(), - ), - entries=frozenset( - (address, coordinates[0], coordinates[1]) - for address, coordinates in relays.items() - ), - ) - - -def validate_bytes( - data: bytes, - *, - minimum_unique_relays: int = MIN_UNIQUE_RELAYS, - maximum_bytes: int = MAX_BYTES, - maximum_rows: int = MAX_ROWS, - maximum_unique_relays: int = MAX_UNIQUE_RELAYS, -) -> ValidationSummary: - return _validated_dataset( - data, - minimum_unique_relays=minimum_unique_relays, - maximum_bytes=maximum_bytes, - maximum_rows=maximum_rows, - maximum_unique_relays=maximum_unique_relays, - ).summary - - -def validate_update(candidate: bytes, baseline: bytes) -> ValidationSummary: - baseline_dataset = _validated_dataset(baseline, minimum_unique_relays=1) - candidate_dataset = _validated_dataset(candidate) - baseline_summary = baseline_dataset.summary - candidate_summary = candidate_dataset.summary - - minimum_from_baseline = math.ceil( - baseline_summary.unique_relays * MIN_BASELINE_FRACTION - ) - maximum_from_baseline = math.floor( - baseline_summary.unique_relays * MAX_BASELINE_MULTIPLIER - ) - if candidate_summary.unique_relays < minimum_from_baseline: - raise ValidationError( - "candidate loses more than half of the baseline's unique relays " - f"({candidate_summary.unique_relays} < {minimum_from_baseline})" - ) - if candidate_summary.unique_relays > maximum_from_baseline: - raise ValidationError( - "candidate more than doubles the baseline's unique relays " - f"({candidate_summary.unique_relays} > {maximum_from_baseline})" - ) - - retained_entries = len(baseline_dataset.entries & candidate_dataset.entries) - if retained_entries < minimum_from_baseline: - raise ValidationError( - "candidate retains fewer than half of the baseline's exact relay-coordinate entries " - f"({retained_entries} < {minimum_from_baseline})" - ) - return candidate_summary - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--input", required=True, type=Path) - parser.add_argument("--baseline", required=True, type=Path) - parser.add_argument("--output", required=True, type=Path) - parser.add_argument("--github-output", type=Path) - args = parser.parse_args(argv) - - try: - candidate = args.input.read_bytes() - baseline = args.baseline.read_bytes() - summary = validate_update(candidate, baseline) - args.output.write_bytes(candidate) - if args.github_output is not None: - with args.github_output.open("a", encoding="utf-8") as output: - output.write(f"data_rows={summary.data_rows}\n") - output.write(f"unique_relays={summary.unique_relays}\n") - output.write(f"sha256={summary.sha256}\n") - except (OSError, ValidationError) as error: - print(f"georelay validation failed: {error}", file=sys.stderr) - return 1 - - print( - f"validated {summary.unique_relays} unique relays across " - f"{summary.data_rows} rows (sha256 {summary.sha256})" - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())