Merge branch 'nd/set-group-members-main-thread' into nd/support-list-events

# Conflicts:
#	apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt
This commit is contained in:
Narasimha-sc committed 2026-10-19 12:30:12 +00:00
commit b37e62893c
1159 files changed
+42813 -5984

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."
+1036 -162
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
+52 -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)")
}
}
}
@@ -1931,10 +1975,13 @@ enum UserServersError: Decodable {
case storageMissing(protocol: ServerProtocol, user: UserRef?)
case proxyMissing(protocol: ServerProtocol, user: UserRef?)
case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String)
case tooManyHosts(protocol: ServerProtocol, tooManyHostsServer: String)
case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String)
var globalError: String? {
switch self {
case .tooManyHosts:
return globalSMPError
case let .noServers(`protocol`, _):
switch `protocol` {
case .smp: return globalSMPError
@@ -1977,6 +2024,8 @@ enum UserServersError: Decodable {
} else {
return text
}
case let .tooManyHosts(.smp, server):
return String.localizedStringWithFormat(NSLocalizedString("Server %@ has more than two host names.", comment: "servers error"), serverHostname(server))
default:
return nil
}
+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
}
+121 -30
View File
@@ -1362,8 +1362,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
}
}
@@ -1849,8 +1853,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))
}
@@ -2200,13 +2204,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)
@@ -2216,15 +2249,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
}
@@ -2236,7 +2269,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
}
@@ -2365,6 +2398,11 @@ 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)
}
}
func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
@@ -2389,6 +2427,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 +2443,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 +2471,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
@@ -2703,7 +2753,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
@@ -2870,6 +2921,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) }
@@ -2916,23 +2970,19 @@ func processReceivedMsg(_ res: ChatEvent) async {
m.callInvitations[invitation.contact.id] = invitation
}
activateCall(invitation)
case let .callOffer(_, contact, callType, offer, sharedKey, _):
case let .callOffer(_, contact, callType, offer, sharedKey, askConfirmation):
await withCall(contact) { call in
await MainActor.run {
call.callState = .offerReceived
call.sharedKey = sharedKey
}
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
await m.callCommand.processCommand(.offer(
offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey,
iceServers: iceServers,
relay: useRelay
))
if askConfirmation {
showUnencryptedCallAlert(call) {
Task { await processCallOffer(callType, offer, sharedKey) }
}
} else {
await processCallOffer(callType, offer, sharedKey)
}
}
case let .callAnswer(_, contact, answer):
await withCall(contact) { call in
@@ -2943,7 +2993,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) }) {
@@ -3058,6 +3108,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)")
}
@@ -3069,6 +3123,43 @@ func processReceivedMsg(_ res: ChatEvent) async {
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
}
}
func processCallOffer(_ callType: CallType, _ offer: WebRTCSession, _ sharedKey: String?) async {
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
await m.callCommand.processCommand(.offer(
offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey,
iceServers: iceServers,
relay: useRelay
))
}
func showUnencryptedCallAlert(_ call: Call, onContinue: @escaping () -> Void) {
DispatchQueue.main.async {
showAlert(
NSLocalizedString("Call is not encrypted", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("%@ accepted the call without end-to-end encryption.", comment: "alert message"), call.contact.displayName),
actions: {[
UIAlertAction(title: NSLocalizedString("End call", comment: "alert action"), style: .destructive) { _ in
// the call may have ended, or a new one started with the same contact, while the alert was shown
guard m.activeCall === call else { return }
if let uuid = call.callUUID {
CallController.shared.endCall(callUUID: uuid)
} else {
CallController.shared.endCall(call: call) {}
}
},
UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in
if m.activeCall === call { onContinue() }
}
]}
)
}
}
}
func switchToLocalSession() {
@@ -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)
)
@@ -153,6 +153,9 @@ struct DatabaseEncryptionView: View {
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
}
try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
if currentKey != newKey {
shouldDeleteDatabaseBackupsDefault.set(true)
}
encryptionStartedDefault.set(false)
initialRandomDBPassphraseGroupDefault.set(false)
if migration {
@@ -118,6 +118,9 @@ struct DatabaseErrorView: View {
case .errorKeychain:
titleText("Keychain error")
errorView(Text("Cannot access keychain to save database password"))
case .errorKeyGeneration:
titleText("Database error")
errorView(Text("Cannot generate random database passphrase"))
case .invalidConfirmation:
// this can only happen if incorrect parameter is passed
titleText("Invalid migration confirmation")
@@ -217,6 +220,8 @@ struct DatabaseErrorView: View {
)
case .errorKeychain:
am.showAlertMsg(title: "Keychain error")
case .errorKeyGeneration:
am.showAlertMsg(title: "Database error", message: "Cannot generate random database passphrase")
case let .errorSQL(_, error):
am.showAlert(Alert(
title: Text("Database error"),
@@ -453,6 +453,10 @@ struct DatabaseView: View {
ChatReceiver.shared.start()
chatLastStartGroupDefault.set(Date.now)
AppChatState.shared.set(.active)
if shouldDeleteDatabaseBackupsDefault.get() {
deleteDatabaseBackups()
shouldDeleteDatabaseBackupsDefault.set(false)
}
} catch let error {
runChat.wrappedValue = false
showAlert(NSLocalizedString("Error starting chat", comment: ""), message: responseError(error))
+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
@@ -709,6 +709,8 @@ private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert:
alert.wrappedValue = .wrongPassphrase()
case .errorKeychain:
alert.wrappedValue = .keychainError()
case .errorKeyGeneration:
alert.wrappedValue = .databaseError(message: NSLocalizedString("Cannot generate random database passphrase", comment: "alert message"))
case let .errorSQL(_, error):
alert.wrappedValue = .databaseError(message: error)
case let .unknown(error):
@@ -539,7 +539,7 @@ struct MigrateToDevice: View {
Task {
do {
if !hasChatCtrl() {
chatInitControllerRemovingDatabases()
try chatInitControllerRemovingDatabases()
} else if ChatModel.shared.chatRunning == true {
// cannot delete storage if chat is running
try await stopChatAsync()
@@ -735,6 +735,8 @@ private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert:
alert.wrappedValue = .wrongPassphrase()
case .errorKeychain:
alert.wrappedValue = .keychainError()
case .errorKeyGeneration:
alert.wrappedValue = .databaseError(message: NSLocalizedString("Cannot generate random database passphrase", comment: "alert message"))
case let .errorSQL(_, error):
alert.wrappedValue = .databaseError(message: error)
case let .unknown(error):
@@ -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)
}
}
}
@@ -49,6 +49,7 @@ let DEFAULT_SHOULD_IMPORT_APP_SETTINGS = "shouldImportAppSettings"
let DEFAULT_DEVELOPER_TOOLS = "developerTools"
let DEFAULT_ENCRYPTION_STARTED = "encryptionStarted"
let DEFAULT_ENCRYPTION_STARTED_AT = "encryptionStartedAt"
let DEFAULT_SHOULD_DELETE_DATABASE_BACKUPS = "shouldDeleteDatabaseBackups"
let DEFAULT_ACCENT_COLOR_RED = "accentColorRed" // deprecated, only used for migration
let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for migration
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration
@@ -83,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"
@@ -144,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: "{}",
@@ -207,6 +210,8 @@ let encryptionStartedDefault = BoolDefault(defaults: UserDefaults.standard, forK
let encryptionStartedAtDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_ENCRYPTION_STARTED_AT)
let shouldDeleteDatabaseBackupsDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOULD_DELETE_DATABASE_BACKUPS)
let connectViaLinkTabDefault = EnumDefault<ConnectViaLinkTab>(defaults: UserDefaults.standard, forKey: DEFAULT_CONNECT_VIA_LINK_TAB, withDefault: .scan)
let privacySimplexLinkModeDefault = EnumDefault<SimpleXLinkMode>(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description)
@@ -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 }
}
}
+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
+5
View File
@@ -247,6 +247,11 @@ class ShareModel: ObservableObject {
title: "Keychain error",
message: "Cannot access keychain to save database password"
)
case .errorKeyGeneration:
ErrorAlert(
title: "Database error",
message: "Cannot generate random database passphrase"
)
case .invalidConfirmation:
ErrorAlert("Invalid migration confirmation")
case let .unknown(json):
+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/>
+44 -26
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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.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 */,
@@ -2139,7 +2157,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 360;
CURRENT_PROJECT_VERSION = 362;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2189,7 +2207,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 360;
CURRENT_PROJECT_VERSION = 362;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2231,7 +2249,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 360;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2251,7 +2269,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 360;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2276,7 +2294,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 360;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2313,7 +2331,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 360;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2350,7 +2368,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 360;
CURRENT_PROJECT_VERSION = 362;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2401,7 +2419,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 360;
CURRENT_PROJECT_VERSION = 362;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2455,7 +2473,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 360;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2489,7 +2507,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 360;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
+18 -8
View File
@@ -32,7 +32,12 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
} else if useKeychain {
if !hasDatabase() {
logger.debug("chatMigrateInit generating a random DB key")
dbKey = randomDatabasePassword()
guard let key = randomDatabasePassword() else {
let result = (false, DBMigrationResult.errorKeyGeneration)
migrationResult = result
return result
}
dbKey = key
initialRandomDBPassphraseGroupDefault.set(true)
} else if let key = kcDatabasePassword.get() {
dbKey = key
@@ -56,7 +61,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation: MigrationConfirmation = .error) -> (DBMigrationResult, chat_ctrl?) {
let dbPath = url.path
let dbKey = key ?? randomDatabasePassword()
guard let dbKey = key ?? randomDatabasePassword() else { return (.errorKeyGeneration, nil) }
logger.debug("chatInitTemporaryDatabase path: \(dbPath)")
var temporaryController: chat_ctrl? = nil
var cPath = dbPath.cString(using: .utf8)!
@@ -66,14 +71,14 @@ public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation
return (dbMigrationResult(dataFromCString(cjson)), temporaryController)
}
public func chatInitControllerRemovingDatabases() {
public func chatInitControllerRemovingDatabases() throws {
let dbPath = getAppDatabasePath().path
let fm = FileManager.default
// Remove previous databases, otherwise, can be .errorNotADatabase with nil controller
try? fm.removeItem(atPath: dbPath + CHAT_DB)
try? fm.removeItem(atPath: dbPath + AGENT_DB)
let dbKey = randomDatabasePassword()
guard let dbKey = randomDatabasePassword() else { throw RuntimeError("Cannot generate random database passphrase") }
logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)")
var cPath = dbPath.cString(using: .utf8)!
var cKey = dbKey.cString(using: .utf8)!
@@ -144,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)")
}
@@ -171,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? {
@@ -353,6 +362,7 @@ public enum DBMigrationResult: Decodable, Equatable {
case errorMigration(dbFile: String, migrationError: MigrationError)
case errorSQL(dbFile: String, migrationSQLError: String)
case errorKeychain
case errorKeyGeneration
case unknown(json: String)
}
+16 -2
View File
@@ -598,6 +598,7 @@ public enum RcvSwitchStatus: String, Decodable, Hashable {
case sendingQADD = "sending_qadd"
case sendingQUSE = "sending_quse"
case receivedMessage = "received_message"
case receivedQEND = "received_qend"
}
public struct SndQueueInfo: Decodable, Hashable {
@@ -826,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)
@@ -846,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)
@@ -867,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
@@ -890,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)
@@ -911,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")
}
}
+17
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])
@@ -194,6 +204,13 @@ private func restoreBackupFile(fromPath: String, toPath: String) throws {
try fm.copyItem(atPath: fromPath, toPath: toPath)
}
public func deleteDatabaseBackups() {
let fm = FileManager.default
let dbPath = getAppDatabasePath().path
try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK)
try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK)
}
public func hasLegacyDatabase() -> Bool {
hasDatabaseAtPath(getLegacyDatabasePath())
}
+2 -2
View File
@@ -37,7 +37,7 @@ public struct KeyChainItem {
}
}
func randomDatabasePassword() -> String {
func randomDatabasePassword() -> String? {
var keyData = Data(count: 32)
let status = keyData.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
@@ -46,7 +46,7 @@ func randomDatabasePassword() -> String {
return keyData.base64EncodedString()
} else {
logger.error("randomDatabasePassword: error \(status)")
return ""
return nil
}
}
+6 -5
View File
@@ -138,7 +138,7 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) ->
title = NSLocalizedString("Encrypted message: no passphrase", comment: "notification")
case .errorMigration:
title = NSLocalizedString("Encrypted message: database migration error", comment: "notification")
case .errorSQL:
case .errorSQL, .errorKeyGeneration:
title = NSLocalizedString("Encrypted message: database error", comment: "notification")
case .errorKeychain:
title = NSLocalizedString("Encrypted message: keychain error", comment: "notification")
@@ -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:
+1
View File
@@ -134,6 +134,7 @@ Migration results are decoded in Swift as `DBMigrationResult`:
- `.errorMigration(dbFile:, migrationError:)` -- migration failed
- `.errorSQL(dbFile:, migrationSQLError:)` -- SQL error during migration
- `.errorKeychain` -- keychain access failed
- `.errorKeyGeneration` -- random database key generation failed
- `.unknown(json:)` -- unrecognized response
---
+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 |
+1 -1
View File
@@ -101,7 +101,7 @@ Tests are in:
## Resources & Localization
- String resources: `common/src/commonMain/resources/MR/base/strings.xml` + 21 language variants
- String resources: `common/src/commonMain/moko-resources/base/strings.xml` + 21 language variants
- Uses Moko Resources (`dev.icerock.moko:resources`) for cross-platform resource management
- The `adjustFormatting` gradle task validates string resources during build
+2 -2
View File
@@ -9,13 +9,13 @@ plugins {
}
android {
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "chat.simplex.app"
namespace = "chat.simplex.app"
minSdk = 26
targetSdk = 35
targetSdk = 36
// !!!
// skip version code after release to F-Droid, as it uses two version codes
versionCode = (extra["android.version_code"] as String).toInt()
@@ -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)
}
}
@@ -35,6 +35,8 @@
</intent>
</queries>
<!-- enableOnBackInvokedCallback="false": Activity.onBackPressed() overrides are not called on Android 16+ without it.
UnusedAttribute is ignored because the attribute only exists on API 33+, older versions call onBackPressed() anyway -->
<application
android:name="SimplexApp"
android:allowBackup="false"
@@ -44,7 +46,9 @@
android:largeHeap="true"
android:extractNativeLibs="${extract_native_libs}"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
android:enableOnBackInvokedCallback="false"
android:theme="@style/Theme.SimpleX"
tools:ignore="UnusedAttribute">
<!-- android:localeConfig="@xml/locales_config"-->
<!-- Main activity -->
@@ -70,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" />
@@ -16,7 +16,7 @@ import chat.simplex.app.views.call.CallActivity
import chat.simplex.common.model.NotificationPreviewMode
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import chat.simplex.res.*
import kotlinx.datetime.Instant
class CallService: Service() {
@@ -18,7 +18,7 @@ import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import chat.simplex.res.*
import java.lang.ref.WeakReference
class MainActivity: FragmentActivity() {
@@ -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() {
@@ -29,7 +29,7 @@ import chat.simplex.common.model.NotificationsMode
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.*
import kotlinx.coroutines.*
import chat.simplex.res.MR
import chat.simplex.res.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlin.system.exitProcess
@@ -22,7 +22,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.call.RcvCallInvitation
import kotlinx.datetime.Clock
import chat.simplex.res.MR
import chat.simplex.res.*
object NtfManager {
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
@@ -36,6 +36,7 @@ import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.common.AppLock
import chat.simplex.common.helpers.applyAppLocale
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
@@ -44,7 +45,7 @@ import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import chat.simplex.res.*
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.launch
@@ -342,7 +343,10 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
chatModel.activeCallInvitation.value = null
ntfManager.cancelCallNotification()
},
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
acceptCall = {
AppLock.recheckAuthState()
cm.acceptIncomingCall(invitation = invitation)
},
openApp = {
val intent = Intent(context, MainActivity::class.java)
.setAction(NtfManager.OpenChatAction)
+1 -1
View File
@@ -48,7 +48,7 @@ buildscript {
dependencies {
classpath("com.android.tools.build:gradle:${rootProject.extra["gradle.plugin.version"]}")
classpath(kotlin("gradle-plugin", version = rootProject.extra["kotlin.version"] as String))
classpath("dev.icerock.moko:resources-generator:0.23.0")
classpath("dev.icerock.moko:resources-generator:0.27.1")
// Workaround gradle sync issue: https://github.com/gmazzo/gradle-buildconfig-plugin/issues/137#issuecomment-1935739759
classpath("com.squareup:kotlinpoet:1.16.0")
+13 -26
View File
@@ -56,11 +56,6 @@ kotlin {
}
val commonMain by getting {
if (hasSimplexAssets) {
resources.srcDir(simplexAssetsLocal)
} else {
resources.srcDir("src/commonMain/resources/assets/default")
}
dependencies {
api(compose.runtime)
api(compose.foundation)
@@ -78,12 +73,12 @@ kotlin {
// Link Previews
implementation("org.jsoup:jsoup:1.17.2")
// Resources
api("dev.icerock.moko:resources:0.23.0")
api("dev.icerock.moko:resources-compose:0.23.0")
api("dev.icerock.moko:resources:0.27.1")
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 {
@@ -95,7 +90,6 @@ kotlin {
}
}
val androidMain by getting {
kotlin.srcDir("build/generated/moko/androidMain/src")
dependencies {
implementation("androidx.activity:activity-compose:1.9.1")
val workVersion = "2.9.1"
@@ -158,27 +152,20 @@ kotlin {
android {
namespace = "chat.simplex.common"
compileSdk = 35
compileSdk = 36
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
defaultConfig {
minSdk = 26
}
testOptions.targetSdk = 34
lint.targetSdk = 34
val isAndroid = gradle.startParameter.taskNames.find {
val lower = it.lowercase()
lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install")
} != null
if (isAndroid) {
// This is not needed on Android but can't be moved to desktopMain because MR lib don't support this.
// No other ways to exclude a file work, but it's large and should be excluded
kotlin.sourceSets["commonMain"].resources.exclude("/MR/fonts/NotoColorEmoji-Regular.ttf")
}
testOptions.targetSdk = 36
lint.targetSdk = 36
}
multiplatformResources {
multiplatformResourcesPackage = "chat.simplex.res"
// multiplatformResourcesClassName = "MR"
resourcesPackage.set("chat.simplex.res")
resourcesSourceSets {
getByName("commonMain").srcDirs(if (hasSimplexAssets) simplexAssetsLocal.resolve("MR") else file("src/commonMain/resources/assets/default/MR"))
}
}
buildConfig {
@@ -286,8 +273,8 @@ afterEvaluate {
}
return this
}
val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$")
val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree
val fileRegex = Regex("moko-resources/../strings.xml$|moko-resources/..-.../strings.xml$|moko-resources/..-../strings.xml$|moko-resources/base/strings.xml$")
val tree = fileTree("src/commonMain/moko-resources").filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree
val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found")
val lvStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("lv/strings.xml") } ?: throw Exception("No base/strings.xml found")
val treeList = ArrayList(tree.toList())
@@ -6,7 +6,7 @@ import android.provider.Settings
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import chat.simplex.res.*
fun Context.openAppSettingsInSystem() {
Intent().apply {
@@ -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)
Loaded 100 of 1159 files, more files were not shown because too many files have changed in this diff. Show more