Merge remote-tracking branch 'origin/master' into nd/socks-reconnect-backoff

This commit is contained in:
Narasimha-sc committed 2026-10-19 12:32:25 +00:00
commit d421ccab67
624 files changed
+42186 -4961

No files matched your search

+5 -15
View File
@@ -10,21 +10,19 @@ inputs:
github_ref:
required: true
description: "Git reference"
os:
required: true
description: "Target OS"
cache_path:
required: false
default: "~/.cabal/store"
description: "Cache path"
cabal_ver:
required: false
default: 3.10.2.0
description: "GHC version to install"
outputs:
cabal-store:
description: "Cabal store path"
value: ${{ steps.setup.outputs.cabal-store }}
runs:
using: "composite"
steps:
- name: Setup Haskell
id: setup
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ inputs.ghc_ver }}
@@ -37,11 +35,3 @@ runs:
distribution: 'corretto'
java-version: ${{ inputs.java_ver }}
cache: 'gradle'
- name: Restore cached build
uses: actions/cache@v4
with:
path: |
${{ inputs.cache_path }}
dist-newstyle
key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
+30
View File
@@ -0,0 +1,30 @@
name: android-emu
# A placeholder, so that the Android app test workflow can be started by hand on a branch that has its full
# version: GitHub dispatches only workflows whose file is on the default branch. On master it does nothing.
on:
workflow_dispatch:
inputs:
claude:
description: Also run the Claude job
type: boolean
default: false
backend:
description: Also start the test backend
type: boolean
default: false
release:
description: Also test the latest release's onboarding
type: boolean
default: false
permissions:
contents: read
jobs:
placeholder:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Nothing to run here
run: echo "This branch has only the placeholder of android-emu; start it on a branch with the full workflow."
+1031 -165
View File
File diff suppressed because it is too large. Load diff
+481
View File
@@ -0,0 +1,481 @@
name: desktop-linux
# A push runs the jobs only when its head commit's message asks for them: see scripts/ci/desktop-linux/README.md.
on:
push:
branches:
# only f/ branches, so other branches' pull requests don't list skipped runs; elsewhere, run it by hand
- "f/*"
# a squash merge's message lists the merged commits' subjects, with their tags
- "!master"
- "!stable"
workflow_dispatch:
inputs:
claude:
description: Also run the Claude job, which spends the Claude subscription's usage limits
type: boolean
default: false
backend:
description: Also start the test backend
type: boolean
default: false
release:
description: Also test the latest released app, to tell a branch's failure from the runner's
type: boolean
default: false
permissions:
contents: read
actions: read
concurrency:
# a push that asks for no run must not cancel one that does
group: desktop-linux-${{ github.ref }}-${{ (github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:desktop]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]')) && 'requested' || github.run_id }}
cancel-in-progress: true
env:
GHC_VER: 9.6.3
CABAL_VER: 3.10.2.0
JAVA_VER: 17
CORE_PKG: desktop-core-linux-x86_64.tar.gz
BACKEND_PKG: desktop-linux-backend.tar.gz
jobs:
plan:
name: choose jobs, find earlier builds
# the concurrency group repeats this test, as it can't read job outputs
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.head_commit.message, '[app-ci]') ||
contains(github.event.head_commit.message, '[app-ci:desktop]') ||
contains(github.event.head_commit.message, '[claude-test]') ||
contains(github.event.head_commit.message, '[claude-test:desktop]')
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
run-claude: ${{ steps.jobs.outputs.claude }}
run-backend: ${{ steps.jobs.outputs.backend }}
run-release: ${{ steps.jobs.outputs.release }}
claude-backend: ${{ steps.scope.outputs.backend }}
core: ${{ steps.names.outputs.core }}
core-run: ${{ steps.core.outputs.run-id }}
backend: ${{ steps.names.outputs.backend }}
backend-run: ${{ steps.backend.outputs.run-id }}
steps:
- name: Choose jobs
id: jobs
env:
CLAUDE: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }}
BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }}
RELEASE: ${{ inputs.release || contains(github.event.head_commit.message, '[ci-release]') }}
run: |
echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT"
echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT"
echo "release=$RELEASE" | tee -a "$GITHUB_OUTPUT"
- name: Checkout code
uses: actions/checkout@v6
- name: Claude scope
id: scope
if: steps.jobs.outputs.claude == 'true'
env:
BACKEND: ${{ steps.jobs.outputs.backend }}
run: |
backend=$(sed -n 's/^backend: *//p' scripts/ci/desktop-linux/claude-scope)
case "$backend" in
yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;;
*)
echo "::error::claude-scope must say backend: yes or backend: no"
exit 1
;;
esac
if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then
echo "::error::claude-scope says the Claude session uses the test backend: ask for it too"
exit 1
fi
# artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded
- name: Artifact names
id: names
run: |
echo "core=desktop-linux-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/desktop/build-lib-linux.sh', 'scripts/ci/desktop-linux/build-core.sh') }}" >> "$GITHUB_OUTPUT"
echo "backend=desktop-linux-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/desktop-linux/build-backend.sh') }}" >> "$GITHUB_OUTPUT"
- name: Find core library
id: core
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.core }}"
- name: Find backend
id: backend
if: steps.jobs.outputs.backend == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.backend }}"
backend:
name: test backend for the app
needs: plan
if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == ''
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
df -h /
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ env.GHC_VER }}
cabal-version: ${{ env.CABAL_VER }}
- name: Install libraries
run: |
sudo apt-get update
sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev
- name: Build backend
run: scripts/ci/desktop-linux/build-backend.sh ~/backend/${{ env.BACKEND_PKG }}
- name: Upload backend
uses: actions/upload-artifact@v7
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend/${{ env.BACKEND_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
core:
name: core library for Linux
needs: plan
if: needs.plan.outputs.core-run == ''
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
df -h /
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ env.GHC_VER }}
cabal-version: ${{ env.CABAL_VER }}
- name: Install libraries
run: |
sudo apt-get update
sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev patchelf
- name: Build core library
run: scripts/ci/desktop-linux/build-core.sh ~/core/${{ env.CORE_PKG }}
- name: Upload core library
uses: actions/upload-artifact@v7
with:
name: ${{ needs.plan.outputs.core }}
path: ~/core/${{ env.CORE_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
app:
name: desktop app under Xvfb
needs: [plan, core, backend]
# core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it
if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
submodules: recursive
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
df -h /
echo "ANDROID_HOME=$ANDROID_HOME"
ls "$ANDROID_HOME/platforms" "$ANDROID_HOME/build-tools" || true
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
- name: Download core library
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.core }}
path: ~/core
run-id: ${{ needs.plan.outputs.core-run || github.run_id }}
github-token: ${{ github.token }}
- name: Install core library
id: core
run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }}
- name: Download backend
if: needs.plan.outputs.run-backend == 'true'
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
id: backend
if: needs.plan.outputs.run-backend == 'true'
timeout-minutes: 30
run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
# the app and its tests are still built when the backend fails
- name: Setup Java
if: ${{ !cancelled() && steps.core.outcome == 'success' }}
uses: actions/setup-java@v5
with:
distribution: corretto
java-version: ${{ env.JAVA_VER }}
- name: Build app
id: build
if: ${{ !cancelled() && steps.core.outcome == 'success' }}
working-directory: apps/multiplatform
run: |
./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable
app=release/main/app/simplex
ls -la "$app/bin" "$app/lib/app/resources"
du -sh "$app"
LD_LIBRARY_PATH="$app/lib/app/resources" ldd "$app/lib/app/resources/libapp-lib.so"
# for the Claude job; a tarball keeps the app's file modes, which an artifact drops
- name: Pack app
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }}
run: tar -czf "$RUNNER_TEMP/desktop-app.tar.gz" -C apps/multiplatform/release/main/app simplex
- name: Upload app
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }}
uses: actions/upload-artifact@v7
with:
name: desktop-app
path: ${{ runner.temp }}/desktop-app.tar.gz
compression-level: 0
retention-days: 1
if-no-files-found: error
- name: Run desktop tests
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
working-directory: apps/multiplatform
run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :common:desktopTest
- name: Report desktop tests
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: scripts/ci/desktop-linux/test-summary.py apps/multiplatform/common/build/test-results/desktopTest
- name: Run smoke test
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome != 'failure' }}
env:
BACKEND: ${{ steps.backend.outcome }}
run: |
if [ "$BACKEND" = success ]; then
export NAMES_SERVER=$(cat "$RUNNER_TEMP/backend/names-server")
cases=backend
fi
scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$RUNNER_TEMP/smoke-test" ${cases:-}
- name: Stop test backend
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload smoke test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: desktop-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
- name: Upload desktop test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: desktop-test-report
path: |
apps/multiplatform/common/build/reports/tests/desktopTest
apps/multiplatform/common/build/test-results/desktopTest
retention-days: 3
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
uses: actions/upload-artifact@v7
with:
name: desktop-backend-logs
path: ${{ runner.temp }}/backend/*.log
retention-days: 3
if-no-files-found: warn
# Claude Code repeats the smoke test with the ui helper. Each run spends the Claude subscription's usage
# limits, so it runs only when asked for. The app-sim-claude environment's branch rule decides which branches
# may use the token.
claude:
name: Claude tests the desktop app
needs: [plan, app]
if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
environment:
name: app-sim-claude
deployment: false
concurrency:
group: desktop-linux-claude-${{ github.ref }}
cancel-in-progress: true
env:
# the same in both app workflows
CLAUDE_CODE_VERSION: 2.1.300
CLAUDE_MODEL: claude-haiku-5-5
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
- name: Download app
uses: actions/download-artifact@v8
with:
name: desktop-app
path: ${{ runner.temp }}
- name: Unpack app
run: |
mkdir -p "$RUNNER_TEMP/app"
tar -xzf "$RUNNER_TEMP/desktop-app.tar.gz" -C "$RUNNER_TEMP/app"
- name: Download backend
if: needs.plan.outputs.claude-backend == 'yes'
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
id: backend
if: needs.plan.outputs.claude-backend == 'yes'
timeout-minutes: 30
run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
# ui goes on every shell's PATH, where Claude's Bash tool finds it whatever environment it builds
- name: Install Claude Code and the ui command
run: |
npm install --prefix "$RUNNER_TEMP/claude-code" "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION"
echo "$RUNNER_TEMP/claude-code/node_modules/.bin" >> "$GITHUB_PATH"
printf '#!/bin/sh\nexec %s/scripts/ci/desktop-linux/ui "$@"\n' "$PWD" | sudo tee /usr/local/bin/ui
sudo chmod +x /usr/local/bin/ui
- name: Start the app
run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/app/simplex" "$RUNNER_TEMP/claude-test/screens" start
# Claude's Bash is limited to single ui commands by the guard hook in claude-test.sh. Claude Code's credential
# scrub stays off: on Linux it forces its whole Bash sandbox (bubblewrap, socat), which ui isn't set up for.
- name: Run Claude
id: claude
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1
run: scripts/ci/desktop-linux/claude-test.sh "$RUNNER_TEMP/claude-test" "$RUNNER_TEMP/claude-transcript.jsonl"
- name: Report Claude usage
if: ${{ !cancelled() && steps.claude.outcome != 'skipped' }}
run: scripts/ci/desktop-linux/claude-usage.py "$RUNNER_TEMP/claude-transcript.jsonl" "$RUNNER_TEMP/claude-usage.json"
- name: Stop test backend
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload Claude test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: desktop-claude-test
path: |
${{ runner.temp }}/claude-test
${{ runner.temp }}/claude-transcript.jsonl
${{ runner.temp }}/claude-transcript.jsonl.stderr
${{ runner.temp }}/claude-transcript.jsonl.guard.log
${{ runner.temp }}/claude-usage.json
retention-days: 7
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
uses: actions/upload-artifact@v7
with:
name: desktop-claude-backend-logs
path: ${{ runner.temp }}/backend/*.log
retention-days: 3
if-no-files-found: warn
# the latest release's onboarding, to tell a branch's own failure from the runner's
release:
name: released app under Xvfb
needs: plan
if: needs.plan.outputs.run-release == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
filter: tree:0
- name: Download released app
run: scripts/ci/desktop-linux/release-app.sh "$RUNNER_TEMP/release"
- name: Install UI tools
run: |
sudo apt-get update
sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr
dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr
- name: Run smoke test
run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/release/app/opt/simplex" "$RUNNER_TEMP/smoke-test" onboarding
- name: Upload smoke test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: desktop-release-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
+412
View File
@@ -0,0 +1,412 @@
name: ios-sim
on:
push:
branches:
# only f/ branches, so other branches' pull requests don't list skipped runs; elsewhere, run it by hand
- 'f/*'
# a squash merge's message lists the merged commits' subjects, with their tags
- '!master'
- '!stable'
workflow_dispatch:
inputs:
claude:
description: Also run the Claude session
type: boolean
default: false
backend:
description: Start the test backend
type: boolean
default: false
permissions:
contents: read
actions: read
concurrency:
# a push that asks for no run must not cancel one that does
group: ios-sim-${{ github.ref }}-${{ (github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:ios]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]')) && 'requested' || github.run_id }}
cancel-in-progress: true
env:
GHC_VER: 9.6.3
CABAL_VER: 3.10.2.0
CORE_PKG: pkg-ios-aarch64-swift-json.zip
BACKEND_PKG: ios-sim-backend.zip
HOMEBREW_NO_AUTO_UPDATE: 1
HOMEBREW_NO_INSTALL_CLEANUP: 1
# newer images add Command Line Tools whose macOS SDK the default Xcode's linker cannot read
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
jobs:
plan:
name: choose jobs, find earlier builds
# the concurrency group repeats this test, as it can't read job outputs
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.head_commit.message, '[app-ci]') ||
contains(github.event.head_commit.message, '[app-ci:ios]') ||
contains(github.event.head_commit.message, '[claude-test]') ||
contains(github.event.head_commit.message, '[claude-test:ios]')
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
run-claude: ${{ steps.jobs.outputs.claude }}
run-backend: ${{ steps.jobs.outputs.backend }}
claude-backend: ${{ steps.scope.outputs.backend }}
core: ${{ steps.names.outputs.core }}
core-run: ${{ steps.core.outputs.run-id }}
backend: ${{ steps.names.outputs.backend }}
backend-run: ${{ steps.backend.outputs.run-id }}
steps:
- name: Choose jobs
id: jobs
env:
CLAUDE: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') }}
BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }}
run: |
echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT"
echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT"
- name: Checkout code
uses: actions/checkout@v6
- name: Claude scope
id: scope
if: steps.jobs.outputs.claude == 'true'
env:
BACKEND: ${{ steps.jobs.outputs.backend }}
run: |
backend=$(sed -n 's/^backend: *//p' scripts/ci/ios-sim/claude-scope)
case "$backend" in
yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;;
*)
echo "::error::claude-scope must say backend: yes or backend: no"
exit 1
;;
esac
if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then
echo "::error::claude-scope says the Claude sessions use the test backend: ask for it too"
exit 1
fi
# artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded
- name: Artifact names
id: names
run: |
echo "core=ios-sim-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-core.sh') }}" >> "$GITHUB_OUTPUT"
echo "backend=ios-sim-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-backend.sh') }}" >> "$GITHUB_OUTPUT"
- name: Find core library
id: core
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.core }}"
- name: Find backend
id: backend
if: steps.jobs.outputs.backend == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.backend }}"
core:
name: core library for the iOS simulator
needs: plan
if: needs.plan.outputs.core-run == ''
runs-on: macos-26
timeout-minutes: 240
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
sw_vers
sysctl -n hw.ncpu hw.memsize
df -h /
xcodebuild -version
xcrun --show-sdk-path
ls /Library/Developer/CommandLineTools/SDKs || true
- name: Use the macOS 26 SDK
run: scripts/ci/ios-sim/use-macos26-sdk.sh
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ env.GHC_VER }}
cabal-version: ${{ env.CABAL_VER }}
- name: Install libraries
run: brew install openssl@3 libffi gmp
- name: Build core library
id: build
run: scripts/ci/ios-sim/build-core.sh ~/Downloads/${{ env.CORE_PKG }}
- name: Patch core library for the simulator
run: scripts/ci/ios-sim/prepare-libs.sh
- name: Upload core library
uses: actions/upload-artifact@v7
with:
name: ${{ needs.plan.outputs.core }}
path: ~/Downloads/${{ env.CORE_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
backend:
name: test backend for the app
needs: plan
if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == ''
runs-on: macos-26
timeout-minutes: 240
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
sw_vers
xcodebuild -version
xcrun --show-sdk-path
ls /Library/Developer/CommandLineTools/SDKs || true
- name: Use the macOS 26 SDK
run: scripts/ci/ios-sim/use-macos26-sdk.sh
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ env.GHC_VER }}
cabal-version: ${{ env.CABAL_VER }}
- name: Build backend
run: scripts/ci/ios-sim/build-backend.sh ~/Downloads/${{ env.BACKEND_PKG }}
- name: Upload backend
uses: actions/upload-artifact@v7
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/Downloads/${{ env.BACKEND_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
app:
name: app in the iOS simulator
needs: [plan, core, backend]
# core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it
if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }}
runs-on: macos-26
timeout-minutes: 120
env:
START_BACKEND: ${{ needs.plan.outputs.run-backend }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
sw_vers
df -h /
xcodebuild -version
xcrun --show-sdk-path
ls /Library/Developer/CommandLineTools/SDKs || true
xcrun simctl list runtimes
- name: Use the macOS 26 SDK
run: scripts/ci/ios-sim/use-macos26-sdk.sh
- name: Download core library
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.core }}
path: ~/Downloads
run-id: ${{ needs.plan.outputs.core-run || github.run_id }}
github-token: ${{ github.token }}
- name: Prepare libraries
run: |
scripts/ci/ios-sim/prepare-libs.sh
scripts/ios/update-pbxproj.sh
- name: Download backend
if: env.START_BACKEND == 'true'
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/Downloads
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
if: env.START_BACKEND == 'true'
timeout-minutes: 30
run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
- name: Build app
run: |
xcodebuild build -quiet \
-project apps/ios/SimpleX.xcodeproj \
-scheme "SimpleX (iOS)" \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath "$RUNNER_TEMP/DerivedData" \
-onlyUsePackageVersionsFromResolvedFile \
-skipPackagePluginValidation \
-skipMacroValidation \
ARCHS=arm64 \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY=- \
DEVELOPMENT_TEAM= \
PROVISIONING_PROFILE_SPECIFIER=
# for the Claude job; a tarball keeps the app's file modes, which an artifact drops
- name: Pack app
if: needs.plan.outputs.run-claude == 'true'
run: tar -czf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator" SimpleX.app
- name: Upload app
if: needs.plan.outputs.run-claude == 'true'
uses: actions/upload-artifact@v7
with:
name: ios-sim-app
path: ${{ runner.temp }}/SimpleX.app.tar.gz
compression-level: 0
retention-days: 1
if-no-files-found: error
- name: Install AXe
run: scripts/ci/ios-sim/install-axe.sh
- name: Run smoke test
run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator/SimpleX.app" "$RUNNER_TEMP/smoke-test"
- name: Stop test backend
if: ${{ !cancelled() && env.START_BACKEND == 'true' }}
run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload smoke test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: ios-sim-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && env.START_BACKEND == 'true' }}
uses: actions/upload-artifact@v7
with:
name: ios-sim-backend-logs
path: |
${{ runner.temp }}/backend/*.log
${{ runner.temp }}/backend/seed.md
retention-days: 3
if-no-files-found: warn
# The app-sim-claude environment's branch rule decides which branches may use the token.
claude:
name: Claude tests the iOS app
needs: [plan, app]
if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }}
runs-on: macos-26
timeout-minutes: 330
environment:
name: app-sim-claude
deployment: false
concurrency:
group: ios-sim-claude-${{ github.ref }}
cancel-in-progress: true
env:
# the same in both app workflows
CLAUDE_CODE_VERSION: 2.1.300
steps:
# the job holds the Claude token, so the checkout's GitHub token is not left in .git/config
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Show runner
run: |
sw_vers
xcodebuild -version
xcrun simctl list runtimes
- name: Use the macOS 26 SDK
run: scripts/ci/ios-sim/use-macos26-sdk.sh
- name: Download app
uses: actions/download-artifact@v8
with:
name: ios-sim-app
path: ${{ runner.temp }}
- name: Unpack app
run: tar -xzf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP"
- name: Download backend
if: needs.plan.outputs.claude-backend == 'yes'
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/Downloads
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
id: backend
if: needs.plan.outputs.claude-backend == 'yes'
timeout-minutes: 30
run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
- name: Install AXe
run: scripts/ci/ios-sim/install-axe.sh
- name: Run smoke test
run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/SimpleX.app" "$RUNNER_TEMP/claude-test/smoke-test"
- name: Install Claude Code
run: |
npm install -g "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION"
claude --version
- name: Run the device test plan with Claude
timeout-minutes: 240
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
BACKEND_DIR: ${{ steps.backend.outcome == 'success' && format('{0}/backend', runner.temp) || '' }}
run: scripts/ci/ios-sim/claude-test.sh "$RUNNER_TEMP/claude-test"
- name: Report Claude usage
if: ${{ always() }}
run: scripts/ci/ios-sim/claude-usage.sh "$RUNNER_TEMP/claude-test"
- name: Stop test backend
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload Claude test results
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: ios-sim-claude-test
path: ${{ runner.temp }}/claude-test
retention-days: 14
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
uses: actions/upload-artifact@v7
with:
name: ios-sim-claude-backend-logs
path: |
${{ runner.temp }}/backend/*.log
${{ runner.temp }}/backend/seed.md
retention-days: 3
if-no-files-found: warn
+74
View File
@@ -0,0 +1,74 @@
name: packages
on:
pull_request:
paths:
- "packages/simplex-chat-nodejs/**"
- "packages/simplex-chat-client/types/typescript/**"
- "packages/simplex-chat-python/**"
- ".github/workflows/packages.yml"
concurrency:
# build.yml and this workflow both run for a pull request that changes the core and the packages
group: ${{ github.workflow }}-packages-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
NPM_SCOPE: ${{ vars.NPM_SCOPE }}
PYPI_PROJECT: ${{ vars.PYPI_PROJECT }}
PYPI_URL: ${{ vars.PYPI_URL }}
jobs:
addon:
runs-on: ubuntu-latest
outputs:
platforms: ${{ steps.plan.outputs.platforms }}
test: ${{ steps.plan.outputs.test }}
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Build plan
id: plan
env:
BASE: ${{ github.event.pull_request.base.sha }}
run: |
git fetch -q --depth=1 origin "$BASE"
python3 scripts/ci/packages_release.py build-plan --base "$BASE" >> "$GITHUB_OUTPUT"
build:
name: "build ${{ matrix.platform }}"
needs: addon
if: needs.addon.outputs.platforms != '[]'
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.addon.outputs.platforms) }}
defaults:
run:
shell: bash
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Build simplex-chat (npm) and add-on
run: scripts/ci/package-tests.sh build ${{ matrix.platform }}
- name: Test simplex-chat (npm and PyPI)
if: needs.addon.outputs.test
run: scripts/ci/package-tests.sh test
-6
View File
@@ -89,9 +89,3 @@ apps/android/app/release
apps/multiplatform/.kotlin/sessions
# anywhere in the tree: this file holds a real BTCPay api_key and webhook_secret
badge_service.ini
# Badge service Docker deployment: the Dockerfile, compose, and example config are tracked; the
# live config (a secret) and its data volume are not.
scripts/badge-service/badge_service.ini
scripts/badge-service/data/
scripts/badge-service/web/
+13
View File
@@ -179,6 +179,7 @@ SimpleX Chat founder
- [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations)
- [For developers](#for-developers)
- [Develop a chat bot](#develop-a-chat-bot)
- [Agents](#agents)
- [Roadmap](#roadmap)
- [Disclaimers, Security contact, License](#disclaimers)
@@ -331,6 +332,18 @@ See [our new bot API reference](./bots/README.md). Most of it is automatically g
Also see [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
## Agents
- For AI assistants: [simplex.chat/llms.txt](https://simplex.chat/llms.txt), an index of the documentation in Markdown, and [SimpleX for AI agents and developers](https://simplex.chat/llms/agents.md).
- Every page of simplex.chat is also available as Markdown: replace `.html` with `.md` ([simplex.chat/docs/simplex.md](https://simplex.chat/docs/simplex.md)), or add `.md` to the path ([simplex.chat/why.md](https://simplex.chat/why.md)).
- Libraries: [Node.js](./packages/simplex-chat-nodejs/) ([npm](https://www.npmjs.com/package/simplex-chat)) and [Python](./packages/simplex-chat-python/) ([PyPI](https://pypi.org/project/simplex-chat/)).
- [Bot API guide](./bots/README.md) and [API reference](./bots/api/README.md): commands, events and types.
- Example bots: [support bot](./apps/simplex-support-bot/) and [calculator](./apps/simplex-calculator-bot/) in TypeScript, [support bot light](./apps/simplex-support-bot-light/) in Python, [directory service](./apps/simplex-directory-service/) and [broadcast bot](./apps/simplex-broadcast-bot/) in Haskell.
- [Terminal client](./docs/CLI.md): installation, building from source and usage.
- Servers: [SMP](./docs/SERVER.md), [XFTP](./docs/XFTP-SERVER.md) and [chat relay](./docs/CHAT-RELAY.md).
- Protocols: [SimpleX network](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md), [chat](./docs/protocol/simplex-chat.md), [channels](./docs/protocol/channels-overview.md) and [public names](./docs/protocol/names-overview.md).
- For coding agents working in this repository: [project structure](./docs/contributing/PROJECT.md) and [coding and building](./docs/contributing/CODE.md).
## Roadmap
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
+24 -1
View File
@@ -461,9 +461,14 @@ struct ContentView: View {
}
func connectViaUrl_(_ url: URL) {
// an app link must not tear down a redemption in flight
if isAppLink(url) && isBadgeLinkIssuing() { return }
dismissAllSheets() {
var path = url.path
if path == "/r" {
if isAppLink(url) {
// branched on the scheme before the connection dispatch, which an app link must never reach
openAppLink(url)
} else if path == "/r" {
showAlert(
NSLocalizedString("Relay address", comment: "alert title"),
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message")
@@ -498,6 +503,24 @@ struct ContentView: View {
}
}
// the app's own scheme, for every link that is not a connection link, which stays on simplex:
private let appLinkScheme = "simplexchat"
private let badgeLinkPath = "/badge/code/"
func isAppLink(_ url: URL) -> Bool {
url.scheme?.lowercased() == appLinkScheme
}
// a link type added in a later version reaches this build too, so an unknown path asks for an update
private func openAppLink(_ url: URL) {
let path = url.path
if path.hasPrefix(badgeLinkPath) {
openBadgeLink(String(path.dropFirst(badgeLinkPath.count)))
} else {
AlertManager.shared.showAlert(Alert(title: Text("This link is not supported by this app version. Please check for app updates.")))
}
}
final class AlertManager: ObservableObject {
static let shared = AlertManager()
@Published var presentAlert = false
+47 -3
View File
@@ -193,6 +193,8 @@ enum ChatCommand: ChatCmdProtocol {
case apiStandaloneFileInfo(url: String)
// badges
case apiRedeemBadgeCode(userId: Int64, code: String)
case apiPurchaseBadge(userId: Int64, echoedInvoiceId: String?, payment: ServicePayment)
case apiCreateBadgeInvoice(userId: Int64)
case apiGetBadgeState(userId: Int64)
case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64)
case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String)
@@ -419,6 +421,9 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)"
case let .apiStandaloneFileInfo(link): return "/_download info \(link)"
case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)"
case let .apiPurchaseBadge(userId, echoedInvoiceId, payment):
return "/_badge purchase \(userId)\(echoedInvoiceId.map { " invoice=\($0)" } ?? "") \(encodeJSON(payment))"
case let .apiCreateBadgeInvoice(userId): return "/_badge invoice \(userId)"
case let .apiGetBadgeState(userId): return "/_badge state \(userId)"
case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)"
case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode):
@@ -611,6 +616,8 @@ enum ChatCommand: ChatCmdProtocol {
case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile"
case .apiStandaloneFileInfo: return "apiStandaloneFileInfo"
case .apiRedeemBadgeCode: return "apiRedeemBadgeCode"
case .apiPurchaseBadge: return "apiPurchaseBadge"
case .apiCreateBadgeInvoice: return "apiCreateBadgeInvoice"
case .apiGetBadgeState: return "apiGetBadgeState"
case .apiGetBadgeLedger: return "apiGetBadgeLedger"
case .apiAckBadgeAlert: return "apiAckBadgeAlert"
@@ -667,9 +674,11 @@ enum ChatCommand: ChatCmdProtocol {
return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd))
case let .testStorageEncryption(key):
return .testStorageEncryption(key: obfuscate(key))
// a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
// a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
case let .apiRedeemBadgeCode(userId, code):
return .apiRedeemBadgeCode(userId: userId, code: obfuscate(code))
case let .apiPurchaseBadge(userId, echoedInvoiceId, .apple(jws)):
return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: .apple(jws: obfuscate(jws)))
default: return self
}
}
@@ -719,6 +728,29 @@ enum ChatCommand: ChatCmdProtocol {
}
}
enum ServicePayment: Encodable {
case apple(jws: String)
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .apple(jws):
try container.encode("apple", forKey: .type)
try container.encode(jws, forKey: .jws)
}
}
private enum CodingKeys: String, CodingKey {
case type, jws
}
}
struct OpenStorePurchase: Decodable {
var invoiceId: String?
var transactionRef: String?
var creditError: BadgeIssueFailure?
}
// ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient.
// Spec: spec/api.md#ChatResponse0
enum ChatResponse0: Decodable, ChatAPIResult {
@@ -1051,7 +1083,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
// badges
// the full user, not UserRef: its profile carries the badge that setUserBadge just stored
case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?)
case badgeState(user: UserRef, badgeState: BadgeState?)
case badgeInvoice(user: UserRef, invoiceId: String)
case badgeState(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]?)
case badgeLedger(user: UserRef, badgeLedger: [StatementEntry])
var responseType: String {
@@ -1105,6 +1138,7 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case .archiveImported: "archiveImported"
case .appSettings: "appSettings"
case .badgeRedeemed: "badgeRedeemed"
case .badgeInvoice: "badgeInvoice"
case .badgeState: "badgeState"
case .badgeLedger: "badgeLedger"
}
@@ -1161,7 +1195,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case let .archiveImported(archiveErrors): return String(describing: archiveErrors)
case let .appSettings(appSettings): return String(describing: appSettings)
case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))")
case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState))
case let .badgeInvoice(u, invoiceId): return withUser(u, invoiceId)
case let .badgeState(u, badgeState, storePurchases): return withUser(u, "\(String(describing: badgeState))\nstorePurchases: \(String(describing: storePurchases ?? []))")
case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger))
}
}
@@ -1191,6 +1226,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
// group events
case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set<Int64>, byUser: Bool, member_: GroupMember?)
case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole)
case receivedGroupInvitationViaGroup(user: UserRef, groupInfo: GroupInfo, viaGroupInfo: GroupInfo, viaMember: GroupMember, memberRole: GroupMemberRole)
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember)
case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact)
@@ -1248,6 +1284,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
// badges
case badgeChanged(user: User, badgeState: BadgeState?)
case badgeAlert(user: UserRef, badgeAlert: BadgeAlert)
case storePurchaseCredited(user: UserRef, invoiceId: String)
case storePurchaseRefused(user: UserRef, invoiceId: String, refusal: BadgeIssueFailure)
var responseType: String {
switch self {
@@ -1272,6 +1310,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
case .chatItemsDeleted: "chatItemsDeleted"
case .groupChatItemsDeleted: "groupChatItemsDeleted"
case .receivedGroupInvitation: "receivedGroupInvitation"
case .receivedGroupInvitationViaGroup: "receivedGroupInvitationViaGroup"
case .userAcceptedGroupSent: "userAcceptedGroupSent"
case .groupLinkConnecting: "groupLinkConnecting"
case .businessLinkConnecting: "businessLinkConnecting"
@@ -1322,6 +1361,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
case .contactPQEnabled: "contactPQEnabled"
case .badgeChanged: "badgeChanged"
case .badgeAlert: "badgeAlert"
case .storePurchaseCredited: "storePurchaseCredited"
case .storePurchaseRefused: "storePurchaseRefused"
}
}
@@ -1356,6 +1397,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_):
return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))")
case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)")
case let .receivedGroupInvitationViaGroup(u, groupInfo, viaGroupInfo, viaMember, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nviaGroupInfo: \(viaGroupInfo)\nviaMember: \(viaMember)\nmemberRole: \(memberRole)")
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))")
case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))")
@@ -1406,6 +1448,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)")
case let .badgeChanged(u, badgeState): return withUser(u, String(describing: badgeState))
case let .badgeAlert(u, badgeAlert): return withUser(u, String(describing: badgeAlert))
case let .storePurchaseCredited(u, invoiceId): return withUser(u, "invoiceId: \(invoiceId)")
case let .storePurchaseRefused(u, invoiceId, refusal): return withUser(u, "invoiceId: \(invoiceId)\nrefusal: \(refusal.tag)")
}
}
}
+19
View File
@@ -474,10 +474,27 @@ final class ChatModel: ObservableObject {
// another in its place; only the badge alert shows regardless. Set while rendering, so not published.
var chatListBanner: ChatListBanner?
var fileToOpen: (fileId: Int64, chatId: String?, open: (CIFile) -> Void)?
func bannerSlotFree(for banner: ChatListBanner) -> Bool {
chatListBanner == nil || chatListBanner == banner
}
func openWhenReceived(_ file: CIFile, open: @escaping (CIFile) -> Void) {
fileToOpen = (fileId: file.fileId, chatId: chatId, open: open)
}
func receivedFileToOpen(_ file: CIFile) -> Bool {
if let f = fileToOpen { f.fileId == file.fileId && f.chatId == chatId } else { false }
}
func openReceivedFile(_ file: CIFile) {
if let f = fileToOpen, receivedFileToOpen(file) {
fileToOpen = nil
f.open(file)
}
}
static let shared = ChatModel()
let im = ItemsModel.shared
@@ -935,6 +952,8 @@ final class ChatModel: ObservableObject {
func updateCurrentUser(_ newProfile: Profile, _ preferences: FullPreferences? = nil) {
if let current = currentUser {
currentUser?.profile = toLocalProfile(current.profile.profileId, newProfile, "")
currentUser?.profile.localBadge = current.profile.localBadge
currentUser?.profile.contactDomainVerified = current.profile.contactDomainVerified
if let preferences = preferences {
currentUser?.fullPreferences = preferences
}
+72 -18
View File
@@ -1359,8 +1359,12 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? {
switch r {
case .result(.userProfileNoChange): return (profile, [])
case let .result(.userProfileUpdated(_, _, toProfile, updateSummary)): return (toProfile, updateSummary.changedContacts)
case .error(.errorStore(.duplicateName)): return nil;
default: throw r.unexpected
case .error(.errorStore(.duplicateName)), .error(.error(.userExists)): return nil;
default:
await MainActor.run {
showErrorAlert(r.unexpected, NSLocalizedString("Error saving profile", comment: "alert title"))
}
throw r.unexpected
}
}
@@ -1846,8 +1850,8 @@ func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandida
try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer))
}
func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws {
let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates)
func apiSendCallExtraInfo(_ contact: Contact, _ rtcSession: String?, _ rtcIceCandidates: String) async throws {
let extraInfo = WebRTCExtraInfo(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo))
}
@@ -2196,13 +2200,42 @@ func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user:
throw r.unexpected
}
// a refusal is thrown, as BREServiceError with the code
enum BadgePurchaseResult {
case held(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase])
case credited(user: User, badgeState: BadgeState?)
}
// log: false because a store receipt is a bearer secret, like a badge code - it is in the command.
func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, _ payment: ServicePayment) async throws -> BadgePurchaseResult {
let r: ChatResponse2 = try await chatSendCmd(.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: payment), log: false)
switch r {
case let .badgeState(user, badgeState, storePurchases): return .held(user: user, badgeState: badgeState, storePurchases: storePurchases ?? [])
case let .badgeRedeemed(user, _, _, badgeState): return .credited(user: user, badgeState: badgeState)
default: throw r.unexpected
}
}
func apiCreateBadgeInvoice(_ userId: Int64) async throws -> String {
let r: ChatResponse2 = try await chatSendCmd(.apiCreateBadgeInvoice(userId: userId))
if case let .badgeInvoice(_, invoiceId) = r { return invoiceId }
throw r.unexpected
}
// localized where the user can act on it; otherwise the error itself, so a screenshot says what happened
func redeemErrorText(_ error: Error) -> String {
func redeemErrorText(_ error: Error, purchase: Bool) -> String {
if case let .error(.badgeRedeemError(e)) = error as? ChatError {
switch e {
case .invalidCode: return NSLocalizedString("This code is not valid.", comment: "alert message")
case .serviceNotConfigured: return NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message")
case .badgeActive: return NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message")
case .invalidReceipt: break
case .serviceNotConfigured:
return purchase
? NSLocalizedString("This app version cannot buy badges.", comment: "alert message")
: NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message")
case .badgeActive:
return purchase
? NSLocalizedString("This profile already has a badge.", comment: "alert message")
: NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message")
case let .serviceError(code): if let text = badgeServiceErrorText(code) { return text }
case let .invalidResponse(message):
return String.localizedStringWithFormat(NSLocalizedString("The badge service sent an unexpected response: %@", comment: "alert message"), message)
@@ -2212,15 +2245,15 @@ func redeemErrorText(_ error: Error) -> String {
return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
}
func apiGetBadgeState(_ userId: Int64) async throws -> BadgeState? {
func apiGetBadgeState(_ userId: Int64) async throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) {
let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeState(userId: userId))
if case let .badgeState(_, badgeState) = r { return badgeState }
if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) }
throw r.unexpected
}
func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? {
func apiGetBadgeStateSync(_ userId: Int64) throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) {
let r: ChatResponse2 = try chatSendCmdSync(.apiGetBadgeState(userId: userId))
if case let .badgeState(_, badgeState) = r { return badgeState }
if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) }
throw r.unexpected
}
@@ -2232,7 +2265,7 @@ func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws -
func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? {
let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode))
if case let .badgeState(_, badgeState) = r { return badgeState }
if case let .badgeState(_, badgeState, _) = r { return badgeState }
throw r.unexpected
}
@@ -2361,6 +2394,7 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws
ChatReceiver.shared.start()
m.chatRunning = true
chatLastStartGroupDefault.set(Date.now)
Task { await BadgeStore.shared.presentUnfinished() }
if shouldDeleteDatabaseBackupsDefault.get() {
deleteDatabaseBackups()
shouldDeleteDatabaseBackupsDefault.set(false)
@@ -2389,6 +2423,7 @@ private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws {
m.currentUser = try apiSetActiveUser(userId, viewPwd: viewPwd)
m.users = try listUsers()
try getUserChatData()
Task { await BadgeStore.shared.presentUnfinished() }
}
func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: String? = nil) async throws {
@@ -2404,6 +2439,7 @@ func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: S
m.users = users
}
try await getUserChatDataAsync(keepingChatId: keepingChatId)
Task { await BadgeStore.shared.presentUnfinished() }
await MainActor.run {
if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) {
invitation.user = currentUser
@@ -2431,22 +2467,32 @@ func getUserChatData() throws {
private func loadBadgeState() {
do {
let userId = try currentUserId("loadBadgeState")
let badgeState = try apiGetBadgeStateSync(userId)
let (badgeState, storePurchases) = try apiGetBadgeStateSync(userId)
BadgeModel.shared.set(userId: userId, badgeState: badgeState)
BadgeStore.shared.setStorePurchases(userId, storePurchases)
} catch let error {
logger.error("loadBadgeState: \(responseError(error))")
}
}
private func loadBadgeStateAsync(_ userId: Int64) async {
func loadBadgeStateAsync(_ userId: Int64) async {
do {
let badgeState = try await apiGetBadgeState(userId)
await MainActor.run { BadgeModel.shared.set(userId: userId, badgeState: badgeState) }
let (badgeState, storePurchases) = try await apiGetBadgeState(userId)
await MainActor.run {
BadgeModel.shared.set(userId: userId, badgeState: badgeState)
BadgeStore.shared.setStorePurchases(userId, storePurchases)
}
} catch let error {
logger.error("loadBadgeState: \(responseError(error))")
}
}
func loadCurrentBadgeState() async {
if let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) {
await loadBadgeStateAsync(userId)
}
}
private func getUserChatDataAsync(keepingChatId: String?) async throws {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
@@ -2701,7 +2747,8 @@ func processReceivedMsg(_ res: ChatEvent) async {
}
case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_):
await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_)
case let .receivedGroupInvitation(user, groupInfo, _, _):
case let .receivedGroupInvitation(user, groupInfo, _, _),
let .receivedGroupInvitationViaGroup(user, groupInfo, _, _, _):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
@@ -2868,6 +2915,9 @@ func processReceivedMsg(_ res: ChatEvent) async {
await chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileComplete(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
if active(user), let file = aChatItem.chatItem.file, m.receivedFileToOpen(file) {
await MainActor.run { m.openReceivedFile(file) }
}
case let .rcvFileSndCancelled(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
@@ -2937,7 +2987,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
}
case let .callExtraInfo(_, contact, extraInfo):
await withCall(contact) { _ in
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates))
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates, session: extraInfo.rtcSession))
}
case let .callEnded(_, contact):
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
@@ -3052,6 +3102,10 @@ func processReceivedMsg(_ res: ChatEvent) async {
BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert)
}
}
case let .storePurchaseCredited(user, invoiceId):
Task { await BadgeStore.shared.storePurchaseCredited(user, invoiceId) }
case let .storePurchaseRefused(user, invoiceId, refusal):
Task { await BadgeStore.shared.storePurchaseRefused(user, invoiceId, refusal) }
default:
logger.debug("unsupported event: \(res.responseType)")
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "agent.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="124.5 -878.5 711 797" width="21.41px" fill="#000000"><path d="M564.12,-698.86 C564.12,-652.47 526.51,-614.86 480.12,-614.86 C433.73,-614.86 396.12,-652.47 396.12,-698.86 C396.12,-745.25 433.73,-782.86 480.12,-782.86 C526.51,-782.86 564.12,-745.25 564.12,-698.86 Z M373.01,-371.05 C373.01,-324.66 335.4,-287.05 289.01,-287.05 C242.62,-287.05 205.01,-324.66 205.01,-371.05 C205.01,-417.44 242.62,-455.05 289.01,-455.05 C335.4,-455.05 373.01,-417.44 373.01,-371.05 Z M755.03,-371.07 C755.03,-324.68 717.42,-287.07 671.03,-287.07 C624.63,-287.07 587.03,-324.68 587.03,-371.07 C587.03,-417.46 624.63,-455.07 671.03,-455.07 C717.42,-455.07 755.03,-417.46 755.03,-371.07 Z"/></svg>

After

Width:  |  Height:  |  Size: 737 B

+3 -1
View File
@@ -43,7 +43,8 @@ struct SimpleXApp: App {
.environmentObject(chatModel)
.environmentObject(AppTheme.shared)
.onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)")
// an app link can carry a secret, such as a badge code that anyone who reads it can redeem
logger.debug("ContentView.onOpenURL: \(isAppLink(url) ? "app link" : url.absoluteString)")
if AppChatState.shared.value == .active {
chatModel.appOpenUrl = url
} else {
@@ -100,6 +101,7 @@ struct SimpleXApp: App {
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
}
Task { await BadgeStore.shared.presentUnfinished() }
if let url = chatModel.appOpenUrlLater {
await MainActor.run {
chatModel.appOpenUrlLater = nil
+234 -32
View File
@@ -9,6 +9,7 @@
import Foundation
import Combine
import StoreKit
import SimpleXChat
// TODO [badges] product ids will come from app config and prices from the badge service catalog;
// hardcoded here so the App Store integration can be tested before the purchase API lands.
@@ -27,11 +28,27 @@ let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in
BadgePeriod.allCases.map { badgeProductId(level, $0) }
}
// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id.
// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction,
// which is how the service learns which invoice a store transaction settles.
// the only products sent to the badge service: nothing delivers a subscription yet
let badgeOneTimeProductIds: Set<String> = Set(BadgeLevel.allCases.map { badgeProductId($0, .oneMonth) })
// subscriptions are for sale once renewals are delivered
let badgePeriodsForSale: [BadgePeriod] = [.oneMonth]
// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core.
// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction.
func newBadgeInvoiceId() -> UUID { UUID() }
// the page's app flag rides in the fragment, which never reaches the service
let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true"
// the dev deployment, for testing store purchases: swap with the line above
// let badgePageUrl = "https://smp7.simplex.im/#/tier?app=true"
// where the store allows a link out to the badge page: the US for now, a set expected to widen.
// An unknown storefront does not count, as this decides whether the store sees a link out of the app.
var badgeBrowserAllowed: Bool {
SKPaymentQueue.default().storefront?.countryCode == "USA"
}
enum BadgePrice {
case loading
case price(String)
@@ -49,21 +66,32 @@ struct BadgeStoreReceipt {
// the signed token the badge service verifies - never transaction.jsonRepresentation
let jws: String
let productId: String
let transactionId: UInt64
let invoiceId: UUID?
let environment: String?
let signatureVerified: Bool
let transaction: Transaction
var echoedInvoiceId: String? { invoiceId.map(coreInvoiceId) }
}
// core mints the id in lower case, and UUID formats it in upper case
func coreInvoiceId(_ invoiceId: UUID) -> String { invoiceId.uuidString.lowercased() }
enum BadgePurchaseOutcome {
case purchased(BadgeStoreReceipt)
case pending
case pending(invoiceId: String?)
case cancelled
}
enum BadgePurchaseState {
case issuing
case waitingForApproval
}
enum BadgeStoreError: Error {
case productUnavailable(productId: String)
case unknownPurchaseResult
case noActiveProfile
case invalidInvoiceId(String)
}
final class BadgeStore: ObservableObject {
@@ -73,14 +101,53 @@ final class BadgeStore: ObservableObject {
@Published private var state: LoadState = .notLoaded
private var products: [String: Product] = [:]
// core's open store purchases for the profile they were read for: core knows whose a purchase is
@Published private var storePurchases: (userId: Int64, purchases: [OpenStorePurchase])? = nil
// whether a store sheet this run opened has not returned
@Published private var buying = false
// kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing
// by invoice id, so a pending purchase shows only under the profile whose record it names
@Published private var waitingForApproval: Set<String> = []
// set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was
// closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again
@Published private var reconciledOnce = false
// the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run
private var sweepFailureShown = false
private var transactionUpdates: Task<Void, Never>? = nil
private init() {}
func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice {
func purchaseState(_ userId: Int64?) -> BadgePurchaseState? {
let purchases = openStorePurchases(userId)
if purchases.contains(where: { $0.transactionRef != nil }) { return .issuing }
if purchases.contains(where: { $0.invoiceId.map(waitingForApproval.contains) == true }) { return .waitingForApproval }
return nil
}
func creditError(_ userId: Int64?) -> BadgeIssueFailure? {
openStorePurchases(userId).compactMap(\.creditError).first
}
var checkingPurchases: Bool { !reconciledOnce }
func canBuy(_ userId: Int64?) -> Bool {
reconciledOnce && !buying && purchaseState(userId) == nil
}
func setStorePurchases(_ userId: Int64, _ purchases: [OpenStorePurchase]) {
storePurchases = (userId, purchases)
}
private func openStorePurchases(_ userId: Int64?) -> [OpenStorePurchase] {
guard let storePurchases, storePurchases.userId == userId else { return [] }
return storePurchases.purchases
}
func price(_ level: BadgeLevel, _ period: BadgePeriod, compact: Bool = true) -> BadgePrice {
switch state {
case .notLoaded, .loading: return .loading
case .loaded, .failed:
if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) }
if let p = products[badgeProductId(level, period)] { return .price(compact ? compactPrice(p) : p.displayPrice) }
return .unavailable
}
}
@@ -119,33 +186,158 @@ final class BadgeStore: ObservableObject {
}
}
func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome {
// A one-time purchase has its core record before the store charges, and every store outcome reaches it.
func purchase(_ level: BadgeLevel, _ period: BadgePeriod) async throws -> BadgePurchaseOutcome {
let productId = badgeProductId(level, period)
guard let product = await MainActor.run(body: { products[productId] }) else {
throw BadgeStoreError.productUnavailable(productId: productId)
}
switch try await product.purchase(options: [.appAccountToken(invoiceId)]) {
case let .success(verification):
let transaction: Transaction
let signatureVerified: Bool
switch verification {
case let .verified(t):
transaction = t
signatureVerified = true
case let .unverified(t, _):
transaction = t
signatureVerified = false
// a subscription is never sent to core, so nothing would ever finish it later
guard badgeOneTimeProductIds.contains(productId) else {
let outcome = try await storePurchase(product, newBadgeInvoiceId())
if case let .purchased(receipt) = outcome { await receipt.transaction.finish() }
return outcome
}
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else {
throw BadgeStoreError.noActiveProfile
}
let invoice = try await apiCreateBadgeInvoice(userId)
guard let invoiceId = UUID(uuidString: invoice) else { throw BadgeStoreError.invalidInvoiceId(invoice) }
await MainActor.run { buying = true }
await loadBadgeStateAsync(userId)
do {
let outcome = try await storePurchase(product, invoiceId)
if case let .purchased(receipt) = outcome, receipt.signatureVerified {
try await handOver(receipt)
}
// nothing is delivered in this build, so the transaction is finished right away; once the
// service issues credentials it must only be finished after the credential is stored
await transaction.finish()
return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified))
case .pending: return .pending
await MainActor.run { buying = false }
return outcome
} catch let error {
await MainActor.run { buying = false }
throw error
}
}
private func storePurchase(_ product: Product, _ invoiceId: UUID) async throws -> BadgePurchaseOutcome {
switch try await product.purchase(options: [.appAccountToken(invoiceId)]) {
case let .success(verification): return .purchased(storeReceipt(verification))
case .pending:
let pending = coreInvoiceId(invoiceId)
await MainActor.run { _ = waitingForApproval.insert(pending) }
return .pending(invoiceId: pending)
case .userCancelled: return .cancelled
@unknown default: throw BadgeStoreError.unknownPurchaseResult
}
}
// Core holds the receipt and credits it; the transaction stays unfinished until core answers it credited
// or refused, as an unfinished transaction is what the store re-delivers if anything is lost on the way.
private func handOver(_ receipt: BadgeStoreReceipt) async throws {
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return }
// a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer
do {
switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws)) {
case let .held(user, badgeState, storePurchases):
await MainActor.run {
// the answer is the owner's, which may be another profile and a hidden one
if active(user) {
BadgeModel.shared.set(userId: user.userId, badgeState: badgeState)
setStorePurchases(user.userId, storePurchases)
}
}
case let .credited(user, badgeState):
await MainActor.run {
if active(user) {
BadgeModel.shared.set(userId: user.userId, badgeState: badgeState)
ChatModel.shared.updateUser(user)
if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) }
}
}
await receipt.transaction.finish()
}
} catch let error where badgeReceiptRefused(error) {
logger.error("BadgeStore.handOver: \(responseError(error))")
await receipt.transaction.finish()
}
}
func storePurchaseCredited(_ user: UserRef, _ invoiceId: String) async {
await MainActor.run {
// an open badges screen is the notice only for the active profile, whose badge it shows
if !active(user) || !badgesViewShown { showBadgeAddedAlert(user) }
}
await storePurchaseResolved(user, invoiceId)
}
func storePurchaseRefused(_ user: UserRef, _ invoiceId: String, _ refusal: BadgeIssueFailure) async {
await MainActor.run {
if active(user) && openStorePurchases(user.userId).contains(where: { $0.invoiceId == invoiceId }) {
showAlert(refusal.purchaseAlertTitle, message: refusal.purchaseText)
}
}
await storePurchaseResolved(user, invoiceId)
}
private func storePurchaseResolved(_ user: UserRef, _ invoiceId: String) async {
// the row goes before the store is read, or the screen shows it open while the store answers;
// the transaction is finished whoever owns it, as only the app can, while the cached row is the active profile's
await MainActor.run {
if active(user), let open = storePurchases, open.userId == user.userId {
storePurchases = (open.userId, open.purchases.filter { $0.invoiceId != invoiceId })
}
}
for await verification in Transaction.unfinished {
let receipt = storeReceipt(verification)
if receipt.echoedInvoiceId == invoiceId {
await receipt.transaction.finish()
return
}
}
}
// at launch, on return to the foreground and on a profile switch, never on a timer
func presentUnfinished() async {
await listenForTransactions()
for await verification in Transaction.unfinished {
await reconcile(verification)
}
await loadCurrentBadgeState()
await MainActor.run { reconciledOnce = true }
}
// transactions the store settles outside a purchase call, such as an approved Ask to Buy
@MainActor
private func listenForTransactions() {
if transactionUpdates == nil {
transactionUpdates = Task.detached {
for await verification in Transaction.updates {
await BadgeStore.shared.reconcile(verification)
}
}
}
}
// an unverified one-time transaction is never sent, and left unfinished rather than lost
private func reconcile(_ verification: VerificationResult<Transaction>) async {
let receipt = storeReceipt(verification)
if !badgeOneTimeProductIds.contains(receipt.productId) {
await receipt.transaction.finish()
} else if receipt.signatureVerified {
// the receipt stays unfinished, so the next sweep hands it over again
do { try await handOver(receipt) } catch let error {
logger.error("BadgeStore.reconcile: \(responseError(error))")
await MainActor.run {
// showAlert presents on the top view controller, which the launch sweep can precede;
// an alert with nowhere to go must not spend the one this run gets
if !sweepFailureShown, getTopViewController() != nil {
sweepFailureShown = true
showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(error, purchase: true))
}
}
}
}
}
@MainActor
private func startLoading() -> Bool {
switch state {
@@ -169,15 +361,25 @@ private func compactPrice(_ product: Product) -> String {
: product.displayPrice
}
private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt {
var environment: String? = nil
if #available(iOS 16.0, *) { environment = t.environment.rawValue }
private func storeReceipt(_ verification: VerificationResult<Transaction>) -> BadgeStoreReceipt {
let (t, signatureVerified) = switch verification {
case let .verified(t): (t, true)
case let .unverified(t, _): (t, false)
}
return BadgeStoreReceipt(
jws: jws,
jws: verification.jwsRepresentation,
productId: t.productID,
transactionId: t.id,
invoiceId: t.appAccountToken,
environment: environment,
signatureVerified: signatureVerified
signatureVerified: signatureVerified,
transaction: t
)
}
// the codes core refuses a receipt with for good, after which the store may stop re-delivering it;
// a sweep that hands over a receipt refused while the app was not running learns it only from this answer
private func badgeReceiptRefused(_ error: Error) -> Bool {
if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError {
return code == .receiptInvalid || code == .receiptUsed || code == .testPurchase
}
return false
}
@@ -9,15 +9,9 @@
import SwiftUI
import SimpleXChat
struct BadgeUserPreview<Trailing: View>: View {
struct BadgeUserPreview: View {
@EnvironmentObject var chatModel: ChatModel
let level: BadgeLevel
let trailing: () -> Trailing
init(level: BadgeLevel, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) {
self.level = level
self.trailing = trailing
}
var body: some View {
let user = chatModel.currentUser
@@ -29,12 +23,9 @@ struct BadgeUserPreview<Trailing: View>: View {
)
return VStack(spacing: 12) {
ProfileImage(imageStr: user?.image, size: 128)
HStack(alignment: .center, spacing: 6) {
NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle)
.lineLimit(1)
.minimumScaleFactor(0.75)
trailing()
}
NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
}
}
@@ -0,0 +1,131 @@
//
// BadgesCheckOrderView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 29.09.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct BadgesCheckOrderView: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
@ObservedObject private var store = BadgeStore.shared
let level: BadgeLevel
let period: BadgePeriod
@State private var purchasing = false
// presented from this view, not AlertManager: its host is behind the sheet these views open in
@State private var alert: SomeAlert?
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .center, spacing: 16) {
Text("Check your order")
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 0) {
orderRow("Your badge", Text(level.title))
orderRow("Duration", Text(period.label))
Divider()
.padding(.top, 8)
.padding(.bottom, 6)
orderRow("Total", period.priceText(store.price(level, period, compact: false)))
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.top, 20)
Spacer(minLength: 20)
VStack(spacing: 10) {
payButton()
.padding(.vertical, 10)
BadgeBillingFooter(period: period)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
.padding(.horizontal, 25)
.padding(.top, 0)
.padding(.bottom, 20)
.frame(minHeight: g.size.height)
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
.task { await store.load() }
.alert(item: $alert) { $0.alert }
}
private func orderRow(_ title: LocalizedStringKey, _ value: Text) -> some View {
HStack {
Text(title)
.foregroundColor(theme.colors.secondary)
Spacer()
value
.fontWeight(.medium)
}
.font(.body)
.padding(.vertical, 8)
}
private func payButton() -> some View {
let price = store.price(level, period, compact: false)
let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId)
return Button {
purchase()
} label: {
period.payText(price)
}
.buttonStyle(OnboardingButtonStyle(isDisabled: disabled))
.disabled(disabled)
}
private func purchase() {
purchasing = true
Task {
do {
if case let .purchased(receipt) = try await store.purchase(level, period), !receipt.signatureVerified {
await MainActor.run {
alert = SomeAlert(
alert: mkAlert(
title: "Cannot verify this purchase",
message: "The store returned a transaction that Apple has not signed. SimpleX cannot verify this purchase with the App Store."
),
id: "badgePurchaseUnverified"
)
}
}
await MainActor.run { purchasing = false }
} catch let error {
logger.error("BadgesCheckOrderView.purchase: \(String(describing: error))")
await MainActor.run {
purchasing = false
alert = SomeAlert(
alert: Alert(
title: Text("Purchase error"),
message: Text(verbatim: redeemErrorText(error, purchase: true))
),
id: "badgePurchaseError"
)
}
}
}
}
}
struct BadgesCheckOrderView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesCheckOrderView(level: .supporter, period: .oneMonth)
}
}
}
@@ -1,5 +1,5 @@
//
// BadgesYourLevelView.swift
// BadgesChooseBadgeView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
@@ -23,17 +23,24 @@ enum BadgeLevel: String, CaseIterable, Identifiable {
}
}
var filesDescription: LocalizedStringKey {
var fileSize: LocalizedStringKey {
switch self {
case .supporter: "Send 2GB files"
case .legend: "Send 5GB files"
case .supporter: "Files up to 2 GB"
case .legend: "Files up to 5 GB"
}
}
var tagline: LocalizedStringKey {
var fileStorage: LocalizedStringKey {
switch self {
case .supporter: "Optional profile badge\nand 2GB files"
case .legend: "Optional profile badge\nand 5GB files"
case .supporter: "Stored for 7 days"
case .legend: "Stored for 21 days"
}
}
var summary: LocalizedStringKey {
switch self {
case .supporter: "Supporter: 2 GB files available for 7 days."
case .legend: "Legend: 5 GB files available for 21 days."
}
}
@@ -45,32 +52,33 @@ enum BadgeLevel: String, CaseIterable, Identifiable {
}
}
struct BadgesYourLevelView: View {
struct BadgesChooseBadgeView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject private var store = BadgeStore.shared
@State private var selectedLevel: BadgeLevel = .supporter
@State private var continueActive = false
@State private var howItWorksActive = false
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .center, spacing: 16) {
Text("Your level")
Text("Choose your badge")
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
BadgeUserPreview(level: selectedLevel) {
Image(systemName: "chevron.down")
.font(.body)
.foregroundColor(theme.colors.primary)
}
.padding(.top, 4)
Text("Larger files that stay available longer.")
.font(.body)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 20)
BadgeUserPreview(level: selectedLevel)
.padding(.top, 4)
Spacer(minLength: 12)
// fixedSize + maxHeight on the cards so both match the taller one when a
// store price wraps in one of them
@@ -80,13 +88,20 @@ struct BadgesYourLevelView: View {
}
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 20)
Spacer(minLength: 12)
VStack(spacing: 10) {
continueButton()
.padding(.vertical, 10)
howItWorksButton()
.frame(height: 22)
// redeeming a code is here only when Support SimpleX offers the browser instead
Group {
if badgeBrowserAllowed {
RedeemCodeButton()
} else {
Color.clear
}
}
.frame(height: 22)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
@@ -110,17 +125,20 @@ struct BadgesYourLevelView: View {
Image(badgeImageName(level.badgeType))
.resizable()
.scaledToFit()
.frame(width: 60, height: 60)
.padding(.top, 20)
.frame(width: 44, height: 44)
.padding(.top, 16)
Text(level.title)
.font(.title3)
.fontWeight(.bold)
Text(level.filesDescription)
.font(.subheadline)
.foregroundColor(theme.colors.secondary)
BadgePeriod.monthly.priceText(store.price(level, .monthly))
.font(.body)
.padding(.bottom, 20)
VStack(spacing: 2) {
Text(level.fileSize)
Text(level.fileStorage)
}
.font(.subheadline)
.foregroundColor(theme.colors.secondary)
.padding(.bottom, 16)
}
.multilineTextAlignment(.center)
.padding(.horizontal, 12)
@@ -145,30 +163,7 @@ struct BadgesYourLevelView: View {
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
NavigationLink(isActive: $continueActive) {
BadgesPayView(level: selectedLevel)
.modifier(ThemedBackground())
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
private func howItWorksButton() -> some View {
ZStack {
Button {
howItWorksActive = true
} label: {
HStack(spacing: 4) {
Image(systemName: "info.circle")
Text("How badges protect your privacy").fontWeight(.medium)
}
.font(.body)
}
NavigationLink(isActive: $howItWorksActive) {
BadgesHowItWorksView()
BadgesHowLongView(level: selectedLevel)
.modifier(ThemedBackground())
} label: {
EmptyView()
@@ -179,10 +174,10 @@ struct BadgesYourLevelView: View {
}
}
struct BadgesYourLevelView_Previews: PreviewProvider {
struct BadgesChooseBadgeView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesYourLevelView()
BadgesChooseBadgeView()
}
}
}
@@ -0,0 +1,222 @@
//
// BadgesHowLongView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// TODO [badges]: replace with types produced by the badge purchase API when it lands.
enum BadgePeriod: String, CaseIterable, Identifiable {
case oneMonth
case monthly
case annual
var id: String { rawValue }
var icon: String {
switch self {
case .oneMonth: "calendar"
case .monthly: "arrow.clockwise"
case .annual: "arrow.clockwise"
}
}
var label: LocalizedStringKey {
switch self {
case .oneMonth: "1 month"
case .monthly: "Monthly"
case .annual: "Annual"
}
}
var months: Int {
switch self {
case .oneMonth: 1
case .monthly: 1
case .annual: 12
}
}
func priceText(_ price: BadgePrice) -> Text {
switch price {
case .loading: return Text(verbatim: "…")
case .unavailable: return Text(verbatim: "—")
case let .price(p):
switch self {
case .oneMonth: return Text(verbatim: p)
case .monthly: return Text("\(p)/month")
case .annual: return Text("\(p)/year")
}
}
}
func payText(_ price: BadgePrice) -> Text {
switch price {
case .loading: return Text("Loading…")
case .unavailable: return Text("Not available")
case let .price(p):
switch self {
case .oneMonth: return Text("Pay \(p)")
case .monthly: return Text("Pay \(p)/month")
case .annual: return Text("Pay \(p)/year")
}
}
}
}
struct BadgesHowLongView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject private var store = BadgeStore.shared
let level: BadgeLevel
@State private var selectedPeriod: BadgePeriod = badgePeriodsForSale.contains(.monthly) ? .monthly : .oneMonth
@State private var continueActive = false
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .center, spacing: 16) {
Text("How long?")
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text(level.summary)
.font(.body)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
BadgeUserPreview(level: level)
.padding(.top, 4)
Spacer(minLength: 20)
// fixedSize + maxHeight on the cards so they all match the tallest one -
// only Annual carries a savings line, and prices wrap at large fonts
HStack(alignment: .top, spacing: 12) {
ForEach(BadgePeriod.allCases) { periodCard($0) }
}
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 20)
VStack(spacing: 10) {
continueButton()
.padding(.vertical, 10)
BadgeBillingFooter(period: selectedPeriod)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
.padding(.horizontal, 25)
.padding(.top, 0)
.padding(.bottom, 20)
.frame(minHeight: g.size.height)
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
.task { await store.load() }
}
private func periodCard(_ period: BadgePeriod) -> some View {
let isSelected = period == selectedPeriod
let forSale = badgePeriodsForSale.contains(period)
return Button {
selectedPeriod = period
} label: {
VStack(spacing: 12) {
Image(systemName: period.icon)
.resizable()
.scaledToFit()
.frame(width: 32, height: 32)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
.opacity(forSale ? 1 : 0.4)
Text(period.label)
.font(.subheadline)
period.priceText(store.price(level, period))
.font(.headline)
if let percent = savingsPercent(period) {
Text("\(percent)% off")
.font(.footnote)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
}
}
.foregroundColor(forSale ? nil : theme.colors.secondary)
.multilineTextAlignment(.center)
.padding(.vertical, 25)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2)
)
}
.buttonStyle(.plain)
.disabled(!forSale)
}
private func savingsPercent(_ period: BadgePeriod) -> Int? {
period == .annual ? store.annualSavings(level) : nil
}
private func continueButton() -> some View {
ZStack {
Button {
continueActive = true
} label: {
Text("Continue")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
NavigationLink(isActive: $continueActive) {
BadgesCheckOrderView(level: level, period: selectedPeriod)
.modifier(ThemedBackground(grouped: true))
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
}
struct BadgeBillingFooter: View {
@EnvironmentObject var theme: AppTheme
let period: BadgePeriod
var body: some View {
Text(billingFooter)
.font(.footnote)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.frame(height: 22)
}
private var billingFooter: LocalizedStringKey {
// TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld);
// a top-up must count from the end of the existing balance
let endDate = Calendar.current.date(byAdding: .month, value: period.months, to: Date()) ?? Date()
let date = DateFormatter.localizedString(from: endDate, dateStyle: .long, timeStyle: .none)
switch period {
case .monthly, .annual: return "Renews on \(date). Cancel anytime."
case .oneMonth: return "Ends on \(date)."
}
}
}
struct BadgesHowLongView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesHowLongView(level: .supporter)
}
}
}
@@ -1,269 +0,0 @@
//
// BadgesPayView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// TODO [badges]: replace with types produced by the badge purchase API when it lands.
enum BadgePeriod: String, CaseIterable, Identifiable {
case oneMonth
case monthly
case annual
var id: String { rawValue }
var icon: String {
switch self {
case .oneMonth: "calendar"
case .monthly: "arrow.clockwise"
case .annual: "arrow.clockwise"
}
}
var label: LocalizedStringKey {
switch self {
case .oneMonth: "1 month"
case .monthly: "Monthly"
case .annual: "Annual"
}
}
func priceText(_ price: BadgePrice) -> Text {
switch price {
case .loading: return Text(verbatim: "…")
case .unavailable: return Text(verbatim: "—")
case let .price(p):
switch self {
case .oneMonth: return Text(verbatim: p)
case .monthly: return Text("\(p)/month")
case .annual: return Text("\(p)/year")
}
}
}
func payText(_ price: BadgePrice) -> Text {
switch price {
case .loading: return Text("Loading…")
case .unavailable: return Text("Not available")
case let .price(p):
switch self {
case .oneMonth: return Text("Pay \(p)")
case .monthly: return Text("Pay \(p)/month")
case .annual: return Text("Pay \(p)/year")
}
}
}
}
struct BadgesPayView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject private var store = BadgeStore.shared
let level: BadgeLevel
@State private var selectedPeriod: BadgePeriod = .monthly
@State private var purchasing = false
// presented from this view, not AlertManager: its host is behind the sheet these views open in
@State private var alert: SomeAlert?
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .center, spacing: 16) {
Text(level.title)
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
BadgeUserPreview(level: level)
.padding(.top, 4)
Text(level.tagline)
.font(.body)
.foregroundColor(theme.colors.onBackground)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
Spacer(minLength: 20)
// fixedSize + maxHeight on the cards so all three match the tallest one -
// only Annual carries a savings line, and prices wrap at large fonts
HStack(alignment: .top, spacing: 12) {
periodCard(.oneMonth)
periodCard(.monthly)
periodCard(.annual)
}
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 20)
VStack(spacing: 10) {
payButton()
.padding(.vertical, 10)
Text(billingFooter)
.font(.footnote)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.frame(height: 22)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
.padding(.horizontal, 25)
.padding(.top, 0)
.padding(.bottom, 20)
.frame(minHeight: g.size.height)
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
.task { await store.load() }
.alert(item: $alert) { $0.alert }
}
private func periodCard(_ period: BadgePeriod) -> some View {
let isSelected = period == selectedPeriod
return Button {
selectedPeriod = period
} label: {
VStack(spacing: 12) {
Image(systemName: period.icon)
.resizable()
.scaledToFit()
.frame(width: 32, height: 32)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
Text(period.label)
.font(.title3)
.fontWeight(.bold)
period.priceText(store.price(level, period))
.font(.body)
if let percent = savingsPercent(period) {
Text("Save \(percent)%")
.font(.footnote)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
}
}
.multilineTextAlignment(.center)
.padding(.vertical, 25)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2)
)
}
.buttonStyle(.plain)
}
private func savingsPercent(_ period: BadgePeriod) -> Int? {
period == .annual ? store.annualSavings(level) : nil
}
private func payButton() -> some View {
let price = store.price(level, selectedPeriod)
let disabled = !price.canPurchase || purchasing
return Button {
purchase()
} label: {
selectedPeriod.payText(price)
}
.buttonStyle(OnboardingButtonStyle(isDisabled: disabled))
.disabled(disabled)
}
private func purchase() {
let period = selectedPeriod
let invoiceId = newBadgeInvoiceId()
purchasing = true
Task {
do {
let outcome = try await store.purchase(level, period, invoiceId: invoiceId)
await MainActor.run {
purchasing = false
switch outcome {
case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId)
case .pending:
alert = SomeAlert(
alert: mkAlert(
title: "Purchase pending",
message: "The purchase is awaiting approval. This build does not deliver purchases approved later."
),
id: "badgePurchasePending"
)
case .cancelled: break
}
}
} catch let error {
logger.error("BadgesPayView.purchase: \(String(describing: error))")
await MainActor.run {
purchasing = false
alert = SomeAlert(
alert: Alert(
title: Text("Purchase error"),
message: Text(verbatim: String(describing: error))
),
id: "badgePurchaseError"
)
}
}
}
}
// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands.
private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) {
let returnedInvoice: String
if let returned = receipt.invoiceId {
returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)"
} else {
returnedInvoice = "none"
}
var lines = [
"Product: \(receipt.productId)",
"Invoice: \(invoiceId.uuidString)",
"Invoice returned by Apple: \(returnedInvoice)",
"Transaction: \(receipt.transactionId)"
]
if let environment = receipt.environment { lines.append("Environment: \(environment)") }
lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")")
lines.append("Token: \(receipt.jws.count) bytes")
let summary = lines.joined(separator: "\n")
// logged as well as shown: the alert races StoreKit's own sheets, the log always lands
logger.debug("badge purchase succeeded\n\(summary)")
alert = SomeAlert(
alert: Alert(
title: Text("Purchase successful"),
message: Text(verbatim: summary),
primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws },
secondaryButton: .cancel(Text("Ok"))
),
id: "badgePurchased"
)
}
private var billingFooter: LocalizedStringKey {
// TODO [badges] source the actual date from the purchase state machine when wired.
var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22
let stubDate = Calendar.current.date(from: comps) ?? Date()
let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none)
switch selectedPeriod {
case .monthly, .annual: return "Renews on \(date). Cancel anytime."
case .oneMonth: return "Ends on \(date)."
}
}
}
struct BadgesPayView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesPayView(level: .supporter)
}
}
}
@@ -0,0 +1,100 @@
//
// BadgesPurchaseStateView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 29.09.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct BadgesPurchaseStateView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) private var dismiss
let title: LocalizedStringKey
var message: LocalizedStringKey? = nil
var failure: BadgeIssueFailure? = nil
var showsAsSheet: Bool = false
var body: some View {
GeometryReader { g in
ScrollView {
content
.padding(.horizontal, 25)
.padding(.top, showsAsSheet ? 48 : 0)
.padding(.bottom, 20)
.frame(minHeight: g.size.height)
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
}
private var content: some View {
VStack(alignment: .center, spacing: 16) {
Text(title)
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
if let message {
Text(message)
.font(.body)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
if let failure {
VStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red)
Text(failure.purchaseText)
.font(.body)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer()
ProgressView().scaleEffect(2)
Spacer()
VStack(spacing: 10) {
Button {
dismiss()
} label: {
Text("Dismiss")
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
.padding()
}
.padding(.vertical, 10)
Color.clear
.frame(height: 22)
}
}
}
}
extension BadgePurchaseState {
var title: LocalizedStringKey {
switch self {
case .issuing: "Issuing your badge"
case .waitingForApproval: "Waiting for approval"
}
}
var message: LocalizedStringKey {
switch self {
case .issuing: "Your payment is complete. The badge will be added to this profile."
case .waitingForApproval: "Nothing has been charged."
}
}
}
@@ -33,10 +33,52 @@ private func formatBadgeCodeInput(_ s: String) -> String {
return groups.joined(separator: "-")
}
enum BadgeRedeemOutcome {
case redeemed
case refused(message: String)
case cancelled
}
// sets the badge before returning, so a caller that dismisses on .redeemed lands on Your Badge
// instead of showing the switch from Support. .cancelled when the user cancels the retry alert.
func redeemBadgeCode(_ user: User, _ code: String) async -> BadgeRedeemOutcome {
do {
guard let redeemed = try await apiRedeemBadgeCode(user.userId, code) else { return .cancelled }
return await MainActor.run { () -> BadgeRedeemOutcome in
BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState)
ChatModel.shared.updateUser(redeemed.user)
if let badgeState = redeemed.badgeState, !badgeState.shown {
// a replay adds no purchase; a fresh code's badge can be retired on arrival
return .refused(message: redeemed.newBadge
? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message")
: NSLocalizedString("This code has already been used.", comment: "alert message")
)
}
UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN)
return .redeemed
}
} catch let error {
logger.error("apiRedeemBadgeCode: \(responseError(error))")
return .refused(message: redeemErrorText(error, purchase: false))
}
}
func showCannotRedeemAlert(_ message: String) {
showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message)
}
// a hidden profile is named only while it is the active one, which is unlocked and on screen
func showBadgeAddedAlert(_ user: any UserLike) {
guard let owner = ChatModel.shared.getUser(user.userId), active(owner) || !owner.hidden else { return }
showAlert(
NSLocalizedString("Badge added", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("The badge was added to the profile %@.", comment: "alert message"), owner.displayName)
)
}
struct BadgesRedeemCodeView: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
@AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false
@Environment(\.dismiss) var dismiss: DismissAction
@State private var code = ""
@State private var canonicalCode: String? = nil
@@ -184,39 +226,217 @@ struct BadgesRedeemCodeView: View {
guard let sending = canonicalCode, let user = chatModel.currentUser else { return }
submitting = true
Task {
do {
guard let redeemed = try await apiRedeemBadgeCode(user.userId, sending) else {
await MainActor.run { submitting = false }
return
}
await MainActor.run {
submitting = false
// set before dismissing: BadgesView then switches Support to Your Badge while this screen
// still covers it, so the pop lands on Your Badge instead of showing the switch
BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState)
chatModel.updateUser(redeemed.user)
if let badgeState = redeemed.badgeState, !badgeState.shown {
// a replay adds no purchase; a fresh code's badge can be retired on arrival
let message = redeemed.newBadge
? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message")
: NSLocalizedString("This code has already been used.", comment: "alert message")
showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message)
} else {
supporterBannerShown = true
dismiss()
}
}
} catch let error {
logger.error("apiRedeemBadgeCode: \(responseError(error))")
await MainActor.run {
submitting = false
showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: redeemErrorText(error))
let outcome = await redeemBadgeCode(user, sending)
await MainActor.run {
submitting = false
switch outcome {
case .redeemed: dismiss()
case let .refused(message): showCannotRedeemAlert(message)
case .cancelled: break
}
}
}
}
}
private weak var badgeLinkSheet: UIViewController?
// the open screen's own state, not a copy of its step, so the two cannot disagree
private weak var badgeLinkState: BadgeLinkState?
func isBadgeLinkIssuing() -> Bool {
badgeLinkSheet?.presentingViewController != nil && badgeLinkState?.step == .issuing
}
func openBadgeLink(_ codeText: String) {
guard let code = parseBadgeCode(codeText) else {
return showCannotRedeemAlert(NSLocalizedString("This code is not valid.", comment: "alert message"))
}
let state = BadgeLinkState()
badgeLinkSheet = showAppSheet {
NavigationView {
BadgesRedeemLinkView(code: code, state: state)
.modifier(ThemedBackground())
}
}
badgeLinkState = state
}
enum BadgeLinkStep {
case confirming
case issuing
case redeemed
case viewingBadge
}
final class BadgeLinkState: ObservableObject {
@Published var step = BadgeLinkStep.confirming
}
// Any web page can send a badge link, and a profile holds one badge at a time,
// so this screen asks before redeeming, names the profile, and offers nothing but the redemption.
// It is a code redemption with the code hidden, so it inherits the redeem screen's behaviour: an
// interrupted request is not resumed, and the code stays on the page that issued the link.
struct BadgesRedeemLinkView: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
@ObservedObject private var badgeModel = BadgeModel.shared
let code: String
@ObservedObject var state: BadgeLinkState
var body: some View {
switch state.step {
case .confirming: if profileHasBadge { badgeHeld() } else { confirming() }
case .issuing: beingIssued()
case .redeemed, .viewingBadge: BadgesView(showsAsSheet: true)
}
}
// read for the profile the screen names, when it renders, so the two cannot disagree;
// core refuses a code while the profile shows a badge
private var profileHasBadge: Bool {
badgeModel.userId == chatModel.currentUser?.userId && badgeModel.badgeState?.shown == true
}
// the screen cannot tell a repeated link for the badge it shows from a code for another badge,
// so it asserts neither
private func badgeHeld() -> some View {
linkStep(
"Profile already has a badge",
primary: ("View your badge", { state.step = .viewingBadge }),
textButton: ("Cancel", { closeIfShowing() })
) {
linkText(NSLocalizedString("A badge can be added to another profile, or here once this badge ends.", comment: "badge link, profile has a badge"))
linkText(NSLocalizedString("Its code is on the page you bought it on, under Show code.", comment: "badge link, profile has a badge"))
}
}
private func confirming() -> some View {
linkStep(
"Add badge to your profile?",
primary: ("Add badge", { redeemFromLink() }),
textButton: ("Cancel", { closeIfShowing() })
) {
linkText(String.localizedStringWithFormat(NSLocalizedString("The badge will be added to the profile %@.", comment: "badge link confirmation"), chatModel.currentUser?.displayName ?? ""))
}
}
// leaving does not cancel: the badge is still added after the screen closes
private func beingIssued() -> some View {
linkStep("Issuing your badge", textButton: ("Dismiss", { closeIfShowing() })) {
linkText(NSLocalizedString("The badge will be added to this profile.", comment: "badge link, being issued"))
Spacer()
ProgressView().scaleEffect(2)
}
}
// with no primary, the text button takes its place, so a button does not move between steps
private func linkStep<Content: View>(
_ title: LocalizedStringKey,
primary: (label: LocalizedStringKey, action: () -> Void)? = nil,
textButton: (label: LocalizedStringKey, action: () -> Void),
@ViewBuilder content: () -> Content
) -> some View {
let textButtonLabel = Text(textButton.label)
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
return VStack(alignment: .center, spacing: 16) {
Text(title)
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
content()
Spacer()
VStack(spacing: 10) {
if let primary {
Button {
primary.action()
} label: {
Text(primary.label)
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
.padding(.vertical, 10)
Button {
textButton.action()
} label: {
textButtonLabel
}
.frame(height: 22)
} else {
Button {
textButton.action()
} label: {
// the inset OnboardingButtonStyle gives a primary
textButtonLabel.padding()
}
.padding(.vertical, 10)
Color.clear
.frame(height: 22)
}
}
}
.padding(.horizontal, 25)
.padding(.top, 48)
.padding(.bottom, 20)
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
}
private func linkText(_ text: String) -> some View {
Text(text)
.font(.body)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
private func redeemFromLink() {
// a second tap before the screen changes must not send the code again
guard state.step == .confirming else { return }
guard let user = chatModel.currentUser else { return closeIfShowing() }
state.step = .issuing
Task {
let outcome = await redeemBadgeCode(user, code)
await MainActor.run {
switch outcome {
case .redeemed:
if !isShowing { showBadgeAddedAlert(user) }
// a covered screen is not left on the spinner; a closed one is not written to
if isOpen { state.step = .redeemed }
case let .refused(message): closeIfShowing { showCannotRedeemAlert(message) }
case .cancelled: closeIfShowing()
}
}
}
}
private var isOpen: Bool {
badgeLinkState === state && badgeLinkSheet?.presentingViewController != nil
}
// an alert over this screen still counts: dismissing from the presenter dismisses it too
private var isShowing: Bool {
guard isOpen, let sheet = badgeLinkSheet else { return false }
return sheet.presentedViewController == nil || sheet.presentedViewController is UIAlertController
}
// An outcome can arrive after this screen was closed or covered: it must not close another screen,
// and a covered one goes back to asking, so it is never left locked on the spinner.
private func closeIfShowing(then: @escaping () -> Void = {}) {
if isShowing, let presenter = badgeLinkSheet?.presentingViewController {
presenter.dismiss(animated: true, completion: then)
} else {
if isOpen { state.step = .confirming }
then()
}
}
}
struct BadgesRedeemCodeView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
@@ -11,13 +11,14 @@ import SimpleXChat
struct BadgesSupportSimplexView: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
@ObservedObject private var store = BadgeStore.shared
// set true when presented as a sheet root (from the chat-list banner) — that path doesn't
// reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top
var showsAsSheet: Bool = false
@State private var whyBuiltActive = false
@State private var howItWorksActive = false
@State private var chooseLevelActive = false
@State private var redeemCodeActive = false
var body: some View {
GeometryReader { g in
@@ -34,8 +35,6 @@ struct BadgesSupportSimplexView: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// TODO [badges] restore whyBuiltButton() when in-app purchase lands: the level screen
// returns to the flow and howItWorksButton() moves there, leaving this one alone here.
howItWorksButton()
Spacer(minLength: 0)
@@ -46,11 +45,27 @@ struct BadgesSupportSimplexView: View {
Spacer(minLength: 0)
// In-app purchase is not released: the badge is chosen on the site, and the code
// redeemed here. Revert this commit to restore the block below.
// VStack(spacing: 10) {
// chooseLevelButton()
// .padding(.vertical, 10)
// Group {
// if badgeBrowserAllowed {
// buyInBrowserButton()
// } else {
// RedeemCodeButton()
// }
// }
// .frame(height: 22)
// }
VStack(spacing: 10) {
redeemCodeButton()
chooseBadgeOnSiteButton()
.padding(.vertical, 10)
getCodeButton()
.frame(height: 22)
Group {
RedeemCodeButton()
}
.frame(height: 22)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
@@ -65,18 +80,19 @@ struct BadgesSupportSimplexView: View {
.navigationBarTitleDisplayMode(.inline)
}
// the in-app purchase path, kept compiling and uncalled until payments return after the MVP
private func chooseLevelButton() -> some View {
ZStack {
let disabled = !store.canBuy(chatModel.currentUser?.userId)
return ZStack {
Button {
chooseLevelActive = true
} label: {
Text("Choose your level")
Text("Choose your badge")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
.buttonStyle(OnboardingButtonStyle(isDisabled: disabled))
.disabled(disabled)
NavigationLink(isActive: $chooseLevelActive) {
BadgesYourLevelView()
BadgesChooseBadgeView()
.modifier(ThemedBackground())
} label: {
EmptyView()
@@ -125,14 +141,42 @@ struct BadgesSupportSimplexView: View {
}
}
private func redeemCodeButton() -> some View {
// In-app purchase is not released: delete with the commented block above to restore it.
private func chooseBadgeOnSiteButton() -> some View {
Button {
UIApplication.shared.open(URL(string: badgePageUrl)!)
} label: {
Text("Choose your badge")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
private func buyInBrowserButton() -> some View {
Button {
UIApplication.shared.open(URL(string: badgePageUrl)!)
} label: {
Text("Buy in browser")
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
}
}
}
struct RedeemCodeButton: View {
@EnvironmentObject var theme: AppTheme
@State private var redeemCodeActive = false
var body: some View {
ZStack {
Button {
redeemCodeActive = true
} label: {
Text("Redeem badge code")
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
NavigationLink(isActive: $redeemCodeActive) {
BadgesRedeemCodeView()
@@ -144,17 +188,6 @@ struct BadgesSupportSimplexView: View {
.hidden()
}
}
private func getCodeButton() -> some View {
Button {
openExternalLink(URL(string: "https://simplex.chat/badges/")!)
} label: {
Text("Get your code")
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
}
}
}
struct PhoneSupporterHero: View {
@@ -12,6 +12,7 @@ import SimpleXChat
struct BadgesView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject private var badgeModel = BadgeModel.shared
@ObservedObject private var store = BadgeStore.shared
var showsAsSheet: Bool = false
private var shownBadge: BadgeState? {
@@ -25,15 +26,28 @@ struct BadgesView: View {
if let badgeState = shownBadge {
BadgesYourBadgeView(badgeState: badgeState, showsAsSheet: showsAsSheet)
.transition(.opacity)
} else if let purchaseState = store.purchaseState(chatModel.currentUser?.userId) {
// holds the purchase screens' slot, so a consumable cannot be bought twice
BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, failure: store.creditError(chatModel.currentUser?.userId), showsAsSheet: showsAsSheet)
.transition(.opacity)
} else if store.checkingPurchases {
BadgesPurchaseStateView(title: "Checking your purchases", showsAsSheet: showsAsSheet)
.transition(.opacity)
} else {
BadgesSupportSimplexView(showsAsSheet: showsAsSheet)
.transition(.opacity)
}
}
.animation(.default, value: shownBadge != nil)
.animation(.default, value: store.purchaseState(chatModel.currentUser?.userId))
.animation(.default, value: store.checkingPurchases)
.onAppear { badgesViewShown = true }
.onDisappear { badgesViewShown = false }
}
}
private(set) var badgesViewShown = false
var supportSimpleXAlertAction: UIAlertAction {
UIAlertAction(title: NSLocalizedString("Support SimpleX", comment: "alert button"), style: .default) { _ in
openBadgesView()
+26 -12
View File
@@ -161,10 +161,10 @@ struct ActiveCallView: View {
CallSoundsPlayer.shared.stop()
}
}
case let .ice(iceCandidates):
case let .ice(iceCandidates, session):
Task {
do {
try await apiSendCallExtraInfo(call.contact, iceCandidates)
try await apiSendCallExtraInfo(call.contact, session, iceCandidates)
} catch {
logger.error("apiSendCallExtraInfo \(responseError(error))")
}
@@ -172,15 +172,24 @@ struct ActiveCallView: View {
case let .connection(state):
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
case .connected = callStatus {
call.direction == .outgoing
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
// connectedAt is kept on reconnection, so that call duration is not reset
if call.connectedAt == nil {
call.direction == .outgoing
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
call.connectedAt = .now
}
call.callState = .connected
call.connectedAt = .now
CallSoundsPlayer.shared.stop()
if !wasConnected {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
}
} else if state.connectionState == "reconnecting" {
if call.callState != .reconnecting {
CallSoundsPlayer.shared.startConnectingCallSound()
}
call.callState = .reconnecting
}
if state.connectionState == "closed" {
closeCallView(client)
@@ -190,17 +199,22 @@ struct ActiveCallView: View {
m.activeCall = nil
m.activeCallViewIsCollapsed = false
}
Task {
do {
try await apiCallStatus(call.contact, state.connectionState)
} catch {
logger.error("apiCallStatus \(responseError(error))")
// "reconnecting" is not a core call status
if state.connectionState != "reconnecting" {
Task {
do {
try await apiCallStatus(call.contact, state.connectionState)
} catch {
logger.error("apiCallStatus \(responseError(error))")
}
}
}
case let .connected(connectionInfo):
call.callState = .connected
call.connectionInfo = connectionInfo
call.connectedAt = .now
if call.connectedAt == nil {
call.connectedAt = .now
}
if !wasConnected {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
+14 -6
View File
@@ -77,6 +77,7 @@ enum CallState {
case answerReceived // incoming - webrtc answer received via API
case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed
case connected
case reconnecting
case ended
var text: LocalizedStringKey {
@@ -89,6 +90,7 @@ enum CallState {
case .answerReceived: return "received confirmation…"
case .negotiated: return "connecting…"
case .connected: return "connected"
case .reconnecting: return "reconnecting…"
case .ended: return "ended"
}
}
@@ -119,7 +121,7 @@ enum WCallCommand: Equatable, Encodable, Decodable {
case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String)
case ice(iceCandidates: String, session: String? = nil)
case media(source: CallMediaSource, enable: Bool)
case end
@@ -131,6 +133,7 @@ enum WCallCommand: Equatable, Encodable, Decodable {
case offer
case answer
case iceCandidates
case session
case enable
case iceServers
case relay
@@ -174,9 +177,10 @@ enum WCallCommand: Equatable, Encodable, Decodable {
try container.encode("answer", forKey: .type)
try container.encode(answer, forKey: .answer)
try container.encode(iceCandidates, forKey: .iceCandidates)
case let .ice(iceCandidates):
case let .ice(iceCandidates, session):
try container.encode("ice", forKey: .type)
try container.encode(iceCandidates, forKey: .iceCandidates)
try container.encodeIfPresent(session, forKey: .session)
case let .media(source, enable):
try container.encode("media", forKey: .type)
try container.encode(source, forKey: .media)
@@ -213,7 +217,8 @@ enum WCallCommand: Equatable, Encodable, Decodable {
self = .answer(answer: answer, iceCandidates: iceCandidates)
case "ice":
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates)
let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session)
self = .ice(iceCandidates: iceCandidates, session: session)
case "media":
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
@@ -231,7 +236,7 @@ enum WCallResponse: Equatable, Decodable {
case capabilities(capabilities: CallCapabilities)
case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities)
case answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String)
case ice(iceCandidates: String, session: String? = nil)
case connection(state: ConnectionState)
case connected(connectionInfo: ConnectionInfo)
case peerMedia(source: CallMediaSource, enabled: Bool)
@@ -246,6 +251,7 @@ enum WCallResponse: Equatable, Decodable {
case offer
case answer
case iceCandidates
case session
case state
case connectionInfo
case message
@@ -290,7 +296,8 @@ enum WCallResponse: Equatable, Decodable {
self = .answer(answer: answer, iceCandidates: iceCandidates)
case "ice":
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates)
let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session)
self = .ice(iceCandidates: iceCandidates, session: session)
case "connection":
let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state)
self = .connection(state: state)
@@ -333,9 +340,10 @@ extension WCallResponse: Encodable {
try container.encode("answer", forKey: .type)
try container.encode(answer, forKey: .answer)
try container.encode(iceCandidates, forKey: .iceCandidates)
case let .ice(iceCandidates):
case let .ice(iceCandidates, session):
try container.encode("ice", forKey: .type)
try container.encode(iceCandidates, forKey: .iceCandidates)
try container.encodeIfPresent(session, forKey: .session)
case let .connection(state):
try container.encode("connection", forKey: .type)
try container.encode(state, forKey: .state)
+135 -28
View File
@@ -20,6 +20,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}()
private static let ivTagBytes: Int = 28
private static let enableEncryption: Bool = true
private static let reconnectTimeout: UInt64 = 30_000_000_000
private static let iceRestartInterval: UInt64 = 10_000_000_000
private var chat_ctrl = getChatCtrl()
struct Call {
@@ -37,6 +39,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
var frameEncryptor: RTCFrameEncryptor?
var frameDecryptor: RTCFrameDecryptor?
var peerHasOldVersion: Bool
var wasConnected = false
var reconnection: Task<Void, Never>? = nil
var iceRestarts: Task<Void, Never>? = nil
}
struct NotConnectedCall {
@@ -257,11 +262,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
resp = .error(message: "answer: remote description is not set")
}
}
case let .ice(iceCandidates):
case let .ice(iceCandidates, session):
if let pc = pc,
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
addIceCandidates(pc, remoteIceCandidates)
resp = .ok
if let session {
resp = await receiveRestartSession(pc, session, remoteIceCandidates)
} else {
addIceCandidates(pc, remoteIceCandidates)
resp = .ok
}
} else {
resp = .error(message: "ice: call not started")
}
@@ -301,6 +310,101 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
}
private func startReconnecting(_ connection: RTCPeerConnection) async {
activeCall?.reconnection = Task {
_ = try? await Task.sleep(nanoseconds: WebRTCClient.reconnectTimeout)
if Task.isCancelled { return }
await sendConnectionState(connection, "disconnected")
endCall()
}
// Only the party that sent the initial offer restarts ICE, so that restart offers never collide
if connection.localDescription?.type == .offer {
activeCall?.iceRestarts = Task {
while !Task.isCancelled {
await restartIce(connection)
_ = try? await Task.sleep(nanoseconds: WebRTCClient.iceRestartInterval)
}
}
}
await sendConnectionState(connection, "reconnecting")
}
private func stopReconnecting() {
activeCall?.reconnection?.cancel()
activeCall?.reconnection = nil
activeCall?.iceRestarts?.cancel()
activeCall?.iceRestarts = nil
}
private func restartIce(_ connection: RTCPeerConnection) async {
// the previous restart offer is not answered yet
if connection.signalingState != .stable { return }
let (offer, error) = await connection.offer(iceRestart: true)
if let offer {
await sendRestartSession(offer)
} else {
logger.error("WebRTCClient: ICE restart error: \(error?.localizedDescription ?? "unknown error")")
}
}
private func receiveRestartSession(_ pc: RTCPeerConnection, _ session: String, _ remoteIceCandidates: [RTCIceCandidate]) async -> WCallResponse {
guard let desc: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: session)),
let type = desc.type, let sdp = desc.sdp else {
return .error(message: "ice: invalid session")
}
let remoteDesc = RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp)
if type == .offer && pc.localDescription?.type != .offer {
if (try? await pc.setRemoteDescription(remoteDesc)) == nil {
return .error(message: "ice: remote offer is not set")
}
let (answer, error) = await pc.answer()
guard let answer else {
return .error(message: "ice: answer error: \(error?.localizedDescription ?? "unknown error")")
}
addIceCandidates(pc, remoteIceCandidates)
await sendRestartSession(answer)
} else if type == .answer && pc.signalingState == .haveLocalOffer {
if (try? await pc.setRemoteDescription(remoteDesc)) == nil {
return .error(message: "ice: remote answer is not set")
}
addIceCandidates(pc, remoteIceCandidates)
} else {
return .error(message: "ice: unexpected ICE restart \(type.rawValue)")
}
return .ok
}
private func sendRestartSession(_ desc: RTCSessionDescription) async {
await sendCallResponse(.init(
corrId: nil,
resp: .ice(
iceCandidates: compressToBase64(input: encodeJSON(await getInitialIceCandidates())),
session: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: desc.type.toSdpType(), sdp: desc.sdp)))
),
command: nil)
)
waitForMoreIceCandidates()
}
private func sendConnectionState(_ connection: RTCPeerConnection, _ connectionState: String) async {
guard let iceConnectionState = connection.iceConnectionState.toString(),
let iceGatheringState = connection.iceGatheringState.toString(),
let signalingState = connection.signalingState.toString()
else {
return
}
await sendCallResponse(.init(
corrId: nil,
resp: .connection(state: ConnectionState(
connectionState: connectionState,
iceConnectionState: iceConnectionState,
iceGatheringState: iceGatheringState,
signalingState: signalingState)
),
command: nil)
)
}
// Spec: spec/services/calls.md#sendIceCandidates
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
await self.sendCallResponse(.init(
@@ -657,6 +761,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
(notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
guard let call = activeCall else { return }
logger.debug("WebRTCClient: ending the call")
call.reconnection?.cancel()
call.iceRestarts?.cancel()
call.connection.close()
call.connection.delegate = nil
call.frameEncryptor?.delegate = nil
@@ -678,16 +784,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
extension WebRTC.RTCPeerConnection {
func mediaConstraints() -> RTCMediaConstraints {
RTCMediaConstraints(
mandatoryConstraints: [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue,
kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue],
optionalConstraints: nil)
func mediaConstraints(iceRestart: Bool = false) -> RTCMediaConstraints {
var constraints = [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue,
kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue]
if iceRestart {
constraints[kRTCMediaConstraintsIceRestart] = kRTCMediaConstraintsValueTrue
}
return RTCMediaConstraints(mandatoryConstraints: constraints, optionalConstraints: nil)
}
func offer() async -> (RTCSessionDescription?, Error?) {
func offer(iceRestart: Bool = false) async -> (RTCSessionDescription?, Error?) {
await withCheckedContinuation { cont in
offer(for: mediaConstraints()) { (sdp, error) in
offer(for: mediaConstraints(iceRestart: iceRestart)) { (sdp, error) in
self.processSDP(cont, sdp, error)
}
}
@@ -766,24 +874,20 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)")
guard let connectionStateString = newState.toString(),
let iceConnectionStateString = connection.iceConnectionState.toString(),
let iceGatheringStateString = connection.iceGatheringState.toString(),
let signalingStateString = connection.signalingState.toString()
else {
return
}
guard let connectionStateString = newState.toString() else { return }
Task {
await sendCallResponse(.init(
corrId: nil,
resp: .connection(state: ConnectionState(
connectionState: connectionStateString,
iceConnectionState: iceConnectionStateString,
iceGatheringState: iceGatheringStateString,
signalingState: signalingStateString)
),
command: nil)
)
if activeCall?.wasConnected == true {
switch newState {
case .disconnected, .failed:
if activeCall?.reconnection == nil {
await startReconnecting(connection)
}
return
case .new, .checking: return
default: ()
}
}
await sendConnectionState(connection, connectionStateString)
switch newState {
case .checking:
@@ -792,7 +896,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
}
let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true
setSpeakerEnabledAndConfigureSession(enableSpeaker)
case .connected: sendConnectedEvent(connection)
case .connected:
stopReconnecting()
activeCall?.wasConnected = true
sendConnectedEvent(connection)
case .disconnected, .failed: endCall()
default: ()
}
@@ -50,7 +50,7 @@ struct CIChatLinkHeader: View {
HStack(alignment: .top) {
ProfileImage(
imageStr: chatLink.image,
iconName: chatLink.iconName,
icon: Image(systemName: chatLink.iconName),
size: 44,
color: Color(uiColor: .tertiaryLabel)
)
@@ -26,7 +26,7 @@ struct CIFileView: View {
var body: some View {
if smallViewSize != nil {
fileIndicator()
.simultaneousGesture(TapGesture().onEnded(fileAction))
.simultaneousGesture(TapGesture().onEnded { fileAction(file) })
} else {
// reserve exact space for the overlaid meta (timestamp + all icons), rendered transparently - matches MsgContentView
let encrypted: Bool? = if let fileSource = file?.fileSource { fileSource.cryptoArgs != nil } else { nil }
@@ -56,7 +56,7 @@ struct CIFileView: View {
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
.simultaneousGesture(TapGesture().onEnded(fileAction))
.simultaneousGesture(TapGesture().onEnded { fileAction(file) })
.disabled(!itemInteractive)
}
}
@@ -85,7 +85,7 @@ struct CIFileView: View {
return false
}
private func fileAction() {
private func fileAction(_ file: CIFile?) {
logger.debug("CIFileView fileAction")
if let file = file {
switch (file.fileStatus) {
@@ -93,6 +93,7 @@ struct CIFileView: View {
if let prohibited = file.fileProhibited {
showProhibitedFileAlert(file, prohibited)
} else {
m.openWhenReceived(file) { fileAction($0) }
Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task")
if let user = m.currentUser {
@@ -117,7 +118,7 @@ struct CIFileView: View {
case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete")
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
openFile(file, fileSource)
}
case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError")
@@ -128,12 +129,12 @@ struct CIFileView: View {
case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
openFile(file, fileSource)
}
case .sndComplete:
logger.debug("CIFileView fileAction - in .sndComplete")
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
openFile(file, fileSource)
}
case let .sndError(sndFileError):
logger.debug("CIFileView fileAction - in .sndError")
@@ -146,6 +147,14 @@ struct CIFileView: View {
}
}
private func openFile(_ file: CIFile, _ fileSource: CryptoFile) {
if isMarkdownFile(file) {
showMarkdownFile(file)
} else {
saveCryptoFile(fileSource)
}
}
@ViewBuilder private func fileIndicator() -> some View {
if let file = file {
switch file.fileStatus {
@@ -39,6 +39,13 @@ struct CIGroupInvitationView: View {
Divider().frame(width: frameWidth)
if chat.chatInfo.groupInfo != nil {
Text(chatItem.chatDir.sent ? String.localizedStringWithFormat(NSLocalizedString("For %@ only", comment: ""), groupInvitation.localDisplayName) : NSLocalizedString("For you only", comment: ""))
.font(.caption)
.foregroundColor(theme.colors.secondary)
.overlay(DetermineWidth())
}
if action {
VStack(alignment: .leading, spacing: 2) {
groupInvitationText()
@@ -108,7 +115,7 @@ struct CIGroupInvitationView: View {
return HStack(alignment: .top) {
ProfileImage(
imageStr: groupInvitation.groupProfile.image,
iconName: "person.2.circle.fill",
icon: Image(systemName: "person.2.circle.fill"),
size: 44,
color: color
)
@@ -54,6 +54,7 @@ struct CIImageView: View {
if let prohibited = file.fileProhibited {
showProhibitedFileAlert(file, prohibited)
} else {
m.openWhenReceived(file) { _ in showFullScreenImage = true }
Task {
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
@@ -52,7 +52,7 @@ struct CILinkView: View {
func openBrowserAlert(uri: String) {
let (url, err) = sanitizeUri(uri)
if let url {
let uriStr = url.uri.absoluteString
let uriStr = displayedUri(url.uri.absoluteString)
showAlert(
NSLocalizedString("Open link?", comment: "alert title"),
message: uriStr.count > 160 ? "\(uriStr.prefix(160))…" : uriStr,
@@ -90,9 +90,9 @@ func openBrowserAlert(uri: String) {
func showInvalidLinkAlert(_ uri: String, error: String? = nil) {
let message = if let error, !error.isEmpty {
error + "\n" + uri
error + "\n" + displayedUri(uri)
} else {
uri
displayedUri(uri)
}
showAlert(
NSLocalizedString("Invalid link", comment: "alert title"),
@@ -101,6 +101,25 @@ func showInvalidLinkAlert(_ uri: String, error: String? = nil) {
)
}
func unicodeMarker(_ s: Unicode.Scalar) -> String {
String(format: "[U+%04X]", s.value)
}
func displayedUri(_ uri: String) -> String {
let authorityStart = uri.range(of: "://")?.upperBound ?? uri.startIndex
let authority = authorityStart ..< (uri[authorityStart...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex)
var shown = ""
for i in uri.unicodeScalars.indices {
let s = uri.unicodeScalars[i]
if s.properties.generalCategory == .format || (!s.isASCII && authority.contains(i)) {
shown += unicodeMarker(s)
} else {
shown.unicodeScalars.append(s)
}
}
return shown
}
func sanitizeUri(_ s: String) -> (url: (uri: URL, sanitizedUri: URL?)?, error: String?) {
let parsed = parseSanitizeUri(s, safe: false)
return if let uri = URL(string: s), let uriInfo = parsed?.uriInfo {
@@ -120,6 +120,25 @@ struct CIVideoView: View {
}
}
private func playReceived(_ file: CIFile) {
setupPlayer(file)
if urlDecrypted == nil {
decrypt(file: file, completed: playVideo)
} else {
playVideo()
}
}
private func playVideo() {
if smallView {
showFullScreenPlayer = urlDecrypted != nil
} else if let urlDecrypted {
m.stopPreviousRecPlay = urlDecrypted
videoPlaying = true
player?.play()
}
}
private func setupPlayer(_ file: CIFile?) {
let newUrl = getLoadedVideo(file)
if newUrl == url {
@@ -424,6 +443,7 @@ struct CIVideoView: View {
if let prohibited = file.fileProhibited {
showProhibitedFileAlert(file, prohibited)
} else {
m.openWhenReceived(file, open: playReceived)
Task {
if let user = m.currentUser {
await receiveFile(user, file.fileId, false, false)
@@ -303,6 +303,11 @@ struct VoiceMessagePlayer: View {
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
playPauseIcon(icon, theme.colors.primary)
.simultaneousGesture(TapGesture().onEnded {
chatModel.openWhenReceived(recordingFile) { file in
if let recordingSource = getLoadedFileSource(file) {
startPlayback(recordingSource)
}
}
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
@@ -334,7 +334,7 @@ struct FramedItemView: View {
@inline(__always)
private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View {
MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline, prefix: prefix, stripLink: stripLink)
MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, markdownBlocks: qi.markdownBlocks, textStyle: .subheadline, prefix: prefix, stripLink: stripLink)
.lineLimit(lines)
.padding(.bottom, 6)
}
@@ -364,13 +364,16 @@ struct FramedItemView: View {
chat: chat,
text: text,
formattedText: ft,
markdownBlocks: text == "" ? nil : ci.markdownBlocks,
textStyle: .body,
meta: ci.meta,
mentions: ci.mentions,
userMemberId: chat.chatInfo.groupInfo?.membership.memberId,
rightToLeft: rtl,
prefix: txtPrefix,
stripLink: stripLink
stripLink: stripLink,
blockLayout: true,
cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote
)
.environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme)))
.multilineTextAlignment(rtl ? .trailing : .leading)
@@ -0,0 +1,215 @@
import SwiftUI
import SimpleXChat
import Ink
private let MAX_MARKDOWN_VIEW_SIZE: Int64 = 1_048_576
private let MAX_MARKDOWN_LINE_LENGTH = 15_000
private let MAX_MARKDOWN_NESTING = 16
private let MAX_MARKDOWN_LINK_NESTING = 4
private let MAX_MARKDOWN_TABLE_ROWS = 200
private let MAX_MARKDOWN_IMAGE_DIMENSION: CGFloat = 4320
private let MAX_MARKDOWN_IMAGE_PIXELS: CGFloat = 2160 * 4320
func isMarkdownFile(_ file: CIFile) -> Bool {
let ext = (file.fileName as NSString).pathExtension.lowercased()
return file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && (ext == "md" || ext == "markdown")
}
func showMarkdownFile(_ file: CIFile) {
guard let fileSource = getLoadedFileSource(file) else { return }
Task {
do {
let data = try getFileData(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs)
let text = decodedText(data)
let lines = text.components(separatedBy: "\n")
let html = withinMarkdownLimits(lines)
? MarkdownParser(modifiers: [Modifier(target: .html) { escapedHTML($0.markdown) }, imagesWithinBudget()]).html(from: text)
: nil
let shownLines = lines.flatMap(chunkedLine)
let hasBidiControls = text.unicodeScalars.contains(where: isBidiControl)
await MainActor.run {
showAppSheet {
MarkdownFileView(file: file, fileSource: fileSource, lines: shownLines, html: html, hasBidiControls: hasBidiControls)
}
}
} catch {
await MainActor.run {
showAlert(NSLocalizedString("Error reading file", comment: "alert title"), message: error.localizedDescription)
}
}
}
}
private func decodedText(_ data: Data) -> String {
var text = String.UnicodeScalarView()
var parser = Unicode.UTF8.ForwardParser()
var bytes = data.makeIterator()
while true {
switch parser.parseScalar(from: &bytes) {
case let .valid(encoded): text.append(replacingControlChar(Unicode.UTF8.decode(encoded)))
case let .error(length): text.append(contentsOf: repeatElement("?" as Unicode.Scalar, count: length))
case .emptyInput: return String(text).replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
}
}
}
private func replacingControlChar(_ s: Unicode.Scalar) -> Unicode.Scalar {
s.value < 0x20 && s != "\t" && s != "\n" && s != "\r" ? "?" : s
}
private func withinMarkdownLimits(_ lines: [String]) -> Bool {
var tableRows = 0
var linkNesting = 0
for line in lines {
tableRows = line.contains("|") ? tableRows + 1 : 0
if line.count > MAX_MARKDOWN_LINE_LENGTH || nestingLevel(line) > MAX_MARKDOWN_NESTING || tableRows > MAX_MARKDOWN_TABLE_ROWS || !linkNestingWithinLimit(line, &linkNesting) {
return false
}
}
return true
}
private func linkNestingWithinLimit(_ line: String, _ nesting: inout Int) -> Bool {
if line.isEmpty { nesting = 0 }
for c in line {
if c == "[" {
nesting += 1
if nesting > MAX_MARKDOWN_LINK_NESTING { return false }
} else if c == "]" && nesting > 0 {
nesting -= 1
}
}
return true
}
private func nestingLevel(_ line: String) -> Int {
let content = line.drop(while: { $0 == " " || $0 == "\t" || $0 == ">" })
let prefix = line[..<content.startIndex]
let quotes = prefix.filter { $0 == ">" }.count
let listItem = content.first.map { "-*+".contains($0) || $0.isNumber } ?? false
return quotes + (listItem ? (prefix.count - quotes) / 2 : 0)
}
private func chunkedLine(_ line: String) -> [String] {
let chars = Array(line)
return stride(from: 0, to: max(chars.count, 1), by: MAX_MARKDOWN_LINE_LENGTH).map {
String(chars[$0 ..< min($0 + MAX_MARKDOWN_LINE_LENGTH, chars.count)])
}
}
private func escapedHTML(_ s: Substring) -> String {
s.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
private func imagesWithinBudget() -> Modifier {
var pixels = MAX_MARKDOWN_IMAGE_PIXELS
return Modifier(target: .images) { image in
let src = image.html.dropFirst("<img src=\"".count).prefix(while: { $0 != "\"" })
guard src.hasPrefix("data:image/") else { return escapedHTML(image.markdown) }
let parts = src.split(separator: ",", maxSplits: 1)
guard parts.count == 2,
parts[0].hasSuffix(";base64"),
let data = Data(base64Encoded: String(parts[1])),
let size = UIImage(data: data)?.size,
size.width <= MAX_MARKDOWN_IMAGE_DIMENSION,
size.height <= MAX_MARKDOWN_IMAGE_DIMENSION,
size.width * size.height <= pixels
else { return "" }
pixels -= size.width * size.height
return "<img src=\"\(src)\"/>"
}
}
private func isBidiControl(_ s: Unicode.Scalar) -> Bool {
[0x061C, 0x200E, 0x200F].contains(s.value) || (0x202A...0x202E).contains(s.value) || (0x2066...0x2069).contains(s.value)
}
private func withInvisibleCharMarkers(_ line: String) -> Text {
var text = Text(verbatim: "")
var plain = ""
for s in line.unicodeScalars {
if s.properties.generalCategory == .format {
text = text + Text(verbatim: plain) + Text(verbatim: unicodeMarker(s)).foregroundColor(.orange)
plain = ""
} else {
plain.unicodeScalars.append(s)
}
}
return text + Text(verbatim: plain)
}
private func openMarkdownFileLink(_ url: URL) {
let uri = url.absoluteString
let parsed = parseSimpleXMarkdown(uri)
let format = parsed?.count == 1 ? parsed?.first?.format : nil
switch format {
case let .simplexLink(_, _, simplexUri, _)?:
if let simplexUrl = URL(string: simplexUri) {
ChatModel.shared.appOpenUrl = simplexUrl
} else {
showInvalidLinkAlert(uri)
}
case .uri? where uri.hasPrefix("https://") || uri.hasPrefix("http://"):
openBrowserAlert(uri: uri)
default:
showInvalidLinkAlert(uri)
}
}
struct MarkdownFileView: View {
@AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW) private var textView = false
let file: CIFile
let fileSource: CryptoFile
let lines: [String]
let html: String?
let hasBidiControls: Bool
var body: some View {
NavigationView {
VStack(spacing: 0) {
if hasBidiControls {
Text("This file has text direction characters. The text can be shown in a different order than it is written.")
.foregroundColor(.orange)
.padding()
}
if let html, !textView {
ConditionsWebView(html: html, openLink: openMarkdownFileLink)
.padding(.horizontal)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(lines.indices, id: \.self) { i in
withInvisibleCharMarkers(lines[i])
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
}
.padding()
}
}
}
.modifier(ThemedBackground())
.navigationTitle(file.fileName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if html != nil {
Button {
textView.toggle()
} label: {
Image(systemName: textView ? "doc.richtext" : "chevron.left.forwardslash.chevron.right")
}
}
Button {
saveCryptoFile(fileSource)
} label: {
Image(systemName: "square.and.arrow.up")
}
}
}
}
.navigationViewStyle(.stack)
}
}
@@ -32,6 +32,7 @@ struct MsgContentView: View {
@EnvironmentObject var theme: AppTheme
var text: String
var formattedText: [FormattedText]? = nil
var markdownBlocks: [MarkdownBlock]? = nil
var textStyle: UIFont.TextStyle
var sender: String? = nil
var meta: CIMeta? = nil
@@ -40,6 +41,8 @@ struct MsgContentView: View {
var rightToLeft = false
var prefix: NSAttributedString? = nil
var stripLink: String? = nil
var blockLayout = false
var cardColor: Color? = nil
@State private var showSecrets: Set<Int> = []
@State private var typingIdx = 0
@State private var timer: Timer?
@@ -51,7 +54,7 @@ struct MsgContentView: View {
@AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true
var body: some View {
let v = msgContentView()
let v = contentView()
if meta?.isLive == true {
v.onAppear {
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
@@ -95,11 +98,67 @@ struct MsgContentView: View {
typingIdx = 0
}
@ViewBuilder
private func contentView() -> some View {
let fts = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText }
if blockLayout, let fts, !fts.isEmpty, let blocks = markdownBlocks {
MarkdownBlocksView(
formattedText: fts,
blocks: blocks,
textStyle: textStyle,
cardColor: cardColor ?? theme.appColors.receivedQuote,
rightToLeft: rightToLeft,
blockText: { index, range, font, secretStart, last in blockTextView(fts, blocks, index, range, font, secretStart, last) },
metaLine: { metaLineView() }
)
} else {
msgContentView()
}
}
private func blockTextView(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ index: Int, _ range: Range<Int>, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> some View {
let r = messageText(
text,
fts,
markdownBlocks: blocks,
textStyle: textStyle,
sender: index == 0 ? sender : nil,
mentions: mentions,
userMemberId: userMemberId,
showSecrets: showSecrets,
commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady,
backgroundColor: containerBackground,
prefix: index == 0 ? prefix : nil,
range: range,
baseFont: font,
secretStart: secretStart
)
let s = r.string
let t: Text
if last, let mt = meta {
if mt.isLive {
s.append(typingIndicator(mt.recent))
}
t = Text(AttributedString(s)) + reserveSpaceForMeta(mt)
} else {
t = Text(AttributedString(s))
}
return msgTextResultView(r, t, showSecrets: $showSecrets, sendCommand: { cmd in sendCommandMsg(chat, cmd) })
}
@ViewBuilder
private func metaLineView() -> some View {
if let mt = meta {
(mt.isLive ? Text(AttributedString(typingIndicator(mt.recent))) : Text(verbatim: "")) + reserveSpaceForMeta(mt, newLine: false)
}
}
@inline(__always)
private func msgContentView() -> some View {
let r = messageText(
text,
formattedText,
markdownBlocks: markdownBlocks,
textStyle: textStyle,
sender: sender,
mentions: mentions,
@@ -131,8 +190,8 @@ struct MsgContentView: View {
}
@inline(__always)
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature)
private func reserveSpaceForMeta(_ mt: CIMeta, newLine: Bool = true) -> Text {
(rightToLeft && newLine ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature)
}
}
@@ -317,8 +376,11 @@ private struct FullProfileDescriptionView: View {
.listRowBackground(Color.clear)
Section {
let r = markdownText(description, showSecrets: showSecrets, backgroundColor: theme.colors.background)
msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
let parsed = parseSimpleXMarkdownText(description)
MarkdownView(
text: description, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks,
backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets
)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
@@ -338,9 +400,11 @@ func markdownText(
showSecrets: Set<Int>? = nil,
backgroundColor: Color
) -> MsgTextResult {
messageText(
let parsed = parseSimpleXMarkdownText(s)
return messageText(
s,
parseSimpleXMarkdown(s),
parsed?.formattedText,
markdownBlocks: parsed?.markdownBlocks,
textStyle: textStyle,
sender: sender,
preview: preview,
@@ -396,6 +460,7 @@ func markdownProfileDescription(
func messageText(
_ text: String,
_ formattedText: [FormattedText]?,
markdownBlocks: [MarkdownBlock]? = nil,
textStyle: UIFont.TextStyle = .body,
sender: String?,
preview: Bool = false,
@@ -405,21 +470,24 @@ func messageText(
commands: Bool = false,
backgroundColor: UIColor,
prefix: NSAttributedString? = nil,
stripLink: String? = nil
stripLink: String? = nil,
range: Range<Int>? = nil,
baseFont: UIFont? = nil,
secretStart: Int = 0
) -> MsgTextResult {
let text = if let stripLink { stripTextLink(text, stripLink) } else { text }
let formattedText = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText }
let res = NSMutableAttributedString()
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
let font = UIFont.preferredFont(forTextStyle: textStyle)
let descr = baseFont?.fontDescriptor ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
let font = baseFont ?? UIFont.preferredFont(forTextStyle: textStyle)
let plain: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor.label
]
let secretColor = backgroundColor.withAlphaComponent(1)
var link: [NSAttributedString.Key: Any]?
var hasSecrets = false
var handleTaps = false
var secretIdx: Int = secretStart
if let sender {
if preview {
@@ -437,19 +505,57 @@ func messageText(
}
if let fts = formattedText, fts.count > 0 {
if let range {
let start = min(max(range.lowerBound, 0), fts.count)
appendSegments(fts[start..<max(start, min(range.upperBound, fts.count))], false)
} else if let markdownBlocks {
let headings = headingSegments(fts.count, markdownBlocks)
var i = 0
while i < fts.count {
var j = i + 1
while j < fts.count && headings[j] == headings[i] { j += 1 }
appendSegments(fts[i..<j], headings[i])
i = j
}
} else {
appendSegments(fts[...], false)
}
} else {
res.append(NSMutableAttributedString(string: text, attributes: plain))
}
return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps)
func segmentFonts(_ heading: Bool) -> (descr: UIFontDescriptor, font: UIFont) {
if !heading { return (descr, font) }
let d = descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]])
return (d, UIFont(descriptor: d, size: d.pointSize))
}
func appendSegments(_ fts: ArraySlice<FormattedText>, _ heading: Bool) {
let (descr, font) = segmentFonts(heading)
let plain: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor.label
]
var link: [NSAttributedString.Key: Any]?
var bold: UIFont?
var italic: UIFont?
var snippet: UIFont?
var small: UIFont?
var mention: UIFont?
var secretIdx: Int = 0
for ft in fts {
var t = ft.text
var attrs = plain
switch (ft.format) {
case .bold:
bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize)
attrs[.font] = bold
case let .bold(singleStar):
if singleStar && markdownBlocks != nil {
italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
attrs[.font] = italic
} else {
bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize)
attrs[.font] = bold
}
case .italic:
italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
attrs[.font] = italic
@@ -490,12 +596,13 @@ func messageText(
attrs[webLinkAttrKey] = true
handleTaps = true
}
t = displayedUri(t)
case let .hyperLink(text, uri):
attrs = linkAttrs()
if let text { t = text }
if !preview {
attrs[linkAttrKey] = uri
attrs[webLinkAttrKey] = true
if !ft.isEmailLink { attrs[webLinkAttrKey] = true }
handleTaps = true
}
case let .simplexLink(text, linkType, simplexUri, smpHosts):
@@ -562,24 +669,319 @@ func messageText(
attrs[modalAttrKey] = Format.modal(modalName: modalName, text: text)
handleTaps = true
}
case .escape: t = ""
case let .markup(token):
if range != nil { t = token.drawnText } else if markdownBlocks != nil { t = token.previewText }
case .unknown: ()
case .none: ()
}
res.append(NSAttributedString(string: t, attributes: attrs))
}
} else {
res.append(NSMutableAttributedString(string: text, attributes: plain))
}
return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps)
func linkAttrs() -> [NSAttributedString.Key: Any] {
link = link ?? [
.font: font,
.foregroundColor: uiLinkColor,
.underlineStyle: NSUnderlineStyle.single.rawValue
]
return link!
func linkAttrs() -> [NSAttributedString.Key: Any] {
link = link ?? [
.font: font,
.foregroundColor: uiLinkColor,
.underlineStyle: NSUnderlineStyle.single.rawValue
]
return link!
}
}
}
let swipeExclusionSpace = "swipeExclusion"
private let maxGridCells = 1000
struct SwipeExclusionFrames: PreferenceKey {
static var defaultValue: [CGRect] = []
static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
value += nextValue()
}
}
struct MarkdownView: View {
var text: String
var formattedText: [FormattedText]?
var markdownBlocks: [MarkdownBlock]?
var sender: String? = nil
var mentions: [String: CIMention]? = nil
var userMemberId: String? = nil
var backgroundColor: Color
var cardColor: Color
@Binding var showSecrets: Set<Int>
var body: some View {
if let fts = formattedText, !fts.isEmpty, let blocks = markdownBlocks {
MarkdownBlocksView(
formattedText: fts,
blocks: blocks,
textStyle: .body,
cardColor: cardColor,
rightToLeft: isRightToLeft(text),
blockText: { index, range, font, secretStart, _ in
let r = messageText(
text, fts, markdownBlocks: blocks, sender: index == 0 ? sender : nil, mentions: mentions, userMemberId: userMemberId,
showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor), range: range, baseFont: font, secretStart: secretStart
)
msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
},
metaLine: { EmptyView() }
)
} else {
let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor))
msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
}
}
}
private struct BlocksLayout {
let font: UIFont
let starts: [Int]
let secrets: [Int]
let lastIsText: Bool
init(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ textStyle: UIFont.TextStyle) {
font = UIFont.preferredFont(forTextStyle: textStyle)
starts = blocks.reduce(into: [0]) { $0.append($0[$0.count - 1] + $1.textRanges.count) }
secrets = fts.reduce(into: [0]) { $0.append($0[$0.count - 1] + ($1.isSecret ? 1 : 0)) }
lastIsText = switch blocks.last {
case .paragraph, .heading, .quote, .list: true
default: false
}
}
}
struct MarkdownBlocksView<BlockText: View, MetaLine: View>: View {
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
var formattedText: [FormattedText]
var blocks: [MarkdownBlock]
var textStyle: UIFont.TextStyle
var cardColor: Color
var rightToLeft: Bool
@ViewBuilder var blockText: (_ index: Int, _ range: Range<Int>, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> BlockText
@ViewBuilder var metaLine: () -> MetaLine
var body: some View {
let l = BlocksLayout(formattedText, blocks, textStyle)
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(blocks.enumerated()), id: \.offset) { k, block in
if k > 0 && paragraphBreak(blocks[k - 1].endIndex, block.startIndex) {
Color.clear.frame(height: l.font.lineHeight / 2)
}
blockView(block, l.starts[k], l)
}
if !l.lastIsText {
metaLine()
}
}
.multilineTextAlignment(.leading)
.environment(\.layoutDirection, rightToLeft ? .rightToLeft : .leftToRight)
}
private func text(_ index: Int, _ r: SegmentRange, _ font: UIFont, _ l: BlocksLayout) -> BlockText {
blockText(index, r.from..<max(r.from, r.to), font, l.secrets[min(max(r.from, 0), formattedText.count)], l.lastIsText && index == l.starts[l.starts.count - 1] - 1)
}
@ViewBuilder private func blockView(_ block: MarkdownBlock, _ start: Int, _ l: BlocksLayout) -> some View {
switch block {
case let .paragraph(content):
text(start, content, l.font, l)
case let .heading(level, content):
text(start, content, headingFont(l.font, level), l)
case let .quote(content):
HStack(alignment: .top, spacing: 10) {
RoundedRectangle(cornerRadius: 2).fill(Color(uiColor: .label).opacity(0.16)).frame(width: 4)
text(start, content, l.font, l).opacity(0.72)
}
.fixedSize(horizontal: false, vertical: true)
case let .list(items):
ForEach(Array(listEntries(items, 0).enumerated()), id: \.offset) { j, e in
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(e.entry.number ?? "•").font(.system(size: l.font.pointSize))
text(start + j, e.entry.content, l.font, l)
}
.padding(.leading, 20 * CGFloat(e.level))
}
case let .codeBlock(_, content):
CodeBlockCard(color: cardColor, radius: 12 * roundness, copyText: codeText(content)) {
text(start, content, monospacedFont(l.font), l).fixedSize(horizontal: true, vertical: false)
}
case let .table(columns, header, rows):
tableView(columns, [header] + rows, start, l)
case .rule:
Rectangle().fill(Color(uiColor: .secondaryLabel).opacity(0.35)).frame(height: 1).padding(.vertical, 6)
}
}
@ViewBuilder private func tableView(_ columns: [TableAlign], _ tableRows: [[SegmentRange]], _ start: Int, _ l: BlocksLayout) -> some View {
let rowStarts = tableRows.reduce(into: [start]) { $0.append($0[$0.count - 1] + $1.count) }
let columnCount = max(columns.count, tableRows.map(\.count).max() ?? 0)
let lineColor = Color(uiColor: .secondaryLabel).opacity(0.35)
let radius = 12 * roundness
if #available(iOS 16, *) {
if columnCount * tableRows.count <= maxGridCells {
HScroll {
Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) {
ForEach(Array(tableRows.enumerated()), id: \.offset) { r, row in
GridRow {
ForEach(0..<columnCount, id: \.self) { c in
Group {
if c < row.count {
text(rowStarts[r] + c, row[c], r == 0 ? boldFont(l.font) : l.font, l).fixedSize()
} else {
Text(verbatim: "")
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: cellAlignment(c < columns.count ? columns[c] : .none))
.background(r == 0 ? cardColor : r % 2 == 0 ? Color(uiColor: .secondaryLabel).opacity(0.1) : Color.clear)
.overlay(Rectangle().stroke(lineColor, lineWidth: 0.5))
}
}
}
}
.fixedSize()
.clipShape(RoundedRectangle(cornerRadius: radius))
.overlay(RoundedRectangle(cornerRadius: radius).stroke(lineColor, lineWidth: 1))
}
} else {
tableSourceCard(tableRows, l, radius)
}
} else {
tableSourceCard(tableRows, l, radius)
}
}
private func tableSourceCard(_ tableRows: [[SegmentRange]], _ l: BlocksLayout, _ radius: CGFloat) -> some View {
let source = tableSource(tableRows)
return CodeBlockCard(color: cardColor, radius: radius, copyText: source) {
Text(verbatim: source).font(.system(size: l.font.pointSize * 0.875, design: .monospaced)).fixedSize(horizontal: true, vertical: false)
}
}
private func paragraphBreak(_ from: Int, _ to: Int) -> Bool {
let start = min(max(from, 0), formattedText.count)
return formattedText[start..<max(start, min(to, formattedText.count))].contains {
if case .markup(.paragraphBreak) = $0.format { true } else { false }
}
}
private func codeText(_ r: SegmentRange) -> String {
let start = min(max(r.from, 0), formattedText.count)
return formattedText[start..<max(start, min(r.to, formattedText.count))].map {
switch $0.format {
case let .markup(token): token.drawnText
case .escape: ""
default: $0.text
}
}.joined()
}
private func tableSource(_ tableRows: [[SegmentRange]]) -> String {
let start = min(max((tableRows.first?.first?.from ?? 1) - 1, 0), formattedText.count)
let end = min((tableRows.last?.last?.to ?? 0) + 1, formattedText.count)
return formattedText[start..<max(start, end)].map(\.text).joined()
}
}
private extension MarkdownBlock {
var startIndex: Int {
if case let .rule(mark) = self { mark.from } else { textRanges.first?.range.from ?? 0 }
}
var endIndex: Int {
if case let .rule(mark) = self { mark.to } else { textRanges.last?.range.to ?? 0 }
}
}
private func headingFont(_ font: UIFont, _ level: Int) -> UIFont {
let scale: CGFloat = switch level {
case 1: 1.5
case 2: 1.3
case 3: 1.15
default: 1
}
return UIFont(descriptor: boldFont(font).fontDescriptor, size: font.pointSize * scale)
}
private func boldFont(_ font: UIFont) -> UIFont {
UIFont(descriptor: font.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: font.pointSize)
}
private func monospacedFont(_ font: UIFont) -> UIFont {
UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.875, weight: .regular)
}
private func cellAlignment(_ align: TableAlign) -> Alignment {
switch align {
case .right: .trailing
case .center: .center
default: .leading
}
}
private struct CodeBlockCard<Content: View>: View {
@EnvironmentObject var theme: AppTheme
var color: Color
var radius: CGFloat
var copyText: String
@ViewBuilder var content: () -> Content
@State private var showCopy = false
var body: some View {
HScroll {
content()
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .topTrailing) {
if showCopy {
Button {
UIPasteboard.general.string = copyText
showCopy = false
} label: {
Image(systemName: "doc.on.doc")
.foregroundColor(theme.colors.primary)
.frame(width: 28, height: 28)
.background(color)
.clipShape(RoundedRectangle(cornerRadius: radius * 2 / 3))
}
.padding(4)
}
}
.background(color)
.clipShape(RoundedRectangle(cornerRadius: radius))
.onTapGesture { showCopy.toggle() }
}
}
private struct HScroll<Content: View>: View {
@ViewBuilder var content: () -> Content
var body: some View {
if #available(iOS 16, *) {
ViewThatFits(in: .horizontal) {
content()
scrollView()
}
} else {
scrollView()
}
}
private func scrollView() -> some View {
ScrollView(.horizontal, showsIndicators: false) {
content()
}
.background {
GeometryReader { g in
Color.clear.preference(key: SwipeExclusionFrames.self, value: [g.frame(in: .named(swipeExclusionSpace))])
}
}
}
}
@@ -607,7 +1009,9 @@ func stripTextLink(_ text: String, _ link: String) -> String {
func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [FormattedText]? {
guard var ft, ft.last?.text == link else { return ft }
ft.removeLast()
if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") {
if let i = ft.indices.last, case .markup = ft[i].format {
ft.removeLast()
} else if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") {
ft[i].text = String(ft[i].text.dropLast())
if ft[i].text.isEmpty { ft.removeLast() }
}
@@ -267,7 +267,7 @@ struct ChatItemInfoView: View {
private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
let backgroundColor = chatItemFrameColor(ci, theme)
return VStack(alignment: .leading, spacing: 4) {
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor)
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, itemVersion.markdownBlocks, nil, backgroundColor: backgroundColor)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(backgroundColor)
@@ -295,9 +295,12 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading)
}
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View {
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ markdownBlocks: [MarkdownBlock]?, _ sender: String? = nil, backgroundColor: Color) -> some View {
if text != "" {
TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor)
TextBubble(
text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: ci.mentions, userMemberId: userMemberId,
backgroundColor: backgroundColor, cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote
)
} else {
Text("no text")
.italic()
@@ -309,15 +312,19 @@ struct ChatItemInfoView: View {
@EnvironmentObject var theme: AppTheme
var text: String
var formattedText: [FormattedText]?
var markdownBlocks: [MarkdownBlock]?
var sender: String? = nil
var mentions: [String: CIMention]?
var userMemberId: String?
var backgroundColor: Color
var cardColor: Color
@State private var showSecrets: Set<Int> = []
var body: some View {
let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor))
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
MarkdownView(
text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: mentions, userMemberId: userMemberId,
backgroundColor: backgroundColor, cardColor: cardColor, showSecrets: $showSecrets
)
}
}
@@ -342,7 +349,7 @@ struct ChatItemInfoView: View {
private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
let backgroundColor = quotedMsgFrameColor(qi, theme)
return VStack(alignment: .leading, spacing: 4) {
textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor)
textBubble(qi.text, qi.formattedText, qi.markdownBlocks, qi.getSender(nil), backgroundColor: backgroundColor)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, theme))
+7 -4
View File
@@ -1735,6 +1735,7 @@ struct ChatView: View {
@State private var markReadTask: Task<Void, Never>? = nil
@State private var actionSheet: SomeActionSheet? = nil
@State private var swipeOffset: CGFloat = 0
@State private var swipeExclusionFrames: [CGRect] = []
var revealed: Bool { revealedItems.contains(chatItem.id) }
@@ -1968,7 +1969,7 @@ struct ChatView: View {
.padding(.trailing, 12)
}
HStack(alignment: .top, spacing: 10) {
ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background)
ProfileImage(imageStr: groupInfo.image, icon: Image(systemName: groupInfo.chatIconName), size: memberImageSize, backgroundColor: theme.colors.background)
.simultaneousGesture(TapGesture().onEnded {
showChatInfoSheet = true
})
@@ -2052,7 +2053,7 @@ struct ChatView: View {
}
HStack(alignment: .top, spacing: 10) {
if ci.meta.showGroupAsSender {
ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background)
ProfileImage(imageStr: groupInfo.image, icon: Image(systemName: groupInfo.chatIconName), size: memberImageSize, backgroundColor: theme.colors.background)
.simultaneousGesture(TapGesture().onEnded {
showChatInfoSheet = true
})
@@ -2156,12 +2157,14 @@ struct ChatView: View {
.padding(.bottom, 4)
}
}
.coordinateSpace(name: swipeExclusionSpace)
.onPreferenceChange(SwipeExclusionFrames.self) { swipeExclusionFrames = $0 }
.offset(x: swipeOffset)
.contentShape(Rectangle())
.simultaneousGesture(
DragGesture(minimumDistance: 10)
DragGesture(minimumDistance: 10, coordinateSpace: .named(swipeExclusionSpace))
.onChanged { value in
guard canReply else { return }
guard canReply, !swipeExclusionFrames.contains(where: { $0.contains(value.startLocation) }) else { return }
let x = value.translation.width
if x < 0 {
swipeOffset = max(x * 0.63, -56)
@@ -11,7 +11,7 @@ struct ComposeChatLinkView: View {
HStack(alignment: .center, spacing: 8) {
ProfileImage(
imageStr: chatLink.image,
iconName: chatLink.iconName,
icon: Image(systemName: chatLink.iconName),
size: 44
)
.padding(.leading, 12)
@@ -767,6 +767,7 @@ struct ComposeView: View {
let connectedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connStatus == .ready && $0.wrapped.activeConn?.connFailedErr == nil }.count
let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
guard total > 0 || chatModel.membersLoaded else { return nil }
let noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total
return (hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays)
}
@@ -71,7 +71,7 @@ struct ContextItemView: View {
}
private func contextMsgPreview(_ contextItem: ChatItem) -> some View {
let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr)
let r = messageText(contextItem.text, contextItem.formattedText, markdownBlocks: contextItem.markdownBlocks, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr)
let t = attachment() + Text(AttributedString(r.string))
return t.if(r.hasSecrets, transform: hiddenSecretsView)
@@ -37,7 +37,7 @@ struct GroupProfileView: View {
var body: some View {
List {
EditProfileImage(profileImage: $groupProfile.image, iconName: groupInfo.chatIconName, showChooseSource: $showChooseSource)
EditProfileImage(profileImage: $groupProfile.image, icon: Image(systemName: groupInfo.chatIconName), showChooseSource: $showChooseSource)
.if(!focusDisplayName) { $0.padding(.top) }
Section {
@@ -59,8 +59,11 @@ struct GroupWelcomeView: View {
}
private func textPreview() -> some View {
let r = markdownText(welcomeText, showSecrets: showSecrets, backgroundColor: theme.colors.background)
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
let parsed = parseSimpleXMarkdownText(welcomeText)
return MarkdownView(
text: welcomeText, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks,
backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets
)
.frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -299,7 +299,7 @@ struct ChatPreviewView: View {
func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
let (itemText, itemFormattedText) = chatItemPreviewText(cItem)
let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix())
let r = messageText(itemText, itemFormattedText, markdownBlocks: itemFormattedText == nil ? nil : cItem.markdownBlocks, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix())
return (Text(AttributedString(r.string)), r.hasSecrets)
func chatItemPreviewText(_ ci: ChatItem) -> (String, [FormattedText]?) {
@@ -444,7 +444,7 @@ struct ChatPreviewView: View {
smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) {
ProfileImage(
imageStr: chatLink.image,
iconName: chatLink.iconName,
icon: Image(systemName: chatLink.iconName),
size: dynamicMediaSize,
color: Color(uiColor: .tertiaryLabel)
)
+6 -2
View File
@@ -31,14 +31,18 @@ private struct PrivacySensitive: ViewModifier {
// Presented from the top view controller instead of a .sheet on a parent view, so an alert button or
// a view that is itself in a sheet can open it.
func showAppSheet<Content: View>(@ViewBuilder content: () -> Content) {
@discardableResult
func showAppSheet<Content: View>(@ViewBuilder content: () -> Content) -> UIViewController? {
if let topController = getTopViewController() {
let v = content()
.modifier(PrivacySensitive())
.environmentObject(ChatModel.shared)
.environmentObject(AppTheme.shared)
topController.present(UIHostingController(rootView: v), animated: true)
let sheet = UIHostingController(rootView: v)
topController.present(sheet, animated: true)
return sheet
}
return nil
}
extension View {
@@ -19,7 +19,7 @@ struct ChatInfoImage: View {
let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color }
return ProfileImage(
imageStr: chat.chatInfo.image,
iconName: chatIconName(chat.chatInfo),
icon: chatIcon(chat.chatInfo),
size: size,
color: iconColor
)
@@ -12,7 +12,7 @@ import SimpleXChat
struct ProfileImage: View {
@EnvironmentObject var theme: AppTheme
var imageStr: String? = nil
var iconName: String = "person.crop.circle.fill"
var icon: Image = Image(systemName: "person.crop.circle.fill")
var size: CGFloat
var color = Color(uiColor: .tertiarySystemGroupedBackground)
var backgroundColor: Color? = nil
@@ -24,7 +24,7 @@ struct ProfileImage: View {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred)
} else {
let c = color.asAnotherColorFromSecondaryVariant(theme)
Image(systemName: iconName)
icon
.resizable()
.foregroundColor(c)
.scaledToFit()
@@ -98,7 +98,7 @@ func openExternalLink(_ url: URL) {
} else {
showAlert(
title: NSLocalizedString("Open external link?", comment: "alert title"),
message: s,
message: displayedUri(s),
buttonTitle: NSLocalizedString("Open", comment: "alert button"),
buttonAction: { UIApplication.shared.open(url) },
cancelButton: true
@@ -50,7 +50,7 @@ struct AddChannelView: View {
Spacer(minLength: 0)
ZStack(alignment: .center) {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: profile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128)
ProfileImage(imageStr: profile.image, icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: 128)
if profile.image != nil {
Button {
profile.image = nil
@@ -285,7 +285,7 @@ struct AddChannelView: View {
let total = groupRelays.count
return List {
Group {
ProfileImage(imageStr: gInfo.groupProfile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128)
ProfileImage(imageStr: gInfo.groupProfile.image, icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: 128)
.frame(maxWidth: .infinity, alignment: .center)
Text(gInfo.groupProfile.displayName)
@@ -71,7 +71,7 @@ struct AddGroupView: View {
Spacer(minLength: 0)
ZStack(alignment: .center) {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: profile.image, iconName: "person.2.circle.fill", size: 128)
ProfileImage(imageStr: profile.image, icon: Image(systemName: "person.2.circle.fill"), size: 128)
if profile.image != nil {
Button {
profile.image = nil
@@ -1129,11 +1129,9 @@ private func showPrepareContactAlert(
profileImage:
ProfileImage(
imageStr: contactShortLinkData.profile.image,
iconName: contactShortLinkData.business
? "briefcase.circle.fill"
: contactShortLinkData.profile.peerType == .bot
? "cube.fill"
: "person.crop.circle.fill",
icon: contactShortLinkData.business
? Image(systemName: "briefcase.circle.fill")
: peerTypeIcon(contactShortLinkData.profile.peerType),
size: alertProfileImageSize
),
profileBadge: contactShortLinkData.localBadge,
@@ -1186,9 +1184,9 @@ private func showPrepareGroupAlert(
profileImage:
ProfileImage(
imageStr: groupShortLinkData.groupProfile.image,
iconName: isChannel
icon: Image(systemName: isChannel
? "antenna.radiowaves.left.and.right.circle.fill"
: "person.2.circle.fill",
: "person.2.circle.fill"),
size: alertProfileImageSize
),
theme: theme,
@@ -1240,7 +1238,7 @@ private func showOpenKnownContactAlert(
profileImage:
ProfileImage(
imageStr: contact.profile.image,
iconName: contact.chatIconName,
icon: contact.chatIcon,
size: alertProfileImageSize
),
profileBadge: contact.active ? contact.profile.localBadge : nil,
@@ -1288,7 +1286,7 @@ private func showOpenKnownGroupAlert(
profileImage:
ProfileImage(
imageStr: groupInfo.groupProfile.image,
iconName: groupInfo.chatIconName,
icon: Image(systemName: groupInfo.chatIconName),
size: alertProfileImageSize
),
theme: theme,
@@ -1605,7 +1603,7 @@ func planAndConnect(
profileImage:
ProfileImage(
imageStr: groupSLinkData.groupProfile.image,
iconName: "antenna.radiowaves.left.and.right.circle.fill",
icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"),
size: alertProfileImageSize
),
theme: theme,
@@ -1632,7 +1630,7 @@ func planAndConnect(
profileImage:
ProfileImage(
imageStr: groupSLinkData.groupProfile.image,
iconName: "person.2.circle.fill",
icon: Image(systemName: "person.2.circle.fill"),
size: alertProfileImageSize
),
theme: theme,
@@ -12,11 +12,15 @@ import WebKit
struct ConditionsWebView: UIViewRepresentable {
@State var html: String
var openLink: (URL) -> Void = openExternalLink
@EnvironmentObject var theme: AppTheme
@State var pageLoaded = false
func makeUIView(context: Context) -> WKWebView {
let view = WKWebView()
let config = WKWebViewConfiguration()
config.defaultWebpagePreferences.allowsContentJavaScript = false
config.websiteDataStore = .nonPersistent()
let view = WKWebView(frame: .zero, configuration: config)
view.backgroundColor = .clear
view.isOpaque = false
view.navigationDelegate = context.coordinator
@@ -35,8 +39,9 @@ struct ConditionsWebView: UIViewRepresentable {
}
private func loadPage(_ webView: WKWebView) {
let nonce = UUID().uuidString
let styles = """
<style>
<style nonce="\(nonce)">
body {
color: \(theme.colors.onBackground.toHTMLHex());
font-family: Helvetica;
@@ -48,9 +53,23 @@ struct ConditionsWebView: UIViewRepresentable {
font-family: Menlo;
background: \(theme.colors.secondary.opacity(theme.colors.isLight ? 0.2 : 0.3).toHTMLHex());
}
pre {
overflow-x: auto;
}
table {
border-collapse: collapse;
}
th, td {
border: 1px solid \(theme.colors.secondary.toHTMLHex());
padding: 4px 8px;
}
img {
max-width: 100%;
}
</style>
"""
let head = "<head><meta name='viewport' content='width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=no'>\(styles)</head>"
let csp = "<meta http-equiv='Content-Security-Policy' content=\"default-src 'none'; style-src 'nonce-\(nonce)'; img-src data:; base-uri 'none'; form-action 'none'\">"
let head = "<head>\(csp)<meta name='viewport' content='width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=no'>\(styles)</head>"
webView.loadHTMLString(head + html, baseURL: nil)
DispatchQueue.main.async {
pageLoaded = true
@@ -58,10 +77,16 @@ struct ConditionsWebView: UIViewRepresentable {
}
func makeCoordinator() -> Cordinator {
Cordinator()
Cordinator(openLink: openLink)
}
class Cordinator: NSObject, WKNavigationDelegate {
let openLink: (URL) -> Void
init(openLink: @escaping (URL) -> Void) {
self.openLink = openLink
}
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
@@ -71,9 +96,9 @@ struct ConditionsWebView: UIViewRepresentable {
switch navigationAction.navigationType {
case .linkActivated:
decisionHandler(.cancel)
openExternalLink(url)
openLink(url)
default:
decisionHandler(.allow)
decisionHandler(url.scheme == "about" ? .allow : .cancel)
}
}
}
@@ -84,6 +84,7 @@ let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage"
let DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView"
let DEFAULT_CURRENT_THEME = "currentTheme"
let DEFAULT_SYSTEM_DARK_THEME = "systemDarkTheme"
@@ -145,6 +146,7 @@ let appDefaults: [String: Any] = [
DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true,
DEFAULT_SHOW_SENT_VIA_RPOXY: false,
DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false,
DEFAULT_MARKDOWN_FILE_TEXT_VIEW: false,
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue,
DEFAULT_THEME_OVERRIDES: "{}",
@@ -10,33 +10,66 @@ import SwiftUI
import SimpleXChat
struct StorageView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@State var appGroupFiles: [String: Int64] = [:]
@State var documentsFiles: [String: Int64] = [:]
var body: some View {
ScrollView {
VStack(alignment: .leading) {
directoryView("App group:", appGroupFiles)
directoryView("App group:", getGroupContainerDirectory(), appGroupFiles)
if !documentsFiles.isEmpty {
directoryView("Documents:", documentsFiles)
directoryView("Documents:", getDocumentsDirectory(), documentsFiles)
}
}
}
.padding()
.onAppear {
appGroupFiles = traverseFiles(in: getGroupContainerDirectory())
documentsFiles = traverseFiles(in: getDocumentsDirectory())
}
.onAppear(perform: loadFiles)
}
private func loadFiles() {
appGroupFiles = traverseFiles(in: getGroupContainerDirectory())
documentsFiles = traverseFiles(in: getDocumentsDirectory())
}
@ViewBuilder
private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View {
private func directoryView(_ name: LocalizedStringKey, _ dir: URL, _ contents: [String: Int64]) -> some View {
Text(name).font(.headline)
ForEach(Array(contents), id: \.key) { (key, value) in
Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary)))
let sizeText = Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary)))
if dir.appendingPathComponent(key).path == getTempFilesDirectory().path {
let stopped = m.chatRunning == false
HStack {
sizeText
Button("Delete temp data", role: .destructive, action: confirmDeleteTempFiles)
.disabled(!stopped)
}
if !stopped {
Text("Stop chat in Settings → Chat data → Database passphrase & export to delete temp data.")
.font(.footnote)
.foregroundColor(theme.colors.secondary)
}
} else {
sizeText
}
}
}
private func confirmDeleteTempFiles() {
showAlert(
NSLocalizedString("Delete temp data?", comment: "alert title"),
message: NSLocalizedString("Files being sent or received will never complete. Videos in unsent drafts will be sent without the file.", comment: "alert message"),
actions: {[
UIAlertAction(title: NSLocalizedString("Delete", comment: "alert action"), style: .destructive) { _ in
deleteTempFiles()
loadFiles()
},
cancelAlertAction
]}
)
}
private func traverseFiles(in dir: URL) -> [String: Int64] {
var res: [String: Int64] = [:]
let fm = FileManager.default
@@ -139,7 +139,7 @@ struct UserAddressView: View {
title: Text("Share address with SimpleX contacts?"),
message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."),
primaryButton: .default(Text("Share")) {
setProfileAddress($progressIndicator, true)
setProfileAddress($progressIndicator, true) { shareViaProfile = false }
shareViaProfile = true
}, secondaryButton: .cancel()
)
@@ -318,7 +318,7 @@ struct UserAddressView: View {
alert = .shareOnCreate
progressIndicator = false
} else {
setProfileAddress($progressIndicator, true)
setProfileAddress($progressIndicator, true) { shareViaProfile = false }
shareViaProfile = true
}
} else {
@@ -601,7 +601,7 @@ struct AddressSettingsState: Equatable {
}
}
private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool) {
private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool, revert: @escaping () -> Void) {
progressIndicator.wrappedValue = true
Task {
do {
@@ -613,7 +613,11 @@ private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool) {
await MainActor.run { progressIndicator.wrappedValue = false }
} catch let error {
logger.error("apiSetProfileAddress: \(responseError(error))")
await MainActor.run { progressIndicator.wrappedValue = false }
await MainActor.run {
progressIndicator.wrappedValue = false
revert()
showErrorAlert(error, NSLocalizedString("Error saving profile", comment: "alert title"))
}
}
}
}
@@ -709,7 +713,10 @@ struct UserAddressSettingsView: View {
title: NSLocalizedString("Share", comment: "alert action"),
style: .default,
handler: { _ in
setProfileAddress($progressIndicator, on)
setProfileAddress($progressIndicator, on) {
ignoreShareViaProfileChange = true
shareViaProfile = !on
}
}
)
]}
@@ -731,7 +738,10 @@ struct UserAddressSettingsView: View {
title: NSLocalizedString("Stop sharing", comment: "alert action"),
style: .default,
handler: { _ in
setProfileAddress($progressIndicator, on)
setProfileAddress($progressIndicator, on) {
ignoreShareViaProfileChange = true
shareViaProfile = !on
}
}
)
]}
@@ -29,14 +29,14 @@ struct UserProfile: View {
var body: some View {
List {
EditProfileImage(profileImage: $profile.image, iconName: "person.crop.circle.fill", showChooseSource: $showChooseSource)
EditProfileImage(profileImage: $profile.image, icon: Image(systemName: "person.crop.circle.fill"), showChooseSource: $showChooseSource)
.padding(.top)
Section {
HStack {
TextField("Enter your name…", text: $profile.displayName)
.focused($focusDisplayName)
if !validDisplayName(profile.displayName) {
if !validNewProfileName {
Button {
alert = .invalidNameError(validName: mkValidName(profile.displayName))
} label: {
@@ -147,10 +147,15 @@ struct UserProfile: View {
(chatModel.currentUser?.profile.description ?? "") != description.trimmingCharacters(in: .whitespacesAndNewlines)
) &&
profile.displayName.trimmingCharacters(in: .whitespaces) != "" &&
validDisplayName(profile.displayName) &&
validNewProfileName &&
bioFitsLimit()
}
private var validNewProfileName: Bool {
profile.displayName == chatModel.currentUser?.profile.displayName
|| validDisplayName(profile.displayName)
}
private func saveProfile() {
focusDisplayName = false
Task {
@@ -216,7 +221,7 @@ struct EditProfileImage: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
@Binding var profileImage: String?
var iconName: String
var icon: Image
@Binding var showChooseSource: Bool
var body: some View {
@@ -232,7 +237,7 @@ struct EditProfileImage: View {
}
} else {
ZStack(alignment: .center) {
ProfileImage(imageStr: profileImage, iconName: iconName, size: 160)
ProfileImage(imageStr: profileImage, icon: icon, size: 160)
editImageButton { showChooseSource = true }
}
}
File diff suppressed because it is too large. Load diff
@@ -35,12 +35,14 @@
<target>#tajný#</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%1$@ supported SimpleX Chat. The badge expired on %2$@." xml:space="preserve">
<trans-unit id="%1$@ supported SimpleX Chat. The badge expired on %2$@." xml:space="preserve" approved="no">
<source>%1$@ supported SimpleX Chat. The badge expired on %2$@.</source>
<target state="needs-translation">%1$@ podpořil SimpleX Chat. Odznak vypršel %2$@.</target>
<note>badge alert</note>
</trans-unit>
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve">
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve" approved="no">
<source>%1$02d hrs %2$02d min %3$02d sec</source>
<target state="translated">%1$02d hod %2$02d min %3$02d sek</target>
<note>countdown</note>
</trans-unit>
<trans-unit id="%@" xml:space="preserve">
@@ -93,8 +95,9 @@
<target>%@ staženo</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ invested in SimpleX Chat crowdfunding." xml:space="preserve">
<trans-unit id="%@ invested in SimpleX Chat crowdfunding." xml:space="preserve" approved="no">
<source>%@ invested in SimpleX Chat crowdfunding.</source>
<target state="translated">%@ investoval do SimpleX Chat crowdfunding.</target>
<note>badge alert</note>
</trans-unit>
<trans-unit id="%@ is connected!" xml:space="preserve">
@@ -122,8 +125,9 @@
<target>%@ servery</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ supports SimpleX Chat." xml:space="preserve">
<trans-unit id="%@ supports SimpleX Chat." xml:space="preserve" approved="no">
<source>%@ supports SimpleX Chat.</source>
<target state="translated">%@ podporuje SimpleX Chat.</target>
<note>badge alert</note>
</trans-unit>
<trans-unit id="%@ uploaded" xml:space="preserve">
@@ -156,8 +160,9 @@
<target>%@:</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%d day" xml:space="preserve">
<trans-unit id="%d day" xml:space="preserve" approved="no">
<source>%d day</source>
<target state="translated">%d den</target>
<note>time interval</note>
</trans-unit>
<trans-unit id="%d days" xml:space="preserve">
@@ -205,30 +210,36 @@
<target>%d měsíce</target>
<note>time interval</note>
</trans-unit>
<trans-unit id="%d owner" xml:space="preserve">
<trans-unit id="%d owner" xml:space="preserve" approved="no">
<source>%d owner</source>
<target state="translated">%d majitel</target>
<note>channel owners count</note>
</trans-unit>
<trans-unit id="%d owners" xml:space="preserve">
<trans-unit id="%d owners" xml:space="preserve" approved="no">
<source>%d owners</source>
<target state="translated">%d majitelů</target>
<note>channel owners count</note>
</trans-unit>
<trans-unit id="%d owners &amp; contributors" xml:space="preserve">
<trans-unit id="%d owners &amp; contributors" xml:space="preserve" approved="no">
<source>%d owners &amp; contributors</source>
<target state="translated">%d majitelů a přispěvatelů</target>
<note>channel members count</note>
</trans-unit>
<trans-unit id="%d relays failed" xml:space="preserve">
<trans-unit id="%d relays failed" xml:space="preserve" approved="no">
<source>%d relays failed</source>
<target state="translated">%d relé selhalo</target>
<note>channel relay bar
channel subscriber relay bar</note>
</trans-unit>
<trans-unit id="%d relays not active" xml:space="preserve">
<trans-unit id="%d relays not active" xml:space="preserve" approved="no">
<source>%d relays not active</source>
<target state="translated">%d relé není aktivní</target>
<note>channel relay bar
channel subscriber relay bar</note>
</trans-unit>
<trans-unit id="%d relays removed" xml:space="preserve">
<trans-unit id="%d relays removed" xml:space="preserve" approved="no">
<source>%d relays removed</source>
<target state="translated">%d relé odstraněno</target>
<note>channel relay bar
channel subscriber relay bar</note>
</trans-unit>
@@ -247,12 +258,14 @@ channel subscriber relay bar</note>
<target>%d přeskočené zprávy</target>
<note>integrity error chat item</note>
</trans-unit>
<trans-unit id="%d subscriber" xml:space="preserve">
<trans-unit id="%d subscriber" xml:space="preserve" approved="no">
<source>%d subscriber</source>
<target state="needs-translation">%d odběratel</target>
<note>channel subscriber count</note>
</trans-unit>
<trans-unit id="%d subscribers" xml:space="preserve">
<trans-unit id="%d subscribers" xml:space="preserve" approved="no">
<source>%d subscribers</source>
<target state="translated">%d odběratelů</target>
<note>channel subscriber count</note>
</trans-unit>
<trans-unit id="%d weeks" xml:space="preserve">
@@ -260,38 +273,46 @@ channel subscriber relay bar</note>
<target>%d týdnů</target>
<note>time interval</note>
</trans-unit>
<trans-unit id="%d/%d relays active" xml:space="preserve">
<trans-unit id="%d/%d relays active" xml:space="preserve" approved="no">
<source>%1$d/%2$d relays active</source>
<target state="needs-translation">%1$d/%2$d relé aktivní</target>
<note>channel creation progress
channel relay bar progress</note>
</trans-unit>
<trans-unit id="%d/%d relays active, %d errors" xml:space="preserve">
<trans-unit id="%d/%d relays active, %d errors" xml:space="preserve" approved="no">
<source>%1$d/%2$d relays active, %3$d errors</source>
<target state="translated">%1$d/%2$d relé aktivní, %3$d chyb</target>
<note>channel relay bar</note>
</trans-unit>
<trans-unit id="%d/%d relays active, %d failed" xml:space="preserve">
<trans-unit id="%d/%d relays active, %d failed" xml:space="preserve" approved="no">
<source>%1$d/%2$d relays active, %3$d failed</source>
<target state="translated">%1$d/%2$d relé aktivní, %3$d selhalo</target>
<note>channel creation progress with errors
channel relay bar</note>
</trans-unit>
<trans-unit id="%d/%d relays active, %d removed" xml:space="preserve">
<trans-unit id="%d/%d relays active, %d removed" xml:space="preserve" approved="no">
<source>%1$d/%2$d relays active, %3$d removed</source>
<target state="translated">%1$d/%2$d relé aktivní, %3$d odstraněno</target>
<note>channel relay bar</note>
</trans-unit>
<trans-unit id="%d/%d relays connected" xml:space="preserve">
<trans-unit id="%d/%d relays connected" xml:space="preserve" approved="no">
<source>%1$d/%2$d relays connected</source>
<target state="translated">%1$d/%2$d relé připojeno</target>
<note>channel subscriber relay bar progress</note>
</trans-unit>
<trans-unit id="%d/%d relays connected, %d errors" xml:space="preserve">
<trans-unit id="%d/%d relays connected, %d errors" xml:space="preserve" approved="no">
<source>%1$d/%2$d relays connected, %3$d errors</source>
<target state="translated">%1$d/%2$d relé připojeno, %3$d chyb</target>
<note>channel subscriber relay bar</note>
</trans-unit>
<trans-unit id="%d/%d relays connected, %d failed" xml:space="preserve">
<trans-unit id="%d/%d relays connected, %d failed" xml:space="preserve" approved="no">
<source>%1$d/%2$d relays connected, %3$d failed</source>
<target state="translated">%1$d/%2$d relé připojeno, %3$d selhalo</target>
<note>channel subscriber relay bar</note>
</trans-unit>
<trans-unit id="%d/%d relays connected, %d removed" xml:space="preserve">
<trans-unit id="%d/%d relays connected, %d removed" xml:space="preserve" approved="no">
<source>%1$d/%2$d relays connected, %3$d removed</source>
<target state="translated">%1$d/%2$d relé připojeno, %3$d odstraněno</target>
<note>channel subscriber relay bar</note>
</trans-unit>
<trans-unit id="%lld" xml:space="preserve">
@@ -304,8 +325,9 @@ channel relay bar</note>
<target>%lld %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%lld channel events" xml:space="preserve">
<trans-unit id="%lld channel events" xml:space="preserve" approved="no">
<source>%lld channel events</source>
<target state="translated">%lld událostí kanálu</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%lld contact(s) selected" xml:space="preserve">
@@ -408,8 +430,9 @@ channel relay bar</note>
<target>%u zpráv přeskočeno.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="(from owner)" xml:space="preserve">
<trans-unit id="(from owner)" xml:space="preserve" approved="no">
<source>(from owner)</source>
<target state="translated">(od majitele)</target>
<note>chat link info line</note>
</trans-unit>
<trans-unit id="(new)" xml:space="preserve">
@@ -442,8 +465,9 @@ channel relay bar</note>
<target>**Nejsoukromější**: nepoužívejte server oznámení SimpleX Chat, pravidelně kontrolujte zprávy na pozadí (závisí na tom, jak často aplikaci používáte).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." xml:space="preserve">
<trans-unit id="**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." xml:space="preserve" approved="no">
<source>**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection.</source>
<target state="translated">**Čtěte prosím**: Jako ochrana zabezpečení, použití stejné databáze na dvou zařízeních rozbije dešifrování zpráv z vašich připojení.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Please note**: you will NOT be able to recover or change passphrase if you lose it." xml:space="preserve">
@@ -461,8 +485,9 @@ channel relay bar</note>
<target>**Skenovat / Vložit odkaz**: pro připojení pomocí odkazu který jste obdrželi.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Test relay** to retrieve its name." xml:space="preserve">
<trans-unit id="**Test relay** to retrieve its name." xml:space="preserve" approved="no">
<source>**Test relay** to retrieve its name.</source>
<target state="needs-translation">**Test relé** pro načtení jeho názvu.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Warning**: Instant push notifications require passphrase saved in Keychain." xml:space="preserve">
@@ -508,10 +533,13 @@ channel relay bar</note>
- a více!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="- opt-in to send link previews.&#10;- prevent hyperlink phishing.&#10;- remove link tracking." xml:space="preserve">
<trans-unit id="- opt-in to send link previews.&#10;- prevent hyperlink phishing.&#10;- remove link tracking." xml:space="preserve" approved="no">
<source>- opt-in to send link previews.
- prevent hyperlink phishing.
- remove link tracking.</source>
<target state="translated">- opt-in odesílání náhledu odkazů.
- zabránění phishingu hyperlinků.
- odstranění sledování odkazů.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="- optionally notify deleted contacts.&#10;- profile names with spaces.&#10;- and more!" xml:space="preserve">
@@ -575,8 +603,9 @@ time interval</note>
<target>1 rok</target>
<note>delete after time</note>
</trans-unit>
<trans-unit id="1-time link" xml:space="preserve">
<trans-unit id="1-time link" xml:space="preserve" approved="no">
<source>1-time link</source>
<target state="translated">Jednorázový odkaz</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="1-time link can be used *with one contact only* - share in person or via any messenger." xml:space="preserve">
@@ -611,8 +640,9 @@ time interval</note>
<target>Ještě pár věcí</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="A link for one person to connect" xml:space="preserve">
<trans-unit id="A link for one person to connect" xml:space="preserve" approved="no">
<source>A link for one person to connect</source>
<target state="translated">Odkaz k připojení pro jednu osobu</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="A new contact" xml:space="preserve">
@@ -662,8 +692,9 @@ time interval</note>
<target>O operátorech</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Accent" xml:space="preserve">
<trans-unit id="Accent" xml:space="preserve" approved="no">
<source>Accent</source>
<target state="translated">Zbarvení</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Accept" xml:space="preserve">
@@ -720,12 +751,14 @@ swipe action</note>
<target>Přijaté podmínky</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Acknowledged" xml:space="preserve">
<trans-unit id="Acknowledged" xml:space="preserve" approved="no">
<source>Acknowledged</source>
<target state="translated">Potvrzeno</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Acknowledgement errors" xml:space="preserve">
<trans-unit id="Acknowledgement errors" xml:space="preserve" approved="no">
<source>Acknowledgement errors</source>
<target state="translated">Chyba potvrzení</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Active" xml:space="preserve">
@@ -738,8 +771,9 @@ swipe action</note>
<target>Aktivní spojení</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Add" xml:space="preserve">
<trans-unit id="Add" xml:space="preserve" approved="no">
<source>Add</source>
<target state="translated">Přidat</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts." xml:space="preserve">
@@ -40,8 +40,9 @@
<target>%1$@ hat SimpleX Chat unterstützt. Das Abzeichen ist am %2$@ abgelaufen.</target>
<note>badge alert</note>
</trans-unit>
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve">
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve" approved="no">
<source>%1$02d hrs %2$02d min %3$02d sec</source>
<target state="translated">%1$02d h %2$02d min %3$02d s</target>
<note>countdown</note>
</trans-unit>
<trans-unit id="%@" xml:space="preserve">
@@ -159,8 +160,9 @@
<target>%@:</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%d day" xml:space="preserve">
<trans-unit id="%d day" xml:space="preserve" approved="no">
<source>%d day</source>
<target state="translated">%d Tag</target>
<note>time interval</note>
</trans-unit>
<trans-unit id="%d days" xml:space="preserve">
@@ -2723,8 +2725,9 @@ Das ist Ihr eigener Einmal-Link!</target>
<target>Link wird erstellt…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve">
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve" approved="no">
<source>Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)</source>
<target state="translated">Crowdfunding-Investoren können vor Verkaufsbeginn Namen reservieren: [simplex.domains](https://simplex.domains/)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Crowdfunding on Wefunder" xml:space="preserve">
@@ -8950,8 +8953,9 @@ copied message info</note>
<target>SimpleX-Name ist nicht verifiziert</target>
<note>alert title</note>
</trans-unit>
<trans-unit id="SimpleX name sale starts in" xml:space="preserve">
<trans-unit id="SimpleX name sale starts in" xml:space="preserve" approved="no">
<source>SimpleX name sale starts in</source>
<target state="translated">Der Verkauf von SimpleX-Namen startet in</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="SimpleX one-time invitation" xml:space="preserve">
@@ -10107,8 +10111,9 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt
<target>Einstellungen aktualisieren?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve">
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve" approved="no">
<source>Update the app to register a SimpleX domain</source>
<target state="translated">Aktualisieren Sie die App, um eine SimpleX-Domain registrieren zu können</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Updated conditions" xml:space="preserve">
@@ -11758,8 +11763,9 @@ pref value</note>
<target>weitergeleitet</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="forwarded from" xml:space="preserve">
<trans-unit id="forwarded from" xml:space="preserve" approved="no">
<source>forwarded from</source>
<target state="translated">weitergeleitet aus</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="group" xml:space="preserve">
@@ -12269,8 +12275,9 @@ Zuletzt empfangene Nachricht: %2$@</target>
<target>Ungeschützt</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve">
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve" approved="no">
<source>until you can register a SimpleX domain</source>
<target state="translated">bis Sie eine SimpleX-Domain registrieren können</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="updated channel profile" xml:space="preserve">
@@ -40,8 +40,9 @@
<target>%1$@ ha apoyado a SimpleX Chat. La insignia caducó el %2$@.</target>
<note>badge alert</note>
</trans-unit>
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve">
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve" approved="no">
<source>%1$02d hrs %2$02d min %3$02d sec</source>
<target state="translated">%1$02d hrs %2$02d min %3$02d seg</target>
<note>countdown</note>
</trans-unit>
<trans-unit id="%@" xml:space="preserve">
@@ -159,8 +160,9 @@
<target>%@:</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%d day" xml:space="preserve">
<trans-unit id="%d day" xml:space="preserve" approved="no">
<source>%d day</source>
<target state="translated">%d día</target>
<note>time interval</note>
</trans-unit>
<trans-unit id="%d days" xml:space="preserve">
@@ -1249,9 +1251,9 @@ swipe action</note>
<target>Icono de la aplicación</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App passcode" xml:space="preserve">
<trans-unit id="App passcode" xml:space="preserve" approved="no">
<source>App passcode</source>
<target>Código de acceso de la aplicación</target>
<target state="translated">Código de acceso</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App passcode is replaced with self-destruct passcode." xml:space="preserve">
@@ -2723,8 +2725,9 @@ This is your own one-time link!</source>
<target>Creando enlace…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve">
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve" approved="no">
<source>Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)</source>
<target state="translated">Los inversores de crowdfunding pueden reservar nombres antes del comienzo de la venta: [simplex.domains](https://simplex.domains/)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Crowdfunding on Wefunder" xml:space="preserve">
@@ -2737,9 +2740,9 @@ This is your own one-time link!</source>
<target>Crowdfunding en Wefunder.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Current Passcode" xml:space="preserve">
<trans-unit id="Current Passcode" xml:space="preserve" approved="no">
<source>Current Passcode</source>
<target>Código de Acceso</target>
<target state="translated">Código de Acceso</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Current conditions text couldn't be loaded, you can review conditions via this link:" xml:space="preserve">
@@ -8950,8 +8953,9 @@ copied message info</note>
<target>Nombre SimpleX no verificado</target>
<note>alert title</note>
</trans-unit>
<trans-unit id="SimpleX name sale starts in" xml:space="preserve">
<trans-unit id="SimpleX name sale starts in" xml:space="preserve" approved="no">
<source>SimpleX name sale starts in</source>
<target state="translated">La venta de nombres SimpleX comienza en</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="SimpleX one-time invitation" xml:space="preserve">
@@ -10107,8 +10111,9 @@ Se te pedirá que completes la autenticación antes de activar esta función.</t
<target>¿Actualizar configuración?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve">
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve" approved="no">
<source>Update the app to register a SimpleX domain</source>
<target state="translated">Actualiza la app para registrar un dominio SimpleX</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Updated conditions" xml:space="preserve">
@@ -11758,8 +11763,9 @@ pref value</note>
<target>reenviado</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="forwarded from" xml:space="preserve">
<trans-unit id="forwarded from" xml:space="preserve" approved="no">
<source>forwarded from</source>
<target state="translated">reenviado desde</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="group" xml:space="preserve">
@@ -12269,8 +12275,9 @@ last received msg: %2$@</source>
<target>desprotegida</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve">
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve" approved="no">
<source>until you can register a SimpleX domain</source>
<target state="translated">hasta que puedas registrar un dominio SimpleX</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="updated channel profile" xml:space="preserve">
@@ -40,8 +40,9 @@
<target>%1$@ a soutenu SimpleX Chat. Le badge a expiré le %2$@.</target>
<note>badge alert</note>
</trans-unit>
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve">
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve" approved="no">
<source>%1$02d hrs %2$02d min %3$02d sec</source>
<target state="translated">%1$02d h %2$02d min %3$02d s</target>
<note>countdown</note>
</trans-unit>
<trans-unit id="%@" xml:space="preserve">
@@ -159,8 +160,9 @@
<target>%@ :</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%d day" xml:space="preserve">
<trans-unit id="%d day" xml:space="preserve" approved="no">
<source>%d day</source>
<target state="translated">%d jour</target>
<note>time interval</note>
</trans-unit>
<trans-unit id="%d days" xml:space="preserve">
@@ -2723,8 +2725,9 @@ Il s'agit de votre propre lien unique !</target>
<target>Création d'un lien…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve">
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve" approved="no">
<source>Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)</source>
<target state="translated">Les investisseurs du financement participatif peuvent réserver des noms avant le début de la vente : [simplex.domains](https://simplex.domains/)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Crowdfunding on Wefunder" xml:space="preserve">
@@ -8950,8 +8953,9 @@ copied message info</note>
<target>Nom SimpleX non vérifié</target>
<note>alert title</note>
</trans-unit>
<trans-unit id="SimpleX name sale starts in" xml:space="preserve">
<trans-unit id="SimpleX name sale starts in" xml:space="preserve" approved="no">
<source>SimpleX name sale starts in</source>
<target state="translated">La vente du nom SimpleX commence dans</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="SimpleX one-time invitation" xml:space="preserve">
@@ -10107,8 +10111,9 @@ Une authentification vous sera demandée avant que cette fonctionnalité ne soit
<target>Mettre à jour les paramètres ?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve">
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve" approved="no">
<source>Update the app to register a SimpleX domain</source>
<target state="translated">Mettez à jour l’application pour enregistrer un domaine SimpleX</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Updated conditions" xml:space="preserve">
@@ -11758,8 +11763,9 @@ pref value</note>
<target>transféré</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="forwarded from" xml:space="preserve">
<trans-unit id="forwarded from" xml:space="preserve" approved="no">
<source>forwarded from</source>
<target state="translated">transféré depuis</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="group" xml:space="preserve">
@@ -12269,8 +12275,9 @@ dernier message reçu : %2$@</target>
<target>non protégé</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve">
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve" approved="no">
<source>until you can register a SimpleX domain</source>
<target state="translated">jusqu’à ce que vous puissiez enregistrer un domaine SimpleX</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="updated channel profile" xml:space="preserve">
@@ -40,8 +40,9 @@
<target>%1$@ támogatta a SimpleX Chatet. A kitűző lejárt ekkor: %2$@.</target>
<note>badge alert</note>
</trans-unit>
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve">
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve" approved="no">
<source>%1$02d hrs %2$02d min %3$02d sec</source>
<target state="translated">%1$02d óra %2$02d perc %3$02d mp</target>
<note>countdown</note>
</trans-unit>
<trans-unit id="%@" xml:space="preserve">
@@ -159,8 +160,9 @@
<target>%@:</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%d day" xml:space="preserve">
<trans-unit id="%d day" xml:space="preserve" approved="no">
<source>%d day</source>
<target state="translated">%d nap</target>
<note>time interval</note>
</trans-unit>
<trans-unit id="%d days" xml:space="preserve">
@@ -2723,8 +2725,9 @@ Ez a saját egyszer használható meghívója!</target>
<target>Hivatkozás létrehozása…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve">
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve" approved="no">
<source>Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)</source>
<target state="translated">A közösségi finanszírozásban részt vevő befektetők már az értékesítés megkezdése előtt lefoglalhatnak SimpleX-neveket: [simplex.domains](https://simplex.domains/)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Crowdfunding on Wefunder" xml:space="preserve">
@@ -8950,8 +8953,9 @@ copied message info</note>
<target>Nincs ellenőrizve a SimpleX-név</target>
<note>alert title</note>
</trans-unit>
<trans-unit id="SimpleX name sale starts in" xml:space="preserve">
<trans-unit id="SimpleX name sale starts in" xml:space="preserve" approved="no">
<source>SimpleX name sale starts in</source>
<target state="translated">SimpleX-nevek értékesítésének kezdete:</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="SimpleX one-time invitation" xml:space="preserve">
@@ -10107,8 +10111,9 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll
<target>Frissíti a beállításokat?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve">
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve" approved="no">
<source>Update the app to register a SimpleX domain</source>
<target state="translated">Frissítse az alkalmazást egy SimpleX-domainnév regisztrálásához</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Updated conditions" xml:space="preserve">
@@ -10587,9 +10592,9 @@ alert title</note>
<target>Ha egy inkognitóprofilt oszt meg valamelyik partnerével, a rendszer ezt az inkognitóprofilt fogja használni azokban a csoportokban, ahová az adott partnere meghívja Önt.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Why SimpleX is built." xml:space="preserve">
<trans-unit id="Why SimpleX is built." xml:space="preserve" approved="no">
<source>Why SimpleX is built.</source>
<target>Miért jött létre a SimpleX?</target>
<target state="translated">Miért jött létre a SimpleX.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="WiFi" xml:space="preserve">
@@ -11758,8 +11763,9 @@ pref value</note>
<target>továbbított</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="forwarded from" xml:space="preserve">
<trans-unit id="forwarded from" xml:space="preserve" approved="no">
<source>forwarded from</source>
<target state="translated">továbbítva innen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="group" xml:space="preserve">
@@ -12269,8 +12275,9 @@ utoljára fogadott üzenet: %2$@</target>
<target>nem védett</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve">
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve" approved="no">
<source>until you can register a SimpleX domain</source>
<target state="translated">egy SimpleX-domainnév regisztrációjáig</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="updated channel profile" xml:space="preserve">
@@ -40,8 +40,9 @@
<target>%1$@ ha sostenuto SimpleX Chat. La targhetta è scaduta il %2$@.</target>
<note>badge alert</note>
</trans-unit>
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve">
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve" approved="no">
<source>%1$02d hrs %2$02d min %3$02d sec</source>
<target state="translated">%1$02d ore %2$02d min %3$02d sec</target>
<note>countdown</note>
</trans-unit>
<trans-unit id="%@" xml:space="preserve">
@@ -159,8 +160,9 @@
<target>%@:</target>
<note>copied message info</note>
</trans-unit>
<trans-unit id="%d day" xml:space="preserve">
<trans-unit id="%d day" xml:space="preserve" approved="no">
<source>%d day</source>
<target state="translated">%d giorno</target>
<note>time interval</note>
</trans-unit>
<trans-unit id="%d days" xml:space="preserve">
@@ -2723,8 +2725,9 @@ Questo è il tuo link una tantum!</target>
<target>Creazione link…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve">
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve" approved="no">
<source>Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)</source>
<target state="translated">Gli investitori della raccolta fondi possono prenotare i nomi prima che inizi la vendita: [simplex.domains](https://simplex.domains/)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Crowdfunding on Wefunder" xml:space="preserve">
@@ -5336,19 +5339,19 @@ Altri miglioramenti sono in arrivo!</target>
<target>Permette di avere molte connessioni anonime senza dati condivisi tra di loro in un unico profilo di chat.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="It can happen when you or your connection used the old database backup." xml:space="preserve">
<trans-unit id="It can happen when you or your connection used the old database backup." xml:space="preserve" approved="no">
<source>It can happen when you or your connection used the old database backup.</source>
<target>Può accadere quando tu o il tuo contatto avete usato il backup del database vecchio.</target>
<target state="translated">Può accadere quando tu o il tuo contatto avete usato il backup del vecchio database.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="It can happen when:&#10;1. The messages expired in the sending client after 2 days or on the server after 30 days.&#10;2. Message decryption failed, because you or your contact used old database backup.&#10;3. The connection was compromised." xml:space="preserve">
<trans-unit id="It can happen when:&#10;1. The messages expired in the sending client after 2 days or on the server after 30 days.&#10;2. Message decryption failed, because you or your contact used old database backup.&#10;3. The connection was compromised." xml:space="preserve" approved="no">
<source>It can happen when:
1. The messages expired in the sending client after 2 days or on the server after 30 days.
2. Message decryption failed, because you or your contact used old database backup.
3. The connection was compromised.</source>
<target>Può accadere quando:
<target state="translated">Può accadere quando:
1. I messaggi sono scaduti sul client mittente dopo 2 giorni o sul server dopo 30 giorni.
2. La decifrazione del messaggio è fallita, perché tu o il tuo contatto avete usato un backup del database vecchio.
2. La decifrazione del messaggio è fallita, perché tu o il tuo contatto avete usato un backup del vecchio database.
3. La connessione è stata compromessa.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -8950,8 +8953,9 @@ copied message info</note>
<target>Nome SimpleX non verificato</target>
<note>alert title</note>
</trans-unit>
<trans-unit id="SimpleX name sale starts in" xml:space="preserve">
<trans-unit id="SimpleX name sale starts in" xml:space="preserve" approved="no">
<source>SimpleX name sale starts in</source>
<target state="translated">La vendita di nomi SimpleX inizia in</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="SimpleX one-time invitation" xml:space="preserve">
@@ -10107,8 +10111,9 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio
<target>Aggiornare le impostazioni?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve">
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve" approved="no">
<source>Update the app to register a SimpleX domain</source>
<target state="translated">Aggiorna l'app per registrare un dominio SimpleX</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Updated conditions" xml:space="preserve">
@@ -11758,8 +11763,9 @@ pref value</note>
<target>inoltrato</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="forwarded from" xml:space="preserve">
<trans-unit id="forwarded from" xml:space="preserve" approved="no">
<source>forwarded from</source>
<target state="translated">inoltrato da</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="group" xml:space="preserve">
@@ -12269,8 +12275,9 @@ ultimo msg ricevuto: %2$@</target>
<target>non protetto</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve">
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve" approved="no">
<source>until you can register a SimpleX domain</source>
<target state="translated">fino a quando potrai registrare un dominio SimpleX</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="updated channel profile" xml:space="preserve">
@@ -12283,9 +12290,9 @@ ultimo msg ricevuto: %2$@</target>
<target>ha aggiornato il profilo del gruppo</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="updated profile" xml:space="preserve">
<trans-unit id="updated profile" xml:space="preserve" approved="no">
<source>updated profile</source>
<target>profilo aggiornato</target>
<target state="translated">ha aggiornato il profilo</target>
<note>profile update event chat item</note>
</trans-unit>
<trans-unit id="v%@" xml:space="preserve">
+3
View File
@@ -89,6 +89,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?)
case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest)
case newChatItems(user: UserRef, chatItems: [AChatItem])
case duplicateGroupMessage(user: UserRef, member: GroupMember)
case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer)
case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer)
@@ -101,6 +102,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case .contactConnected: "contactConnected"
case .receivedContactRequest: "receivedContactRequest"
case .newChatItems: "newChatItems"
case .duplicateGroupMessage: "duplicateGroupMessage"
case .rcvFileSndCancelled: "rcvFileSndCancelled"
case .sndFileComplete: "sndFileComplete"
case .sndFileRcvCancelled: "sndFileRcvCancelled"
@@ -117,6 +119,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString)
case let .duplicateGroupMessage(u, member): return withUser(u, String(describing: member))
case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
+16 -4
View File
@@ -73,6 +73,16 @@ public enum NSENotificationData {
default: nil
}
}
func replaces(_ bestAttempt: NSENotificationData) -> Bool {
switch (self, bestAttempt) {
case (.noNtf, .connectionEvent): true
case (.noNtf, _): false
case (.callInvitation, _): true
case (_, .callInvitation): false
default: true
}
}
}
// Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid
@@ -573,9 +583,9 @@ class NotificationService: UNNotificationServiceExtension {
// A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above.
// If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once,
// instead of requesting further messages.
if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil {
if let bestAttempt = notificationEntities[id]?.msgBestAttemptNtf, ntf.replaces(bestAttempt) {
notificationEntities[id]?.msgBestAttemptNtf = ntf
} // otherwise keep call as best attempt
}
return false
} else {
// We should not get to this branch, as notifications are not delivered for muted users.
@@ -1022,8 +1032,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)?
cItem = autoReceiveFile(file) ?? cItem
}
let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf
let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir {
groupMember.id
let chatIdOrMemberId = if case let .group(groupInfo, _) = cInfo, let memberId = cItem.meta.forwardedByMember ?? cItem.chatDir.groupMemberId {
groupMemberEntityId(groupInfo.groupId, memberId)
} else {
chatItem.chatInfo.id
}
@@ -1031,6 +1041,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)?
} else {
return nil
}
case let .duplicateGroupMessage(_, member):
return (member.id, .noNtf)
case let .rcvFileSndCancelled(_, aChatItem, _):
cleanupFile(aChatItem)
return nil
+3 -3
View File
@@ -25,7 +25,7 @@ struct ShareView: View {
HStack {
profileImage(
chatInfoId: chat.chatInfo.id,
iconName: chatIconName(chat.chatInfo),
icon: chatIcon(chat.chatInfo),
size: 30
)
Text(chat.chatInfo.displayName).foregroundStyle(
@@ -209,11 +209,11 @@ struct ShareView: View {
.background(Material.ultraThin)
}
@ViewBuilder private func profileImage(chatInfoId: ChatInfo.ID, iconName: String, size: Double) -> some View {
@ViewBuilder private func profileImage(chatInfoId: ChatInfo.ID, icon: Image, size: Double) -> some View {
if let uiImage = model.profileImages[chatInfoId] {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius)
} else {
Image(systemName: iconName)
icon
.resizable()
.foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: size, height: size)
+10
View File
@@ -42,6 +42,16 @@
<string>simplex</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>chat.simplex.app.deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>simplexchat</string>
</array>
</dict>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
+34 -16
View File
@@ -166,8 +166,13 @@
647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; };
647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; };
647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; };
647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */; };
647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2B30666C640032110E /* BadgesHowLongView.swift */; };
64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */; };
64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */; };
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; };
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; };
@@ -181,20 +186,18 @@
64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF1302F423300072BDE /* BadgesPayView.swift */; };
64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */; };
64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */; };
64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */; };
64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF4302F423300072BDE /* BadgeStore.swift */; };
64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */; };
64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */; };
64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -268,6 +271,8 @@
E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; };
E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; };
E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; };
E5C0BBEB2F82B45500EA7527 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */; };
E5C0BBEC2F82B45500EA7527 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */; };
E5DBF1932F88169800E1D7FD /* ConnectBannerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */; };
E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; };
@@ -558,8 +563,13 @@
647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = "<group>"; };
647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = "<group>"; };
647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = "<group>"; };
647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesChooseBadgeView.swift; sourceTree = "<group>"; };
647C9E2B30666C640032110E /* BadgesHowLongView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowLongView.swift; sourceTree = "<group>"; };
64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesCheckOrderView.swift; sourceTree = "<group>"; };
64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPurchaseStateView.swift; sourceTree = "<group>"; };
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownFileView.swift; sourceTree = "<group>"; };
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = "<group>"; };
@@ -575,19 +585,17 @@
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = "<group>"; };
64C03BF1302F423300072BDE /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = "<group>"; };
64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = "<group>"; };
64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = "<group>"; };
64C03BF4302F423300072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = "<group>"; };
64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = "<group>"; };
64C03BF6302F423300072BDE /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = "<group>"; };
64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -656,6 +664,7 @@
E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = "<group>"; };
E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = "<group>"; };
E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = "<group>"; };
E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SharedAssets.xcassets; sourceTree = "<group>"; };
E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectBannerCard.swift; sourceTree = "<group>"; };
@@ -760,8 +769,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -848,8 +857,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -947,6 +956,7 @@
5C2E260D27A30E2400F70299 /* Views */,
5CA059C5279559F40002BEB4 /* Assets.xcassets */,
E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */,
E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */,
D7AA2C3429A936B400737B40 /* MediaEncryption.playground */,
5C13730C2815740A00F43030 /* DebugJSON.playground */,
);
@@ -1123,6 +1133,7 @@
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */,
648010AA281ADD15009009B9 /* CIFileView.swift */,
6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */,
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */,
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
@@ -1217,14 +1228,16 @@
64C03BF8302F423300072BDE /* Badges */ = {
isa = PBXGroup;
children = (
647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */,
647C9E2B30666C640032110E /* BadgesHowLongView.swift */,
64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */,
64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */,
644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */,
64EB8A9A3054347A0089FFDF /* BadgesView.swift */,
64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */,
64C03BF1302F423300072BDE /* BadgesPayView.swift */,
64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */,
64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */,
64C03BF4302F423300072BDE /* BadgeStore.swift */,
64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */,
64C03BF6302F423300072BDE /* BadgeUserPreview.swift */,
64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */,
64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */,
@@ -1497,6 +1510,7 @@
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */,
5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */,
E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */,
E5C0BBEB2F82B45500EA7527 /* SharedAssets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1529,6 +1543,7 @@
files = (
E5DCF9712C590272007928CC /* Localizable.strings in Resources */,
E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */,
E5C0BBEC2F82B45500EA7527 /* SharedAssets.xcassets in Resources */,
E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -1600,6 +1615,7 @@
5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */,
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */,
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */,
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */,
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */,
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
@@ -1649,13 +1665,11 @@
640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */,
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */,
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */,
64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */,
64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */,
64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */,
64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */,
64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */,
64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */,
64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */,
64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */,
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */,
5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */,
@@ -1672,6 +1686,10 @@
CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */,
647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */,
647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */,
64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */,
64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */,
5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */,
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */,
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */,
+8 -4
View File
@@ -149,12 +149,15 @@ public func recvSimpleXMsg<R: ChatAPIResult>(_ ctrl: chat_ctrl? = nil, messageTi
}
public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? {
parseSimpleXMarkdownText(s)?.formattedText
}
public func parseSimpleXMarkdownText(_ s: String) -> ParsedMarkdown? {
var c = s.cString(using: .utf8)!
if let cjson = chat_parse_markdown(&c) {
if let d = dataFromCString(cjson) {
do {
let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d)
return r.formattedText
return try jsonDecoder.decode(ParsedMarkdown.self, from: d)
} catch {
logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)")
}
@@ -176,8 +179,9 @@ public func parseBadgeCode(_ s: String) -> String? {
return canonical.isEmpty ? nil : canonical
}
struct ParsedMarkdown: Decodable {
var formattedText: [FormattedText]?
public struct ParsedMarkdown: Decodable {
public var formattedText: [FormattedText]?
public var markdownBlocks: [MarkdownBlock]?
}
public func parseServerAddress(_ s: String) -> ServerAddress? {
+15 -2
View File
@@ -827,6 +827,7 @@ public enum ChatErrorType: Decodable, Hashable {
public enum BadgeRedeemError: Decodable, Hashable {
case invalidCode
case invalidReceipt
case serviceNotConfigured
case badgeActive
case serviceError(serviceError: BadgeServiceErrorCode)
@@ -847,12 +848,14 @@ public enum BadgeServiceErrorCode: Decodable, Hashable {
case paymentNotEntitled
case paymentPending
case providerUnavailable
case providerNotConfigured
case rateLimited
case codeInvalid
case codeUsed
case codeExpired
case receiptInvalid
case receiptUsed
case testPurchase
case internalError
case unknown(String)
@@ -868,11 +871,13 @@ public enum BadgeServiceErrorCode: Decodable, Hashable {
case .paymentNotEntitled: "payment_not_entitled"
case .paymentPending: "payment_pending"
case .providerUnavailable: "provider_unavailable"
case .providerNotConfigured: "provider_not_configured"
case .rateLimited: "rate_limited"
case .codeInvalid: "code_invalid"
case .codeUsed: "code_used"
case .codeExpired: "code_expired"
case .receiptInvalid: "receipt_invalid"
case .testPurchase: "test_purchase"
case .receiptUsed: "receipt_used"
case .internalError: "internal"
case let .unknown(s): s
@@ -891,11 +896,13 @@ public enum BadgeServiceErrorCode: Decodable, Hashable {
case "payment_not_entitled": self = .paymentNotEntitled
case "payment_pending": self = .paymentPending
case "provider_unavailable": self = .providerUnavailable
case "provider_not_configured": self = .providerNotConfigured
case "rate_limited": self = .rateLimited
case "code_invalid": self = .codeInvalid
case "code_used": self = .codeUsed
case "code_expired": self = .codeExpired
case "receipt_invalid": self = .receiptInvalid
case "test_purchase": self = .testPurchase
case "receipt_used": self = .receiptUsed
case "internal": self = .internalError
case let s: self = .unknown(s)
@@ -912,8 +919,14 @@ public func badgeServiceErrorText(_ code: BadgeServiceErrorCode) -> String? {
case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message")
case .unknownPurchaseKey: NSLocalizedString("The badge service does not recognize this badge.", comment: "alert message")
case .internalError: NSLocalizedString("The badge service reported an internal error.", comment: "alert message")
case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .productUnavailable,
.paymentNotEntitled, .paymentPending, .providerUnavailable, .receiptInvalid, .receiptUsed, .unknown: nil
case .receiptInvalid: NSLocalizedString("The store did not confirm this purchase.", comment: "alert message")
case .testPurchase: NSLocalizedString("Sandbox purchases are not credited. Badges can only be bought in the App Store version of the app.", comment: "alert message")
case .receiptUsed: NSLocalizedString("This purchase was already credited to a badge on another device or profile.", comment: "alert message")
case .paymentPending: NSLocalizedString("The payment has not been confirmed yet.", comment: "alert message")
case .providerUnavailable: NSLocalizedString("The store could not be reached.", comment: "alert message")
case .providerNotConfigured: NSLocalizedString("Badges cannot be bought in the app yet.", comment: "alert message")
case .productUnavailable: NSLocalizedString("This badge is not on sale.", comment: "alert message")
case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .paymentNotEntitled, .unknown: nil
}
}
+3 -1
View File
@@ -34,10 +34,12 @@ public struct WebRTCSession: Codable {
// Spec: spec/services/calls.md#WebRTCExtraInfo
public struct WebRTCExtraInfo: Codable {
public init(rtcIceCandidates: String) {
public init(rtcSession: String?, rtcIceCandidates: String) {
self.rtcSession = rtcSession
self.rtcIceCandidates = rtcIceCandidates
}
public var rtcSession: String?
public var rtcIceCandidates: String
}
+159 -8
View File
@@ -133,6 +133,7 @@ public struct Profile: Codable, NamedChat, Hashable {
self.image = image
self.contactLink = contactLink
self.preferences = preferences
self.peerType = peerType
self.contactDomain = contactDomain
}
@@ -227,6 +228,7 @@ public enum ChatPeerType: Hashable {
case human
case bot
case business
case agent
case unknown(String)
public var text: String {
@@ -234,6 +236,7 @@ public enum ChatPeerType: Hashable {
case .human: "human"
case .bot: "bot"
case .business: "business"
case .agent: "agent"
case let .unknown(s): s
}
}
@@ -245,6 +248,7 @@ extension ChatPeerType: Codable {
case "human": self = .human
case "bot": self = .bot
case "business": self = .business
case "agent": self = .agent
case let s: self = .unknown(s)
}
}
@@ -255,6 +259,14 @@ extension ChatPeerType: Codable {
}
}
public func peerTypeIcon(_ peerType: ChatPeerType?) -> Image {
switch peerType {
case .bot: Image(systemName: "cube.fill")
case .agent: Image("agent")
default: Image(systemName: "person.crop.circle.fill")
}
}
// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status.
// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter.
public enum BadgeType: Hashable {
@@ -368,6 +380,24 @@ public enum BadgeIssueFailure: Decodable, Hashable {
}
}
public var purchaseText: String {
switch self {
case let .serviceError(code, _):
badgeServiceErrorText(code)
?? String.localizedStringWithFormat(NSLocalizedString("The badge service refused the purchase: %@", comment: "badge purchase error"), code.text)
case .invalidCredential: NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message")
case .serviceTimeout, .network, .unexpected: text
}
}
public var purchaseAlertTitle: String {
switch self {
case .serviceError(.testPurchase, _):
NSLocalizedString("Sandbox purchase", comment: "alert title")
default: NSLocalizedString("Purchase error", comment: "alert title")
}
}
// the stored form, for support
public var tag: String {
switch self {
@@ -583,7 +613,8 @@ public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias
contactLink: profile.contactLink,
preferences: profile.preferences,
peerType: profile.peerType,
localAlias: localAlias
localAlias: localAlias,
contactDomain: profile.contactDomain
)
}
@@ -2512,8 +2543,8 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
activeConn?.customUserProfileId != nil
}
public var chatIconName: String {
isBot ? "cube.fill" : "person.crop.circle.fill"
public var chatIcon: Image {
peerTypeIcon(profile.peerType)
}
public func allowsFeature(_ feature: ChatFeature) -> Bool {
@@ -3253,7 +3284,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public var relayLink: String?
public var memberVerifiedCode: SecurityCode?
public var id: String { "#\(groupId) @\(groupMemberId)" }
public var id: String { groupMemberEntityId(groupId, groupMemberId) }
public var ready: Bool { get { activeConn?.connStatus == .ready } }
public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } }
public var sendMsgEnabled: Bool { get {
@@ -3442,6 +3473,10 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
)
}
public func groupMemberEntityId(_ groupId: Int64, _ groupMemberId: Int64) -> String {
"#\(groupId) @\(groupMemberId)"
}
public struct GroupSupportChat: Codable, Hashable {
public var chatTs: Date
public var unread: Int
@@ -3758,11 +3793,12 @@ public struct CIReaction: Decodable, Hashable {
}
public struct ChatItem: Identifiable, Decodable, Hashable {
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) {
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, markdownBlocks: [MarkdownBlock]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) {
self.chatDir = chatDir
self.meta = meta
self.content = content
self.formattedText = formattedText
self.markdownBlocks = markdownBlocks
self.mentions = mentions
self.quotedItem = quotedItem
self.reactions = reactions
@@ -3773,6 +3809,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
public var meta: CIMeta
public var content: CIContent
public var formattedText: [FormattedText]?
public var markdownBlocks: [MarkdownBlock]?
public var mentions: [String: CIMention]?
public var quotedItem: CIQuote?
public var reactions: [CIReactionCount]
@@ -3782,7 +3819,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
public var isLiveDummy: Bool = false
private enum CodingKeys: String, CodingKey {
case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file
case chatDir, meta, content, formattedText, markdownBlocks, mentions, quotedItem, reactions, file
}
public var id: Int64 { meta.itemId }
@@ -4215,6 +4252,10 @@ public enum CIDirection: Decodable, Hashable {
}
}
public var groupMemberId: Int64? {
if case let .groupRcv(groupMember) = self { groupMember.groupMemberId } else { nil }
}
public func sameDirection(_ dir: CIDirection) -> Bool {
switch (self, dir) {
case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId
@@ -4240,6 +4281,7 @@ public struct CIMeta: Decodable, Hashable {
public var userMention: Bool
public var deletable: Bool
public var editable: Bool
public var forwardedByMember: Int64?
public var showGroupAsSender: Bool
public var msgVerified: MsgVerified?
@@ -4819,6 +4861,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable {
public var sentAt: Date
public var content: MsgContent
public var formattedText: [FormattedText]?
public var markdownBlocks: [MarkdownBlock]?
public var text: String {
switch (content.text, content) {
case let ("", .voice(_, duration)): return durationText(duration)
@@ -5632,14 +5675,119 @@ public struct FormattedText: Decodable, Hashable {
public var linkUri: String? {
switch format {
case .uri: text
case let .hyperLink(_, linkUri): linkUri
case let .hyperLink(_, linkUri): isEmailLink ? nil : linkUri
default: nil
}
}
public var isEmailLink: Bool {
if case let .hyperLink(_, linkUri) = format { linkUri.hasPrefix("mailto:") } else { false }
}
}
public enum BlockToken: Decodable, Hashable {
case lineBreak
case softBreak
case hardBreak
case paragraphBreak(lineBreaks: Int)
case indent
case headingMark(level: Int)
case headingClose
case headingUnderline(level: Int)
case listMark(number: String?)
case quoteMark
case fenceMark(language: String?)
case rowMark
case cellMark
case delimiterRow(columns: [TableAlign])
case ruleMark
public var drawnText: String {
switch self {
case .softBreak: " "
case .lineBreak, .hardBreak: "\n"
case let .paragraphBreak(lineBreaks): String(repeating: "\n", count: lineBreaks)
default: ""
}
}
public var previewText: String {
switch self {
case .softBreak, .hardBreak, .cellMark: " "
case .lineBreak, .paragraphBreak: "\n"
case let .listMark(number): (number ?? "•") + " "
default: ""
}
}
}
public enum MarkdownBlock: Decodable, Hashable {
case paragraph(content: SegmentRange)
case heading(level: Int, content: SegmentRange)
case codeBlock(language: String?, content: SegmentRange)
case quote(content: SegmentRange)
case list(items: [ListEntry])
case table(columns: [TableAlign], header: [SegmentRange], rows: [[SegmentRange]])
case rule(mark: SegmentRange)
}
public struct ListEntry: Decodable, Hashable {
public var number: String?
public var content: SegmentRange
public var items: [ListEntry]
}
public struct SegmentRange: Decodable, Hashable {
public var from: Int
public var to: Int
}
public enum TableAlign: String, Decodable, Hashable {
case none
case left
case center
case right
}
public struct BlockTextRange {
public var range: SegmentRange
public var inRow: Bool
}
extension MarkdownBlock {
public var textRanges: [BlockTextRange] {
switch self {
case let .paragraph(content), let .heading(_, content), let .codeBlock(_, content), let .quote(content):
[BlockTextRange(range: content, inRow: false)]
case let .list(items):
listEntries(items, 0).map { BlockTextRange(range: $0.entry.content, inRow: false) }
case let .table(_, header, rows):
([header] + rows).flatMap { row in row.enumerated().map { c, cell in BlockTextRange(range: cell, inRow: c > 0) } }
case .rule:
[]
}
}
}
public func listEntries(_ items: [ListEntry], _ level: Int) -> [(entry: ListEntry, level: Int)] {
items.flatMap { [(entry: $0, level: level)] + listEntries($0.items, level + 1) }
}
public func blockTextRanges(_ blocks: [MarkdownBlock]) -> [BlockTextRange] {
blocks.flatMap { $0.textRanges }
}
public func headingSegments(_ size: Int, _ blocks: [MarkdownBlock]) -> [Bool] {
var bold = [Bool](repeating: false, count: size)
for case let .heading(_, content) in blocks {
let from = min(max(content.from, 0), size)
for i in from..<max(from, min(content.to, size)) { bold[i] = true }
}
return bold
}
public enum Format: Decodable, Equatable, Hashable {
case bold
case bold(singleStar: Bool)
case italic
case strikeThrough
case snippet
@@ -5655,6 +5803,8 @@ public enum Format: Decodable, Equatable, Hashable {
case modal(modalName: String, text: String)
case email
case phone
case escape
case markup(token: BlockToken)
case unknown
// client-only format that opens a modal when tapped, see openMarkdownModal
@@ -6358,6 +6508,7 @@ public struct ChatItemVersion: Decodable, Hashable {
public var chatItemVersionId: Int64
public var msgContent: MsgContent
public var formattedText: [FormattedText]?
public var markdownBlocks: [MarkdownBlock]?
public var itemVersionTs: Date
public var createdAt: Date
}
+7 -6
View File
@@ -7,6 +7,7 @@
//
import Foundation
import SwiftUI
public protocol ChatLike {
var chatInfo: ChatInfo { get }
@@ -91,13 +92,13 @@ private func canForwardToChat(_ cInfo: ChatInfo) -> Bool {
}
}
public func chatIconName(_ cInfo: ChatInfo) -> String {
public func chatIcon(_ cInfo: ChatInfo) -> Image {
switch cInfo {
case let .direct(contact): contact.chatIconName
case let .group(groupInfo, _): groupInfo.chatIconName
case .local: "folder.circle.fill"
case .contactRequest: "person.crop.circle.fill"
default: "circle.fill"
case let .direct(contact): contact.chatIcon
case let .group(groupInfo, _): Image(systemName: groupInfo.chatIconName)
case .local: Image(systemName: "folder.circle.fill")
case .contactRequest: Image(systemName: "person.crop.circle.fill")
default: Image(systemName: "circle.fill")
}
}
+10
View File
@@ -140,6 +140,16 @@ public func deleteAppFiles() {
}
}
public func deleteTempFiles() {
let fm = FileManager.default
do {
try fm.removeItem(at: getTempFilesDirectory())
try fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true)
} catch {
logger.error("FileUtils deleteTempFiles error: \(error.localizedDescription)")
}
}
public func fileSize(_ url: URL) -> Int? { // in bytes
do {
let val = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
+5 -4
View File
@@ -201,10 +201,11 @@ func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String {
if let md = cItem.formattedText {
var res = ""
for ft in md {
if case .secret = ft.format {
res = res + "..."
} else {
res = res + ft.text
switch ft.format {
case .secret: res = res + "..."
case let .markup(token): res = res + token.previewText
case .escape: ()
default: res = res + ft.text
}
}
return res
+10 -2
View File
@@ -103,6 +103,14 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central
5. `CallViewRenderers` manages WebRTC video rendering surfaces.
6. Call status updates are sent via `apiCallStatus(contact:status:)`.
### 6.1 Reconnection
1. A connected call loses its connection, e.g. when the device switches between Wi-Fi and mobile network.
2. The call shows "reconnecting…" and plays the connecting sound. Call duration continues, as this state is not reported to the core.
3. The callee (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer via `apiSendCallExtraInfo` with `rtcSession`; the caller answers the same way.
4. When the connection is restored, the call returns to connected state.
5. If the connection is not restored in 30 seconds, the call ends. Clients without ICE restart support ignore the restart offer, so the call ends in this case.
### 7. Audio Routing
1. `CallAudioDeviceManager` handles audio device selection.
@@ -141,7 +149,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central
| `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs |
| `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data |
| `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) |
| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer |
| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer, and ICE restart offer/answer |
| `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting |
| `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` |
| `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera |
@@ -152,7 +160,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central
|-------|-------|----------|
| Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout |
| Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry |
| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status |
| WebRTC peer connection failure | NAT traversal, network issues | Before connection: call ends with error status; after connection: reconnection with ICE restart, call ends if not reconnected in 30 seconds |
| CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up |
| No camera/mic permission | User denied permissions | Permission request dialog shown |
+1 -1
View File
@@ -179,7 +179,7 @@ The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.sw
| `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) |
| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) |
| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) |
| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) |
| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates or ICE restart offer/answer | [L171](../Shared/Model/AppAPITypes.swift#L171) |
| `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) |
| `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) |
| `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) |
+7 -1
View File
@@ -189,7 +189,7 @@ Renders emoji-only messages (messages containing only emoji characters) in a lar
**File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift)
Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions).
Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). Blocks of `markdownBlocks` (headings, lists, quotes, code blocks, tables, rules) are drawn as a column of views by `MarkdownBlocksView`; code blocks and tables scroll sideways.
### DeletedItemView / MarkedDeletedItemView
@@ -238,6 +238,12 @@ Renders voice messages with waveform visualization, play/pause control, and dura
Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download.
### MarkdownFileView
**File**: [`Shared/Views/Chat/ChatItem/MarkdownFileView.swift`](../../Shared/Views/Chat/ChatItem/MarkdownFileView.swift)
Sheet with a viewer of Markdown files up to `MAX_MARKDOWN_VIEW_SIZE` (1 MB), formatted or as text, with Save. Opened by a tap on the file item, or when reception completes after the tap that started it.
### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14)
**File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift)
+2
View File
@@ -200,6 +200,8 @@ SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Sh
URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1181).
App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectViaUrl_()` branches on that scheme before the path switch, so an app link never reaches `planAndConnect()`, and `openAppLink()` dispatches on its path. A badge link (`simplexchat:/badge/code/<code>`) goes to `openBadgeLink()` in [`BadgesRedeemCodeView.swift`](../../Shared/Views/Badges/BadgesRedeemCodeView.swift), which presents `BadgesRedeemLinkView` as an app sheet. The sheet names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the sheet renders), which core would refuse, the sheet does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or a swipe closes the sheet without cancelling anything; if the redemption then succeeds with the sheet closed or covered, an alert says the badge was added and names the profile, unless the profile is hidden and no longer active, when nothing is shown (`showBadgeAddedAlert()`, which the store lane shares). While the sheet shows a redemption in flight (`isBadgeLinkIssuing()`), `connectViaUrl_()` ignores a further app link; otherwise it dismisses the sheets as for any link, which replaces an earlier badge link sheet. Any other path is a link type from a later version, and gets an alert asking to check for app updates. An app link can carry a secret, so `onOpenURL` logs it as "app link" rather than the URL.
### Call Deep Link
Call invitations from notifications:
+12 -4
View File
@@ -116,7 +116,10 @@ Manages the WebRTC peer connection, media streams, and data channels.
| [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 |
| [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 |
| [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 |
| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 |
| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L409) | Sends gathered ICE candidates | L409 |
| [`startReconnecting`](../../Shared/Views/Call/WebRTCClient.swift#L313) | Reports "reconnecting", starts reconnection timeout and, for the party that sent the initial offer, periodic ICE restarts | L313 |
| [`restartIce`](../../Shared/Views/Call/WebRTCClient.swift#L339) | Creates ICE restart offer and sends it with candidates in `.ice(iceCandidates:session:)` | L339 |
| [`receiveRestartSession`](../../Shared/Views/Call/WebRTCClient.swift#L350) | Applies ICE restart offer (and sends the answer) or answer received in `.ice` command | L350 |
| [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 |
| [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 |
| [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 |
@@ -179,6 +182,10 @@ All call signaling travels through the same encrypted SMP message channels used
5. P2P connection established
```
### Reconnection
When a connected call loses ICE connection (`.disconnected` or `.failed`), `WebRTCClient` reports `"reconnecting"` connection state instead of ending the call. `ActiveCallView` sets `.reconnecting` call state and plays the connecting sound; this state is not sent to the core via `apiCallStatus`, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds: the restart offer and answer are sent via `apiSendCallExtraInfo` with `rtcSession` (`x.call.extra`). When ICE connects again, the call returns to `.connected`. If it does not reconnect in 30 seconds, `WebRTCClient` reports `"disconnected"` and ends the call.
### API Commands
| Command | Direction | Purpose |
@@ -187,7 +194,7 @@ All call signaling travels through the same encrypted SMP message channels used
| `apiRejectCall(contact:)` | Callee -> Caller | Reject call |
| `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer |
| `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer |
| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates |
| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates, or ICE restart offer/answer in `rtcSession` |
| `apiEndCall(contact:)` | Either | End call |
| `apiGetCallInvitations` | -- | Get pending invitations |
| `apiCallStatus(contact:, callStatus:)` | -- | Report status change |
@@ -317,10 +324,11 @@ struct WebRTCSession {
}
```
### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42)
### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L45)
```swift
struct WebRTCExtraInfo {
var rtcSession: String? // ICE restart offer or answer
var rtcIceCandidates: String // Additional ICE candidates
}
```
@@ -330,7 +338,7 @@ struct WebRTCExtraInfo {
Stored in `ChatModel.activeCall`:
- Contact reference
- Call UUID
- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.)
- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, `.reconnecting`, etc.)
- Media type
- WebRTCClient reference
+5 -1
View File
@@ -285,9 +285,13 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file)
| Source Location | Spec Document | Product Document |
|----------------|---------------|-----------------|
| desktop/.../desktop/Main.kt | spec/architecture.md | product/flows/onboarding.md |
| desktop/.../desktop/Main.kt | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md |
| common/.../common/DesktopApp.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md |
| common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md |
| common/.../common/AppLinks.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md |
| common/.../common/SingleInstance.kt (desktopMain) | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md |
| common/.../common/WindowActivation.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md |
| common/.../common/platform/AppLinkScheme.desktop.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md |
| common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md |
| common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md |
| common/.../common/platform/AnimatedImage.desktop.kt (desktopMain) | spec/client/chat-view.md | product/views/chat.md |
@@ -5,6 +5,7 @@ import chat.simplex.common.views.badges.BadgeProduct
import chat.simplex.common.views.badges.BadgePurchaseOutcome
import chat.simplex.common.views.badges.BadgeStoreError
import chat.simplex.common.views.badges.BadgeStoreProductId
import chat.simplex.common.views.badges.BadgeStoreReceipt
// Play Billing is only in the google flavor, so the Play country stays unknown here
fun loadPlayStoreCountry() {}
@@ -22,3 +23,11 @@ suspend fun loadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptio
@Suppress("UNUSED_PARAMETER")
suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome =
throw BadgeStoreError.StoreUnavailable
@Suppress("UNUSED_PARAMETER")
suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {}
@Suppress("UNUSED_PARAMETER")
suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {}
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> = emptyList()
@@ -5,6 +5,7 @@ import chat.simplex.common.platform.androidAppContext
import chat.simplex.common.platform.androidPlayStoreCountry
import chat.simplex.common.platform.mainActivity
import chat.simplex.common.views.badges.*
import chat.simplex.common.views.helpers.withLongRunningApi
import com.android.billingclient.api.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
@@ -40,7 +41,7 @@ fun loadPlayStoreCountry() {
// One long-lived client for badges: ProductDetails obtained from it are passed back to it when the
// purchase is launched, and the purchase result arrives on its listener rather than as a return value.
// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher
// volatile: written by the purchase on a background dispatcher, read by the listener on another thread
@Volatile private var badgeBillingClient: BillingClient? = null
@Volatile private var badgeOffers: Map<BadgeStoreProductId, BadgeOffer> = emptyMap()
@Volatile private var badgePurchase: CompletableDeferred<BadgePurchaseOutcome>? = null
@@ -80,8 +81,7 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable
val client = connectedBadgeBillingClient()
val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId)
val details = offer.details
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details)
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(offer.details)
offer.offerToken?.let { productParams.setOfferToken(it) }
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams.build()))
@@ -94,19 +94,42 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
if (launched.responseCode != BillingClient.BillingResponseCode.OK) {
throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage)
}
val outcome = purchase.await()
if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt)
return outcome
return purchase.await()
} finally {
badgePurchase = null
}
}
// one-time purchases Play still holds: bought and not consumed, or awaiting a slow payment
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> {
val client = connectedBadgeBillingClient()
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()
val queried = CompletableDeferred<List<Purchase>>()
client.queryPurchasesAsync(params) { result, purchases ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
queried.complete(purchases)
} else {
queried.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage))
}
}
return queried.await().mapNotNull(::badgePurchaseOutcome)
}
private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->
val pending = badgePurchase ?: return@PurchasesUpdatedListener
val pending = badgePurchase
if (pending == null) {
// settled outside a purchase call, such as a slow payment completing
if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcile(it) } }
}
return@PurchasesUpdatedListener
}
when {
result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null ->
pending.complete(badgePurchaseOutcome(purchases))
result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> {
val outcomes = purchases.mapNotNull(::badgePurchaseOutcome)
pending.complete(outcomes.firstOrNull() ?: BadgePurchaseOutcome.Cancelled)
withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcile(it) } }
}
result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED ->
pending.complete(BadgePurchaseOutcome.Cancelled)
else ->
@@ -114,17 +137,17 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p
}
}
private fun badgePurchaseOutcome(purchases: List<Purchase>): BadgePurchaseOutcome {
val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled
if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending
return BadgePurchaseOutcome.Purchased(
private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) {
Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending(purchase.accountIdentifiers?.obfuscatedAccountId)
Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased(
BadgeStoreReceipt(
token = purchase.purchaseToken,
productId = purchase.products.firstOrNull() ?: "",
orderId = purchase.orderId,
invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId
invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId,
acknowledged = purchase.isAcknowledged
)
)
else -> null
}
private suspend fun connectedBadgeBillingClient(): BillingClient {
@@ -203,21 +226,34 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? {
return BadgeOffer(id, product, this, offer.offerToken)
}
// nothing is delivered in this build, so the purchase is finished right away; once the service issues
// credentials it must only be finished after the credential is stored. One-time products are consumed
// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged
// purchase after 3 days.
private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) {
val done = CompletableDeferred<BillingResult>()
if (details.productType == BillingClient.ProductType.SUBS) {
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build()
client.acknowledgePurchase(params) { done.complete(it) }
} else {
// acknowledged without consuming, so Play stops its 3-day refund clock and still lists the purchase until it is finished
suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {
if (!receipt.acknowledged) acknowledge(connectedBadgeBillingClient(), receipt.token)
}
// consumed if one-time so it can be bought again, else acknowledged, as Play refunds an unacknowledged purchase
// after 3 days; decided by product id, as after a restart there is no ProductDetails for the purchase
suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {
val client = connectedBadgeBillingClient()
if (receipt.productId in badgeOneTimeProductIds) {
val done = CompletableDeferred<BillingResult>()
val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build()
client.consumeAsync(params) { result, _ -> done.complete(result) }
}
val result = done.await()
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}")
checkBillingResult(done.await())
} else {
acknowledge(client, receipt.token)
}
}
private suspend fun acknowledge(client: BillingClient, token: String) {
val done = CompletableDeferred<BillingResult>()
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build()
client.acknowledgePurchase(params) { done.complete(it) }
checkBillingResult(done.await())
}
private fun checkBillingResult(result: BillingResult) {
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage)
}
}
@@ -74,6 +74,15 @@
<data android:scheme="simplex" />
</intent-filter>
<!-- open simplexchat:/ app URI -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="simplexchat" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
@@ -28,7 +28,9 @@ import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.badges.BadgeProduct
import chat.simplex.common.views.badges.BadgePurchaseOutcome
import chat.simplex.common.views.badges.BadgeStore
import chat.simplex.common.views.badges.BadgeStoreProductId
import chat.simplex.common.views.badges.BadgeStoreReceipt
import chat.simplex.common.views.call.*
import chat.simplex.common.views.database.deleteOldChatArchive
import chat.simplex.common.views.helpers.*
@@ -113,6 +115,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
}
BadgeStore.presentUnfinished()
}
}
Lifecycle.Event.ON_RESUME -> {
@@ -352,6 +355,12 @@ class SimplexApp: Application(), LifecycleEventObserver {
override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId)
override suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) = acknowledgeBadgePurchase(receipt)
override suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) = finishBadgePurchase(receipt)
override suspend fun androidUnfinishedBadgePurchases(): List<BadgePurchaseOutcome> = unfinishedBadgePurchases()
@SuppressLint("SourceLockedOrientationActivity")
@Composable
override fun androidLockPortraitOrientation() {
+2 -2
View File
@@ -77,8 +77,8 @@ kotlin {
api("dev.icerock.moko:resources-compose:0.27.1")
// Markdown
implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0")
implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.27.0")
implementation("com.mikepenz:multiplatform-markdown-renderer:0.34.0")
implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.34.0")
}
}
val commonTest by getting {
@@ -25,6 +25,9 @@ val defaultLocale: Locale = Locale.getDefault()
actual fun isAppVisibleAndFocused(): Boolean = isAppOnForeground
// the manifest declares the scheme
actual fun appLinkSchemeRegistered(): Boolean = true
@SuppressLint("StaticFieldLeak")
lateinit var androidAppContext: Context
var mainActivity: WeakReference<FragmentActivity> = WeakReference(null)
@@ -13,6 +13,7 @@ import androidx.core.graphics.scale
import boofcv.android.ConvertBitmap
import boofcv.struct.image.GrayU8
import chat.simplex.common.R
import chat.simplex.common.views.helpers.decodeSampledBitmapFromByteArray
import chat.simplex.common.views.helpers.errorBitmap
import chat.simplex.common.views.helpers.getFileName
import java.io.ByteArrayOutputStream
@@ -41,6 +42,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
}
}
actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? =
try {
decodeSampledBitmapFromByteArray(Base64.decode(base64, Base64.DEFAULT), width, width, MAX_IMAGE_DIMENSION).asImageBitmap()
} catch (e: Exception) {
Log.e(TAG, "decodeBase64Image error: $e")
null
}
actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String {
var img = image
var str = compressImageStr(img)
@@ -2,6 +2,7 @@ package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.painter.Painter
import java.io.File
@@ -18,3 +19,5 @@ actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this
actual fun Modifier.desktopPointerHoverIconHand(): Modifier = this
actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = Modifier
actual fun newPasswordContentType(): ContentType = ContentType.NewPassword + ContentType.Password
@@ -52,8 +52,7 @@ import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import java.io.Closeable
@@ -168,13 +167,24 @@ actual fun ActiveCallView() {
CallSoundsPlayer.stop()
}
is WCallResponse.Ice -> withBGApi {
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates)
}
is WCallResponse.Connection ->
try {
// the call is restored and NOT sent to the core
// Makes sure the core DOES NOT record the duration wrong if a reconnect happens
if (r.state.connectionState == "reconnecting") {
if (call.callState != CallState.Reconnecting) {
CallSoundsPlayer.startConnectingCallSound(scope)
}
updateActiveCall(call) { it.copy(callState = CallState.Reconnecting) }
} else try {
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
if (callStatus == WebRTCCallStatus.Connected) {
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) }
if (call.callState == CallState.Reconnecting) {
CallSoundsPlayer.stop()
}
// connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = it.connectedAt ?: Clock.System.now()) }
}
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
} catch (e: Throwable) {
@@ -705,18 +715,17 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
val wv = webView.value
if (wv != null) {
LaunchedEffect(Unit) {
snapshotFlow { callCommand.firstOrNull() }
.distinctUntilChanged()
.filterNotNull()
.collect {
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(wv, cmd)
}
while (isActive) {
// waiting for the first command would miss a command equal to the previous one
snapshotFlow { callCommand.isNotEmpty() }.first { it }
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(wv, cmd)
}
}
}
}
}
val assetLoader = WebViewAssetLoader.Builder()
@@ -0,0 +1,7 @@
package chat.simplex.common.views.chat
import androidx.compose.runtime.*
// Android keyboards have their own emoji panel
@Composable
actual fun ComposeEmojiButton(pickerShown: MutableState<Boolean>, enabled: Boolean, onEmoji: (String) -> Unit) {}
@@ -194,11 +194,12 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
}
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap {
internal fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int, maxDimension: Int = Int.MAX_VALUE): Bitmap {
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeByteArray(data, 0, data.size, this)
if (outWidth > maxDimension || outHeight > maxDimension) throw IOException("Image dimensions exceed limit")
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
@@ -4,6 +4,9 @@
//#include <stdlib.h>
//#include <android/log.h>
// from libc (stdlib.h is not included because of the reallocarray stub below)
void free(void *ptr);
// from the RTS
void hs_init_with_rtsopts(int * argc, char **argv[]);
@@ -49,6 +52,13 @@ Java_chat_simplex_common_platform_CoreKt_initHS(__unused JNIEnv *env, __unused j
// from simplex-chat
typedef long* chat_ctrl;
// Strings returned by chat_* functions are allocated with malloc and owned by the caller.
static jstring decode_and_free(JNIEnv *env, char *string) {
jstring res = (*env)->NewStringUTF(env, string);
free(string);
return res;
}
/*
When you start using any new function from Haskell libraries,
you have to add the function name to the file libsimplex.dll.def in the root directory.
@@ -80,7 +90,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
jlong _ctrl = (jlong) 0;
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
jstring res = decode_and_free(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
(*env)->ReleaseStringUTFChars(env, confirm, _confirm);
@@ -99,7 +109,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatCloseStore(JNIEnv *env, __unused jclass clazz, jlong controller) {
jstring res = (*env)->NewStringUTF(env, chat_close_store((void*)controller));
jstring res = decode_and_free(env, chat_close_store((void*)controller));
return res;
}
@@ -109,7 +119,7 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmdRetry(JNIEnv *env, __unused
//jint length = (jint) (*env)->GetStringUTFLength(env, msg);
//for (int i = 0; i < length; ++i)
// __android_log_print(ANDROID_LOG_ERROR, "simplex", "%d: %02x\n", i, _msg[i]);
jstring res = (*env)->NewStringUTF(env, chat_send_cmd_retry((void*)controller, _msg, retryNum));
jstring res = decode_and_free(env, chat_send_cmd_retry((void*)controller, _msg, retryNum));
(*env)->ReleaseStringUTFChars(env, msg, _msg);
return res;
}
@@ -117,25 +127,25 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmdRetry(JNIEnv *env, __unused
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatSendRemoteCmdRetry(JNIEnv *env, __unused jclass clazz, jlong controller, jint rhId, jstring msg, jint retryNum) {
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_send_remote_cmd_retry((void*)controller, rhId, _msg, retryNum));
jstring res = decode_and_free(env, chat_send_remote_cmd_retry((void*)controller, rhId, _msg, retryNum));
(*env)->ReleaseStringUTFChars(env, msg, _msg);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
return decode_and_free(env, chat_recv_msg((void*)controller));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) {
return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait));
return decode_and_free(env, chat_recv_msg_wait((void*)controller, wait));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str));
jstring res = decode_and_free(env, chat_parse_markdown(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
@@ -143,7 +153,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, __unused
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
jstring res = decode_and_free(env, chat_parse_server(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
@@ -151,7 +161,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused j
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, __unused jclass clazz, jstring str, jint safe) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_uri(_str, safe));
jstring res = decode_and_free(env, chat_parse_uri(_str, safe));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
@@ -160,7 +170,7 @@ JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
jstring res = decode_and_free(env, chat_password_hash(_pwd, _salt));
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
(*env)->ReleaseStringUTFChars(env, salt, _salt);
return res;
@@ -169,7 +179,7 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) {
const char *_name = (*env)->GetStringUTFChars(env, name, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_valid_name(_name));
jstring res = decode_and_free(env, chat_valid_name(_name));
(*env)->ReleaseStringUTFChars(env, name, _name);
return res;
}
@@ -177,7 +187,7 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseBadgeCode(JNIEnv *env, jclass clazz, jstring code) {
const char *_code = (*env)->GetStringUTFChars(env, code, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_badge_code(_code));
jstring res = decode_and_free(env, chat_parse_badge_code(_code));
(*env)->ReleaseStringUTFChars(env, code, _code);
return res;
}
@@ -195,7 +205,7 @@ Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz
const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);
jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer);
jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer);
jstring res = (*env)->NewStringUTF(env, chat_write_file((void*)controller, _path, buff, capacity));
jstring res = decode_and_free(env, chat_write_file((void*)controller, _path, buff, capacity));
(*env)->ReleaseStringUTFChars(env, path, _path);
return res;
}
@@ -229,6 +239,7 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz,
arr = (*env)->NewByteArray(env, len);
(*env)->SetByteArrayRegion(env, arr, 0, len, res + 1);
}
free(res);
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"),
@@ -243,7 +254,7 @@ JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jlong controller, jstring from_path, jstring to_path) {
const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE);
const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_encrypt_file((void*)controller, _from_path, _to_path));
jstring res = decode_and_free(env, chat_encrypt_file((void*)controller, _from_path, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res;
@@ -255,7 +266,7 @@ Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass cla
const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE);
const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE);
const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path));
jstring res = decode_and_free(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, key, _key);
(*env)->ReleaseStringUTFChars(env, nonce, _nonce);
@@ -64,6 +64,7 @@ jstring decode_to_utf8_string(JNIEnv *env, char *string) {
(*env)->DeleteLocalRef(env, bb);
(*env)->DeleteLocalRef(env, charset);
(*env)->DeleteLocalRef(env, cb);
free(string);
return res;
}
@@ -239,6 +240,7 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz,
arr = (*env)->NewByteArray(env, len);
(*env)->SetByteArrayRegion(env, arr, 0, len, res + 1);
}
free(res);
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"),
@@ -415,7 +415,10 @@ fun CenterPartOfScreen() {
}
}
}
else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {}
else -> {
ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {}
ModalManager.center.showInView()
}
}
}
@@ -132,6 +132,8 @@ object BadgeModel {
enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake }
data class FileToOpen(val fileId: Long, val inChatList: Boolean)
/*
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
* */
@@ -205,8 +207,21 @@ object ChatModel {
// another in its place; only the badge alert shows regardless. Set while rendering, so not a state.
var chatListBanner: ChatListBanner? = null
var fileToOpen: FileToOpen? = null
fun bannerSlotFree(banner: ChatListBanner): Boolean = chatListBanner == null || chatListBanner == banner
fun openWhenReceived(file: CIFile, inChatList: Boolean) {
fileToOpen = FileToOpen(file.fileId, inChatList)
}
fun receivedFileToOpen(file: CIFile, inChatList: Boolean): Boolean {
val f = fileToOpen
if (f == null || f.fileId != file.fileId || f.inChatList != inChatList || !file.loaded) return false
fileToOpen = null
return !inChatList || appPlatform.isDesktop || chatId.value == null
}
// preferences
val notificationPreviewMode by lazy {
mutableStateOf(
@@ -1115,7 +1130,7 @@ object ChatModel {
fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) {
val current = currentUser.value ?: return
val updated = current.copy(
profile = newProfile.toLocalProfile(current.profile.profileId),
profile = newProfile.toLocalProfile(current.profile.profileId).copy(localBadge = current.profile.localBadge, contactDomainVerified = current.profile.contactDomainVerified),
fullPreferences = preferences ?: current.fullPreferences
)
val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId }
@@ -1939,7 +1954,7 @@ data class Contact(
activeConn?.customUserProfileId != null
val chatIconName: ImageResource get() =
if (isBot) MR.images.ic_cube else MR.images.ic_account_circle_filled
peerTypeIcon(profile.peerType)
fun allowsFeature(feature: ChatFeature): Boolean = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
@@ -2150,6 +2165,7 @@ sealed class ChatPeerType {
@Serializable @SerialName("human") object Human: ChatPeerType()
@Serializable @SerialName("bot") object Bot: ChatPeerType()
@Serializable @SerialName("business") object Business: ChatPeerType()
@Serializable @SerialName("agent") object Agent: ChatPeerType()
@Serializable @SerialName("unknown") data class Unknown(val type: String): ChatPeerType()
val text: String
@@ -2157,6 +2173,7 @@ sealed class ChatPeerType {
is Human -> "human"
is Bot -> "bot"
is Business -> "business"
is Agent -> "agent"
is Unknown -> type
}
}
@@ -2168,11 +2185,19 @@ object ChatPeerTypeSerializer : KSerializer<ChatPeerType> {
"human" -> ChatPeerType.Human
"bot" -> ChatPeerType.Bot
"business" -> ChatPeerType.Business
"agent" -> ChatPeerType.Agent
else -> ChatPeerType.Unknown(v)
}
override fun serialize(encoder: Encoder, value: ChatPeerType) = encoder.encodeString(value.text)
}
fun peerTypeIcon(peerType: ChatPeerType?): ImageResource =
when (peerType) {
ChatPeerType.Bot -> MR.images.ic_cube
ChatPeerType.Agent -> MR.images.ic_agent
else -> MR.images.ic_account_circle_filled
}
// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status.
// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter.
@Serializable(with = BadgeTypeSerializer::class)
@@ -2271,6 +2296,12 @@ sealed class BadgeIssueFailure {
is Unexpected -> String.format(generalGetString(MR.strings.badges_error_unexpected), message)
}
val purchaseText: String get() = when (this) {
is ServiceError -> badgeServiceErrorText(code) ?: String.format(generalGetString(MR.strings.badges_error_purchase_refused), code.text)
is InvalidCredential -> generalGetString(MR.strings.badges_error_credential_not_verified)
is ServiceTimeout, is Network, is Unexpected -> text
}
// the stored form, for support
val tag: String get() = when (this) {
is ServiceError -> "serviceError ${if (retryable) "retry" else "final"} ${code.text}"
@@ -3376,6 +3407,7 @@ data class ChatItem (
val meta: CIMeta,
val content: CIContent,
val formattedText: List<FormattedText>? = null,
val markdownBlocks: List<MarkdownBlock>? = null,
val mentions: Map<String, CIMention>? = null,
val quotedItem: CIQuote? = null,
val reactions: List<CIReactionCount>,
@@ -4381,7 +4413,8 @@ class CIQuote (
val sharedMsgId: String? = null,
val sentAt: Instant,
val content: MsgContent,
val formattedText: List<FormattedText>? = null
val formattedText: List<FormattedText>? = null,
val markdownBlocks: List<MarkdownBlock>? = null
): ItemContent {
override val text: String by lazy {
if (content.text == "" && content is MsgContent.MCVoice)
@@ -5133,7 +5166,7 @@ class FormattedText(val text: String, val format: Format? = null) {
val linkUri: String? get() =
when (format) {
is Format.Uri -> text
is Format.HyperLink -> format.linkUri
is Format.HyperLink -> if (format.isEmail) null else format.linkUri
else -> null
}
@@ -5144,7 +5177,7 @@ class FormattedText(val text: String, val format: Format? = null) {
@Serializable
sealed class Format {
@Serializable @SerialName("bold") class Bold: Format()
@Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format()
@Serializable @SerialName("italic") class Italic: Format()
@Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
@Serializable @SerialName("snippet") class Snippet: Format()
@@ -5152,7 +5185,9 @@ sealed class Format {
@Serializable @SerialName("small") class Small: Format()
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
@Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format()
@Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() {
val isEmail: Boolean get() = linkUri.startsWith("mailto:")
}
@Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List<String>): Format() {
val simplexLinkText: String get() =
"${linkType.description} $viaHosts"
@@ -5169,6 +5204,8 @@ sealed class Format {
}
@Serializable @SerialName("email") class Email: Format()
@Serializable @SerialName("phone") class Phone: Format()
@Serializable @SerialName("escape") class Escape: Format()
@Serializable @SerialName("markup") class Markup(val token: BlockToken): Format()
@Serializable @SerialName("unknown") class Unknown: Format()
val style: SpanStyle @Composable get() = when (this) {
@@ -5188,6 +5225,8 @@ sealed class Format {
is Email -> linkStyle
is Phone -> linkStyle
is Modal -> linkStyle
is Escape -> SpanStyle()
is Markup -> SpanStyle()
is Unknown -> SpanStyle()
}
@@ -5198,6 +5237,87 @@ sealed class Format {
}
}
@Serializable
sealed class BlockToken {
@Serializable @SerialName("lineBreak") class LineBreak: BlockToken()
@Serializable @SerialName("softBreak") class SoftBreak: BlockToken()
@Serializable @SerialName("hardBreak") class HardBreak: BlockToken()
@Serializable @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int): BlockToken()
@Serializable @SerialName("indent") class Indent: BlockToken()
@Serializable @SerialName("headingMark") class HeadingMark(val level: Int): BlockToken()
@Serializable @SerialName("headingClose") class HeadingClose: BlockToken()
@Serializable @SerialName("headingUnderline") class HeadingUnderline(val level: Int): BlockToken()
@Serializable @SerialName("listMark") class ListMark(val number: String? = null): BlockToken()
@Serializable @SerialName("quoteMark") class QuoteMark: BlockToken()
@Serializable @SerialName("fenceMark") class FenceMark(val language: String? = null): BlockToken()
@Serializable @SerialName("rowMark") class RowMark: BlockToken()
@Serializable @SerialName("cellMark") class CellMark: BlockToken()
@Serializable @SerialName("delimiterRow") class DelimiterRow(val columns: List<TableAlign>): BlockToken()
@Serializable @SerialName("ruleMark") class RuleMark: BlockToken()
val drawnText: String get() = when (this) {
is SoftBreak -> " "
is LineBreak, is HardBreak -> "\n"
is ParagraphBreak -> "\n".repeat(lineBreaks)
else -> ""
}
val previewText: String get() = when (this) {
is SoftBreak, is HardBreak, is CellMark -> " "
is LineBreak, is ParagraphBreak -> "\n"
is ListMark -> (number ?: "•") + " "
else -> ""
}
}
@Serializable
sealed class MarkdownBlock {
@Serializable @SerialName("paragraph") class Paragraph(val content: SegmentRange): MarkdownBlock()
@Serializable @SerialName("heading") class Heading(val level: Int, val content: SegmentRange): MarkdownBlock()
@Serializable @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange): MarkdownBlock()
@Serializable @SerialName("quote") class Quote(val content: SegmentRange): MarkdownBlock()
@Serializable @SerialName("list") class ItemList(val items: List<ListEntry>): MarkdownBlock()
@Serializable @SerialName("table") class Table(val columns: List<TableAlign>, val header: List<SegmentRange>, val rows: List<List<SegmentRange>>): MarkdownBlock()
@Serializable @SerialName("rule") class Rule(val mark: SegmentRange): MarkdownBlock()
}
@Serializable
class ListEntry(val number: String? = null, val content: SegmentRange, val items: List<ListEntry>)
@Serializable
class SegmentRange(val from: Int, val to: Int)
@Serializable
enum class TableAlign {
@SerialName("none") None,
@SerialName("left") Left,
@SerialName("center") Center,
@SerialName("right") Right
}
class BlockTextRange(val range: SegmentRange, val inRow: Boolean)
val MarkdownBlock.textRanges: List<BlockTextRange> get() = when (this) {
is MarkdownBlock.Paragraph -> listOf(BlockTextRange(content, false))
is MarkdownBlock.Heading -> listOf(BlockTextRange(content, false))
is MarkdownBlock.CodeBlock -> listOf(BlockTextRange(content, false))
is MarkdownBlock.Quote -> listOf(BlockTextRange(content, false))
is MarkdownBlock.ItemList -> listEntries(items, 0).map { (entry, _) -> BlockTextRange(entry.content, false) }
is MarkdownBlock.Table -> (listOf(header) + rows).flatMap { row -> row.mapIndexed { c, cell -> BlockTextRange(cell, c > 0) } }
is MarkdownBlock.Rule -> emptyList()
}
fun listEntries(items: List<ListEntry>, level: Int): List<Pair<ListEntry, Int>> =
items.flatMap { listOf(it to level) + listEntries(it.items, level + 1) }
fun blockTextRanges(blocks: List<MarkdownBlock>): List<BlockTextRange> = blocks.flatMap { it.textRanges }
fun headingSegments(size: Int, blocks: List<MarkdownBlock>): BooleanArray {
val bold = BooleanArray(size)
blocks.forEach { if (it is MarkdownBlock.Heading) for (i in it.content.from.coerceIn(0, size) until it.content.to.coerceIn(0, size)) bold[i] = true }
return bold
}
@Serializable
enum class SimplexLinkType(val linkType: String) {
contact("contact"),
@@ -5683,6 +5803,7 @@ data class ChatItemVersion(
val chatItemVersionId: Long,
val msgContent: MsgContent,
val formattedText: List<FormattedText>?,
val markdownBlocks: List<MarkdownBlock>? = null,
val itemVersionTs: Instant,
val createdAt: Instant,
)
@@ -27,6 +27,8 @@ import chat.simplex.common.model.SMPProxyMode.Always
import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.badges.BadgeStore
import chat.simplex.common.views.badges.BadgeStoreError
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.item.contentModerationPostLink
import chat.simplex.common.views.chat.item.showContentBlockedAlert
@@ -142,6 +144,7 @@ class AppPreferences {
val appearanceBarsBlurRadius = mkIntPreference(SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS, if (deviceSupportsBlur) 50 else 0)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
val markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
@@ -429,6 +432,7 @@ class AppPreferences {
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
private const val SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW = "MarkdownFileTextView"
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage"
@@ -590,13 +594,38 @@ object ChatController {
}
}
// log = false because a store receipt is a bearer secret, like a badge code - it is in the command.
suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment): BadgePurchaseResult {
val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment), log = false)
return when {
r is API.Result && r.res is CR.BadgeStateR -> BadgePurchaseResult.Held(r.res.user, r.res.badgeState, r.res.storePurchases)
r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Credited(r.res.user.updateRemoteHostId(rh), r.res.badgeState)
r is API.Error -> BadgePurchaseResult.Failed(r.err)
else -> {
// the response type alone - it names a case or a JSON key, never the service's message
Log.e(TAG, "apiPurchaseBadge: unexpected ${r.responseType}")
BadgePurchaseResult.Failed(null)
}
}
}
suspend fun apiCreateBadgeInvoice(rh: Long?, userId: Long): String {
val r = sendCmd(rh, CC.ApiCreateBadgeInvoice(userId))
if (r is API.Result && r.res is CR.BadgeInvoice) return r.res.invoiceId
if (r is API.Error) throw BadgeStoreError.ApiError(r.err)
throw Exception("apiCreateBadgeInvoice: unexpected ${r.responseType}")
}
// localized where the user can act on it; otherwise the error itself, so a screenshot says what happened
fun redeemErrorText(err: ChatError?): String {
fun redeemErrorText(err: ChatError?, purchase: Boolean): String {
if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.CEBadgeRedeemError) {
when (val e = err.errorType.badgeRedeemError) {
is BadgeRedeemError.InvalidCode -> return generalGetString(MR.strings.badges_error_invalid_code)
is BadgeRedeemError.ServiceNotConfigured -> return generalGetString(MR.strings.badges_error_service_not_configured)
is BadgeRedeemError.BadgeActive -> return generalGetString(MR.strings.badges_error_already_active)
is BadgeRedeemError.InvalidReceipt -> {}
is BadgeRedeemError.ServiceNotConfigured ->
return generalGetString(if (purchase) MR.strings.badges_error_service_not_configured_purchase else MR.strings.badges_error_service_not_configured)
is BadgeRedeemError.BadgeActive ->
return generalGetString(if (purchase) MR.strings.badges_error_already_active_purchase else MR.strings.badges_error_already_active)
is BadgeRedeemError.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it }
is BadgeRedeemError.InvalidResponse -> return String.format(generalGetString(MR.strings.badges_error_bad_service_response), e.message)
is BadgeRedeemError.UnknownKeyIndex, is BadgeRedeemError.CredentialNotVerified -> return generalGetString(MR.strings.badges_error_credential_not_verified)
@@ -605,9 +634,9 @@ object ChatController {
return "${generalGetString(MR.strings.error_prefix)}: ${err?.string ?: generalGetString(MR.strings.badges_error_unknown)}"
}
suspend fun apiGetBadgeState(rh: Long?, userId: Long): BadgeState? {
suspend fun apiGetBadgeState(rh: Long?, userId: Long): Pair<BadgeState?, List<OpenStorePurchase>> {
val r = sendCmd(rh, CC.ApiGetBadgeState(userId))
if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState
if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState to r.res.storePurchases
throw Exception("apiGetBadgeState: unexpected ${r.responseType}")
}
@@ -640,11 +669,14 @@ object ChatController {
// Not thrown: a failed badge read must not stop the app starting, and the model is left alone
// rather than set to nil, which would read as "no badge".
private suspend fun loadBadgeState(rhId: Long?) {
suspend fun loadBadgeState(rhId: Long?) {
try {
val userId = currentUserId("loadBadgeState")
val badgeState = apiGetBadgeState(rhId, userId)
withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) }
val (badgeState, storePurchases) = apiGetBadgeState(rhId, userId)
withContext(Dispatchers.Main) {
BadgeModel.set(rhId, userId, badgeState)
BadgeStore.setStorePurchases(rhId, userId, storePurchases)
}
} catch (e: Exception) {
Log.e(TAG, "loadBadgeState: ${e.message}")
}
@@ -689,6 +721,7 @@ object ChatController {
}
apiStartChat()
appPrefs.chatStopped.set(false)
withLongRunningApi { BadgeStore.presentUnfinished() }
if (appPrefs.shouldDeleteDatabaseBackups.get()) {
deleteDatabaseBackups()
appPrefs.shouldDeleteDatabaseBackups.set(false)
@@ -758,6 +791,7 @@ object ChatController {
chatModel.users.clear()
chatModel.users.addAll(users)
getUserChatData(rhId, keepingChatId = keepingChatId)
withLongRunningApi { BadgeStore.presentUnfinished() }
val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId }
if (invitation != null && currentUser != null) {
chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser))
@@ -1927,8 +1961,10 @@ object ChatController {
val r = sendCmd(rh, CC.ApiUpdateProfile(userId, profile))
if (r is API.Result && r.res is CR.UserProfileNoChange) return profile to emptyList()
if (r is API.Result && r.res is CR.UserProfileUpdated) return r.res.toProfile to r.res.updateSummary.changedContacts
if (r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName) {
if (r is API.Error && (r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName || r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.UserExists)) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc))
} else if (!(networkErrorAlert(r))) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_profile), "${r.responseType}: ${r.details}")
}
Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
return null
@@ -1940,7 +1976,12 @@ object ChatController {
return when {
r is API.Result && r.res is CR.UserProfileNoChange -> null
r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh)
else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}")
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiSetProfileAddress", generalGetString(MR.strings.error_saving_profile), r)
}
throw Exception("failed to set profile address: ${r.responseType} ${r.details}")
}
}
}
@@ -2144,8 +2185,8 @@ object ChatController {
return r.result is CR.CmdOk
}
suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean {
val extraInfo = WebRTCExtraInfo(rtcIceCandidates)
suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String): Boolean {
val extraInfo = WebRTCExtraInfo(rtcSession, rtcIceCandidates)
val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo))
return r.result is CR.CmdOk
}
@@ -3121,6 +3162,13 @@ object ChatController {
// TODO NtfManager.shared.notifyGroupInvitation
}
}
is CR.ReceivedGroupInvitationViaGroup -> {
if (active(r.user)) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, r.groupInfo)
}
}
}
is CR.UserAcceptedGroupSent -> {
if (!active(r.user)) return
@@ -3388,7 +3436,7 @@ object ChatController {
}
is CR.CallExtraInfo -> {
withCall(r, r.contact) { _ ->
chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates))
chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates, session = r.extraInfo.rtcSession))
}
}
is CR.CallEnded -> {
@@ -3580,6 +3628,10 @@ object ChatController {
BadgeModel.setAlert(rhId, r.user.userId, r.badgeAlert)
}
}
is CR.StorePurchaseCredited ->
withLongRunningApi { BadgeStore.storePurchaseCredited(rhId, r.user, r.invoiceId) }
is CR.StorePurchaseRefused ->
withLongRunningApi { BadgeStore.storePurchaseRefused(rhId, r.user, r.invoiceId, r.refusal) }
else ->
Log.d(TAG , "unsupported event: ${msg.responseType}")
}
@@ -3621,7 +3673,7 @@ object ChatController {
getUserChatData(null)
}
private fun activeUser(rhId: Long?, user: UserLike): Boolean =
fun activeUser(rhId: Long?, user: UserLike): Boolean =
rhId == chatModel.remoteHostId() && user.userId == chatModel.currentUser.value?.userId
private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
@@ -3916,6 +3968,17 @@ sealed class BadgeRedeemResult {
class Failed(val err: ChatError?): BadgeRedeemResult()
}
@Serializable
data class OpenStorePurchase(val invoiceId: String? = null, val transactionRef: String? = null, val creditError: BadgeIssueFailure? = null)
// a refusal is Failed, with the code in BREServiceError
sealed class BadgePurchaseResult {
class Held(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List<OpenStorePurchase>): BadgePurchaseResult()
class Credited(val user: User, val badgeState: BadgeState?): BadgePurchaseResult()
// err is null for a response of an unexpected type, which is logged where it is received
class Failed(val err: ChatError?): BadgePurchaseResult()
}
// ChatCommand
// Spec: spec/api.md#CC
sealed class CC {
@@ -4096,6 +4159,8 @@ sealed class CC {
class ApiStandaloneFileInfo(val url: String): CC()
// badges
class ApiRedeemBadgeCode(val userId: Long, val code: String): CC()
class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val payment: ServicePayment): CC()
class ApiCreateBadgeInvoice(val userId: Long): CC()
class ApiGetBadgeState(val userId: Long): CC()
class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC()
class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC()
@@ -4321,6 +4386,8 @@ sealed class CC {
is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}"
is ApiStandaloneFileInfo -> "/_download info $url"
is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code"
is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""} ${json.encodeToString(payment)}"
is ApiCreateBadgeInvoice -> "/_badge invoice $userId"
is ApiGetBadgeState -> "/_badge state $userId"
is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId"
is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode"
@@ -4505,6 +4572,8 @@ sealed class CC {
is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile"
is ApiStandaloneFileInfo -> "apiStandaloneFileInfo"
is ApiRedeemBadgeCode -> "apiRedeemBadgeCode"
is ApiPurchaseBadge -> "apiPurchaseBadge"
is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice"
is ApiGetBadgeState -> "apiGetBadgeState"
is ApiGetBadgeLedger -> "apiGetBadgeLedger"
is ApiAckBadgeAlert -> "apiAckBadgeAlert"
@@ -4529,8 +4598,11 @@ sealed class CC {
is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd))
is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd))
is TestStorageEncryption -> TestStorageEncryption(obfuscate(key))
// a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
// a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code))
is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, when (payment) {
is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token))
})
else -> this
}
@@ -4564,6 +4636,12 @@ sealed class CC {
}
}
// the field must stay typed as the sealed base, or kotlinx omits the "type" tag core parses by
@Serializable
sealed class ServicePayment {
@Serializable @SerialName("google") class Google(val productId: String, val token: String): ServicePayment()
}
fun onOff(b: Boolean): String = if (b) "on" else "off"
// /_badge ack takes the kind in core's text encoding, not the JSON tag
@@ -6808,6 +6886,7 @@ sealed class CR {
@Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR()
@Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("receivedGroupInvitationViaGroup") class ReceivedGroupInvitationViaGroup(val user: UserRef, val groupInfo: GroupInfo, val viaGroupInfo: GroupInfo, val viaMember: GroupMember, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR()
@Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@@ -6901,10 +6980,13 @@ sealed class CR {
// badges
// the full user, not UserRef: its profile carries the badge that setUserBadge just stored
@Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR()
@Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR()
@Serializable @SerialName("badgeInvoice") class BadgeInvoice(val user: UserRef, val invoiceId: String): CR()
@Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List<OpenStorePurchase> = emptyList()): CR()
@Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List<StatementEntry>): CR()
@Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR()
@Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR()
@Serializable @SerialName("storePurchaseCredited") class StorePurchaseCredited(val user: UserRef, val invoiceId: String): CR()
@Serializable @SerialName("storePurchaseRefused") class StorePurchaseRefused(val user: UserRef, val invoiceId: String, val refusal: BadgeIssueFailure): CR()
// general
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
@@ -7008,6 +7090,7 @@ sealed class CR {
is LeftMemberUser -> "leftMemberUser"
is GroupMembers -> "groupMembers"
is ReceivedGroupInvitation -> "receivedGroupInvitation"
is ReceivedGroupInvitationViaGroup -> "receivedGroupInvitationViaGroup"
is GroupDeletedUser -> "groupDeletedUser"
is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting"
is MemberAccepted -> "memberAccepted"
@@ -7092,10 +7175,13 @@ sealed class CR {
is ArchiveImported -> "archiveImported"
is AppSettingsR -> "appSettings"
is BadgeRedeemed -> "badgeRedeemed"
is BadgeInvoice -> "badgeInvoice"
is BadgeStateR -> "badgeState"
is BadgeLedger -> "badgeLedger"
is BadgeChanged -> "badgeChanged"
is BadgeAlertR -> "badgeAlert"
is StorePurchaseCredited -> "storePurchaseCredited"
is StorePurchaseRefused -> "storePurchaseRefused"
is Response -> "* $type"
is Invalid -> "* invalid json"
}
@@ -7199,6 +7285,7 @@ sealed class CR {
is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo))
is GroupMembers -> withUser(user, json.encodeToString(group))
is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole")
is ReceivedGroupInvitationViaGroup -> withUser(user, "groupInfo: $groupInfo\nviaGroupInfo: $viaGroupInfo\nviaMember: $viaMember\nmemberRole: $memberRole")
is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo))
is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member")
is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
@@ -7300,10 +7387,13 @@ sealed class CR {
is ArchiveImported -> "${archiveErrors.map { it.string } }"
is AppSettingsR -> json.encodeToString(appSettings)
is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}")
is BadgeStateR -> withUser(user, json.encodeToString(badgeState))
is BadgeInvoice -> withUser(user, invoiceId)
is BadgeStateR -> withUser(user, "${json.encodeToString(badgeState)}\nstorePurchases: ${json.encodeToString(storePurchases)}")
is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger))
is BadgeChanged -> withUser(user, json.encodeToString(badgeState))
is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert))
is StorePurchaseCredited -> withUser(user, "invoiceId: $invoiceId")
is StorePurchaseRefused -> withUser(user, "invoiceId: $invoiceId\nrefusal: ${refusal.tag}")
is Response -> json
is Invalid -> str
}
@@ -7359,6 +7449,7 @@ sealed class SimplexDomainError {
sealed class BadgeRedeemError {
val string: String get() = when (this) {
is InvalidCode -> "invalidCode"
is InvalidReceipt -> "invalidReceipt"
is ServiceNotConfigured -> "serviceNotConfigured"
is BadgeActive -> "badgeActive"
is ServiceError -> "serviceError ${serviceError.text}"
@@ -7367,6 +7458,7 @@ sealed class BadgeRedeemError {
is CredentialNotVerified -> "credentialNotVerified"
}
@Serializable @SerialName("invalidCode") object InvalidCode : BadgeRedeemError()
@Serializable @SerialName("invalidReceipt") object InvalidReceipt : BadgeRedeemError()
@Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError()
@Serializable @SerialName("badgeActive") object BadgeActive : BadgeRedeemError()
@Serializable @SerialName("serviceError") class ServiceError(val serviceError: BadgeServiceErrorCode) : BadgeRedeemError()
@@ -7388,12 +7480,14 @@ sealed class BadgeServiceErrorCode {
object PaymentNotEntitled: BadgeServiceErrorCode()
object PaymentPending: BadgeServiceErrorCode()
object ProviderUnavailable: BadgeServiceErrorCode()
object ProviderNotConfigured: BadgeServiceErrorCode()
object RateLimited: BadgeServiceErrorCode()
object CodeInvalid: BadgeServiceErrorCode()
object CodeUsed: BadgeServiceErrorCode()
object CodeExpired: BadgeServiceErrorCode()
object ReceiptInvalid: BadgeServiceErrorCode()
object ReceiptUsed: BadgeServiceErrorCode()
object TestPurchase: BadgeServiceErrorCode()
object Internal: BadgeServiceErrorCode()
data class Unknown(val code: String): BadgeServiceErrorCode()
@@ -7409,12 +7503,14 @@ sealed class BadgeServiceErrorCode {
is PaymentNotEntitled -> "payment_not_entitled"
is PaymentPending -> "payment_pending"
is ProviderUnavailable -> "provider_unavailable"
is ProviderNotConfigured -> "provider_not_configured"
is RateLimited -> "rate_limited"
is CodeInvalid -> "code_invalid"
is CodeUsed -> "code_used"
is CodeExpired -> "code_expired"
is ReceiptInvalid -> "receipt_invalid"
is ReceiptUsed -> "receipt_used"
is TestPurchase -> "test_purchase"
is Internal -> "internal"
is Unknown -> code
}
@@ -7428,6 +7524,13 @@ fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) {
is BadgeServiceErrorCode.UnsupportedVersion -> generalGetString(MR.strings.badges_error_unsupported_version)
is BadgeServiceErrorCode.UnknownPurchaseKey -> generalGetString(MR.strings.badges_error_unknown_purchase)
is BadgeServiceErrorCode.Internal -> generalGetString(MR.strings.badges_error_service_internal)
is BadgeServiceErrorCode.ReceiptInvalid -> generalGetString(MR.strings.badges_error_receipt_invalid)
is BadgeServiceErrorCode.TestPurchase -> generalGetString(MR.strings.badges_error_test_purchase)
is BadgeServiceErrorCode.ReceiptUsed -> generalGetString(MR.strings.badges_error_receipt_used)
is BadgeServiceErrorCode.PaymentPending -> generalGetString(MR.strings.badges_error_payment_pending)
is BadgeServiceErrorCode.ProviderUnavailable -> generalGetString(MR.strings.badges_error_provider_unavailable)
is BadgeServiceErrorCode.ProviderNotConfigured -> generalGetString(MR.strings.badges_error_provider_not_configured)
is BadgeServiceErrorCode.ProductUnavailable -> generalGetString(MR.strings.badges_error_product_unavailable)
else -> null
}
@@ -7445,12 +7548,14 @@ object BadgeServiceErrorCodeSerializer : KSerializer<BadgeServiceErrorCode> {
"payment_not_entitled" -> BadgeServiceErrorCode.PaymentNotEntitled
"payment_pending" -> BadgeServiceErrorCode.PaymentPending
"provider_unavailable" -> BadgeServiceErrorCode.ProviderUnavailable
"provider_not_configured" -> BadgeServiceErrorCode.ProviderNotConfigured
"rate_limited" -> BadgeServiceErrorCode.RateLimited
"code_invalid" -> BadgeServiceErrorCode.CodeInvalid
"code_used" -> BadgeServiceErrorCode.CodeUsed
"code_expired" -> BadgeServiceErrorCode.CodeExpired
"receipt_invalid" -> BadgeServiceErrorCode.ReceiptInvalid
"receipt_used" -> BadgeServiceErrorCode.ReceiptUsed
"test_purchase" -> BadgeServiceErrorCode.TestPurchase
"internal" -> BadgeServiceErrorCode.Internal
else -> BadgeServiceErrorCode.Unknown(v)
}
@@ -8034,7 +8139,7 @@ sealed class StoreError {
is UserChatRelayNotFound -> "userChatRelayNotFound $chatRelayId"
is GroupRelayNotFound -> "groupRelayNotFound $groupRelayId"
is GroupRelayNotFoundByMemberId -> "groupRelayNotFoundByMemberId $groupMemberId"
is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $authorGroupMemberId"
is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $forwardedByGroupMemberId"
is RemoteHostNotFound -> "remoteHostNotFound $remoteHostId"
is RemoteHostUnknown -> "remoteHostUnknown"
is RemoteHostDuplicateCA -> "remoteHostDuplicateCA"
Loaded 100 of 624 files, more files were not shown because too many files have changed in this diff. Show more