Compare commits

..

No commits in common. "stable" and "v5.4.0" have entirely different histories.

1778 changed files with 67851 additions and 297033 deletions

View file

@ -1,47 +0,0 @@
name: "Prebuilt steps for build"
description: "Reusable steps for multiple jobs"
inputs:
java_ver:
required: true
description: "Java version to install"
ghc_ver:
required: true
description: "GHC version to install"
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.1.0
description: "GHC version to install"
runs:
using: "composite"
steps:
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ inputs.ghc_ver }}
cabal-version: ${{ inputs.cabal_ver }}
- name: Setup Java
if: startsWith(inputs.github_ref, 'refs/tags/v')
uses: actions/setup-java@v3
with:
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') }}

View file

@ -1,39 +0,0 @@
name: "Upload binary and update hash"
description: "Reusable steps for multiple jobs"
inputs:
bin_path:
required: true
description: "Path to binary to upload"
bin_name:
required: true
description: "Name of uploaded binary"
bin_hash:
required: true
description: "Message with SHA to include in release"
github_ref:
required: true
description: "Github reference"
github_token:
required: true
description: "Github token"
runs:
using: "composite"
steps:
- name: Upload file with specific name
if: startsWith(inputs.github_ref, 'refs/tags/v')
uses: simplex-chat/upload-release-action@v2
with:
repo_token: ${{ inputs.github_token }}
file: ${{ inputs.bin_path }}
asset_name: ${{ inputs.bin_name }}
tag: ${{ inputs.github_ref }}
- name: Add hash to release notes
if: startsWith(inputs.github_ref, 'refs/tags/v')
uses: simplex-chat/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ inputs.github_token }}
with:
append_body: true
body: |
${{ inputs.bin_hash }}

View file

@ -1,44 +0,0 @@
name: 'Set Swap Space'
description: 'Add moar swap'
branding:
icon: 'crop'
color: 'orange'
inputs:
swap-size-gb:
description: 'Swap space to create, in Gigabytes.'
required: false
default: '10'
runs:
using: "composite"
steps:
- name: Swap space report before modification
shell: bash
run: |
echo "Memory and swap:"
free -h
echo
swapon --show
echo
- name: Set Swap
shell: bash
run: |
export SWAP_FILE=$(swapon --show=NAME | tail -n 1)
echo "Swap file: $SWAP_FILE"
if [ -z "$SWAP_FILE" ]; then
SWAP_FILE=/opt/swapfile
else
sudo swapoff $SWAP_FILE
sudo rm $SWAP_FILE
fi
sudo fallocate -l ${{ inputs.swap-size-gb }}G $SWAP_FILE
sudo chmod 600 $SWAP_FILE
sudo mkswap $SWAP_FILE
sudo swapon $SWAP_FILE
- name: Swap space report after modification
shell: bash
run: |
echo "Memory and swap:"
free -h
echo
swapon --show
echo

View file

@ -5,75 +5,23 @@ on:
branches:
- master
- stable
- users
tags:
- "v*"
- "!*-fdroid"
- "!*-armv7a"
pull_request:
paths-ignore:
- "apps/ios"
- "apps/multiplatform"
- "blog"
- "docs"
- "fastlane"
- "images"
- "packages"
- "website"
- "README.md"
- "PRIVACY.md"
# This workflow uses custom actions (prepare-build and prepare-release) defined in:
#
# .github/actions/
# ├── prepare-build
# │ └── action.yml
# └── prepare-release
# └── action.yml
# Important!
# Do not use always(), it makes build unskippable.
# See: https://github.com/actions/runner/issues/1846#issuecomment-1246102753
jobs:
# =============================
# Global variables
# =============================
# That is the only and less hacky way to setup global variables
# to use in strategy matrix (env:/YAML anchors doesn't work).
# See: https://github.com/orgs/community/discussions/56787#discussioncomment-6041789
# https://github.com/actions/runner/issues/1182
# https://stackoverflow.com/a/77549656
variables:
runs-on: ubuntu-latest
outputs:
GHC_VER: 9.6.3
JAVA_VER: 17
steps:
- name: Dummy job when we have just simple variables
if: false
run: echo
# =============================
# Create release
# =============================
# Create release, but only if it's triggered by tag push.
# On pull requests/commits push, this job will always complete.
maybe-release:
prepare-release:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Clone project
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/checkout@v3
- name: Build changelog
id: build_changelog
if: startsWith(github.ref, 'refs/tags/v')
uses: simplex-chat/release-changelog-builder-action@v5
uses: mikepenz/release-changelog-builder-action@v4
with:
configuration: .github/changelog_conf.json
failOnError: true
@ -83,8 +31,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
if: startsWith(github.ref, 'refs/tags/v')
uses: simplex-chat/action-gh-release@v2
uses: softprops/action-gh-release@v1
with:
body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: true
@ -94,295 +41,147 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# =========================
# Linux Build
# =========================
build-linux:
name: "ubuntu-${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
needs: [maybe-release, variables]
runs-on: ubuntu-${{ matrix.os }}
build:
name: build-${{ matrix.os }}
if: always()
needs: prepare-release
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: 22.04
ghc: "8.10.7"
should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }}
- os: 22.04
ghc: ${{ needs.variables.outputs.GHC_VER }}
cli_asset_name: simplex-chat-ubuntu-22_04-x86-64
- os: ubuntu-20.04
cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-20_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
- os: ubuntu-22.04
cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-22_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
should_run: true
- os: 24.04
ghc: ${{ needs.variables.outputs.GHC_VER }}
cli_asset_name: simplex-chat-ubuntu-24_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-24_04-x86_64.deb
should_run: true
- os: macos-latest
cache_path: ~/.cabal/store
asset_name: simplex-chat-macos-x86-64
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
- os: windows-latest
cache_path: C:/cabal
asset_name: simplex-chat-windows-x86-64
desktop_asset_name: simplex-desktop-windows-x86_64.msi
steps:
- name: Checkout Code
if: matrix.should_run == true
- name: Configure pagefile (Windows)
if: matrix.os == 'windows-latest'
uses: al-cheb/configure-pagefile-action@v1.3
with:
minimum-size: 16GB
maximum-size: 16GB
disk-root: "C:"
- name: Clone project
uses: actions/checkout@v3
- name: Setup swap
if: matrix.ghc == '8.10.7' && matrix.should_run == true
uses: ./.github/actions/swap
- name: Setup Haskell
uses: haskell-actions/setup@v2
with:
swap-size-gb: 30
ghc-version: "9.6.3"
cabal-version: "3.10.1.0"
# Otherwise we run out of disk space with Docker build
- name: Free disk space
if: matrix.should_run == true
shell: bash
run: ./scripts/ci/linux_util_free_space.sh
- name: Restore cached build
if: matrix.should_run == true
uses: actions/cache@v4
- name: Cache dependencies
uses: actions/cache@v3
with:
path: |
~/.cabal/store
${{ matrix.cache_path }}
dist-newstyle
key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
- name: Set up Docker Buildx
if: matrix.should_run == true
uses: simplex-chat/docker-setup-buildx-action@v3
# / Unix
- name: Build and cache Docker image
if: matrix.should_run == true
uses: simplex-chat/docker-build-push-action@v6
with:
context: .
load: true
file: Dockerfile.build
tags: build/${{ matrix.os }}:latest
build-args: |
TAG=${{ matrix.os }}
GHC=${{ matrix.ghc }}
# Docker needs these flags for AppImage build:
# --device /dev/fuse
# --cap-add SYS_ADMIN
# --security-opt apparmor:unconfined
- name: Start container
if: matrix.should_run == true
- name: Unix prepare cabal.project.local for Mac
if: matrix.os == 'macos-latest'
shell: bash
run: |
docker run -t -d \
--device /dev/fuse \
--cap-add SYS_ADMIN \
--security-opt apparmor:unconfined \
--name builder \
-v ~/.cabal:/root/.cabal \
-v /home/runner/work/_temp:/home/runner/work/_temp \
-v ${{ github.workspace }}:/project \
build/${{ matrix.os }}:latest
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Prepare cabal.project.local
if: matrix.should_run == true
- name: Install AppImage dependencies
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
run: sudo apt install -y desktop-file-utils
- name: Install pkg-config for Mac
if: matrix.os == 'macos-latest'
run: brew install pkg-config
- name: Unix prepare cabal.project.local for Ubuntu
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
# chmod/git commands are used to workaround permission issues when cache is restored
- name: Build CLI
if: matrix.should_run == true
shell: docker exec -t builder sh -eu {0}
run: |
chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*'
cabal clean
cabal update
cabal build -j --enable-tests
mkdir -p /out
for i in simplex-chat simplex-chat-test; do
bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
chmod +x "$bin"
mv "$bin" /out/
done
strip /out/simplex-chat
- name: Copy tests from container
if: matrix.should_run == true
- name: Unix build CLI
id: unix_cli_build
if: matrix.os != 'windows-latest'
shell: bash
run: |
docker cp builder:/out/simplex-chat-test .
- name: Copy CLI from container and prepare it
id: linux_cli_prepare
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
run: |
docker cp builder:/out/simplex-chat ./${{ matrix.cli_asset_name }}
path="${{ github.workspace }}/${{ matrix.cli_asset_name }}"
cabal build --enable-tests
path=$(cabal list-bin simplex-chat)
echo "bin_path=$path" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload CLI
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
uses: ./.github/actions/prepare-release
- name: Unix upload CLI binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }}
bin_name: ${{ matrix.cli_asset_name }}
bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.unix_cli_build.outputs.bin_path }}
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
- name: Build Desktop
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: docker exec -t builder sh -eu {0}
- name: Unix update CLI binary hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.unix_cli_build.outputs.bin_hash }}
- name: Setup Java
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/setup-java@v3
with:
distribution: 'corretto'
java-version: '17'
cache: 'gradle'
- name: Linux build desktop
id: linux_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
shell: bash
run: |
scripts/desktop/build-lib-linux.sh
cd apps/multiplatform
./gradlew packageDeb
- name: Prepare Desktop
id: linux_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
run: |
path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb )
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload Desktop
uses: ./.github/actions/prepare-release
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
with:
bin_path: ${{ steps.linux_desktop_build.outputs.package_path }}
bin_name: ${{ matrix.desktop_asset_name }}
bin_hash: ${{ steps.linux_desktop_build.outputs.package_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Build AppImage
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
shell: docker exec -t builder sh -eu {0}
- name: Linux make AppImage
id: linux_appimage_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
shell: bash
run: |
scripts/desktop/make-appimage-linux.sh
- name: Prepare AppImage
id: linux_appimage_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
shell: bash
run: |
path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/*imple*.AppImage)
path=$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage)
echo "appimage_path=$path" >> $GITHUB_OUTPUT
echo "appimage_hash=$(echo SHA2-256\(simplex-desktop-x86_64.AppImage\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload AppImage
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
uses: ./.github/actions/prepare-release
with:
bin_path: ${{ steps.linux_appimage_build.outputs.appimage_path }}
bin_name: "simplex-desktop-x86_64.AppImage"
bin_hash: ${{ steps.linux_appimage_build.outputs.appimage_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Fix permissions for cache
if: matrix.should_run == true
shell: bash
run: |
sudo chmod -R 777 dist-newstyle ~/.cabal
sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal
- name: Run tests
if: matrix.should_run == true
timeout-minutes: 120
shell: bash
run: |
i=1
attempts=1
${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
while [ "$i" -le "$attempts" ]; do
if ./simplex-chat-test; then
break
else
echo "Attempt $i failed, retrying..."
i=$((i + 1))
sleep 1
fi
done
if [ "$i" -gt "$attempts" ]; then
echo "All "$attempts" attempts failed."
exit 1
fi
# =========================
# MacOS Build
# =========================
build-macos:
name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
needs: [maybe-release, variables]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
ghc: ${{ needs.variables.outputs.GHC_VER }}
cli_asset_name: simplex-chat-macos-aarch64
desktop_asset_name: simplex-desktop-macos-aarch64.dmg
openssl_dir: "/opt/homebrew/opt"
- os: macos-13
ghc: ${{ needs.variables.outputs.GHC_VER }}
cli_asset_name: simplex-chat-macos-x86-64
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
openssl_dir: "/usr/local/opt"
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Prepare build
uses: ./.github/actions/prepare-build
with:
java_ver: ${{ needs.variables.outputs.JAVA_VER }}
ghc_ver: ${{ matrix.ghc }}
os: ${{ matrix.os }}
github_ref: ${{ github.ref }}
- name: Install OpenSSL
run: brew install openssl@3.0
- name: Prepare cabal.project.local
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
echo " extra-include-dirs: ${{ matrix.opnessl_dir }}/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: ${{ matrix.openssl_dir}}/openssl@3.0/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Build CLI
id: mac_cli_build
shell: bash
run: |
cabal build -j --enable-tests
path=$(cabal list-bin simplex-chat)
echo "bin_path=$path" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload CLI
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/actions/prepare-release
with:
bin_path: ${{ steps.mac_cli_build.outputs.bin_path }}
bin_name: ${{ matrix.cli_asset_name }}
bin_hash: ${{ steps.mac_cli_build.outputs.bin_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Desktop
- name: Mac build desktop
id: mac_desktop_build
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
shell: bash
env:
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
@ -392,77 +191,79 @@ jobs:
scripts/ci/build-desktop-mac.sh
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload Desktop
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/actions/prepare-release
- name: Linux upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
uses: svenstaro/upload-release-action@v2
with:
bin_path: ${{ steps.mac_desktop_build.outputs.package_path }}
bin_name: ${{ matrix.desktop_asset_name }}
bin_hash: ${{ steps.mac_desktop_build.outputs.package_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.linux_desktop_build.outputs.package_path }}
asset_name: ${{ matrix.desktop_asset_name }}
tag: ${{ github.ref }}
- name: Run tests
timeout-minutes: 120
- name: Linux update desktop package hash
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.linux_desktop_build.outputs.package_hash }}
- name: Linux upload AppImage to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.linux_appimage_build.outputs.appimage_path }}
asset_name: simplex-desktop-x86_64.AppImage
tag: ${{ github.ref }}
- name: Linux update AppImage hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.linux_appimage_build.outputs.appimage_hash }}
- name: Mac upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.mac_desktop_build.outputs.package_path }}
asset_name: ${{ matrix.desktop_asset_name }}
tag: ${{ github.ref }}
- name: Mac update desktop package hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.mac_desktop_build.outputs.package_hash }}
- name: Unix test
if: matrix.os != 'windows-latest'
timeout-minutes: 30
shell: bash
run: |
i=1
attempts=1
${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
while [ "$i" -le "$attempts" ]; do
if cabal test --test-show-details=direct; then
break
else
echo "Attempt $i failed, retrying..."
i=$((i + 1))
sleep 1
fi
done
if [ "$i" -gt "$attempts" ]; then
echo "All "$attempts" attempts failed."
exit 1
fi
run: cabal test --test-show-details=direct
# =========================
# Windows Build
# =========================
# Unix /
build-windows:
name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
needs: [maybe-release, variables]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
ghc: ${{ needs.variables.outputs.GHC_VER }}
cli_asset_name: simplex-chat-windows-x86-64
desktop_asset_name: simplex-desktop-windows-x86_64.msi
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Prepare build
uses: ./.github/actions/prepare-build
with:
java_ver: ${{ needs.variables.outputs.JAVA_VER }}
ghc_ver: ${{ matrix.ghc }}
os: ${{ matrix.os }}
cache_path: "C:/cabal"
github_ref: ${{ github.ref }}
- name: Configure pagefile (Windows)
uses: simplex-chat/configure-pagefile-action@v1.4
with:
minimum-size: 16GB
maximum-size: 16GB
disk-root: "C:"
# / Windows
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
- name: 'Setup MSYS2'
uses: simplex-chat/setup-msys2@v2
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: msys2/setup-msys2@v2
with:
msystem: ucrt64
update: true
@ -474,14 +275,15 @@ jobs:
toolchain:p
cmake:p
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
- name: Build CLI
id: windows_cli_build
- name: Windows build
id: windows_build
if: matrix.os == 'windows-latest'
shell: msys2 {0}
run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
export PATH=$PATH:/c/ghcup/bin
scripts/desktop/prepare-openssl-windows.sh
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-3.0.15 | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
rm cabal.project.local 2>/dev/null || true
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
@ -491,42 +293,61 @@ jobs:
rm -rf dist-newstyle/src/direct-sq*
sed -i "s/, unix /--, unix /" simplex-chat.cabal
cabal build -j --enable-tests
cabal build --enable-tests
rm -rf dist-newstyle/src/direct-sq*
path=$(cabal list-bin simplex-chat | tail -n 1)
echo "bin_path=$path" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload CLI
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/actions/prepare-release
- name: Windows upload CLI binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
bin_path: ${{ steps.windows_cli_build.outputs.bin_path }}
bin_name: ${{ matrix.cli_asset_name }}
bin_hash: ${{ steps.windows_cli_build.outputs.bin_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.windows_build.outputs.bin_path }}
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
- name: Build Desktop
- name: Windows update CLI binary hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.windows_build.outputs.bin_hash }}
- name: Windows build desktop
id: windows_desktop_build
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
shell: msys2 {0}
run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
export PATH=$PATH:/c/ghcup/bin
scripts/desktop/build-lib-windows.sh
cd apps/multiplatform
./gradlew packageMsi
rm -rf dist-newstyle/src/direct-sq*
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload Desktop
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/actions/prepare-release
- name: Windows upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
bin_path: ${{ steps.windows_desktop_build.outputs.package_path }}
bin_name: ${{ matrix.desktop_asset_name }}
bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.windows_desktop_build.outputs.package_path }}
asset_name: ${{ matrix.desktop_asset_name }}
tag: ${{ github.ref }}
- name: Windows update desktop package hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.windows_desktop_build.outputs.package_hash }}
# Windows /

View file

@ -5,20 +5,14 @@ on:
pull_request_target:
types: [opened, closed, synchronize]
permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request'
# Beta Release
uses: cla-assistant/github-action@v2.3.0
uses: cla-assistant/github-action@v2.1.3-beta
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret

View file

@ -1,45 +0,0 @@
name: Reproduce latest release
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * *' # every day at 02:00 night
jobs:
reproduce:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Get latest release
shell: bash
run: |
curl --proto '=https' \
--tlsv1.2 \
-sSf -L \
'https://api.github.com/repos/simplex-chat/simplex-chat/releases/latest' \
2>/dev/null | \
grep -i "tag_name" | \
awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV
- name: Execute reproduce script
run: |
${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG"
- name: Check if build has been reproduced
env:
url: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_URL }}
user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }}
pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }}
run: |
if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then
exit 0
else
curl --proto '=https' --tlsv1.2 -sSf \
-u "${user}:${pass}" \
-H 'Content-Type: application/json' \
-d '{"title": "👾 GitHub: Runner", "description": "⛔️ '"$TAG"' did not reproduce."}' \
"$url"
exit 1
fi

View file

@ -4,13 +4,13 @@ on:
push:
branches:
- master
- stable
paths:
- website/**
- images/**
- blog/**
- docs/**
- .github/workflows/web.yml
- PRIVACY.md
jobs:
build:
@ -33,7 +33,7 @@ jobs:
./website/web.sh
- name: Deploy
uses: simplex-chat/actions-gh-pages@v3
uses: peaceiris/actions-gh-pages@v3
with:
publish_dir: ./website/_site
github_token: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View file

@ -54,14 +54,12 @@ website/translations.json
website/src/img/images/
website/src/images/
website/src/js/lottie.min.js
website/src/privacy.md
# Generated files
website/package/generated*
# Ignore build tool output, e.g. code coverage
website/.nyc_output/
website/coverage/
result
# Ignore API documentation
website/api-docs/

View file

@ -1,41 +1,32 @@
ARG TAG=22.04
FROM ubuntu:focal AS build
FROM ubuntu:${TAG} AS build
### Build stage
# Install curl and git and simplex-chat dependencies
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
# Specify bootstrap Haskell versions
ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
# Install curl and simplex-chat-related dependencies
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
# Install ghcup
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
chmod +x /usr/bin/ghcup
# Install ghc
RUN ghcup install ghc 9.6.3
# Install cabal
RUN ghcup install cabal 3.10.1.0
# Set both as default
RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
RUN ghcup set ghc 9.6.3 && \
ghcup set cabal 3.10.1.0
COPY . /project
WORKDIR /project
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Adjust build
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library'
RUN cabal install
# Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
mv "$bin" ./ && \
strip ./simplex-chat
# Copy compiled app from build stage
FROM scratch AS export-stage
COPY --from=build /project/simplex-chat /
COPY --from=build /root/.cabal/bin/simplex-chat /

View file

@ -1,92 +0,0 @@
# syntax=docker/dockerfile:1.7.0-labs
ARG TAG=24.04
FROM ubuntu:${TAG} AS build
### Build stage
ARG GHC=9.6.3
ARG CABAL=3.10.1.0
ARG JAVA=17
ENV TZ=Etc/UTC \
DEBIAN_FRONTEND=noninteractive
# Install curl, git and and simplex-chat dependencies
RUN apt-get update && \
apt-get install -y curl \
libpq-dev \
git \
sqlite3 \
libsqlite3-dev \
build-essential \
libgmp3-dev \
zlib1g-dev \
llvm \
cmake \
llvm-dev \
libnuma-dev \
libssl-dev \
desktop-file-utils \
patchelf \
ca-certificates \
zip \
wget \
fuse3 \
file \
appstream \
gpg \
unzip &&\
ln -s /bin/fusermount /bin/fusermount3 || :
# Install Java Coretto
# Required, because official Java in Ubuntu
# depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final
# /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin
# to fix this :(
RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\
echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\
apt update &&\
apt install -y java-${JAVA}-amazon-corretto-jdk
# Specify bootstrap Haskell versions
ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC}
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL}
# Do not install Stack
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
# Install ghcup
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Set both as default
RUN ghcup set ghc "${GHC}" && \
ghcup set cabal "${CABAL}"
#=====================
# Install Android SDK
#=====================
ARG SDK_VERSION=13114758
ENV SDK_VERSION=$SDK_VERSION \
ANDROID_HOME=/root
RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \
unzip tools.zip && rm tools.zip && \
mv cmdline-tools tools && mkdir "$ANDROID_HOME/cmdline-tools" && mv tools "$ANDROID_HOME/cmdline-tools/" && \
ln -s "$ANDROID_HOME/cmdline-tools/tools" "$ANDROID_HOME/cmdline-tools/latest"
ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin"
# https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded
RUN mkdir -p ~/.android ~/.gradle && \
touch ~/.android/repositories.cfg && \
echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\
yes | sdkmanager --licenses >/dev/null
ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools
WORKDIR /project

View file

@ -1,246 +1,134 @@
---
layout: layouts/privacy.html
permalink: /privacy/index.html
---
# SimpleX Chat Terms & Privacy Policy
# SimpleX Chat Operators Privacy Policy and Conditions of Use
SimpleX Chat is the first communication platform that has no user profile IDs of any kind, not even random numbers. Not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we cannot observe your connections graph.
## Summary
[Introduction](#introduction) and [General principles](#general-principles) cover SimpleX Chat network design, the network operators, and the principles of privacy and security provided by SimpleX network.
[Privacy policy](#privacy-policy) covers:
- data stored only on your device - [your profiles](#user-profiles), delivered [messages and files](#messages-and-files). You can transfer this information to another device, and you are responsible for its preservation - if you delete the app it will be lost.
- [private message delivery](#private-message-delivery) that protects your IP address and connection graph from the destination servers.
- [undelivered messages and files](#storage-of-messages-and-files-on-the-servers) stored on the servers.
- [how users connect](#connections-with-other-users) without any user profile identifiers.
- [iOS push notifications](#ios-push-notifications) privacy limitations.
- [user support](#user-support), [SimpleX directory](#simplex-directory) and [any other data](#another-information-stored-on-the-servers) that may be stored on the servers.
- [preset server operators](#preset-server-operators) and the [information they may share](#information-preset-server-operators-may-share).
- [source code license](#source-code-license) and [updates to this document](#updates).
[Conditions of Use](#conditions-of-use-of-software-and-infrastructure) are the conditions you need to accept to use SimpleX Chat applications and the relay servers of preset operators. Their purpose is to protect the users and preset server operators.
*Please note*: this summary and any links in this document are provided for information only - they are not a part of the Privacy Policy and Conditions of Use.
## Introduction
SimpleX Chat (also referred to as SimpleX) is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts.
Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
If you believe that some of the clauses in this document are not aligned with our mission or principles, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Privacy Policy
### General principles
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and encryption to provide secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the servers via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack).
SimpleX network software uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from being compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack).
SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol allowing to establish private connections without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
SimpleX software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
SimpleX software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server operators, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the servers operated by SimpleX Chat Ltd, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
SimpleX network operators are not communication service provider, and provide public relays "as is", as experimental, without any guarantees of availability or data retention. The operators of the relay servers preset in the app ("Preset Server Operators"), including SimpleX Chat Ltd, are committed to maintain a high level of availability, reliability and security. SimpleX client apps can have multiple preset relay server operators that you can opt-in or opt-out of using. You are and will continue to be able to use any other operators or your own servers.
SimpleX network design is based on the principles of users and data sovereignty, and device and operator portability.
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
The cryptographic review of SimpleX protocols design was done in July 2024 by Trail of Bits see [the announcement](/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
### Your information
### Information you provide
#### User profiles
Servers used by SimpleX Chat apps do not create, store or identify user chat profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
We do not store user profiles. The profile you create in the app is local to your device.
When you create the local profile, no records are created on any of the relay servers, and infrastructure operators, whether preset in the app or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption.
When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
#### Messages and Files
SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 4mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band.
SimpleX Chat cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 256kb, 1mb or 8mb via all or some of the configured file servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band.
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too.
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline SimpleX Chat temporarily stores end-to-end encrypted messages on the messaging (SMP) servers that are preset in the app or chosen by the users.
#### Private message delivery
The messages are permanently removed from the preset servers as soon as they are delivered. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
You do not have control over which servers are used to send messages to your contacts - these servers are chosen by your contacts. To send messages your client by default uses configured servers to forward messages to the destination servers, thus protecting your IP address from the servers chosen by your contacts.
The files are stored on file (XFTP) servers for the time configured in the file servers you use (48 hours for preset file servers).
In case you use preset servers of more than one operator, the app will prefer to use a server of an operator different from the operator of the destination server to forward messages, preventing destination server to correlate messages as belonging to one client.
You can additionally use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by you.
*Please note*: the clients allow changing configuration to connect to the destination servers directly. It is not recommended - if you make such change, your IP address will be visible to the destination servers.
#### Storage of messages and files on the servers
The messages are removed from the relay servers as soon as all messages of the file they were stored in are delivered and saving new messages switches to another file, as long as these servers use unmodified published code. Undelivered messages are also marked as delivered after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers).
The encrypted messages can be stored for some time after they are delivered or expired (because servers use append-only logs for message storage). This time varies, and may be longer in connections with fewer messages, but it is usually limited to 1 month, including any backup storage.
If a messaging or file servers are restarted, the encrypted message or the record of the file can be stored in a backup file until it is overwritten by the next restart (usually within 1 week).
#### Connections with other users
When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers, or, if available, the relays of two different operators, for increased privacy, in case you have more than one relay server configured in the app, which is the default.
When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on chosen messaging servers, that can be the preset servers or the servers that you configured in the app, in case it allows such configuration. SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default.
Preset and unmodified SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and operators to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
#### Connection links privacy
When you create a connection with another user, the app generates a link/QR code that can be shared with the user to establish the connection via any channel (email, any other messenger, or a video call). This link is safe to share via insecure channels, as long as you can identify the recipient and also trust that this channel did not replace this link (to mitigate the latter risk you can validate the security code via the app).
While the connection "links" contain SimpleX Chat Ltd domain name `simplex.chat`, this site is never accessed by the app, and is only used for these purposes:
- to direct the new users to the app download instructions,
- to show connection QR code that can be scanned via the app,
- to "namespace" these links,
- to open links directly in the installed app when it is clicked outside of the app.
You can always safely replace the initial part of the link `https://simplex.chat/` either with `simplex:/` (which is a URI scheme provisionally registered with IANA) or with any other domain name where you can self-host the app download instructions and show the connection QR code (but in case it is your domain, it will not open in the app). Also, while the page renders QR code, all the information needed to render it is only available to the browser, as the part of the "link" after `#` symbol is not sent to the website server.
At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
#### iOS Push Notifications
This section applies only to the notification servers operated by SimpleX Chat Ltd.
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers.
Notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers.
You can read more about the design of iOS push notifications [here](./blog/20220404-simplex-chat-instant-notifications.md#our-ios-approach-has-one-trade-off).
It also does not allow to see message content or sizes, as the actual messages are not sent via the notification server, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot observe it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
#### Another information stored on the servers
Additional technical information can be stored on the network servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX network design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
#### SimpleX Directory
#### SimpleX Directory Service
This section applies only to the experimental group directory operated by SimpleX Chat Ltd.
[SimpleX directory service](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the group. You can connect to SimpleX Directory Service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
#### User Support.
#### Public groups and content channels
If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
You may participate in a public group and receive content from a public channel (Group). In case you send messages or comments to the Group, you grant a license:
- to all recipients:
- to share your messages with the new Group members and outside of the group, e.g. via quoting (replying), forwarding and copy-pasting your message. When your message is deleted or marked as deleted, the copies of your message will not be deleted.
- to retain a copy of your messages according to the Group settings (e.g., the Group may allow irreversible message deletion from the recipient devices for a limited period of time, or it may only allow to edit and mark messages as deleted on recipient devices). Deleting message from the recipient devices or marking message as deleted revokes the license to share the message.
- to Group owners: to share your messages with the new Group members as history of the Group. Currently, the Group history shared with the new members is limited to 100 messages.
### Information we may share
Group owners may use chat relays or automated bots (Chat Relays) to re-broadcast member messages to all members, for efficiency. The Chat Relays may be operated by the group owners, by preset operators or by 3rd parties. The Chat Relays have access to and will retain messages in line with Group settings, for technical functioning of the Group. Neither you nor group owners grant any content license to Chat Relay operators.
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
#### User Support
We use a third party for email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according to their privacy policies and terms of service.
The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
### Preset Server Operators
Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics.
Preset server operators must not provide general access to their servers or the data on their servers to each other.
Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing illegal content identified in publicly accessible resources (contact and group addresses, and downloadable files). This control port access only allows deleting known links and files, and accessing aggregate server-wide statistics, but does NOT allow enumerating any information on the servers or accessing statistics related to specific users.
### Information Preset Server Operators May Share
The preset server operators use third parties. While they do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via the servers. Hosting and network providers can also store IP addresses and other transport information as part of their logs.
SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
The cases when the preset server operators may share the data temporarily stored on the servers:
- To meet any applicable law, or enforceable governmental request or court order.
- To enforce applicable terms, including investigation of potential violations.
- To meet any applicable law, regulation, legal process or enforceable governmental request.
- To enforce applicable Terms, including investigation of potential violations.
- To detect, prevent, or otherwise address fraud, security, or technical issues.
- To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law.
- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law.
By the time of updating this document, the preset server operators were not served with any enforceable requests and did not provide any information from the servers to any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
### Source code license
As this software is fully open-source and provided under AGPLv3 license, all infrastructure owners and operators, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the servers.
In addition to the AGPLv3 license terms, the preset relay server operators are committed to the software users that these servers will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
At the time of updating this document, we have never provided or have been requested the access to our servers or any information from our servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process.
### Updates
This Privacy Policy applies to SimpleX Chat Ltd and all other preset server operators you use in the app.
We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
This Privacy Policy may be updated as needed so that it is current, accurate, and as clear as possible. When it is updated, you will have to review and accept the changed policy within 30 days of such changes to continue using preset relay servers. Even if you fail to accept the changed policy, your continued use of SimpleX Chat software applications and preset relay servers confirms your acceptance of the updated Privacy Policy.
Please also read our Terms of Service below.
Please also read The Conditions of Use of Software and Infrastructure below.
If you have questions about our Privacy Policy please contact us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
If you have questions about this Privacy Policy please contact SimpleX Chat Ltd via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Terms of Service
## Conditions of Use of Software and Infrastructure
You accept our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of SimpleX Chat software or using any of server infrastructure (collectively referred to as "Applications") operated by the Preset Server Operators, including SimpleX Chat Ltd, whether these servers are preset in the software or not.
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
**Minimal age**. You must be at least 13 years old to use SimpleX Chat Applications. The minimum age to use SimpleX Applications without parental approval may be higher in your country.
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
**Infrastructure**. Infrastructure of the preset server operators includes messaging and file relay servers. SimpleX Chat Ltd also provides iOS push notification servers for public use. This infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
**Client applications**. SimpleX Chat client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on SimpleX Chat code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any tracking information with SimpleX Chat Ltd, preset server operators or any other third parties. If you ever discover any tracking or analytics code, please report it to SimpleX Chat Ltd, so it can be removed.
**Operating our services**. For the purpose of operating our Services, you agree that your end-to-end encrypted messages are transferred via our servers in the United Kingdom, the United States and other countries where we have or use facilities and service providers or partners.
**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to the [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about the privacy model and known security and privacy risks.
**Software**. You agree to downloading and installing updates to our Services when they are available; they would only be automatic if you configure your devices in this way.
**Privacy of user data**. Servers do not retain any data you transmit for any longer than necessary to deliver the messages between apps. Preset server operators collect aggregate statistics across all their servers, as supported by published code and can be enabled by any infrastructure operator, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. SimpleX Chat Ltd does not have information about how many people use SimpleX Chat applications, it only knows an approximate number of app installations and the aggregate traffic through the preset servers. In any case, preset server operators do not and will not sell or in any way monetize user data. The future business model assumes charging for some optional Software features instead, in a transparent and fair way.
**Traffic and device costs**. You are solely responsible for the traffic and device costs on which you use our Services, and any associated taxes.
**Operating Infrastructure**. For the purpose of using SimpleX Chat Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where preset server operators have or use facilities and service providers or partners. The information about geographic location and hosting providers of the preset messaging servers is available on server pages.
**Legal and acceptable usage**. You agree to use our Services only for legal and acceptable purposes. You will not use (or assist others in using) our Services in ways that: 1) violate or infringe the rights of SimpleX Chat, our users, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam.
**Software**. You agree to downloading and installing updates to SimpleX Chat Applications when they are available; they would only be automatic if you configure your devices in this way.
**Damage to SimpleX Chat**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Services in unauthorized manners, or in ways that harm SimpleX Chat, our Services, or systems. For example, you must not 1) access our Services or systems without authorization, other than by using the apps; 2) disrupt the integrity or performance of our Services; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Services.
**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using SimpleX Chat Applications, and any associated taxes.
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
**Legal usage**. You agree to use SimpleX Chat Applications only for legal purposes. You will not use (or assist others in using) the Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, other preset server operators, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While server operators cannot access content or identify messages or groups, in some cases the links to the illegal communications can be shared publicly on social media or websites. Preset server operators reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via their servers, whether they were reported by the users or discovered by the operators themselves.
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the application you use. Legacy databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app. In this case, if you make a backup of the app data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the beta version of desktop app currently stores the database passphrase in the configuration file in plaintext, so you may need to remove passphrase from the device via the app configuration.
**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software or by using a 3rd party client applications that satisfies the requirements of the Conditions of use (see the next section); 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
**Storing the files on the device**. The files are stored on your device unencrypted. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access the files.
**3rd party client applications**. You may use a 3rd party application (App) to access preset operators' Infrastructure or systems, provided that this App:
- is compatible with the protocol specifications not older than 1 year,
- provides user-to-user messaging only or enables automated chat bots sending messages requested by users (in case of bots, it must be made clear to the users that these are automated bots),
- implements the same limits, rules and restrictions as Software,
- requires that the users accept the same Conditions of use of preset operators' Infrastructure as in Software prior to providing access to this Infrastructure,
- displays the notice that it is the App for using SimpleX network,
- provides its source code under open-source license accessible to the users via the App interface. In case the App uses the source code of Software, the App's source code must be provided under AGPLv3 license, and in case it is developed without using Software code its source code must be provided under any widely recognized free open-source license,
- does NOT use the branding of SimpleX Chat Ltd without the permission,
- does NOT pretend to be Software,
- complies with these Conditions of use.
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. SimpleX Chat Ltd and other preset server operators are not responsible for any data loss.
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings.
**Your Rights**. You own the messages and the information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the app.
**Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted.
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
**No Access to Emergency Services**. SimpleX Chat Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
**Third-party services**. SimpleX Chat Applications may allow you to access, use, or interact with the websites of SimpleX Chat Ltd, preset server operators or other third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
**Disclaimers**. YOU USE OUR SERVICES AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR SERVICES ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR SERVICES WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR SERVICES WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR SERVICES. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
**Your Rights**. You own the messages and the information you transmit through SimpleX Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design.
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR TERMS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use SimpleX Chat Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE).
**Availability**. Our Services may be interrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Services, including certain features and the support for certain devices and platforms, at any time.
**SimpleX Chat Ltd Rights**. SimpleX Chat Ltd (and, where applicable, preset server operators) owns all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with the Applications. You may not use SimpleX Chat Ltd copyrights, trademarks, domains, logos, and other intellectual property rights unless you have SimpleX Chat Ltd written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Terms, us, or our Services in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Terms, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat and you, without regard to conflict of law provisions.
**Disclaimers**. YOU USE SIMPLEX APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. SIMPLEX CHAT LTD PROVIDES APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY THEM IS ACCURATE, COMPLETE, OR USEFUL, THAT THEIR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT THEIR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN THE USERS USE APPLICATIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF THEIR USERS OR OTHER THIRD PARTIES. YOU RELEASE SIMPLEX CHAT LTD, OTHER PRESET OPERATORS, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
**Changes to the terms**. SimpleX Chat may update the Terms from time to time. Your continued use of our Services confirms your acceptance of our updated Terms and supersedes any prior Terms. You will comply with all applicable export control and trade sanctions laws. Our Terms cover the entire agreement between you and SimpleX Chat regarding our Services. If you do not agree with our Terms, you should stop using our Services.
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR SIMPLEX APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. THE AGGREGATE LIABILITY OF THE SIMPLEX PARTIES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH THESE CONDITIONS, THE SIMPLEX PARTIES, OR THE APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN THE CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
**Enforcing the terms**. If we fail to enforce any of our Terms, that does not mean we waive the right to enforce them. If any provision of the Terms is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Terms and shall not affect the enforceability of the remaining provisions. Our Services are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Services in any country. If you have specific questions about these Terms, please contact us at chat@simplex.chat.
**Availability**. The Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. SimpleX Chat Ltd may discontinue some or all of their Applications, including certain features and the support for certain devices and platforms, at any time. Preset server operators may discontinue providing the servers, at any time.
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
**Resolving disputes**. You agree to resolve any Claim you have with SimpleX Chat Ltd and/or preset server operators relating to or arising from these Conditions, them, or the Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern these Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd (or preset server operators) and you, without regard to conflict of law provisions.
**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. The updated conditions have to be accepted within 30 days. Even if you fail to accept updated conditions, your continued use of SimpleX Chat Applications confirms your acceptance of the updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. These Conditions cover the entire agreement between you and SimpleX Chat Ltd, and any preset server operators where applicable, regarding SimpleX Chat Applications. If you do not agree with these Conditions, you should stop using the Applications.
**Enforcing the conditions**. If SimpleX Chat Ltd or preset server operators fail to enforce any of these Conditions, that does not mean they waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from the Conditions and shall not affect the enforceability of the remaining provisions. The Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject SimpleX Chat Ltd to any regulations in another country. SimpleX Chat Ltd reserve the right to limit the access to the Applications in any country. Preset operators reserve the right to limit access to their servers in any country. If you have specific questions about these Conditions, please contact SimpleX Chat Ltd at chat@simplex.chat.
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators.
Updated March 3, 2025
Updated August 17, 2023

136
README.md
View file

@ -4,13 +4,13 @@
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat)
<a rel="me" href="https://mastodon.social/@simplex">![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)</a>
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md), [PL](/docs/lang/pl/README.md) |
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
[<img src="./images/trail-of-bits.jpg" height="80">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) &nbsp;&nbsp;&nbsp; [<img src="./images/privacy-guides.jpg" height="64">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) &nbsp;&nbsp;&nbsp; [<img src="./images/whonix-logo.jpg" height="64">](https://www.whonix.org/wiki/Chat#Recommendation) &nbsp;&nbsp;&nbsp; [<img src="./images/kuketz-blog.jpg" height="64">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
[<img src="./images/trail-of-bits.jpg" height="100">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) &nbsp;&nbsp;&nbsp; [<img src="./images/privacy-guides.jpg" height="80">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) &nbsp;&nbsp;&nbsp; [<img src="./images/kuketz-blog.jpg" height="80">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
## Welcome to SimpleX Chat!
@ -18,7 +18,7 @@
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
[Learn more about SimpleX Chat](#contents).
@ -72,9 +72,9 @@ You must:
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FiBkJE72asZX1NUZaYFIeKRVk6oVjb-iv%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAinqu3j74AMjODLoIRR487ZW6ysip_dlpD6Zxk18SPFY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22groupLinkId%22%3A%223wAFGCLygQHR5AwynZOHlQ%3D%3D%22%7D)
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-4](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2Fw2GlucRXtRVgYnbt_9ZP-kmt76DekxxS%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0tJhTyMGUxznwmjb7aT24P1I1Wry_iURTuhOFlMb1Eo%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22WoPxjFqGEDlVazECOSi2dg%3D%3D%22%7D)
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform:
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) for developers who build on SimpleX platform:
- chat bots and automations
- integrations with other apps
@ -83,7 +83,7 @@ There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=s
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmfiivxDKWFuowXrQOp11jsY8TuP__rBL%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAiz3pKNwvKudckFYMUfgoT0s96B0jfZ7ALHAu7rtE9HQ%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22jZeJpXGrRXQJU_-MSJ_v2A%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FJ5ES83pJimY2BRklS8fvy_iQwIU37xra%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0F0STP6UqN_12_k2cjjTrIjFgBGeWhOAmbY1qlk3pnM%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22VmUU0fqmYdCRmVCyvStvHA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FxCHBE_6PBRMqNEpm4UQDHXb9cz-mN7dd%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAetqlcM7zTCRw-iatnwCrvpJSto7lq5Yv6AsBMWv7GSM%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22foO5Xw4hhjOa_x7zET7otw%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FVXQTB0J2lLjYkgjWByhl6-1qmb5fgZHh%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAI6JaEWezfSwvcoTEkk6au-gkjrXR2ew2OqZYMYBvayk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22ORH9OEe8Duissh-hslfeVg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FqpHu0psOUdYfc11yQCzSyq5JhijrBzZT%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEACZ_7fbwlM45wl6cGif8cY47oPQ_AMdP0ATqOYLA6zHY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%229uRQRTir3ealdcSfB0zsrw%3D%3D%22%7D) (Italian-speaking).
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
@ -110,15 +110,6 @@ After you connect, you can [verify connection security code](./blog/20230103-sim
Read about the app features and settings in the new [User guide](./docs/guide/README.md).
## Contribute
We would love to have you join the development! You can help us with:
- [share the color theme](./docs/THEMES.md) you use in Android app!
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
## Help translating SimpleX Chat
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
@ -136,7 +127,6 @@ Join our translators to help SimpleX grow!
|🇫🇮 fi|Suomi | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/fi/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fi/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fi/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/fr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fr/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|🇮🇱 he|עִברִית | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/he/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-|||
|🇭🇺 hu|Magyar | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/hu/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/hu/)<br>-|||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/it/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/it/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|🇯🇵 ja|日本語 | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/ja/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ja/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/ja/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/nl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/nl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/nl/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
@ -144,13 +134,21 @@ Join our translators to help SimpleX grow!
|🇧🇷 pt-BR|Português||[![android app](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[![website](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|🇷🇺 ru|Русский ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/ru/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/th/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/th/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|🇹🇷 tr|Türkçe | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/tr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/tr/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/tr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/tr/)|||
|🇺🇦 uk|Українська| |[![android app](https://hosted.weblate.org/widgets/simplex-chat/uk/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/uk/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/uk/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br>&nbsp;|<br><br>[![website](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
## Please support us with your donations
## Contribute
We would love to have you join the development! You can help us with:
- [share the color theme](./docs/THEMES.md) you use in Android app!
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat!
@ -158,21 +156,20 @@ We are prioritizing users privacy and security - it would be impossible without
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us.
Your donations help us raise more funds any amount, even the price of the cup of coffee, would make a big difference for us.
It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
- BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u
- XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg
- ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq
- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf
- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
- please ask if you want to donate any other coins.
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- USDT:
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
Thank you,
@ -235,21 +232,11 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md)
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
@ -257,6 +244,8 @@ Recent and important updates:
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
[All updates](./blog)
## :zap: Quick installation of a terminal app
@ -296,28 +285,25 @@ What is already implemented:
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
4. [Post-quantum resistant key exchange](./docs/GLOSSARY.md#post-quantum-cryptography) in double ratchet protocol *on every ratchet step*. Read more in [this post](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) and also see this [publication by Apple]( https://security.apple.com/blog/imessage-pq3/) explaining the need for post-quantum key rotation.
5. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
6. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
7. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
8. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
9. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
10. To protect your IP address from unknown messaging relays, and for per-message transport anonymity (compared with Tor/VPN per-connection anonymity), from v6.0 all SimpleX Chat clients use private message routing by default. Read more in [this post](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md#private-message-routing).
11. To protect your IP address from unknown file relays, when SOCKS proxy is not enabled SimpleX Chat clients ask for a confirmation before downloading the files from unknown servers.
12. To protect your IP address from known servers all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
13. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
14. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
15. Manual messaging queue rotations to move conversation to another SMP relay.
16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
17. Local files encryption.
18. [Reproducible server builds](./docs/SERVER.md#reproduce-builds).
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
12. Manual messaging queue rotations to move conversation to another SMP relay.
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
14. Local files encryption, except videos (to be added later).
We plan to add:
1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
3. Reproducible clients builds this is a complex problem, but we are aiming to have it in 2025 at least partially.
4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
2. Post-quantum resistant key exchange in double ratchet protocol.
3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
5. Reproducible builds this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
## For developers
@ -380,17 +366,13 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Message delivery confirmation (with sender opt-out per contact).
- ✅ Desktop client.
- ✅ Encryption of local files stored in the app.
- ✅ Using mobile profiles from the desktop app.
- ✅ Private notes.
- ✅ Improve sending videos (including encryption of locally stored videos).
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- ✅ Support multiple network operators in the app.
- 🏗 Large groups, communities and public channels.
- 🏗 Short links to connect and join groups.
- 🏗 Improve stability and reduce battery usage.
- 🏗 Improve experience for the new users.
- 🏗 Using mobile profiles from the desktop app.
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- Post-quantum resistant key exchange in double ratchet protocol.
- Large groups, communities and public channels.
- Privacy & security slider - a simple way to set all settings at once.
- Improve sending videos (including encryption of locally stored videos).
- Improve experience for the new users.
- SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address.
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
@ -408,9 +390,7 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about) see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
The cryptographic review of SimpleX protocols was done in July 2024 by Trail of Bits see [the announcement](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
@ -420,13 +400,13 @@ We have never provided or have been requested access to our servers or any infor
We do not log IP addresses of the users and we do not perform any traffic correlation on our servers. If transport level security is critical you must use Tor or some other similar network to access messaging servers. We will be improving the client applications to reduce the opportunities for traffic correlation.
Please read more in [Privacy Policy](./PRIVACY.md).
Please read more in [Terms & privacy policy](./PRIVACY.md).
## Security contact
Please see our [Security Policy](./docs/SECURITY.md) on how to report security vulnerabilities to us. We will coordinate the fix and disclosure.
To report a security vulnerability, please send us email to chat@simplex.chat. We will coordinate the fix and disclosure. Please do NOT report security vulnerabilities via GitHub issues.
Please do NOT report security vulnerabilities via GitHub issues.
Please treat any findings of possible traffic correlation attacks allowing to correlate two different conversations to the same user, other than covered in [the threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#threat-model), as security vulnerabilities, and follow this disclosure process.
## License

View file

@ -9,25 +9,39 @@
import Foundation
import UIKit
import SimpleXChat
import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications()
removePasscodesIfReinstalled()
prepareForLaunch()
deleteOldChatArchive()
if #available(iOS 17.0, *) { trackKeyboard() }
return true
}
@available(iOS 17.0, *)
private func trackKeyboard() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@available(iOS 17.0, *)
@objc func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
}
}
@available(iOS 17.0, *)
@objc func keyboardWillHide(_ notification: Notification) {
ChatModel.shared.keyboardHeight = 0
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
let m = ChatModel.shared
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
m.deviceToken = deviceToken
// savedToken is set in startChat, when it is started before this method is called
if m.savedToken != nil {
registerToken(token: deviceToken)
}
@ -54,7 +68,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
try await apiVerifyToken(token: token, nonce: nonce, code: verification)
m.tokenStatus = .active
} catch {
if let cr = error as? ChatError, case .errorAgent(.NTF(.AUTH)) = cr {
if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr {
m.tokenStatus = .expired
}
logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))")
@ -66,7 +80,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
}
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
if m.ntfEnablePeriodic && allowBackgroundRefresh() && BGManager.shared.lastRanLongAgo {
if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic {
receiveMessages(completionHandler)
} else {
completionHandler(.noData)
@ -107,23 +121,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
BGManager.shared.receiveMessages(complete)
}
private func removePasscodesIfReinstalled() {
// Check for the database existence, because app and self destruct passcodes
// will be saved and restored by iOS when a user deletes and re-installs the app.
// In this case the database and settings will be deleted, but the passcodes won't be.
// Deleting passcodes ensures that the user will not get stuck on "Opening app..." screen.
if (kcAppPassword.get() != nil || kcSelfDestructPassword.get() != nil) &&
!UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) && !hasDatabase() {
_ = kcAppPassword.remove()
_ = kcSelfDestructPassword.remove()
_ = kcDatabasePassword.remove()
}
}
private func prepareForLaunch() {
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
}
static func keepScreenOn(_ on: Bool) {
UIApplication.shared.isIdleTimerDisabled = on
}
@ -131,79 +128,13 @@ class AppDelegate: NSObject, UIApplicationDelegate {
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
var window: UIWindow?
static var windowStatic: UIWindow?
var windowScene: UIWindowScene?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
UITableView.appearance().backgroundColor = .clear
guard let windowScene = scene as? UIWindowScene else { return }
self.windowScene = windowScene
window = windowScene.keyWindow
SceneDelegate.windowStatic = windowScene.keyWindow
migrateAccentColorAndTheme()
ThemeManager.applyTheme(currentThemeDefault.get())
ThemeManager.adjustWindowStyle()
}
private func migrateAccentColorAndTheme() {
let defs = UserDefaults.standard
/// For checking migration
// themeOverridesDefault.set([])
// currentThemeDefault.set(DefaultTheme.SYSTEM_THEME_NAME)
// defs.set(0.5, forKey: DEFAULT_ACCENT_COLOR_RED)
// defs.set(0.3, forKey: DEFAULT_ACCENT_COLOR_GREEN)
// defs.set(0.8, forKey: DEFAULT_ACCENT_COLOR_BLUE)
let userInterfaceStyle = getUserInterfaceStyleDefault()
if defs.double(forKey: DEFAULT_ACCENT_COLOR_GREEN) == 0 && userInterfaceStyle == .unspecified {
// No migration needed or already migrated
return
}
let defaultAccentColor = Color(cgColor: CGColor(red: 0.000, green: 0.533, blue: 1.000, alpha: 1))
let accentColor = Color(cgColor: getUIAccentColorDefault())
if accentColor != defaultAccentColor {
let colors = ThemeColors(primary: accentColor.toReadableHex())
var overrides = themeOverridesDefault.get()
var themeIds = currentThemeIdsDefault.get()
switch userInterfaceStyle {
case .light:
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
overrides.append(light)
themeOverridesDefault.set(overrides)
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
currentThemeIdsDefault.set(themeIds)
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
case .dark:
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
overrides.append(dark)
themeOverridesDefault.set(overrides)
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
currentThemeIdsDefault.set(themeIds)
ThemeManager.applyTheme(DefaultTheme.DARK.themeName)
case .unspecified:
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
overrides.append(light)
overrides.append(dark)
themeOverridesDefault.set(overrides)
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
currentThemeIdsDefault.set(themeIds)
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
@unknown default: ()
}
} else if userInterfaceStyle != .unspecified {
let themeName = switch userInterfaceStyle {
case .light: DefaultTheme.LIGHT.themeName
case .dark: DefaultTheme.DARK.themeName
default: DefaultTheme.SYSTEM_THEME_NAME
}
ThemeManager.applyTheme(themeName)
}
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_RED)
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_GREEN)
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_BLUE)
defs.removeObject(forKey: DEFAULT_USER_INTERFACE_STYLE)
window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
}
}

View file

@ -1,12 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.2.svg",
"idiom": "universal"
}
]
}

View file

@ -1,227 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>checkmark.2</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="double.checkmark">
<g id="Notes">
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="322">Weight/Scale Variations</tspan>
</text>
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="533.711" y="322">Ultralight</tspan>
</text>
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="843.422" y="322">Thin</tspan>
</text>
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1138.63" y="322">Light</tspan>
</text>
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1426.84" y="322">Regular</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1723.06" y="322">Medium</tspan>
</text>
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2015.77" y="322">Semibold</tspan>
</text>
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2326.48" y="322">Bold</tspan>
</text>
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2618.19" y="322">Heavy</tspan>
</text>
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2917.4" y="322">Black</tspan>
</text>
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
<path
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
<path
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
<path
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
id="Shape"></path>
</g>
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1953">Design Variations</tspan>
</text>
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
</text>
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
</text>
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
</text>
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
<path
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
id="Shape"></path>
</g>
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
</line>
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="1953">Margins</tspan>
</text>
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
</tspan>
</text>
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
</tspan>
</text>
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
</text>
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="2025">scales and weights.</tspan>
</text>
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
<path
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
id="Shape"></path>
</g>
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1289" y="1953">Exporting</tspan>
</text>
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
</text>
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
</text>
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2952" y="1933">Template v.5.0</tspan>
</text>
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
</text>
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
</text>
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
</text>
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="726">Small</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1156">Medium</tspan>
</text>
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1586">Large</tspan>
</text>
</g>
<g id="Guides" transform="translate(263, 600.785)">
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
</g>
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
<g id="Black-S" transform="translate(2365.995, 0)">
<path
d="M67.46878,71.191381 C71.17968,71.191381 74.06058,69.873022 76.01368,66.99216 L111.07228,15.2343 C112.43948,13.2324 113.02538,11.1328 113.02538,9.2773 C113.02538,4.0039 108.82618,0 103.35738,0 C99.69528,0 97.30278,1.3183 95.05668,4.834 L67.32228,47.9492 L53.69918,32.6172 C51.79488,30.4687 49.54888,29.4433 46.52148,29.4433 C41.05278,29.4433 37,33.4472 37,38.7695 C37,41.2109 37.63478,43.1152 39.73438,45.459 L59.36328,67.67576 C61.51168,70.117162 64.14848,71.191381 67.46878,71.191381 Z"
id="Path"></path>
<path
d="M9.52148,29.4433 C12.54888,29.4433 14.79488,30.4687 16.69918,32.6172 L30.32228,47.9492 L32.291,44.888 L44.484,58.915 L39.01368,66.99216 C37.1235832,69.780091 34.3645791,71.1046997 30.825305,71.1872572 L30.46878,71.191381 C27.14848,71.191381 24.51168,70.117162 22.36328,67.67576 L2.73438,45.459 C0.63478,43.1152 0,41.2109 0,38.7695 C0,33.4472 4.05278,29.4433 9.52148,29.4433 Z M66.35738,0 C71.82618,0 76.02538,4.0039 76.02538,9.2773 C76.02538,11.1328 75.43948,13.2324 74.07228,15.2343 L61.951,33.129 L49.252,18.52 L58.05668,4.834 C60.2386057,1.41874857 62.5586852,0.0771252245 66.0465687,0.00324899359 Z"
id="Combined-Shape"></path>
</g>
<g id="Regular-S" transform="translate(886.905, 3.7109)">
<path
d="M55.87888,66.113294 C57.78318,66.113294 59.29688,65.28322 60.37108,63.62306 L95.96678,7.3242 C96.79688,6.0547 97.08988,5.0293 97.08988,4.0039 C97.08988,1.6113 95.52738,0 93.08598,0 C91.32818,0 90.35158,0.586 89.27738,2.2949 L55.68358,56.2012 L38.00778,32.3731 C36.88478,30.8594 35.81058,30.2246 34.19918,30.2246 C31.75778,30.2246 30,31.9336 30,34.375 C30,35.4004 30.43948,36.5234 31.26958,37.5977 L51.24028,63.5254 C52.60738,65.28322 53.97458,66.113294 55.87888,66.113294 Z"
id="Path"></path>
<path
d="M4.19918,30.2246 C5.81058,30.2246 6.88478,30.8594 8.00778,32.3731 L25.68358,56.2012 L30.332,48.741 L35.554,55.426 L30.37108,63.62306 C29.3457073,65.2077582 27.919881,66.0361177 26.1361316,66.1081489 L25.87888,66.113294 C23.97458,66.113294 22.60738,65.28322 21.24028,63.5254 L1.26958,37.5977 C0.43948,36.5234 0,35.4004 0,34.375 C0,31.9336 1.75778,30.2246 4.19918,30.2246 Z M63.08598,0 C65.52738,0 67.08988,1.6113 67.08988,4.0039 C67.08988,5.0293 66.79688,6.0547 65.96678,7.3242 L48.893,34.328 L43.564,27.508 L59.27738,2.2949 C60.3068217,0.657204167 61.2466272,0.0507828125 62.8702602,0.00309092159 Z"
id="Combined-Shape"></path>
</g>
<g id="Ultralight-S" transform="translate(0, 4.4375)">
<path
d="M36.79298,62.07178 C37.24418,62.07178 37.53178,61.87744 37.78868,61.53417 L76.33598,2.0112 C76.57578,1.6045 76.64168,1.3965 76.64168,1.1885 C76.64168,0.5215 76.12358,0 75.45318,0 C75.01228,0 74.71678,0.1772 74.50538,0.5693 L36.73398,58.97114 L18.24078,37.7768 C17.98048,37.3984 17.67828,37.2177 17.20218,37.2177 C16.48638,37.2177 16,37.7006 16,38.371 C16,38.6699 16.12159,38.9755 16.40678,39.2778 L35.65088,61.52733 C36.01908,61.96826 36.29638,62.07178 36.79298,62.07178 Z"
id="Path"></path>
<path
d="M1.20218,37.2177 C1.67828,37.2177 1.98048,37.3984 2.24078,37.7768 L20.73398,58.97114 L23.078,55.345 L24.636,57.137 L21.78868,61.53417 C21.5603244,61.8392989 21.3077121,62.0267547 20.937503,62.0646445 L20.79298,62.07178 C20.29638,62.07178 20.01908,61.96826 19.65088,61.52733 L0.40678,39.2778 C0.12159,38.9755 0,38.6699 0,38.371 C0,37.7006 0.48638,37.2177 1.20218,37.2177 Z M59.45318,0 C60.12358,0 60.64168,0.5215 60.64168,1.1885 C60.64168,1.3965 60.57578,1.6045 60.33598,2.0112 L32.216,45.432 L30.652,43.634 L58.50538,0.5693 C58.6932911,0.220766667 58.9476516,0.0420308642 59.3115144,0.00661467764 Z"
id="Combined-Shape"></path>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 24 KiB

View file

@ -1,12 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.wide.svg",
"idiom": "universal"
}
]
}

View file

@ -1,218 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>checkmark.wide</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="double.checkmark">
<g id="Notes">
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="322">Weight/Scale Variations</tspan>
</text>
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="533.711" y="322">Ultralight</tspan>
</text>
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="843.422" y="322">Thin</tspan>
</text>
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1138.63" y="322">Light</tspan>
</text>
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1426.84" y="322">Regular</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1723.06" y="322">Medium</tspan>
</text>
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2015.77" y="322">Semibold</tspan>
</text>
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2326.48" y="322">Bold</tspan>
</text>
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2618.19" y="322">Heavy</tspan>
</text>
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2917.4" y="322">Black</tspan>
</text>
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
<path
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
<path
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
<path
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
id="Shape"></path>
</g>
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1953">Design Variations</tspan>
</text>
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
</text>
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
</text>
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
</text>
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
<path
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
id="Shape"></path>
</g>
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
</line>
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="1953">Margins</tspan>
</text>
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
</tspan>
</text>
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
</tspan>
</text>
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
</text>
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="2025">scales and weights.</tspan>
</text>
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
<path
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
id="Shape"></path>
</g>
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1289" y="1953">Exporting</tspan>
</text>
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
</text>
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
</text>
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2952" y="1933">Template v.5.0</tspan>
</text>
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
</text>
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
</text>
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
</text>
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="726">Small</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1156">Medium</tspan>
</text>
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1586">Large</tspan>
</text>
</g>
<g id="Guides" transform="translate(263, 600.785)">
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
</g>
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
<g id="Black-S" transform="translate(2365.995, 0)">
<path
d="M30.46878,71.191381 C34.17968,71.191381 37.06058,69.873022 39.01368,66.99216 L74.07228,15.2343 C75.43948,13.2324 76.02538,11.1328 76.02538,9.2773 C76.02538,4.0039 71.82618,0 66.35738,0 C62.69528,0 60.30278,1.3183 58.05668,4.834 L30.32228,47.9492 L16.69918,32.6172 C14.79488,30.4687 12.54888,29.4433 9.52148,29.4433 C4.05278,29.4433 0,33.4472 0,38.7695 C0,41.2109 0.63478,43.1152 2.73438,45.459 L22.36328,67.67576 C24.51168,70.117162 27.14848,71.191381 30.46878,71.191381 Z"
id="Path"></path>
</g>
<g id="Regular-S" transform="translate(886.905, 3.7109)">
<path
d="M25.87888,66.113294 C27.78318,66.113294 29.29688,65.28322 30.37108,63.62306 L65.96678,7.3242 C66.79688,6.0547 67.08988,5.0293 67.08988,4.0039 C67.08988,1.6113 65.52738,0 63.08598,0 C61.32818,0 60.35158,0.586 59.27738,2.2949 L25.68358,56.2012 L8.00778,32.3731 C6.88478,30.8594 5.81058,30.2246 4.19918,30.2246 C1.75778,30.2246 0,31.9336 0,34.375 C0,35.4004 0.43948,36.5234 1.26958,37.5977 L21.24028,63.5254 C22.60738,65.28322 23.97458,66.113294 25.87888,66.113294 Z"
id="Path"></path>
</g>
<g id="Ultralight-S" transform="translate(0, 4.4375)">
<path
d="M20.79298,62.07178 C21.24418,62.07178 21.53178,61.87744 21.78868,61.53417 L60.33598,2.0112 C60.57578,1.6045 60.64168,1.3965 60.64168,1.1885 C60.64168,0.5215 60.12358,0 59.45318,0 C59.01228,0 58.71678,0.1772 58.50538,0.5693 L20.73398,58.97114 L2.24078,37.7768 C1.98048,37.3984 1.67828,37.2177 1.20218,37.2177 C0.48638,37.2177 0,37.7006 0,38.371 C0,38.6699 0.12159,38.9755 0.40678,39.2778 L19.65088,61.52733 C20.01908,61.96826 20.29638,62.07178 20.79298,62.07178 Z"
id="Path"></path>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 22 KiB

View file

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "Flux_logo_blue_white.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View file

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "Flux_logo_blue.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View file

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "Flux_symbol_blue-white.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "vertical_logo_x1.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "vertical_logo_x2.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "vertical_logo_x3.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_cats@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_cats@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_cats@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

View file

@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_flowers@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_flowers@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_flowers@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

View file

@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_hearts@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_hearts@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_hearts@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

View file

@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_kids@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_kids@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_kids@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

View file

@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_school@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_school@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_school@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

View file

@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_travel@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_travel@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_travel@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

View file

@ -9,46 +9,26 @@ import SwiftUI
import Intents
import SimpleXChat
private enum NoticesSheet: Identifiable {
case whatsNew(updatedConditions: Bool)
var id: String {
switch self {
case .whatsNew: return "whatsNew"
}
}
}
struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared
@ObservedObject var appSheetState = AppSheetState.shared
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var sceneDelegate: SceneDelegate
var contentAccessAuthenticationExtended: Bool
@Environment(\.scenePhase) var scenePhase
@State private var automaticAuthenticationAttempted = false
@State private var canConnectViewCall = false
@State private var lastSuccessfulUnlock: TimeInterval? = nil
@Binding var doAuthenticate: Bool
@Binding var userAuthorized: Bool?
@Binding var canConnectCall: Bool
@Binding var lastSuccessfulUnlock: TimeInterval?
@Binding var showInitializationView: Bool
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
@State private var noticesShown = false
@State private var noticesSheetItem: NoticesSheet? = nil
@State private var showSettings = false
@State private var showWhatsNew = false
@State private var showChooseLAMode = false
@State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true
@State private var chatListActionSheet: ChatListActionSheet? = nil
@State private var chatListUserPickerSheet: UserPickerSheet? = nil
private let callTopPadding: CGFloat = 40
private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
@ -60,53 +40,16 @@ struct ContentView: View {
}
}
private var accessAuthenticated: Bool {
chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended
}
var body: some View {
if #available(iOS 16.0, *) {
allViews()
.scrollContentBackground(.hidden)
} else {
// on iOS 15 scroll view background disabled in SceneDelegate
allViews()
}
}
func allViews() -> some View {
ZStack {
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
// i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() }
if !prefPerformLA || accessAuthenticated {
contentView()
.padding(.top, showCallArea ? callTopPadding : 0)
} else {
lockButton()
.padding(.top, showCallArea ? callTopPadding : 0)
}
if showCallArea, let call = chatModel.activeCall {
VStack {
activeCallInteractiveArea(call)
Spacer()
}
}
contentView()
if chatModel.showCallView, let call = chatModel.activeCall {
callView(call)
}
if chatListUserPickerSheet == nil, let la = chatModel.laRequest {
if !showSettings, let la = chatModel.laRequest {
LocalAuthView(authRequest: la)
.onDisappear {
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
waitingForOrPassedAuth = accessAuthenticated
}
} else if showSetPasscode {
SetAppPasscodeView {
chatModel.contentViewAccessAuthenticated = true
prefPerformLA = true
showSetPasscode = false
privacyLocalAuthModeDefault.set(.passcode)
@ -116,64 +59,31 @@ struct ContentView: View {
showSetPasscode = false
alertManager.showAlert(laPasscodeNotSetAlert())
}
} else if chatModel.chatDbStatus == nil && AppChatState.shared.value != .stopped && waitingForOrPassedAuth {
initializationView()
}
}
.onAppear {
if prefPerformLA { requestNtfAuthorization() }
initAuthenticate()
}
.onChange(of: doAuthenticate) { _ in
initAuthenticate()
}
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
.sheet(isPresented: $showSettings) {
SettingsView(showSettings: $showSettings)
}
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
Button("System authentication") { initialEnableLA() }
Button("Passcode entry") { showSetPasscode = true }
}
.onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
switch (phase) {
case .background:
// also see .onChange(of: scenePhase) in SimpleXApp: on entering background
// it remembers enteredBackgroundAuthenticated and sets chatModel.contentViewAccessAuthenticated to false
automaticAuthenticationAttempted = false
canConnectViewCall = false
case .active:
canConnectViewCall = !prefPerformLA || contentAccessAuthenticationExtended || unlockedRecently()
// condition `!chatModel.contentViewAccessAuthenticated` is required for when authentication is enabled in settings or on initial notice
if prefPerformLA && !chatModel.contentViewAccessAuthenticated {
if AppChatState.shared.value != .stopped {
if contentAccessAuthenticationExtended {
chatModel.contentViewAccessAuthenticated = true
} else {
if !automaticAuthenticationAttempted {
automaticAuthenticationAttempted = true
// authenticate if call kit call is not in progress
if !(CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil) {
authenticateContentViewAccess()
}
}
}
} else {
// when app is stopped automatic authentication is not attempted
chatModel.contentViewAccessAuthenticated = contentAccessAuthenticationExtended
}
}
default:
break
}
}
.onAppear {
reactOnDarkThemeChanges(systemInDarkThemeCurrently)
}
.onChange(of: colorScheme) { scheme in
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
// so when a user changes his global theme from dark to light or back, the app will adapt to it
reactOnDarkThemeChanges(scheme == .dark)
}
.onChange(of: theme.name) { _ in
ThemeManager.adjustWindowStyle()
}
}
@ViewBuilder private func contentView() -> some View {
if let status = chatModel.chatDbStatus, status != .ok {
if prefPerformLA && userAuthorized != true {
lockButton()
} else if chatModel.chatDbStatus == nil && showInitializationView {
initializationView()
} else if let status = chatModel.chatDbStatus, status != .ok {
DatabaseErrorView(status: status)
} else if !chatModel.v3DBMigration.startChat {
MigrateToAppGroupView()
@ -181,11 +91,11 @@ struct ContentView: View {
if case .onboardingComplete = step,
chatModel.currentUser != nil {
mainView()
.actionSheet(item: $chatListActionSheet) { sheet in
switch sheet {
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
}
.actionSheet(item: $chatListActionSheet) { sheet in
switch sheet {
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
}
}
} else {
OnboardingView(onboarding: step)
}
@ -196,11 +106,11 @@ struct ContentView: View {
if CallController.useCallKit() {
ActiveCallView(call: call, canConnectCall: Binding.constant(true))
.onDisappear {
if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() }
if userAuthorized == false && doAuthenticate { runAuthenticate() }
}
} else {
ActiveCallView(call: call, canConnectCall: $canConnectViewCall)
if prefPerformLA && !accessAuthenticated {
ActiveCallView(call: call, canConnectCall: $canConnectCall)
if prefPerformLA && userAuthorized != true {
Rectangle()
.fill(colorScheme == .dark ? .black : .white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
@ -209,94 +119,40 @@ struct ContentView: View {
}
}
private func activeCallInteractiveArea(_ call: Call) -> some View {
HStack {
Text(call.contact.displayName).font(.body).foregroundColor(.white)
Spacer()
CallDuration(call: call)
}
.padding(.horizontal)
.frame(height: callTopPadding)
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
.onTapGesture {
chatModel.activeCallViewIsCollapsed = false
}
}
struct CallDuration: View {
let call: Call
@State var text: String = ""
@State var timer: Timer? = nil
var body: some View {
Text(text).frame(minWidth: text.count <= 5 ? 52 : 77, alignment: .leading).offset(x: 4).font(.body).foregroundColor(.white)
.onAppear {
timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in
if let connectedAt = call.connectedAt {
text = durationText(Int(Date.now.timeIntervalSince1970 - connectedAt.timeIntervalSince1970))
}
}
}
.onDisappear {
_ = timer?.invalidate()
}
}
}
private func lockButton() -> some View {
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") }
}
private func initializationView() -> some View {
VStack {
ProgressView().scaleEffect(2)
Text("Opening app")
Text("Opening database…")
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity )
.background(
Rectangle()
.fill(theme.colors.background)
)
}
private func mainView() -> some View {
ZStack(alignment: .top) {
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet)
.redacted(reason: appSheetState.redactionReasons(protectScreen))
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
.onAppear {
requestNtfAuthorization()
if !prefPerformLA { requestNtfAuthorization() }
// Local Authentication notice is to be shown on next start after onboarding is complete
if (!prefLANoticeShown && prefShowLANotice && chatModel.chats.count > 2) {
if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) {
prefLANoticeShown = true
alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !noticesShown {
let showWhatsNew = shouldShowWhatsNew()
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
noticesShown = showWhatsNew || showUpdatedConditions
if showWhatsNew || showUpdatedConditions {
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
}
if !showWhatsNew {
showWhatsNew = shouldShowWhatsNew()
}
}
}
prefShowLANotice = true
connectViaUrl()
showReRegisterTokenAlert()
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.onChange(of: chatModel.reRegisterTknStatus) { _ in showReRegisterTokenAlert() }
.sheet(item: $noticesSheetItem) { item in
switch item {
case let .whatsNew(updatedConditions):
WhatsNewView(updatedConditions: updatedConditions)
.modifier(ThemedBackground())
.if(updatedConditions) { v in
v.task { await setConditionsNotified_() }
}
}
.sheet(isPresented: $showWhatsNew) {
WhatsNewView()
}
if chatModel.setDeliveryReceipts {
SetDeliveryReceiptsView()
@ -306,21 +162,6 @@ struct ContentView: View {
.onContinueUserActivity("INStartCallIntent", perform: processUserActivity)
.onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity)
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
if let url = userActivity.webpageURL {
logger.debug("onContinueUserActivity.NSUserActivityTypeBrowsingWeb: \(url)")
chatModel.appOpenUrl = url
}
}
}
private func setConditionsNotified_() async {
do {
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
try await setConditionsNotified(conditionsId: conditionsId)
} catch let error {
logger.error("setConditionsNotified error: \(responseError(error))")
}
}
private func processUserActivity(_ activity: NSUserActivity) {
@ -339,53 +180,55 @@ struct ContentView: View {
if let contactId = contacts?.first?.personHandle?.value,
let chat = chatModel.getChat(contactId),
case let .direct(contact) = chat.chatInfo {
let activeCall = chatModel.activeCall
// This line works when a user clicks on a video button in CallKit UI while in call.
// The app tries to make another call to the same contact and overwite activeCall instance making its state broken
if let activeCall, contactId == activeCall.contact.id, mediaType == .video, !activeCall.hasVideo {
Task {
await chatModel.callCommand.processCommand(.media(source: .camera, enable: true))
}
} else if activeCall == nil {
logger.debug("callToRecentContact: schedule call")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
CallController.shared.startCall(contact, mediaType)
}
logger.debug("callToRecentContact: schedule call")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
CallController.shared.startCall(contact, mediaType)
}
}
}
private func unlockedRecently() -> Bool {
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
} else {
return false
private func initAuthenticate() {
logger.debug("initAuthenticate")
if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil {
userAuthorized = false
} else if doAuthenticate {
runAuthenticate()
}
}
private func authenticateContentViewAccess() {
logger.debug("DEBUGGING: authenticateContentViewAccess")
dismissAllSheets(animated: false) {
logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback")
chatModel.chatId = nil
private func runAuthenticate() {
logger.debug("DEBUGGING: runAuthenticate")
if !prefPerformLA {
userAuthorized = true
} else {
logger.debug("DEBUGGING: before dismissAllSheets")
dismissAllSheets(animated: false) {
logger.debug("DEBUGGING: in dismissAllSheets callback")
chatModel.chatId = nil
justAuthenticate()
}
}
}
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
switch (laResult) {
case .success:
chatModel.contentViewAccessAuthenticated = true
canConnectViewCall = true
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
case .failed:
chatModel.contentViewAccessAuthenticated = false
if privacyLocalAuthModeDefault.get() == .passcode {
AlertManager.shared.showAlert(laFailedAlert())
}
case .unavailable:
prefPerformLA = false
canConnectViewCall = true
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
private func justAuthenticate() {
userAuthorized = false
let laMode = privacyLocalAuthModeDefault.get()
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
switch (laResult) {
case .success:
userAuthorized = true
canConnectCall = true
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
case .failed:
if laMode == .passcode {
AlertManager.shared.showAlert(laFailedAlert())
}
case .unavailable:
userAuthorized = true
prefPerformLA = false
canConnectCall = true
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
}
}
}
@ -416,7 +259,6 @@ struct ContentView: View {
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
switch laResult {
case .success:
chatModel.contentViewAccessAuthenticated = true
prefPerformLA = true
alertManager.showAlert(laTurnedOnAlert())
case .failed:
@ -443,12 +285,12 @@ struct ContentView: View {
}
func connectViaUrl() {
let m = ChatModel.shared
if let url = m.appOpenUrl {
m.appOpenUrl = nil
dismissAllSheets() {
dismissAllSheets() {
let m = ChatModel.shared
if let url = m.appOpenUrl {
m.appOpenUrl = nil
var path = url.path
if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") {
if (path == "/contact" || path == "/invitation") {
path.removeFirst()
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
planAndConnect(
@ -465,21 +307,6 @@ struct ContentView: View {
}
}
func showReRegisterTokenAlert() {
dismissAllSheets() {
let m = ChatModel.shared
if let errorTknStatus = m.reRegisterTknStatus, let token = chatModel.deviceToken {
chatModel.reRegisterTknStatus = nil
AlertManager.shared.showAlert(Alert(
title: Text("Notifications error"),
message: Text(tokenStatusInfo(errorTknStatus, register: true)),
primaryButton: .default(Text("Register")) { reRegisterToken(token: token) },
secondaryButton: .cancel()
))
}
}
}
private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
}

File diff suppressed because it is too large Load diff

View file

@ -179,7 +179,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
if playback {
if AVAudioSession.sharedInstance().category != .playback {
logger.log("AudioSession: playback")
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: [.duckOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: .duckOthers)
}
} else {
if AVAudioSession.sharedInstance().category != .soloAmbient {

View file

@ -15,13 +15,7 @@ private let receiveTaskId = "chat.simplex.app.receive"
// TCP timeout + 2 sec
private let waitForMessages: TimeInterval = 6
// This is the smallest interval between refreshes, and also target interval in "off" mode
private let bgRefreshInterval: TimeInterval = 600 // 10 minutes
// This intervals are used for background refresh in instant and periodic modes
private let periodicBgRefreshInterval: TimeInterval = 1200 // 20 minutes
private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes
private let bgRefreshInterval: TimeInterval = 450
private let maxTimerCount = 9
@ -39,14 +33,14 @@ class BGManager {
}
}
func schedule(interval: TimeInterval? = nil) {
func schedule() {
if !ChatModel.shared.ntfEnableLocal {
logger.debug("BGManager.schedule: disabled")
return
}
logger.debug("BGManager.schedule")
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
request.earliestBeginDate = Date(timeIntervalSinceNow: interval ?? runInterval)
request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
@ -54,34 +48,20 @@ class BGManager {
}
}
var runInterval: TimeInterval {
switch ChatModel.shared.notificationMode {
case .instant: maxBgRefreshInterval
case .periodic: periodicBgRefreshInterval
case .off: bgRefreshInterval
}
}
var lastRanLongAgo: Bool {
Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval
}
private func handleRefresh(_ task: BGAppRefreshTask) {
if !ChatModel.shared.ntfEnableLocal {
logger.debug("BGManager.handleRefresh: disabled")
return
}
logger.debug("BGManager.handleRefresh")
let shouldRun_ = lastRanLongAgo
if allowBackgroundRefresh() && shouldRun_ {
schedule()
schedule()
if appStateGroupDefault.get().inactive {
let completeRefresh = completionHandler {
task.setTaskCompleted(success: true)
}
task.expirationHandler = { completeRefresh("expirationHandler") }
receiveMessages(completeRefresh)
} else {
schedule(interval: shouldRun_ ? bgRefreshInterval : runInterval)
logger.debug("BGManager.completionHandler: already active, not started")
task.setTaskCompleted(success: true)
}
@ -110,22 +90,20 @@ class BGManager {
}
self.completed = false
DispatchQueue.main.async {
chatLastBackgroundRunGroupDefault.set(Date.now)
let m = ChatModel.shared
if (!m.chatInitialized) {
setAppState(.bgRefresh)
do {
try initializeChat(start: true)
} catch let error {
fatalError("Failed to start or load chats: \(responseError(error))")
}
}
activateChat(appState: .bgRefresh)
if m.currentUser == nil {
completeReceiving("no current user")
return
}
logger.debug("BGManager.receiveMessages: starting chat")
activateChat(appState: .bgRefresh)
let cr = ChatReceiver()
self.chatReceiver = cr
cr.start()

File diff suppressed because it is too large Load diff

View file

@ -7,19 +7,18 @@
//
import Foundation
import SimpleXChat
import SwiftUI
import AVKit
import SwiftyGif
import LinkPresentation
public func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
if let file = file, file.loaded {
return file.fileSource
}
return nil
}
public func getLoadedImage(_ file: CIFile?) -> UIImage? {
func getLoadedImage(_ file: CIFile?) -> UIImage? {
if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath)
do {
@ -38,7 +37,7 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? {
return nil
}
public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
if let cfArgs = cfArgs {
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
} else {
@ -46,7 +45,7 @@ public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
}
}
public func getLoadedVideo(_ file: CIFile?) -> URL? {
func getLoadedVideo(_ file: CIFile?) -> URL? {
if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath)
if FileManager.default.fileExists(atPath: filePath.path) {
@ -56,13 +55,13 @@ public func getLoadedVideo(_ file: CIFile?) -> URL? {
return nil
}
public func saveAnimImage(_ image: UIImage) -> CryptoFile? {
func saveAnimImage(_ image: UIImage) -> CryptoFile? {
let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil }
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
}
public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
func saveImage(_ uiImage: UIImage) -> CryptoFile? {
let hasAlpha = imageHasAlpha(uiImage)
let ext = hasAlpha ? "png" : "jpg"
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
@ -72,7 +71,7 @@ public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
return nil
}
public func cropToSquare(_ image: UIImage) -> UIImage {
func cropToSquare(_ image: UIImage) -> UIImage {
let size = image.size
let side = min(size.width, size.height)
let newSize = CGSize(width: side, height: side)
@ -85,7 +84,7 @@ public func cropToSquare(_ image: UIImage) -> UIImage {
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image))
}
public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
var img = image
var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0
@ -100,7 +99,7 @@ public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha
return data
}
public func resizeImageToStrSizeSync(_ image: UIImage, maxDataSize: Int64) -> String? {
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
var img = image
let hasAlpha = imageHasAlpha(image)
var str = compressImageStr(img, hasAlpha: hasAlpha)
@ -116,15 +115,7 @@ public func resizeImageToStrSizeSync(_ image: UIImage, maxDataSize: Int64) -> St
return str
}
public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) async -> String? {
resizeImageToStrSizeSync(image, maxDataSize: maxDataSize)
}
public func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
// // Heavy workload to verify if UI gets blocked by the call
// for i in 0..<100 {
// print(image.jpegData(compressionQuality: Double(i) / 100)?.count ?? 0, terminator: ", ")
// }
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
let ext = hasAlpha ? "png" : "jpg"
if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
return "data:image/\(ext);base64,\(data.base64EncodedString())"
@ -138,7 +129,7 @@ private func reduceSize(_ image: UIImage, ratio: CGFloat, hasAlpha: Bool) -> UII
return resizeImage(image, newBounds: bounds, drawIn: bounds, hasAlpha: hasAlpha)
}
public func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage {
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1.0
format.opaque = !hasAlpha
@ -147,7 +138,7 @@ public func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, has
}
}
public func imageHasAlpha(_ img: UIImage) -> Bool {
func imageHasAlpha(_ img: UIImage) -> Bool {
if let cgImage = img.cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@ -167,36 +158,7 @@ public func imageHasAlpha(_ img: UIImage) -> Bool {
return false
}
/// Reduces image size, while consuming less RAM
///
/// Used by ShareExtension to downsize large images
/// before passing them to regular image processing pipeline
/// to avoid exceeding 120MB memory
///
/// - Parameters:
/// - url: Location of the image data
/// - size: Maximum dimension (width or height)
/// - Returns: Downsampled image or `nil`, if the image can't be located
public func downsampleImage(at url: URL, to size: Int64) -> UIImage? {
autoreleasepool {
if let source = CGImageSourceCreateWithURL(url as CFURL, nil) {
CGImageSourceCreateThumbnailAtIndex(
source,
0,
[
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: String(size) as CFString
] as CFDictionary
)
.map { UIImage(cgImage: $0) }
} else { nil }
}
}
public func saveFileFromURL(_ url: URL) -> CryptoFile? {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? {
let savedFile: CryptoFile?
if url.startAccessingSecurityScopedResource() {
do {
@ -221,84 +183,30 @@ public func saveFileFromURL(_ url: URL) -> CryptoFile? {
return savedFile
}
public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
do {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let fileName = uniqueCombine(url.lastPathComponent)
let savedFile: CryptoFile?
if encrypted {
let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: getAppFilePath(fileName).path)
try FileManager.default.removeItem(atPath: url.path)
savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs)
} else {
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
savedFile = CryptoFile.plain(fileName)
}
return savedFile
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
ChatModel.shared.filesToDelete.remove(url)
return CryptoFile.plain(fileName)
} catch {
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
return nil
}
}
public func saveWallpaperFile(url: URL) -> String? {
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true))
do {
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
return destFile.lastPathComponent
} catch {
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
return nil
}
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
}
public func saveWallpaperFile(image: UIImage) -> String? {
let hasAlpha = imageHasAlpha(image)
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true))
let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha)
do {
try dataResized!.write(to: destFile)
return destFile.lastPathComponent
} catch {
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
return nil
}
}
public func removeWallpaperFile(fileName: String? = nil) {
do {
try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: getWallpaperDirectory().path), includingPropertiesForKeys: nil, options: []).forEach { url in
if url.lastPathComponent == fileName {
try FileManager.default.removeItem(at: url)
}
}
} catch {
logger.error("FileUtils.removeWallpaperFile error: \(error)")
}
if let fileName {
WallpaperType.cachedImages.removeValue(forKey: fileName)
}
}
public func removeWallpaperFilesFromTheme(_ theme: ThemeModeOverrides?) {
if let theme {
removeWallpaperFile(fileName: theme.light?.wallpaper?.imageFile)
removeWallpaperFile(fileName: theme.dark?.wallpaper?.imageFile)
}
}
public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
}
private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String {
private func uniqueCombine(_ fileName: String) -> String {
func tryCombine(_ fileName: String, _ n: Int) -> String {
let ns = fileName as NSString
let name = ns.deletingPathExtension
let ext = ns.pathExtension
let suffix = (n == 0) ? "" : "_\(n)"
let f = "\(name)\(suffix).\(ext)"
return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
}
return tryCombine(fileName, 0)
}
@ -319,7 +227,7 @@ private func getTimestamp() -> String {
return df.string(from: Date())
}
public func dropImagePrefix(_ s: String) -> String {
func dropImagePrefix(_ s: String) -> String {
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
}
@ -327,23 +235,8 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
}
public func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
s.outputURL = outputUrl
s.outputFileType = .mp4
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
await s.export()
if let err = s.error {
logger.error("Failed to export video with error: \(err)")
}
return s.status == .completed
}
return false
}
extension AVAsset {
public func generatePreview() -> (UIImage, Int)? {
func generatePreview() -> (UIImage, Int)? {
let generator = AVAssetImageGenerator(asset: self)
generator.appliesPreferredTrackTransform = true
var actualTime = CMTimeMake(value: 0, timescale: 0)
@ -355,7 +248,7 @@ extension AVAsset {
}
extension UIImage {
public func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
if let cgImage = cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@ -401,92 +294,3 @@ extension UIImage {
return self
}
}
public func imageFromBase64(_ base64Encoded: String?) -> UIImage? {
if let base64Encoded {
if let img = imageCache.object(forKey: base64Encoded as NSString) {
return img
} else if let data = Data(base64Encoded: dropImagePrefix(base64Encoded)),
let img = UIImage(data: data) {
imageCacheQueue.async {
imageCache.setObject(img, forKey: base64Encoded as NSString)
}
return img
} else {
return nil
}
} else {
return nil
}
}
private let imageCacheQueue = DispatchQueue.global(qos: .background)
private var imageCache: NSCache<NSString, UIImage> = {
var cache = NSCache<NSString, UIImage>()
cache.countLimit = 1000
return cache
}()
public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSizeSync(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
logger.error("Could not load link preview image")
cb(nil)
}
}
}
public func getLinkPreview(for url: URL) async -> LinkPreview? {
await withCheckedContinuation { cont in
getLinkPreview(url: url) { cont.resume(returning: $0) }
}
}
private let squareToCircleRatio = 0.935
private let radiusFactor = (1 - squareToCircleRatio) / 50
@ViewBuilder public func clipProfileImage(_ img: Image, size: CGFloat, radius: Double, blurred: Bool = false) -> some View {
if radius >= 50 {
blurredFrame(img, size, blurred).clipShape(Circle())
} else if radius <= 0 {
let sz = size * squareToCircleRatio
blurredFrame(img, sz, blurred).padding((size - sz) / 2)
} else {
let sz = size * (squareToCircleRatio + radius * radiusFactor)
blurredFrame(img, sz, blurred)
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
.padding((size - sz) / 2)
}
}
@ViewBuilder private func blurredFrame(_ img: Image, _ size: CGFloat, _ blurred: Bool) -> some View {
let v = img.resizable().frame(width: size, height: size)
if blurred {
v.blur(radius: size / 4)
} else {
v
}
}

View file

@ -1,83 +0,0 @@
//
// NSESubscriber.swift
// SimpleXChat
//
// Created by Evgeny on 09/12/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import Foundation
import SimpleXChat
private var nseSubscribers: [UUID:NSESubscriber] = [:]
// timeout for active notification service extension going into "suspending" state.
// If in two seconds the state does not change, we assume that it was not running and proceed with app activation/answering call.
private let SUSPENDING_TIMEOUT: TimeInterval = 2
// timeout should be larger than SUSPENDING_TIMEOUT
func waitNSESuspended(timeout: TimeInterval, suspended: @escaping (Bool) -> Void) {
if timeout <= SUSPENDING_TIMEOUT {
logger.warning("waitNSESuspended: small timeout \(timeout), using \(SUSPENDING_TIMEOUT + 1)")
}
var state = nseStateGroupDefault.get()
if case .suspended = state {
DispatchQueue.main.async { suspended(true) }
return
}
let id = UUID()
var suspendedCalled = false
checkTimeout()
nseSubscribers[id] = nseMessageSubscriber { msg in
if case let .state(newState) = msg {
state = newState
logger.debug("waitNSESuspended state: \(state.rawValue)")
if case .suspended = newState {
notifySuspended(true)
}
}
}
return
func notifySuspended(_ ok: Bool) {
logger.debug("waitNSESuspended notifySuspended: \(ok)")
if !suspendedCalled {
logger.debug("waitNSESuspended notifySuspended: calling suspended(\(ok))")
suspendedCalled = true
nseSubscribers.removeValue(forKey: id)
DispatchQueue.main.async { suspended(ok) }
}
}
func checkTimeout() {
if !suspending() {
checkSuspendingTimeout()
} else if state == .suspending {
checkSuspendedTimeout()
}
}
func suspending() -> Bool {
suspendedCalled || state == .suspended || state == .suspending
}
func checkSuspendingTimeout() {
DispatchQueue.global().asyncAfter(deadline: .now() + SUSPENDING_TIMEOUT) {
logger.debug("waitNSESuspended check suspending timeout")
if !suspending() {
notifySuspended(false)
} else if state != .suspended {
checkSuspendedTimeout()
}
}
}
func checkSuspendedTimeout() {
DispatchQueue.global().asyncAfter(deadline: .now() + min(timeout - SUSPENDING_TIMEOUT, 1)) {
logger.debug("waitNSESuspended check suspended timeout")
if state != .suspended {
notifySuspended(false)
}
}
}
}

View file

@ -1,73 +0,0 @@
//
// NetworkObserver.swift
// SimpleX (iOS)
//
// Created by Avently on 05.04.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import Network
import SimpleXChat
class NetworkObserver {
static let shared = NetworkObserver()
private let queue: DispatchQueue = DispatchQueue(label: "chat.simplex.app.NetworkObserver")
private var prevInfo: UserNetworkInfo? = nil
private var monitor: NWPathMonitor?
private let monitorLock: DispatchQueue = DispatchQueue(label: "chat.simplex.app.monitorLock")
func restartMonitor() {
monitorLock.sync {
monitor?.cancel()
let mon = NWPathMonitor()
mon.pathUpdateHandler = { [weak self] path in
self?.networkPathChanged(path: path)
}
mon.start(queue: queue)
monitor = mon
}
}
private func networkPathChanged(path: NWPath) {
let info = UserNetworkInfo(
networkType: networkTypeFromPath(path),
online: path.status == .satisfied
)
if (prevInfo != info) {
prevInfo = info
setNetworkInfo(info)
}
}
private func networkTypeFromPath(_ path: NWPath) -> UserNetworkType {
if path.usesInterfaceType(.wiredEthernet) {
.ethernet
} else if path.usesInterfaceType(.wifi) {
.wifi
} else if path.usesInterfaceType(.cellular) {
.cellular
} else if path.usesInterfaceType(.other) {
.other
} else {
.none
}
}
private static var networkObserver: NetworkObserver? = nil
private func setNetworkInfo(_ info: UserNetworkInfo) {
logger.debug("setNetworkInfo Network changed: \(String(describing: info))")
DispatchQueue.main.sync {
ChatModel.shared.networkInfo = info
}
if !hasChatCtrl() { return }
self.monitorLock.sync {
do {
try apiSetNetworkInfo(info)
} catch let err {
logger.error("setNetworkInfo error: \(responseError(err))")
}
}
}
}

View file

@ -26,37 +26,20 @@ enum NtfCallAction {
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
static let shared = NtfManager()
public var navigatingToChat = false
private var granted = false
private var prevNtfTime: Dictionary<ChatId, Date> = [:]
override init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
// Handle notification when app is in background
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler handler: () -> Void) {
logger.debug("NtfManager.userNotificationCenter: didReceive")
if appStateGroupDefault.get() == .active {
processNotificationResponse(response)
} else {
logger.debug("NtfManager.userNotificationCenter: remember response in model")
ChatModel.shared.notificationResponse = response
}
handler()
}
func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
let content = response.notification.request.content
let chatModel = ChatModel.shared
let content = ntfResponse.notification.request.content
let action = ntfResponse.actionIdentifier
logger.debug("NtfManager.processNotificationResponse: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
let action = response.actionIdentifier
logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
if let userId = content.userInfo["userId"] as? Int64,
userId != chatModel.currentUser?.userId {
logger.debug("NtfManager.processNotificationResponse changeActiveUser")
changeActiveUser(userId, viewPwd: nil)
}
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
@ -74,13 +57,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
}
} else {
if let chatId = content.targetContentIdentifier {
self.navigatingToChat = true
ItemsModel.shared.loadOpenChat(chatId) {
self.navigatingToChat = false
}
}
chatModel.chatId = content.targetContentIdentifier
}
handler()
}
private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? {
@ -95,6 +74,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
return nil
}
// Handle notification when the app is in foreground
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
@ -203,12 +183,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
),
UNNotificationCategory(
identifier: ntfCategoryManyEvents,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New events", comment: "notification")
)
])
}
@ -234,28 +208,29 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
}
}
}
center.delegate = self
}
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest")
addNotification(createContactRequestNtf(user, contactRequest, 0))
addNotification(createContactRequestNtf(user, contactRequest))
}
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected")
addNotification(createContactConnectedNtf(user, contact, 0))
addNotification(createContactConnectedNtf(user, contact))
}
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
if cInfo.ntfsEnabled(chatItem: cItem) {
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
if cInfo.ntfsEnabled {
addNotification(createMessageReceivedNtf(user, cInfo, cItem))
}
}
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
logger.debug("NtfManager.notifyCallInvitation")
addNotification(createCallInvitationNtf(invitation, 0))
addNotification(createCallInvitationNtf(invitation))
}
func setNtfBadgeCount(_ count: Int) {
@ -263,8 +238,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
ntfBadgeCountGroupDefault.set(count)
}
func changeNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count))
func decNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
}
func incNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
}
private func addNotification(_ content: UNMutableNotificationContent) {

File diff suppressed because it is too large Load diff

View file

@ -9,42 +9,27 @@
import Foundation
import UIKit
import SimpleXChat
import SwiftUI
private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock")
let appSuspendTimeout: Int = 15 // seconds
let bgSuspendTimeout: Int = 5 // seconds
let terminationTimeout: Int = 3 // seconds
let activationDelay: TimeInterval = 1.5
let nseSuspendTimeout: TimeInterval = 5
private func _suspendChat(timeout: Int) {
// this is a redundant check to prevent logical errors, like the one fixed in this PR
let state = AppChatState.shared.value
let state = appStateGroupDefault.get()
if !state.canSuspend {
logger.error("_suspendChat called, current state: \(state.rawValue)")
logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)")
} else if ChatModel.ok {
AppChatState.shared.set(.suspending)
appStateGroupDefault.set(.suspending)
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
let endTask = beginBGTask(chatSuspended)
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
} else {
AppChatState.shared.set(.suspended)
}
}
let seSubscriber = seMessageSubscriber {
switch $0 {
case let .state(state):
switch state {
case .inactive:
if AppChatState.shared.value.inactive { activateChat() }
case .sendingMessage:
if AppChatState.shared.value.canSuspend { suspendChat() }
}
appStateGroupDefault.set(.suspended)
}
}
@ -56,16 +41,18 @@ func suspendChat() {
func suspendBgRefresh() {
suspendLockQueue.sync {
if case .bgRefresh = AppChatState.shared.value {
if case .bgRefresh = appStateGroupDefault.get() {
_suspendChat(timeout: bgSuspendTimeout)
}
}
}
private var terminating = false
func terminateChat() {
logger.debug("terminateChat")
suspendLockQueue.sync {
switch AppChatState.shared.value {
switch appStateGroupDefault.get() {
case .suspending:
// suspend instantly if already suspending
_chatSuspended()
@ -77,6 +64,7 @@ func terminateChat() {
case .stopped:
chatCloseStore()
default:
terminating = true
// the store will be closed in _chatSuspended when event is received
_suspendChat(timeout: terminationTimeout)
}
@ -85,7 +73,7 @@ func terminateChat() {
func chatSuspended() {
suspendLockQueue.sync {
if case .suspending = AppChatState.shared.value {
if case .suspending = appStateGroupDefault.get() {
_chatSuspended()
}
}
@ -93,111 +81,48 @@ func chatSuspended() {
private func _chatSuspended() {
logger.debug("_chatSuspended")
AppChatState.shared.set(.suspended)
appStateGroupDefault.set(.suspended)
if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.stop()
}
chatCloseStore()
}
func setAppState(_ appState: AppState) {
suspendLockQueue.sync {
AppChatState.shared.set(appState)
if terminating {
chatCloseStore()
}
}
func activateChat(appState: AppState = .active) {
logger.debug("DEBUGGING: activateChat")
terminating = false
suspendLockQueue.sync {
AppChatState.shared.set(appState)
appStateGroupDefault.set(appState)
if ChatModel.ok { apiActivateChat() }
logger.debug("DEBUGGING: activateChat: after apiActivateChat")
}
}
func initChatAndMigrate(refreshInvitations: Bool = true) {
terminating = false
let m = ChatModel.shared
if (!m.chatInitialized) {
m.v3DBMigration = v3DBMigrationDefault.get()
if AppChatState.shared.value == .stopped && storeDBPassphraseGroupDefault.get() && kcDatabasePassword.get() != nil {
initialize(start: true, confirmStart: true)
} else {
initialize(start: true)
}
}
func initialize(start: Bool, confirmStart: Bool = false) {
do {
try initializeChat(start: m.v3DBMigration.startChat && start, confirmStart: m.v3DBMigration.startChat && confirmStart, refreshInvitations: refreshInvitations)
m.v3DBMigration = v3DBMigrationDefault.get()
try initializeChat(start: m.v3DBMigration.startChat, refreshInvitations: refreshInvitations)
} catch let error {
AlertManager.shared.showAlertMsg(
title: start ? "Error starting chat" : "Error opening chat",
message: "Please contact developers.\nError: \(responseError(error))"
)
fatalError("Failed to start or load chats: \(responseError(error))")
}
}
}
func startChatForCall() {
logger.debug("DEBUGGING: startChatForCall")
if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.start()
logger.debug("DEBUGGING: startChatForCall: after ChatReceiver.shared.start")
}
if .active != AppChatState.shared.value {
logger.debug("DEBUGGING: startChatForCall: before activateChat")
activateChat()
logger.debug("DEBUGGING: startChatForCall: after activateChat")
}
}
func startChatAndActivate(_ completion: @escaping () -> Void) {
func startChatAndActivate() {
terminating = false
logger.debug("DEBUGGING: startChatAndActivate")
if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.start()
logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start")
}
if case .active = AppChatState.shared.value {
completion()
} else if nseStateGroupDefault.get().inactive {
activate()
} else {
// setting app state to "activating" to notify NSE that it should suspend
setAppState(.activating)
waitNSESuspended(timeout: nseSuspendTimeout) { ok in
if !ok {
// if for some reason NSE failed to suspend,
// e.g., it crashed previously without setting its state to "suspended",
// set it to "suspended" state anyway, so that next time app
// does not have to wait when activating.
nseStateGroupDefault.set(.suspended)
}
if AppChatState.shared.value == .activating {
activate()
}
}
}
func activate() {
if .active != appStateGroupDefault.get() {
logger.debug("DEBUGGING: startChatAndActivate: before activateChat")
activateChat()
completion()
logger.debug("DEBUGGING: startChatAndActivate: after activateChat")
}
}
// appStateGroupDefault must not be used in the app directly, only via this singleton
class AppChatState {
static let shared = AppChatState()
private var value_ = appStateGroupDefault.get()
var value: AppState {
value_
}
func set(_ state: AppState) {
appStateGroupDefault.set(state)
sendAppState(state)
value_ = state
}
}

View file

@ -16,10 +16,14 @@ struct SimpleXApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var chatModel = ChatModel.shared
@ObservedObject var alertManager = AlertManager.shared
@Environment(\.scenePhase) var scenePhase
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil
@State private var appOpenUrlLater: URL?
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@State private var userAuthorized: Bool?
@State private var doAuthenticate = false
@State private var enteredBackground: TimeInterval? = nil
@State private var canConnectCall = false
@State private var lastSuccessfulUnlock: TimeInterval? = nil
@State private var showInitializationView = false
init() {
DispatchQueue.global(qos: .background).sync {
@ -35,83 +39,53 @@ struct SimpleXApp: App {
}
var body: some Scene {
WindowGroup {
// contentAccessAuthenticationExtended has to be passed to ContentView on view initialization,
// so that it's computed by the time view renders, and not on event after rendering
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
return WindowGroup {
ContentView(
doAuthenticate: $doAuthenticate,
userAuthorized: $userAuthorized,
canConnectCall: $canConnectCall,
lastSuccessfulUnlock: $lastSuccessfulUnlock,
showInitializationView: $showInitializationView
)
.environmentObject(chatModel)
.environmentObject(AppTheme.shared)
.onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)")
if AppChatState.shared.value == .active {
chatModel.appOpenUrl = url
} else {
appOpenUrlLater = url
}
chatModel.appOpenUrl = url
}
.onAppear() {
// Present screen for continue migration if it wasn't finished yet
if chatModel.migrationState != nil {
// It's important, otherwise, user may be locked in undefined state
onboardingStageDefault.set(.step1_SimpleXInfo)
chatModel.onboardingStage = onboardingStageDefault.get()
} else if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
initChatAndMigrate()
}
showInitializationView = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
initChatAndMigrate()
}
}
.onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
AppSheetState.shared.scenePhaseActive = phase == .active
switch (phase) {
case .background:
// --- authentication
// see ContentView .onChange(of: scenePhase) for remaining authentication logic
if chatModel.contentViewAccessAuthenticated {
enteredBackgroundAuthenticated = ProcessInfo.processInfo.systemUptime
}
chatModel.contentViewAccessAuthenticated = false
// authentication ---
if CallController.useCallKit() && chatModel.activeCall != nil {
CallController.shared.shouldSuspendChat = true
} else {
suspendChat()
BGManager.shared.schedule()
}
if userAuthorized == true {
enteredBackground = ProcessInfo.processInfo.systemUptime
}
doAuthenticate = false
canConnectCall = false
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
case .active:
CallController.shared.shouldSuspendChat = false
let appState = AppChatState.shared.value
if appState != .stopped {
startChatAndActivate {
if chatModel.chatRunning == true {
if let ntfResponse = chatModel.notificationResponse {
chatModel.notificationResponse = nil
NtfManager.shared.processNotificationResponse(ntfResponse)
}
if appState.inactive {
Task {
await updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
}
if let url = appOpenUrlLater {
await MainActor.run {
appOpenUrlLater = nil
chatModel.appOpenUrl = url
}
}
}
} else if let url = appOpenUrlLater {
appOpenUrlLater = nil
chatModel.appOpenUrl = url
}
}
let appState = appStateGroupDefault.get()
startChatAndActivate()
if appState.inactive && chatModel.chatRunning == true {
updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
updateCallInvitations()
}
}
doAuthenticate = authenticationExpired()
canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently()
default:
break
}
@ -129,12 +103,12 @@ struct SimpleXApp: App {
if legacyDatabase, case .documents = dbContainerGroupDefault.get() {
dbContainerGroupDefault.set(.documents)
setMigrationState(.offer)
logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath())*.db")
logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath(), privacy: .public)*.db")
} else {
dbContainerGroupDefault.set(.group)
setMigrationState(.ready)
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath())*.db")
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not") present")
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db")
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present")
}
}
@ -144,25 +118,32 @@ struct SimpleXApp: App {
}
private func authenticationExpired() -> Bool {
if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated {
if let enteredBackground = enteredBackground {
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay
return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay
} else {
return true
}
}
private func updateChats() async {
private func unlockedRecently() -> Bool {
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
} else {
return false
}
}
private func updateChats() {
do {
let chats = try await apiGetChatsAsync()
await MainActor.run { chatModel.updateChats(chats) }
let chats = try apiGetChats()
chatModel.updateChats(with: chats)
if let id = chatModel.chatId,
let chat = chatModel.getChat(id),
!NtfManager.shared.navigatingToChat {
Task { await loadChat(chat: chat, clearItems: false) }
let chat = chatModel.getChat(id) {
loadChat(chat: chat)
}
if let ncr = chatModel.ntfContactRequest {
await MainActor.run { chatModel.ntfContactRequest = nil }
chatModel.ntfContactRequest = nil
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
}
@ -172,9 +153,9 @@ struct SimpleXApp: App {
}
}
private func updateCallInvitations() async {
private func updateCallInvitations() {
do {
try await refreshCallInvitations()
try refreshCallInvitations()
} catch let error {
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
}

View file

@ -1,199 +0,0 @@
//
// Theme.swift
// SimpleX (iOS)
//
// Created by Avently on 14.06.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } }
var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 }
func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight }
class AppTheme: ObservableObject, Equatable {
static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper)
var name: String
var base: DefaultTheme
@ObservedObject var colors: Colors
@ObservedObject var appColors: AppColors
@ObservedObject var wallpaper: AppWallpaper
init(name: String, base: DefaultTheme, colors: Colors, appColors: AppColors, wallpaper: AppWallpaper) {
self.name = name
self.base = base
self.colors = colors
self.appColors = appColors
self.wallpaper = wallpaper
}
static func == (lhs: AppTheme, rhs: AppTheme) -> Bool {
lhs.name == rhs.name &&
lhs.colors == rhs.colors &&
lhs.appColors == rhs.appColors &&
lhs.wallpaper == rhs.wallpaper
}
func updateFromCurrentColors() {
objectWillChange.send()
name = CurrentColors.name
base = CurrentColors.base
colors.updateColorsFrom(CurrentColors.colors)
appColors.updateColorsFrom(CurrentColors.appColors)
wallpaper.updateWallpaperFrom(CurrentColors.wallpaper)
}
}
struct ThemedBackground: ViewModifier {
@EnvironmentObject var theme: AppTheme
var grouped: Bool = false
func body(content: Content) -> some View {
content
.background(
theme.base == DefaultTheme.SIMPLEX
? LinearGradient(
colors: [
grouped
? theme.colors.background.lighter(0.4).asGroupedBackground(theme.base.mode)
: theme.colors.background.lighter(0.4),
grouped
? theme.colors.background.darker(0.4).asGroupedBackground(theme.base.mode)
: theme.colors.background.darker(0.4)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
: LinearGradient(
colors: [],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.background(
theme.base == DefaultTheme.SIMPLEX
? Color.clear
: grouped
? theme.colors.background.asGroupedBackground(theme.base.mode)
: theme.colors.background
)
}
}
var systemInDarkThemeCurrently: Bool {
return UITraitCollection.current.userInterfaceStyle == .dark
}
func reactOnDarkThemeChanges(_ inDarkNow: Bool) {
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
}
extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper {
if preset == nil, let image {
// Need to save image from string and to save its path
if let parsed = imageFromBase64(image),
let filename = saveWallpaperFile(image: parsed) {
var copy = self
copy.image = nil
copy.imageFile = filename
return copy
} else {
return ThemeWallpaper()
}
} else {
return self
}
}
func withFilledWallpaperBase64() -> ThemeWallpaper {
let aw = toAppWallpaper()
let type = aw.type
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSizeSync(image, maxDataSize: 5_000_000) } else { nil }
return ThemeWallpaper (
preset: preset,
scale: scale,
scaleType: scaleType,
background: aw.background?.toReadableHex(),
tint: aw.tint?.toReadableHex(),
image: image,
imageFile: nil
)
}
}
extension ThemeModeOverride {
func removeSameColors(_ base: DefaultTheme, colorsToCompare tc: ThemeColors) -> ThemeModeOverride {
let wallpaperType = WallpaperType.from(wallpaper) ?? WallpaperType.empty
let w: ThemeWallpaper
switch wallpaperType {
case let WallpaperType.preset(filename, scale):
let p = PresetWallpaper.from(filename)
w = ThemeWallpaper(
preset: filename,
scale: scale ?? wallpaper?.scale,
scaleType: nil,
background: p?.background[base]?.toReadableHex(),
tint: p?.tint[base]?.toReadableHex(),
image: nil,
imageFile: nil
)
case WallpaperType.image:
w = ThemeWallpaper(
preset: nil,
scale: nil,
scaleType: WallpaperScaleType.fill,
background: Color.clear.toReadableHex(),
tint: Color.clear.toReadableHex(),
image: nil,
imageFile: nil
)
default:
w = ThemeWallpaper()
}
let wallpaper: ThemeWallpaper? = if let wallpaper {
ThemeWallpaper(
preset: wallpaper.preset,
scale: wallpaper.scale != w.scale ? wallpaper.scale : nil,
scaleType: wallpaper.scaleType != w.scaleType ? wallpaper.scaleType : nil,
background: wallpaper.background != w.background ? wallpaper.background : nil,
tint: wallpaper.tint != w.tint ? wallpaper.tint : nil,
image: wallpaper.image,
imageFile: wallpaper.imageFile
)
} else {
nil
}
return ThemeModeOverride(
mode: self.mode,
colors: ThemeColors(
primary: colors.primary != tc.primary ? colors.primary : nil,
primaryVariant: colors.primaryVariant != tc.primaryVariant ? colors.primaryVariant : nil,
secondary: colors.secondary != tc.secondary ? colors.secondary : nil,
secondaryVariant: colors.secondaryVariant != tc.secondaryVariant ? colors.secondaryVariant : nil,
background: colors.background != tc.background ? colors.background : nil,
surface: colors.surface != tc.surface ? colors.surface : nil,
title: colors.title != tc.title ? colors.title : nil,
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
receivedQuote: colors.receivedQuote != tc.receivedQuote ? colors.receivedQuote : nil
),
wallpaper: wallpaper
)
}
}

View file

@ -1,303 +0,0 @@
//
// ThemeManager.swift
// SimpleX (iOS)
//
// Created by Avently on 03.06.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
class ThemeManager {
struct ActiveTheme: Equatable {
let name: String
let base: DefaultTheme
let colors: Colors
let appColors: AppColors
var wallpaper: AppWallpaper = AppWallpaper(background: nil, tint: nil, type: .empty)
func toAppTheme() -> AppTheme {
AppTheme(name: name, base: base, colors: colors, appColors: appColors, wallpaper: wallpaper)
}
}
private static func systemDarkThemeColors() -> (Colors, DefaultTheme) {
switch systemDarkThemeDefault.get() {
case DefaultTheme.DARK.themeName: (DarkColorPalette, DefaultTheme.DARK)
case DefaultTheme.SIMPLEX.themeName: (SimplexColorPalette, DefaultTheme.SIMPLEX)
case DefaultTheme.BLACK.themeName: (BlackColorPalette, DefaultTheme.BLACK)
default: (SimplexColorPalette, DefaultTheme.SIMPLEX)
}
}
private static func nonSystemThemeName() -> String {
let themeName = currentThemeDefault.get()
return if themeName != DefaultTheme.SYSTEM_THEME_NAME {
themeName
} else {
systemInDarkThemeCurrently ? systemDarkThemeDefault.get() : DefaultTheme.LIGHT.themeName
}
}
static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? {
let nonSystemThemeName = nonSystemThemeName()
let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName]
return appSettingsTheme.getTheme(defaultThemeId)
}
static func defaultActiveTheme(_ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ThemeModeOverride {
let perUserTheme = !CurrentColors.colors.isLight ? perUserTheme?.dark : perUserTheme?.light
if let perUserTheme {
return perUserTheme
}
let defaultTheme = defaultActiveTheme(appSettingsTheme)
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil))
}
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
let themeName = currentThemeDefault.get()
let nonSystemThemeName = nonSystemThemeName()
let defaultTheme = defaultActiveTheme(appSettingsTheme)
let baseTheme = switch nonSystemThemeName {
case DefaultTheme.LIGHT.themeName: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
case DefaultTheme.DARK.themeName: ActiveTheme(name: DefaultTheme.DARK.themeName, base: DefaultTheme.DARK, colors: DarkColorPalette.clone(), appColors: DarkColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.DARK)))
case DefaultTheme.SIMPLEX.themeName: ActiveTheme(name: DefaultTheme.SIMPLEX.themeName, base: DefaultTheme.SIMPLEX, colors: SimplexColorPalette.clone(), appColors: SimplexColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.SIMPLEX)))
case DefaultTheme.BLACK.themeName: ActiveTheme(name: DefaultTheme.BLACK.themeName, base: DefaultTheme.BLACK, colors: BlackColorPalette.clone(), appColors: BlackColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.BLACK)))
default: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
}
let perUserTheme = baseTheme.colors.isLight ? perUserTheme?.light : perUserTheme?.dark
let theme = appSettingsTheme.sameTheme(themeOverridesForType ?? perChatTheme?.type ?? perUserTheme?.type ?? defaultTheme?.wallpaper?.toAppWallpaper().type, nonSystemThemeName) ?? defaultTheme
if theme == nil && perUserTheme == nil && perChatTheme == nil && themeOverridesForType == nil {
return ActiveTheme(name: themeName, base: baseTheme.base, colors: baseTheme.colors, appColors: baseTheme.appColors, wallpaper: baseTheme.wallpaper)
}
let presetWallpaperTheme: ThemeColors? = if let themeOverridesForType, case let WallpaperType.preset(filename, _) = themeOverridesForType {
PresetWallpaper.from(filename)?.colors[baseTheme.base]
} else if let wallpaper = perChatTheme?.wallpaper {
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
} else if let wallpaper = perUserTheme?.wallpaper {
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
} else {
if let preset = theme?.wallpaper?.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
}
let themeOrEmpty = theme ?? ThemeOverrides(base: baseTheme.base)
let colors = themeOrEmpty.toColors(themeOrEmpty.base, perChatTheme?.colors, perUserTheme?.colors, presetWallpaperTheme)
return ActiveTheme(
name: themeName,
base: baseTheme.base,
colors: colors,
appColors: themeOrEmpty.toAppColors(themeOrEmpty.base, perChatTheme?.colors, perChatTheme?.type, perUserTheme?.colors, perUserTheme?.type, presetWallpaperTheme),
wallpaper: themeOrEmpty.toAppWallpaper(themeOverridesForType, perChatTheme, perUserTheme, colors.background)
)
}
static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides {
let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get())
let wType = current.wallpaper.type
let wBackground = current.wallpaper.background
let wTint = current.wallpaper.tint
let w: ThemeWallpaper? = if case WallpaperType.empty = wType {
nil
} else {
ThemeWallpaper.from(wType, wBackground?.toReadableHex(), wTint?.toReadableHex()).withFilledWallpaperBase64()
}
return ThemeOverrides(
themeId: "",
base: current.base,
colors: ThemeColors.from(current.colors, current.appColors),
wallpaper: w
)
}
static func applyTheme(_ theme: String) {
currentThemeDefault.set(theme)
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
AppTheme.shared.updateFromCurrentColors()
let tint = UIColor(CurrentColors.colors.primary)
if SceneDelegate.windowStatic?.tintColor != tint {
SceneDelegate.windowStatic?.tintColor = tint
}
// applyNavigationBarColors(CurrentColors.toAppTheme())
}
static func adjustWindowStyle() {
let style = switch currentThemeDefault.get() {
case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light
case DefaultTheme.SYSTEM_THEME_NAME: UIUserInterfaceStyle.unspecified
default: UIUserInterfaceStyle.dark
}
if SceneDelegate.windowStatic?.overrideUserInterfaceStyle != style {
SceneDelegate.windowStatic?.overrideUserInterfaceStyle = style
}
}
// static func applyNavigationBarColors(_ theme: AppTheme) {
// let baseColors = switch theme.base {
// case DefaultTheme.LIGHT: LightColorPaletteApp
// case DefaultTheme.DARK: DarkColorPaletteApp
// case DefaultTheme.SIMPLEX: SimplexColorPaletteApp
// case DefaultTheme.BLACK: BlackColorPaletteApp
// }
// let isDefaultColor = baseColors.title == theme.appColors.title
//
// let title = UIColor(theme.appColors.title)
// if !isDefaultColor && UINavigationBar.appearance().titleTextAttributes?.first as? UIColor != title {
// UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: title]
// UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: title]
// } else {
// UINavigationBar.appearance().titleTextAttributes = nil
// UINavigationBar.appearance().largeTitleTextAttributes = nil
// }
// }
static func changeDarkTheme(_ theme: String) {
systemDarkThemeDefault.set(theme)
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
AppTheme.shared.updateFromCurrentColors()
}
static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let nonSystemThemeName = baseTheme.themeName
let pref = pref ?? themeOverridesDefault
let overrides = pref.get()
let themeId = currentThemeIdsDefault.get()[nonSystemThemeName]
let prevValue = overrides.getTheme(themeId) ?? ThemeOverrides(base: baseTheme)
pref.set(overrides.replace(prevValue.withUpdatedColor(name, color?.toReadableHex())))
var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds)
applyTheme(currentThemeDefault.get())
}
static func applyThemeColor(name: ThemeColor, color: Color? = nil, pref: Binding<ThemeModeOverride>) {
pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex())
}
static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) {
let nonSystemThemeName = baseTheme.themeName
let pref = pref ?? themeOverridesDefault
let overrides = pref.get()
let theme = overrides.sameTheme(type, baseTheme.themeName)
var prevValue = theme ?? ThemeOverrides(base: baseTheme)
prevValue.wallpaper = if let type {
if case WallpaperType.empty = type {
nil as ThemeWallpaper?
} else {
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
}
} else {
nil
}
pref.set(overrides.replace(prevValue))
var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds)
applyTheme(currentThemeDefault.get())
}
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {
let overrides = themeOverridesDefault.get()
let sameWallpaper: ThemeWallpaper? = if let wallpaper = lowerLevelOverride?.wallpaper, lowerLevelOverride?.type?.sameType(type) == true {
wallpaper
} else {
overrides.sameTheme(type, CurrentColors.base.themeName)?.wallpaper
}
guard let sameWallpaper else {
if let type {
var w: ThemeWallpaper = ThemeWallpaper.from(type, nil, nil)
w.scale = nil
w.scaleType = nil
w.background = nil
w.tint = nil
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: w)
} else {
// Make an empty wallpaper to override any top level ones
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: ThemeWallpaper())
}
return true
}
var type = sameWallpaper.toAppWallpaper().type
if case let WallpaperType.image(filename, scale, scaleType) = type, sameWallpaper.imageFile == filename {
// same image file. Needs to be copied first in order to be able to remove the file once it's not needed anymore without affecting main theme override
if let filename = saveWallpaperFile(url: getWallpaperFilePath(filename)) {
type = WallpaperType.image(filename, scale, scaleType)
} else {
logger.error("Error while copying wallpaper from global overrides to chat overrides")
return false
}
}
var prevValue = pref.wrappedValue
var w = ThemeWallpaper.from(type, nil, nil)
w.scale = nil
w.scaleType = nil
w.background = nil
w.tint = nil
prevValue.colors = ThemeColors()
prevValue.wallpaper = w
pref.wrappedValue = prevValue
return true
}
static func applyWallpaper(_ type: WallpaperType?, _ pref: Binding<ThemeModeOverride>) {
var prevValue = pref.wrappedValue
prevValue.wallpaper = if let type {
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
} else {
nil
}
pref.wrappedValue = prevValue
}
static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let wallpaper = theme.wallpaper?.importFromString()
let nonSystemThemeName = theme.base.themeName
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
let overrides = pref.get()
var prevValue = overrides.getTheme(nil, wallpaper?.toAppWallpaper().type, theme.base) ?? ThemeOverrides(base: theme.base)
if let imageFile = prevValue.wallpaper?.imageFile {
try? FileManager.default.removeItem(at: getWallpaperFilePath(imageFile))
}
prevValue.base = theme.base
prevValue.colors = theme.colors
prevValue.wallpaper = wallpaper
pref.set(overrides.replace(prevValue))
currentThemeDefault.set(nonSystemThemeName)
var currentThemeIds = currentThemeIdsDefault.get()
currentThemeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(currentThemeIds)
applyTheme(nonSystemThemeName)
}
static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let nonSystemThemeName = nonSystemThemeName()
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
let overrides = pref.get()
guard let themeId = currentThemeIdsDefault.get()[nonSystemThemeName],
var prevValue = overrides.getTheme(themeId)
else { return }
prevValue.colors = ThemeColors()
prevValue.wallpaper?.background = nil
prevValue.wallpaper?.tint = nil
pref.set(overrides.replace(prevValue))
applyTheme(currentThemeDefault.get())
}
static func resetAllThemeColors(_ pref: Binding<ThemeModeOverride>) {
var prevValue = pref.wrappedValue
prevValue.colors = ThemeColors()
prevValue.wallpaper?.background = nil
prevValue.wallpaper?.tint = nil
pref.wrappedValue = prevValue
}
static func removeTheme(_ themeId: String?) {
var themes = themeOverridesDefault.get().map { $0 }
themes.removeAll(where: { $0.themeId == themeId })
themeOverridesDefault.set(themes)
}
}

View file

@ -9,102 +9,70 @@
import SwiftUI
import WebKit
import SimpleXChat
import AVFoundation
struct ActiveCallView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var call: Call
@Environment(\.scenePhase) var scenePhase
@State private var client: WebRTCClient? = nil
@State private var activeCall: WebRTCClient.Call? = nil
@State private var localRendererAspectRatio: CGFloat? = nil
@State var remoteContentMode: UIView.ContentMode = .scaleAspectFill
@Binding var canConnectCall: Bool
@State var prevColorScheme: ColorScheme = .dark
@State var pipShown = false
@State var wasConnected = false
var body: some View {
ZStack(alignment: .topLeading) {
ZStack(alignment: .bottom) {
if let client = client, call.hasVideo {
GeometryReader { g in
let width = g.size.width * 0.3
ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.primary.opacity(0.000001))
CallViewRemote(client: client, call: call, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, contentMode: $remoteContentMode, pipShown: $pipShown)
.onTapGesture {
remoteContentMode = remoteContentMode == .scaleAspectFill ? .scaleAspectFit : .scaleAspectFill
}
Group {
let localVideoTrack = client.activeCall?.localVideoTrack ?? client.notConnectedCall?.localCameraAndTrack?.1
if localVideoTrack != nil {
CallViewLocal(client: client, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
.onDisappear {
localRendererAspectRatio = nil
}
} else {
Rectangle().fill(.black)
}
}
ZStack(alignment: .bottom) {
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
GeometryReader { g in
let width = g.size.width * 0.3
ZStack(alignment: .topTrailing) {
CallViewRemote(client: client, activeCall: $activeCall)
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio)
.cornerRadius(10)
.frame(width: width, height: localRendererAspectRatio == nil ? (g.size.width < g.size.height ? width * 1.33 : width / 1.33) : width / (localRendererAspectRatio ?? 1))
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
.padding([.top, .trailing], 17)
}
}
}
if let call = m.activeCall, let client = client, (!pipShown || !call.hasVideo) {
ActiveCallOverlay(call: call, client: client)
}
}
if let call = m.activeCall, let client = client {
ActiveCallOverlay(call: call, client: client)
}
}
.allowsHitTesting(!m.activeCallViewIsCollapsed)
.opacity(m.activeCallViewIsCollapsed ? 0 : 1)
.onAppear {
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)")
AppDelegate.keepScreenOn(true)
Task {
await askRequiredPermissions()
}
createWebRTCClient()
dismissAllSheets()
hideKeyboard()
prevColorScheme = colorScheme
}
.onChange(of: canConnectCall) { _ in
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)")
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)")
createWebRTCClient()
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
hideKeyboard()
}
.onDisappear {
logger.debug("ActiveCallView: disappear")
Task { await m.callCommand.setClient(nil) }
AppDelegate.keepScreenOn(false)
client?.endCall()
CallSoundsPlayer.shared.stop()
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient)
if (wasConnected) {
CallSoundsPlayer.shared.vibrate(long: true)
}
}
.background(m.activeCallViewIsCollapsed ? .clear : .black)
// Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it
.preferredColorScheme(m.activeCallViewIsCollapsed || CallController.useCallKit() ? prevColorScheme : .dark)
.onChange(of: m.callCommand) { _ in sendCommandToClient()}
.background(.black)
.preferredColorScheme(.dark)
}
private func createWebRTCClient() {
if client == nil && canConnectCall {
client = WebRTCClient({ msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
sendCommandToClient()
}
}
private func sendCommandToClient() {
if call == m.activeCall,
m.activeCall != nil,
let client = client,
let cmd = m.callCommand {
m.callCommand = nil
logger.debug("sendCallCommand: \(cmd.cmdType)")
Task {
await m.callCommand.setClient(client)
await client.sendCallCommand(command: cmd)
}
}
}
@ -112,12 +80,12 @@ struct ActiveCallView: View {
@MainActor
private func processRtcMessage(msg: WVAPIMessage) {
if call == m.activeCall,
let call = m.activeCall,
let client = client {
let call = m.activeCall,
let client = client {
logger.debug("ActiveCallView: response \(msg.resp.respType)")
switch msg.resp {
case let .capabilities(capabilities):
let callType = CallType(media: call.initialCallType, capabilities: capabilities)
let callType = CallType(media: call.localMedia, capabilities: capabilities)
Task {
do {
try await apiSendCallInvitation(call.contact, callType)
@ -128,17 +96,12 @@ struct ActiveCallView: View {
call.callState = .invitationSent
call.localCapabilities = capabilities
}
if call.hasVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
}
CallSoundsPlayer.shared.startConnectingCallSound()
activeCallWaitDeliveryReceipt()
}
case let .offer(offer, iceCandidates, capabilities):
Task {
do {
try await apiSendCallOffer(call.contact, offer, iceCandidates,
media: call.initialCallType, capabilities: capabilities)
media: call.localMedia, capabilities: capabilities)
} catch {
logger.error("apiSendCallOffer \(responseError(error))")
}
@ -156,7 +119,6 @@ struct ActiveCallView: View {
}
await MainActor.run {
call.callState = .negotiated
CallSoundsPlayer.shared.stop()
}
}
case let .ice(iceCandidates):
@ -171,22 +133,13 @@ struct ActiveCallView: View {
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)
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
call.callState = .connected
call.connectedAt = .now
if !wasConnected {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
}
}
if state.connectionState == "closed" {
closeCallView(client)
if let callUUID = m.activeCall?.callUUID {
CallController.shared.endCall(callUUID: callUUID)
}
m.activeCall = nil
m.activeCallViewIsCollapsed = false
}
Task {
do {
@ -198,23 +151,10 @@ struct ActiveCallView: View {
case let .connected(connectionInfo):
call.callState = .connected
call.connectionInfo = connectionInfo
call.connectedAt = .now
if !wasConnected {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
}
case let .peerMedia(source, enabled):
switch source {
case .mic: call.peerMediaSources.mic = enabled
case .camera: call.peerMediaSources.camera = enabled
case .screenAudio: call.peerMediaSources.screenAudio = enabled
case .screenVideo: call.peerMediaSources.screenVideo = enabled
case .unknown: ()
}
case .ended:
closeCallView(client)
call.callState = .ended
if let uuid = call.callUUID {
if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: uuid)
}
case .ok:
@ -224,57 +164,16 @@ struct ActiveCallView: View {
case .end:
closeCallView(client)
m.activeCall = nil
m.activeCallViewIsCollapsed = false
default: ()
}
case let .error(message):
logger.debug("ActiveCallView: command error: \(message)")
AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message)))
case let .invalid(type):
logger.debug("ActiveCallView: invalid response: \(type)")
AlertManager.shared.showAlert(Alert(title: Text("Invalid response"), message: Text(type)))
}
}
}
private func activeCallWaitDeliveryReceipt() {
ChatReceiver.shared.messagesChannel = { msg in
guard let call = ChatModel.shared.activeCall, call.callState == .invitationSent else {
ChatReceiver.shared.messagesChannel = nil
return
}
if case let .result(.chatItemsStatusesUpdated(_, chatItems)) = msg,
chatItems.contains(where: { ci in
ci.chatInfo.id == call.contact.id &&
ci.chatItem.content.isSndCall &&
ci.chatItem.meta.itemStatus.isSndRcvd
}) {
CallSoundsPlayer.shared.startInCallSound()
ChatReceiver.shared.messagesChannel = nil
}
}
}
private func askRequiredPermissions() async {
let mic = await WebRTCClient.isAuthorized(for: .audio)
await MainActor.run {
call.localMediaSources.mic = mic
}
let cameraAuthorized = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
var camera = call.initialCallType == .audio || cameraAuthorized
if call.initialCallType == .video && !cameraAuthorized {
camera = await WebRTCClient.isAuthorized(for: .video)
await MainActor.run {
if camera, let client {
client.setCameraEnabled(true)
}
}
}
if !mic || !camera {
WebRTCClient.showUnauthorizedAlert(for: !mic ? .audio : .video)
}
}
private func closeCallView(_ client: WebRTCClient) {
if m.activeCall != nil {
m.showCallView = false
@ -286,237 +185,167 @@ struct ActiveCallOverlay: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var call: Call
var client: WebRTCClient
@ObservedObject private var deviceManager = CallAudioDeviceManager.shared
var body: some View {
VStack {
switch call.hasVideo {
case true:
videoCallInfoView(call)
switch call.localMedia {
case .video:
callInfoView(call, .leading)
.foregroundColor(.white)
.opacity(0.8)
.padding(.horizontal)
// Fixed vertical padding required for preserving position of buttons row when changing audio-to-video and back in landscape orientation.
// Otherwise, bigger padding is added by SwiftUI when switching call types
.padding(.vertical, 10)
case false:
ZStack(alignment: .topLeading) {
Button {
chatModel.activeCallViewIsCollapsed = true
} label: {
Label("Back", systemImage: "chevron.left")
.padding()
.foregroundColor(.white.opacity(0.8))
}
VStack {
ProfileImage(imageStr: call.contact.profile.image, size: 192)
audioCallInfoView(call)
}
.foregroundColor(.white)
.opacity(0.8)
.padding(.horizontal)
.padding(.vertical, 10)
.frame(maxHeight: .infinity)
}
}
.padding()
Spacer()
Spacer()
HStack {
toggleMicButton()
Spacer()
audioDeviceButton()
Spacer()
endCallButton()
Spacer()
if call.localMediaSources.camera {
flipCameraButton()
} else {
Color.clear.frame(width: 60, height: 60)
HStack {
toggleAudioButton()
Spacer()
Color.clear.frame(width: 40, height: 40)
Spacer()
endCallButton()
Spacer()
if call.videoEnabled {
flipCameraButton()
} else {
Color.clear.frame(width: 40, height: 40)
}
Spacer()
toggleVideoButton()
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .center)
case .audio:
VStack {
ProfileImage(imageStr: call.contact.profile.image)
.scaledToFit()
.frame(width: 192, height: 192)
callInfoView(call, .center)
}
.foregroundColor(.white)
.opacity(0.8)
.padding()
.frame(maxHeight: .infinity)
Spacer()
toggleCameraButton()
ZStack(alignment: .bottom) {
toggleAudioButton()
.frame(maxWidth: .infinity, alignment: .leading)
endCallButton()
toggleSpeakerButton()
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.bottom, 60)
.padding(.horizontal, 48)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
.frame(maxWidth: 440, alignment: .center)
}
.frame(maxWidth: .infinity)
.onAppear {
deviceManager.start()
}
.onDisappear {
deviceManager.stop()
}
}
private func audioCallInfoView(_ call: Call) -> some View {
private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View {
VStack {
Text(call.contact.chatViewName)
.lineLimit(1)
.font(.title)
.frame(maxWidth: .infinity, alignment: .center)
.frame(maxWidth: .infinity, alignment: alignment)
Group {
Text(call.callState.text)
HStack {
Text(call.encryptionStatus)
if let connInfo = call.connectionInfo {
Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")")
// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))")
Text("(") + Text(connInfo.text) + Text(")")
}
}
}
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func videoCallInfoView(_ call: Call) -> some View {
VStack {
Button {
chatModel.activeCallViewIsCollapsed = true
} label: {
HStack(alignment: .center, spacing: 16) {
Image(systemName: "chevron.left")
.resizable()
.frame(width: 10, height: 18)
Text(call.contact.chatViewName)
.lineLimit(1)
.font(.title)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Group {
Text(call.callState.text)
HStack {
Text(call.encryptionStatus)
if let connInfo = call.connectionInfo {
Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")")
}
}
}
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: .infinity, alignment: alignment)
}
}
private func endCallButton() -> some View {
let cc = CallController.shared
return callButton("phone.down.fill", .red, padding: 10) {
if let uuid = call.callUUID {
return callButton("phone.down.fill", width: 60, height: 60) {
if let uuid = call.callkitUUID {
cc.endCall(callUUID: uuid)
} else {
cc.endCall(call: call) {}
}
}
.foregroundColor(.red)
}
private func toggleMicButton() -> some View {
controlButton(call, call.localMediaSources.mic ? "mic.fill" : "mic.slash", padding: 14) {
private func toggleAudioButton() -> some View {
controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
Task {
if await WebRTCClient.isAuthorized(for: .audio) {
client.setAudioEnabled(!call.localMediaSources.mic)
} else { WebRTCClient.showUnauthorizedAlert(for: .audio) }
}
}
}
func audioDeviceButton() -> some View {
// Check if the only input is microphone. And in this case show toggle button,
// If there are more inputs, it probably means something like bluetooth headphones are available
// and in this case show iOS button for choosing different output.
// There is no way to get available outputs, only inputs
Group {
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
toggleSpeakerButton()
} else {
audioDevicePickerButton()
}
}
.onChange(of: call.localMediaSources.hasVideo) { hasVideo in
let current = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType
let speakerEnabled = current == .builtInSpeaker
let receiverEnabled = current == .builtInReceiver
// react automatically only when receiver were selected, otherwise keep an external device selected
if !speakerEnabled && hasVideo && receiverEnabled {
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled, skipExternalDevice: true)
call.speakerEnabled = !speakerEnabled
client.setAudioEnabled(!call.audioEnabled)
DispatchQueue.main.async {
call.audioEnabled = !call.audioEnabled
}
}
}
}
private func toggleSpeakerButton() -> some View {
controlButton(call, !call.peerMediaSources.mic ? "speaker.slash" : call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill", padding: !call.peerMediaSources.mic ? 16 : call.speakerEnabled ? 15 : 17) {
let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
call.speakerEnabled = !speakerEnabled
}
.onAppear {
deviceManager.call = call
//call.speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
}
}
private func toggleCameraButton() -> some View {
controlButton(call, call.localMediaSources.camera ? "video.fill" : "video.slash", padding: call.localMediaSources.camera ? 16 : 14) {
controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
Task {
if await WebRTCClient.isAuthorized(for: .video) {
client.setCameraEnabled(!call.localMediaSources.camera)
} else { WebRTCClient.showUnauthorizedAlert(for: .video) }
client.setSpeakerEnabledAndConfigureSession(!call.speakerEnabled)
DispatchQueue.main.async {
call.speakerEnabled = !call.speakerEnabled
}
}
}
.disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true)
}
private func flipCameraButton() -> some View {
controlButton(call, "arrow.triangle.2.circlepath", padding: 12) {
private func toggleVideoButton() -> some View {
controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
Task {
client.setVideoEnabled(!call.videoEnabled)
DispatchQueue.main.async {
call.videoEnabled = !call.videoEnabled
}
}
}
}
@ViewBuilder private func flipCameraButton() -> some View {
controlButton(call, "arrow.triangle.2.circlepath") {
Task {
if await WebRTCClient.isAuthorized(for: .video) {
client.flipCamera()
}
client.flipCamera()
}
}
}
private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform)
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
if call.hasMedia {
callButton(imageName, width: 50, height: 38, perform)
.foregroundColor(.white)
.opacity(0.85)
} else {
Color.clear.frame(width: 50, height: 38)
}
}
private func audioDevicePickerButton() -> some View {
AudioDevicePicker()
.opacity(0.8)
.scaleEffect(2)
.padding(10)
.frame(width: 60, height: 60)
.background(call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2))
.clipShape(.circle)
}
private func callButton(_ imageName: String, _ background: Color, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> some View {
Button {
perform()
} label: {
Image(systemName: imageName)
.resizable()
.scaledToFit()
.padding(padding)
.frame(width: 60, height: 60)
.background(background)
.frame(maxWidth: width, maxHeight: height)
}
.foregroundColor(whiteColorWithAlpha)
.clipShape(.circle)
}
private var whiteColorWithAlpha: Color {
get { Color(red: 204 / 255, green: 204 / 255, blue: 204 / 255) }
}
}
struct ActiveCallOverlay_Previews: PreviewProvider {
static var previews: some View {
Group{
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .video), client: WebRTCClient({ _ in }, Binding.constant(nil)))
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
.background(.black)
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .audio), client: WebRTCClient({ _ in }, Binding.constant(nil)))
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
.background(.black)
}
}

View file

@ -1,25 +0,0 @@
//
// MPVolumeView.swift
// SimpleX (iOS)
//
// Created by Avently on 24.04.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import UIKit
import AVKit
struct AudioDevicePicker: UIViewRepresentable {
func makeUIView(context: Context) -> some UIView {
let v = AVRoutePickerView(frame: .zero)
v.activeTintColor = .white
v.tintColor = .white
return v
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}

View file

@ -1,67 +0,0 @@
//
// CallAudioDeviceManager.swift
// SimpleX (iOS)
//
// Created by Avently on 23.04.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
import AVKit
import WebRTC
class CallAudioDeviceManager: ObservableObject {
static let shared = CallAudioDeviceManager()
let audioSession: AVAudioSession
let nc = NotificationCenter.default
var call: Call?
var timer: Timer? = nil
// Actually, only one output
@Published var outputs: [AVAudioSessionPortDescription]
@Published var currentDevice: AVAudioSessionPortDescription? = nil
// All devices that can record audio (the ones that can play audio are not included)
@Published var availableInputs: [AVAudioSessionPortDescription] = []
init(_ audioSession: AVAudioSession? = nil) {
self.audioSession = audioSession ?? RTCAudioSession.sharedInstance().session
self.outputs = self.audioSession.currentRoute.outputs
self.availableInputs = self.audioSession.availableInputs ?? []
}
func reloadDevices() {
outputs = audioSession.currentRoute.outputs
currentDevice = audioSession.currentRoute.outputs.first
availableInputs = audioSession.availableInputs ?? []
call?.speakerEnabled = currentDevice?.portType == .builtInSpeaker
// Workaround situation:
// have bluetooth device connected, choosing speaker, disconnecting bluetooth device. In this case iOS will not post notification, so do it manually
timer?.invalidate()
if availableInputs.contains(where: { $0.portType != .builtInReceiver && $0.portType != .builtInSpeaker }) {
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { t in
self.reloadDevices()
}
}
}
@objc func audioCallback(notification: Notification) {
reloadDevices()
logger.debug("Changes in devices, current audio devices: \(String(describing: self.availableInputs.map({ $0.portType.rawValue }))), output: \(String(describing: self.currentDevice?.portType.rawValue))")
}
func start() {
nc.addObserver(self, selector: #selector(audioCallback), name: AVAudioSession.routeChangeNotification, object: nil)
}
func stop() {
nc.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil)
timer?.invalidate()
}
}

View file

@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
logger.debug("CallController.provider CXStartCallAction")
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
if callManager.startOutgoingCall(callUUID: action.callUUID) {
action.fulfill()
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
} else {
@ -61,30 +61,12 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
logger.debug("CallController.provider CXAnswerCallAction")
Task {
let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500)
logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))")
if !chatIsReady {
action.fail()
return
}
if !ChatModel.shared.callInvitations.values.contains(where: { inv in inv.callUUID == action.callUUID.uuidString.lowercased() }) {
try? await justRefreshCallInvitations()
logger.debug("CallController: updated call invitations chat")
}
await MainActor.run {
logger.debug("CallController.provider will answer on call")
if callManager.answerIncomingCall(callUUID: action.callUUID.uuidString.lowercased()) {
logger.debug("CallController.provider answered on call")
// WebRTC call should be in connected state to fulfill.
// Otherwise no audio and mic working on lockscreen
fulfillOnConnect = action
} else {
logger.debug("CallController.provider will fail the call")
action.fail()
}
}
if callManager.answerIncomingCall(callUUID: action.callUUID) {
// WebRTC call should be in connected state to fulfill.
// Otherwise no audio and mic working on lockscreen
fulfillOnConnect = action
} else {
action.fail()
}
}
@ -93,7 +75,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
// Should be nil here if connection was in connected state
fulfillOnConnect?.fail()
fulfillOnConnect = nil
callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in
callManager.endCall(callUUID: action.callUUID) { ok in
if ok {
action.fulfill()
} else {
@ -104,7 +86,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) {
action.fulfill()
} else {
action.fail()
@ -121,23 +103,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = true
do {
let hasVideo = ChatModel.shared.activeCall?.hasVideo == true
if hasVideo {
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
} else {
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
}
// Without any delay sound is not playing from speaker or external device in incoming call
Task {
for i in 0 ... 3 {
try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000)
if let preferred = audioSession.preferredInputDevice() {
await MainActor.run { try? audioSession.setPreferredInput(preferred) }
} else if hasVideo {
await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
}
}
}
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers)
logger.debug("audioSession category set")
try audioSession.setActive(true)
logger.debug("audioSession activated")
@ -164,7 +130,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
// The delay allows to accept the second call before suspending a chat
// see `.onChange(of: scenePhase)` in SimpleXApp
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat))")
logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat), privacy: .public)")
if ChatModel.shared.activeCall == nil && self?.shouldSuspendChat == true {
self?.shouldSuspendChat = false
suspendChat()
@ -174,48 +140,35 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
}
private func waitUntilChatStarted(timeoutMs: UInt64, stepMs: UInt64) async -> Bool {
logger.debug("CallController waiting until chat started")
var t: UInt64 = 0
repeat {
if ChatModel.shared.chatInitialized, ChatModel.shared.chatRunning == true, case .active = AppChatState.shared.value {
return true
}
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
t += stepMs
} while t < timeoutMs
return false
}
@objc(pushRegistry:didUpdatePushCredentials:forType:)
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue, privacy: .public)")
}
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
logger.debug("CallController: did receive push with type \(type.rawValue)")
logger.debug("CallController: did receive push with type \(type.rawValue, privacy: .public)")
if type != .voIP {
completion()
return
}
if AppChatState.shared.value == .stopped {
self.reportExpiredCall(payload: payload, completion)
return
logger.debug("CallController: initializing chat")
if (!ChatModel.shared.chatInitialized) {
initChatAndMigrate(refreshInvitations: false)
}
startChatAndActivate()
shouldSuspendChat = true
// There are no invitations in the model, as it was processed by NSE
_ = try? justRefreshCallInvitations()
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
// Extract the call information from the push notification payload
let m = ChatModel.shared
if let contactId = payload.dictionaryPayload["contactId"] as? String,
let displayName = payload.dictionaryPayload["displayName"] as? String,
let callUUID = payload.dictionaryPayload["callUUID"] as? String,
let uuid = UUID(uuidString: callUUID),
let callTsInterval = payload.dictionaryPayload["callTs"] as? TimeInterval,
let mediaStr = payload.dictionaryPayload["media"] as? String,
let media = CallMediaType(rawValue: mediaStr) {
let update = self.cxCallUpdate(contactId, displayName, media)
let callTs = Date(timeIntervalSince1970: callTsInterval)
if callTs.timeIntervalSinceNow >= -180 {
let invitation = m.callInvitations[contactId] {
let update = cxCallUpdate(invitation: invitation)
if let uuid = invitation.callkitUUID {
logger.debug("CallController: report pushkit call via CallKit")
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
let update = cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update) { error in
if error != nil {
m.callInvitations.removeValue(forKey: contactId)
}
@ -223,31 +176,11 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
completion()
}
} else {
logger.debug("CallController will expire call 1")
self.reportExpiredCall(update: update, completion)
reportExpiredCall(update: update, completion)
}
} else {
logger.debug("CallController will expire call 2")
self.reportExpiredCall(payload: payload, completion)
reportExpiredCall(payload: payload, completion)
}
//DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
if (!ChatModel.shared.chatInitialized) {
logger.debug("CallController: initializing chat")
do {
try initializeChat(start: true, refreshInvitations: false)
} catch let error {
logger.error("CallController: initializing chat error: \(error)")
if let call = ChatModel.shared.activeCall {
self.endCall(call: call, completed: completion)
}
return
}
}
logger.debug("CallController: initialized chat")
startChatForCall()
logger.debug("CallController: started chat")
self.shouldSuspendChat = true
}
// This function fulfils the requirement to always report a call when PushKit notification is received,
@ -277,8 +210,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)")
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
if invitation.callTs.timeIntervalSinceNow >= -180 {
let update = cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
@ -299,14 +232,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
return update
}
private func cxCallUpdate(_ contactId: String, _ displayName: String, _ media: CallMediaType) -> CXCallUpdate {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: contactId)
update.hasVideo = media == .video
update.localizedCallerName = displayName
return update
}
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting incoming call connected")
if CallController.useCallKit() {
@ -318,14 +243,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting outgoing call connected")
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
if CallController.useCallKit(), let uuid = call.callkitUUID {
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
}
}
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
logger.debug("CallController: reporting remote ended")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
} else if invitation.contact.id == activeCallInvitation?.contact.id {
activeCallInvitation = nil
@ -334,17 +259,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportCallRemoteEnded(call: Call) {
logger.debug("CallController: reporting remote ended")
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
if CallController.useCallKit(), let uuid = call.callkitUUID {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
}
}
func startCall(_ contact: Contact, _ media: CallMediaType) {
logger.debug("CallController.startCall")
let callUUID = callManager.newOutgoingCall(contact, media)
guard let uuid = UUID(uuidString: callUUID) else {
return
}
let uuid = callManager.newOutgoingCall(contact, media)
if CallController.useCallKit() {
let handle = CXHandle(type: .generic, value: contact.id)
let action = CXStartCallAction(call: uuid, handle: handle)
@ -356,17 +278,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
update.localizedCallerName = contact.displayName
self.provider.reportCall(with: uuid, updated: update)
}
} else if callManager.startOutgoingCall(callUUID: callUUID) {
logger.debug("CallController.startCall: call started")
} else {
logger.error("CallController.startCall: no active call")
} else if callManager.startOutgoingCall(callUUID: uuid) {
if callManager.startOutgoingCall(callUUID: uuid) {
logger.debug("CallController.startCall: call started")
} else {
logger.error("CallController.startCall: no active call")
}
}
}
func answerCall(invitation: RcvCallInvitation) {
logger.debug("CallController: answering a call")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
requestTransaction(with: CXAnswerCallAction(call: uuid))
if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
requestTransaction(with: CXAnswerCallAction(call: callUUID))
} else {
callManager.answerIncomingCall(invitation: invitation)
}
@ -375,13 +299,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
}
func endCall(callUUID: String) {
let uuid = UUID(uuidString: callUUID)
logger.debug("CallController: ending the call with UUID \(callUUID)")
func endCall(callUUID: UUID) {
logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
if CallController.useCallKit() {
if let uuid {
requestTransaction(with: CXEndCallAction(call: uuid))
}
requestTransaction(with: CXEndCallAction(call: callUUID))
} else {
callManager.endCall(callUUID: callUUID) { ok in
if ok {
@ -429,7 +350,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) {
controller.request(CXTransaction(action: action)) { error in
if let error = error {
logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)")
logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription, privacy: .public)")
} else {
logger.debug("CallController.requestTransaction requested transaction successfully")
onSuccess()

View file

@ -10,25 +10,25 @@ import Foundation
import SimpleXChat
class CallManager {
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String {
let uuid = UUID().uuidString.lowercased()
let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, initialCallType: media)
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
let uuid = UUID()
let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
call.speakerEnabled = media == .video
ChatModel.shared.activeCall = call
return uuid
}
func startOutgoingCall(callUUID: String) -> Bool {
func startOutgoingCall(callUUID: UUID) -> Bool {
let m = ChatModel.shared
if let call = m.activeCall, call.callUUID == callUUID {
if let call = m.activeCall, call.callkitUUID == callUUID {
m.showCallView = true
Task { await m.callCommand.processCommand(.capabilities(media: call.initialCallType)) }
m.callCommand = .capabilities(media: call.localMedia)
return true
}
return false
}
func answerIncomingCall(callUUID: String) -> Bool {
func answerIncomingCall(callUUID: UUID) -> Bool {
if let invitation = getCallInvitation(callUUID) {
answerIncomingCall(invitation: invitation)
return true
@ -42,9 +42,9 @@ class CallManager {
let call = Call(
direction: .incoming,
contact: invitation.contact,
callUUID: invitation.callUUID,
callkitUUID: invitation.callkitUUID,
callState: .invitationAccepted,
initialCallType: invitation.callType.media,
localMedia: invitation.callType.media,
sharedKey: invitation.sharedKey
)
call.speakerEnabled = invitation.callType.media == .video
@ -57,28 +57,26 @@ class CallManager {
m.activeCall = call
m.showCallView = true
Task {
await m.callCommand.processCommand(.start(
m.callCommand = .start(
media: invitation.callType.media,
aesKey: invitation.sharedKey,
iceServers: iceServers,
relay: useRelay
))
}
)
}
}
func enableMedia(source: CallMediaSource, enable: Bool, callUUID: String) -> Bool {
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
let m = ChatModel.shared
Task { await m.callCommand.processCommand(.media(source: source, enable: enable)) }
m.callCommand = .media(media: media, enable: enable)
return true
}
return false
}
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) {
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
endCall(call: call) { completed(true) }
} else if let invitation = getCallInvitation(callUUID) {
endCall(invitation: invitation) { completed(true) }
@ -92,19 +90,15 @@ class CallManager {
if case .ended = call.callState {
logger.debug("CallManager.endCall: call ended")
m.activeCall = nil
m.activeCallViewIsCollapsed = false
m.showCallView = false
completed()
} else {
logger.debug("CallManager.endCall: ending call...")
m.callCommand = .end
m.activeCall = nil
m.showCallView = false
completed()
Task {
await m.callCommand.processCommand(.end)
await MainActor.run {
m.activeCall = nil
m.activeCallViewIsCollapsed = false
m.showCallView = false
completed()
}
do {
try await apiEndCall(call.contact)
} catch {
@ -126,8 +120,8 @@ class CallManager {
}
}
private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) {
private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
return invitation
}
return nil

View file

@ -6,313 +6,70 @@
import SwiftUI
import WebRTC
import SimpleXChat
import AVKit
struct CallViewRemote: UIViewRepresentable {
var client: WebRTCClient
@ObservedObject var call: Call
@State var enablePip: (Bool) -> Void = {_ in }
@Binding var activeCallViewIsCollapsed: Bool
@Binding var contentMode: UIView.ContentMode
@Binding var pipShown: Bool
var activeCall: Binding<WebRTCClient.Call?>
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>) {
self.client = client
self.activeCall = activeCall
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
let remoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
remoteCameraRenderer.videoContentMode = contentMode
remoteCameraRenderer.tag = 0
let screenVideo = call.peerMediaSources.screenVideo
let remoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
remoteScreenRenderer.videoContentMode = contentMode
remoteScreenRenderer.tag = 1
remoteScreenRenderer.alpha = screenVideo ? 1 : 0
context.coordinator.cameraRenderer = remoteCameraRenderer
context.coordinator.screenRenderer = remoteScreenRenderer
client.addRemoteCameraRenderer(remoteCameraRenderer)
client.addRemoteScreenRenderer(remoteScreenRenderer)
if screenVideo {
addSubviewAndResize(remoteScreenRenderer, remoteCameraRenderer, into: view)
} else {
addSubviewAndResize(remoteCameraRenderer, remoteScreenRenderer, into: view)
}
if AVPictureInPictureController.isPictureInPictureSupported() {
makeViewWithRTCRenderer(remoteCameraRenderer, remoteScreenRenderer, view, context)
if let call = activeCall.wrappedValue {
let remoteRenderer = RTCMTLVideoView(frame: view.frame)
remoteRenderer.videoContentMode = .scaleAspectFill
client.addRemoteRenderer(call, remoteRenderer)
addSubviewAndResize(remoteRenderer, into: view)
}
return view
}
func makeViewWithRTCRenderer(_ remoteCameraRenderer: RTCMTLVideoView, _ remoteScreenRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
let pipRemoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
pipRemoteCameraRenderer.videoContentMode = .scaleAspectFill
let pipRemoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
pipRemoteScreenRenderer.videoContentMode = .scaleAspectFill
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
let pipContentSource = AVPictureInPictureController.ContentSource(
activeVideoCallSourceView: view,
contentViewController: pipVideoCallViewController
)
let pipController = AVPictureInPictureController(contentSource: pipContentSource)
pipController.canStartPictureInPictureAutomaticallyFromInline = true
pipController.delegate = context.coordinator
context.coordinator.pipController = pipController
context.coordinator.willShowHide = { show in
if show {
client.addRemoteCameraRenderer(pipRemoteCameraRenderer)
client.addRemoteScreenRenderer(pipRemoteScreenRenderer)
context.coordinator.relayout()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
activeCallViewIsCollapsed = true
}
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
activeCallViewIsCollapsed = false
}
}
}
context.coordinator.didShowHide = { show in
if show {
remoteCameraRenderer.isHidden = true
remoteScreenRenderer.isHidden = true
} else {
client.removeRemoteCameraRenderer(pipRemoteCameraRenderer)
client.removeRemoteScreenRenderer(pipRemoteScreenRenderer)
remoteCameraRenderer.isHidden = false
remoteScreenRenderer.isHidden = false
}
pipShown = show
}
context.coordinator.relayout = {
let camera = call.peerMediaSources.camera
let screenVideo = call.peerMediaSources.screenVideo
pipRemoteCameraRenderer.alpha = camera ? 1 : 0
pipRemoteScreenRenderer.alpha = screenVideo ? 1 : 0
if screenVideo {
addSubviewAndResize(pipRemoteScreenRenderer, pipRemoteCameraRenderer, pip: true, into: pipVideoCallViewController.view)
} else {
addSubviewAndResize(pipRemoteCameraRenderer, pipRemoteScreenRenderer, pip: true, into: pipVideoCallViewController.view)
}
(pipVideoCallViewController.view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
(pipVideoCallViewController.view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
}
DispatchQueue.main.async {
enablePip = { enable in
if enable != pipShown /* pipController.isPictureInPictureActive */ {
if enable {
pipController.startPictureInPicture()
} else {
pipController.stopPictureInPicture()
}
}
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(client)
}
func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView remote")
let camera = view.subviews.first(where: { $0.tag == 0 })!
let screen = view.subviews.first(where: { $0.tag == 1 })!
let screenVideo = call.peerMediaSources.screenVideo
if screenVideo && screen.alpha == 0 {
screen.alpha = 1
addSubviewAndResize(screen, camera, into: view)
} else if !screenVideo && screen.alpha == 1 {
screen.alpha = 0
addSubviewAndResize(camera, screen, into: view)
}
(view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
(view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
camera.alpha = call.peerMediaSources.camera ? 1 : 0
screen.alpha = call.peerMediaSources.screenVideo ? 1 : 0
DispatchQueue.main.async {
if activeCallViewIsCollapsed != pipShown {
enablePip(activeCallViewIsCollapsed)
} else if pipShown {
context.coordinator.relayout()
}
}
}
// MARK: - Coordinator
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
var cameraRenderer: RTCMTLVideoView?
var screenRenderer: RTCMTLVideoView?
var client: WebRTCClient
var pipController: AVPictureInPictureController? = nil
var willShowHide: (Bool) -> Void = { _ in }
var didShowHide: (Bool) -> Void = { _ in }
var relayout: () -> Void = {}
required init(_ client: WebRTCClient) {
self.client = client
}
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
willShowHide(true)
}
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
didShowHide(true)
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
logger.error("PiP failed to start: \(error.localizedDescription)")
}
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
willShowHide(false)
}
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
didShowHide(false)
}
deinit {
// TODO: deinit is not called when changing call type from audio to video and back,
// which causes many renderers can be created and added to stream (if enabling/disabling
// video while not yet connected in outgoing call)
pipController?.stopPictureInPicture()
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
pipController?.contentSource = nil
pipController?.delegate = nil
pipController = nil
if let cameraRenderer {
client.removeRemoteCameraRenderer(cameraRenderer)
}
if let screenRenderer {
client.removeRemoteScreenRenderer(screenRenderer)
}
}
}
class SampleBufferVideoCallView: UIView {
override class var layerClass: AnyClass {
get { return AVSampleBufferDisplayLayer.self }
}
var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer {
return layer as! AVSampleBufferDisplayLayer
}
}
}
struct CallViewLocal: UIViewRepresentable {
var client: WebRTCClient
var activeCall: Binding<WebRTCClient.Call?>
var localRendererAspectRatio: Binding<CGFloat?>
@State var pipStateChanged: (Bool) -> Void = {_ in }
@Binding var pipShown: Bool
init(client: WebRTCClient, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>) {
self.client = client
self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio
self._pipShown = pipShown
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
let localRenderer = RTCEAGLVideoView(frame: .zero)
context.coordinator.renderer = localRenderer
client.addLocalRenderer(localRenderer)
addSubviewAndResize(localRenderer, nil, into: view)
DispatchQueue.main.async {
pipStateChanged = { shown in
localRenderer.isHidden = shown
}
if let call = activeCall.wrappedValue {
let localRenderer = RTCEAGLVideoView(frame: .zero)
client.addLocalRenderer(call, localRenderer)
client.startCaptureLocalVideo(call)
addSubviewAndResize(localRenderer, into: view)
}
return view
}
func makeCoordinator() -> Coordinator {
Coordinator(client)
}
func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView local")
pipStateChanged(pipShown)
}
// MARK: - Coordinator
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
var renderer: RTCEAGLVideoView?
var client: WebRTCClient
required init(_ client: WebRTCClient) {
self.client = client
}
deinit {
if let renderer {
client.removeLocalRenderer(renderer)
}
}
}
}
private func addSubviewAndResize(_ fullscreen: UIView, _ end: UIView?, pip: Bool = false, into containerView: UIView) {
if containerView.subviews.firstIndex(of: fullscreen) == 0 && ((end == nil && containerView.subviews.count == 1) || (end != nil && containerView.subviews.firstIndex(of: end!) == 1)) {
// Nothing to do, elements on their places
return
}
containerView.removeConstraints(containerView.constraints)
containerView.subviews.forEach { sub in sub.removeFromSuperview()}
containerView.addSubview(fullscreen)
fullscreen.translatesAutoresizingMaskIntoConstraints = false
fullscreen.layer.cornerRadius = 0
fullscreen.layer.masksToBounds = false
if let end {
containerView.addSubview(end)
end.translatesAutoresizingMaskIntoConstraints = false
end.layer.cornerRadius = pip ? 8 : 10
end.layer.masksToBounds = true
}
let constraintFullscreenV = NSLayoutConstraint.constraints(
withVisualFormat: "V:|[fullscreen]|",
private func addSubviewAndResize(_ view: UIView, into containerView: UIView) {
containerView.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|",
options: [],
metrics: nil,
views: ["fullscreen": fullscreen]
)
let constraintFullscreenH = NSLayoutConstraint.constraints(
withVisualFormat: "H:|[fullscreen]|",
views: ["view": view]))
containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|",
options: [],
metrics: nil,
views: ["fullscreen": fullscreen]
)
containerView.addConstraints(constraintFullscreenV)
containerView.addConstraints(constraintFullscreenH)
if let end {
let constraintEndWidth = NSLayoutConstraint(
item: end, attribute: .width, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 : 0.3, constant: 0
)
let constraintEndHeight = NSLayoutConstraint(
item: end, attribute: .height, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 * 1.33 : 0.3 * 1.33, constant: 0
)
let constraintEndX = NSLayoutConstraint(
item: end, attribute: .leading, relatedBy: .equal, toItem: containerView, attribute: .trailing, multiplier: pip ? 0.5 : 0.7, constant: pip ? -8 : -17
)
let constraintEndY = NSLayoutConstraint(
item: end, attribute: .bottom, relatedBy: .equal, toItem: containerView, attribute: .bottom, multiplier: 1, constant: pip ? -8 : -92
)
containerView.addConstraint(constraintEndWidth)
containerView.addConstraint(constraintEndHeight)
containerView.addConstraint(constraintEndX)
containerView.addConstraint(constraintEndY)
}
views: ["view": view]))
containerView.layoutIfNeeded()
}

View file

@ -11,7 +11,6 @@ import SimpleXChat
struct IncomingCallView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var cc = CallController.shared
var body: some View {
@ -31,21 +30,21 @@ struct IncomingCallView: View {
VStack(alignment: .leading, spacing: 6) {
HStack {
if m.users.count > 1 {
ProfileImage(imageStr: invitation.user.image, size: 24, color: .white)
ProfileImage(imageStr: invitation.user.image, color: .white)
.frame(width: 24, height: 24)
}
Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green)
Text(invitation.callTypeText)
}
HStack {
ProfilePreview(profileOf: invitation.contact, color: .white)
.padding(.vertical, 6)
Spacer()
callButton("Reject", "phone.down.fill", .red) {
cc.endCall(invitation: invitation)
}
callButton("Ignore", "multiply", .primary) {
callButton("Ignore", "multiply", .accentColor) {
cc.activeCallInvitation = nil
}
@ -65,7 +64,7 @@ struct IncomingCallView: View {
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.modifier(ThemedBackground())
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.onAppear { dismissAllSheets() }
}
@ -78,7 +77,7 @@ struct IncomingCallView: View {
.frame(width: 24, height: 24)
Text(text)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
}
.frame(minWidth: 44)
})

View file

@ -8,7 +8,6 @@
import Foundation
import AVFoundation
import UIKit
class SoundPlayer {
static let shared = SoundPlayer()
@ -44,63 +43,3 @@ class SoundPlayer {
audioPlayer = nil
}
}
class CallSoundsPlayer {
static let shared = CallSoundsPlayer()
private var audioPlayer: AVAudioPlayer?
private var playerTask: Task = Task {}
private func start(_ soundName: String, delayMs: Double) {
audioPlayer?.stop()
playerTask.cancel()
logger.debug("start \(soundName)")
guard let path = Bundle.main.path(forResource: soundName, ofType: "mp3", inDirectory: "sounds") else {
logger.debug("start: file not found")
return
}
do {
let player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
if player.prepareToPlay() {
audioPlayer = player
}
} catch {
logger.debug("start: AVAudioPlayer error \(error.localizedDescription)")
}
playerTask = Task {
while let player = audioPlayer {
player.play()
do {
try await Task.sleep(nanoseconds: UInt64((player.duration * 1_000_000_000) + delayMs * 1_000_000))
} catch {
break
}
}
}
}
func startConnectingCallSound() {
start("connecting_call", delayMs: 0)
}
func startInCallSound() {
// Taken from https://github.com/TelegramOrg/Telegram-Android
// https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE
start("in_call", delayMs: 1000)
}
func stop() {
playerTask.cancel()
audioPlayer?.stop()
audioPlayer = nil
}
func vibrate(long: Bool) {
// iOS just don't want to vibrate more than once after a short period of time, and all 'styles' feel the same
if long {
AudioServicesPlayAlertSound(kSystemSoundID_Vibrate)
} else {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
}
}
}

View file

@ -18,38 +18,36 @@ class Call: ObservableObject, Equatable {
var direction: CallDirection
var contact: Contact
var callUUID: String?
var initialCallType: CallMediaType
@Published var localMediaSources: CallMediaSources
var callkitUUID: UUID?
var localMedia: CallMediaType
@Published var callState: CallState
@Published var localCapabilities: CallCapabilities?
@Published var peerMediaSources: CallMediaSources = CallMediaSources()
@Published var peerMedia: CallMediaType?
@Published var sharedKey: String?
@Published var audioEnabled = true
@Published var speakerEnabled = false
@Published var videoEnabled: Bool
@Published var connectionInfo: ConnectionInfo?
@Published var connectedAt: Date? = nil
init(
direction: CallDirection,
contact: Contact,
callUUID: String?,
callkitUUID: UUID?,
callState: CallState,
initialCallType: CallMediaType,
localMedia: CallMediaType,
sharedKey: String? = nil
) {
self.direction = direction
self.contact = contact
self.callUUID = callUUID
self.callkitUUID = callkitUUID
self.callState = callState
self.initialCallType = initialCallType
self.localMedia = localMedia
self.sharedKey = sharedKey
self.localMediaSources = CallMediaSources(
mic: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized,
camera: initialCallType == .video && AVCaptureDevice.authorizationStatus(for: .video) == .authorized)
self.videoEnabled = localMedia == .video
}
var encrypted: Bool { get { localEncrypted && sharedKey != nil } }
private var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
var encryptionStatus: LocalizedStringKey {
get {
switch callState {
@ -60,7 +58,7 @@ class Call: ObservableObject, Equatable {
}
}
}
var hasVideo: Bool { get { localMediaSources.hasVideo || peerMediaSources.hasVideo } }
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
}
enum CallDirection {
@ -105,28 +103,18 @@ struct WVAPIMessage: Equatable, Decodable, Encodable {
var command: WCallCommand?
}
struct CallMediaSources: Equatable, Codable {
var mic: Bool = false
var camera: Bool = false
var screenAudio: Bool = false
var screenVideo: Bool = false
var hasVideo: Bool { get { camera || screenVideo } }
}
enum WCallCommand: Equatable, Encodable, Decodable {
case capabilities(media: CallMediaType)
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 media(source: CallMediaSource, enable: Bool)
case media(media: CallMediaType, enable: Bool)
case end
enum CodingKeys: String, CodingKey {
case type
case media
case source
case aesKey
case offer
case answer
@ -177,9 +165,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
case let .ice(iceCandidates):
try container.encode("ice", forKey: .type)
try container.encode(iceCandidates, forKey: .iceCandidates)
case let .media(source, enable):
case let .media(media, enable):
try container.encode("media", forKey: .type)
try container.encode(source, forKey: .media)
try container.encode(media, forKey: .media)
try container.encode(enable, forKey: .enable)
case .end:
try container.encode("end", forKey: .type)
@ -215,9 +203,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates)
case "media":
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
self = .media(source: source, enable: enable)
self = .media(media: media, enable: enable)
case "end":
self = .end
default:
@ -234,7 +222,6 @@ enum WCallResponse: Equatable, Decodable {
case ice(iceCandidates: String)
case connection(state: ConnectionState)
case connected(connectionInfo: ConnectionInfo)
case peerMedia(source: CallMediaSource, enabled: Bool)
case ended
case ok
case error(message: String)
@ -249,8 +236,6 @@ enum WCallResponse: Equatable, Decodable {
case state
case connectionInfo
case message
case source
case enabled
}
var respType: String {
@ -262,7 +247,6 @@ enum WCallResponse: Equatable, Decodable {
case .ice: return "ice"
case .connection: return "connection"
case .connected: return "connected"
case .peerMedia: return "peerMedia"
case .ended: return "ended"
case .ok: return "ok"
case .error: return "error"
@ -297,10 +281,6 @@ enum WCallResponse: Equatable, Decodable {
case "connected":
let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo)
self = .connected(connectionInfo: connectionInfo)
case "peerMedia":
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
let enabled = try container.decode(Bool.self, forKey: CodingKeys.enabled)
self = .peerMedia(source: source, enabled: enabled)
case "ended":
self = .ended
case "ok":
@ -342,10 +322,6 @@ extension WCallResponse: Encodable {
case let .connected(connectionInfo):
try container.encode("connected", forKey: .type)
try container.encode(connectionInfo, forKey: .connectionInfo)
case let .peerMedia(source, enabled):
try container.encode("peerMedia", forKey: .type)
try container.encode(source, forKey: .source)
try container.encode(enabled, forKey: .enabled)
case .ended:
try container.encode("ended", forKey: .type)
case .ok:
@ -359,50 +335,6 @@ extension WCallResponse: Encodable {
}
}
actor WebRTCCommandProcessor {
private var client: WebRTCClient? = nil
private var commands: [WCallCommand] = []
private var running: Bool = false
func setClient(_ client: WebRTCClient?) async {
logger.debug("WebRTC: setClient, commands count \(self.commands.count)")
self.client = client
if client != nil {
await processAllCommands()
} else {
commands.removeAll()
}
}
func processCommand(_ c: WCallCommand) async {
// logger.debug("WebRTC: process command \(c.cmdType)")
commands.append(c)
if !running && client != nil {
await processAllCommands()
}
}
func processAllCommands() async {
logger.debug("WebRTC: process all commands, commands count \(self.commands.count), client == nil \(self.client == nil)")
if let client = client {
running = true
while let c = commands.first, shouldRunCommand(client, c) {
commands.remove(at: 0)
await client.sendCallCommand(command: c)
logger.debug("WebRTC: processed cmd \(c.cmdType)")
}
running = false
}
}
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
switch c {
case .capabilities, .start, .offer, .end: true
default: client.activeCall != nil
}
}
}
struct ConnectionState: Codable, Equatable {
var connectionState: String
var iceConnectionState: String
@ -426,12 +358,26 @@ struct ConnectionInfo: Codable, Equatable {
return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)"
}
}
var protocolText: String {
let unknown = NSLocalizedString("unknown", comment: "connection info")
let local = localCandidate?.protocol?.uppercased() ?? unknown
let localRelay = localCandidate?.relayProtocol?.uppercased() ?? unknown
let remote = remoteCandidate?.protocol?.uppercased() ?? unknown
let localText = localRelay == local || localCandidate?.relayProtocol == nil
? local
: "\(local) (\(localRelay))"
return local == remote
? localText
: "\(localText) / \(remote)"
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
struct RTCIceCandidate: Codable, Equatable {
var candidateType: RTCIceCandidateType?
var `protocol`: String?
var relayProtocol: String?
var sdpMid: String?
var sdpMLineIndex: Int?
var candidate: String
@ -453,18 +399,17 @@ struct RTCIceServer: Codable, Equatable {
}
// the servers are expected in this format:
// stuns:stun.simplex.im:443?transport=tcp
// turns:private2:Hxuq2QxUjnhj96Zq2r4HjqHRj@turn.simplex.im:443?transport=tcp
// stun:stun.simplex.im:443?transport=tcp
// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp
func parseRTCIceServer(_ str: String) -> RTCIceServer? {
var s = replaceScheme(str, "stun:")
s = replaceScheme(s, "stuns:")
s = replaceScheme(s, "turn:")
s = replaceScheme(s, "turns:")
if let u: URL = URL(string: s),
let scheme = u.scheme,
let host = u.host,
let port = u.port,
u.path == "" && (scheme == "stun" || scheme == "stuns" || scheme == "turn" || scheme == "turns") {
u.path == "" && (scheme == "stun" || scheme == "turn" || scheme == "turns") {
let query = u.query == nil || u.query == "" ? "" : "?" + (u.query ?? "")
return RTCIceServer(
urls: ["\(scheme):\(host):\(port)\(query)"],

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,10 @@
import SwiftUI
import SimpleXChat
let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2)
struct ChatInfoToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var imageSize: CGFloat = 32
@ -24,30 +25,30 @@ struct ChatInfoToolbar: View {
}
ChatInfoImage(
chat: chat,
size: imageSize,
color: Color(uiColor: .tertiaryLabel)
color: colorScheme == .dark
? chatImageColorDark
: chatImageColorLight
)
.frame(width: imageSize, height: imageSize)
.padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.lineLimit(1)
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
VStack(spacing: 0) {
v
Text(cInfo.fullName).font(.subheadline)
.lineLimit(1)
.padding(.top, -2)
}
VStack {
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.lineLimit(1)
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
Text(cInfo.fullName).font(.subheadline)
.lineLimit(1)
}
}
}
.foregroundColor(theme.colors.onBackground)
.foregroundColor(.primary)
.frame(width: 220)
}
private var contactVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + textSpace)
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
@ -56,6 +57,5 @@ struct ChatInfoToolbar: View {
struct ChatInfoToolbar_Previews: PreviewProvider {
static var previews: some View {
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
.environmentObject(CurrentColors.toAppTheme())
}
}

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,6 @@ import SwiftUI
class AnimatedImageView: UIView {
var image: UIImage? = nil
var imageView: UIImageView? = nil
var cMode: UIView.ContentMode = .scaleAspectFit
override init(frame: CGRect) {
super.init(frame: frame)
@ -19,12 +18,11 @@ class AnimatedImageView: UIView {
fatalError("Not implemented")
}
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
convenience init(image: UIImage) {
self.init()
self.image = image
self.cMode = contentMode
imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode
imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!)
}
@ -37,7 +35,7 @@ class AnimatedImageView: UIView {
if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode
imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!)
subview.removeFromSuperview()
}
@ -49,15 +47,13 @@ class AnimatedImageView: UIView {
struct SwiftyGif: UIViewRepresentable {
private let image: UIImage
private let contentMode: UIView.ContentMode
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
init(image: UIImage) {
self.image = image
self.contentMode = contentMode
}
func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image, contentMode: contentMode)
AnimatedImageView(image: image)
}
func updateUIView(_ imageView: AnimatedImageView, context: Context) {

View file

@ -11,7 +11,6 @@ import SimpleXChat
struct CICallItemView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
var status: CICallStatus
@ -23,7 +22,7 @@ struct CICallItemView: View {
switch status {
case .pending:
if sent {
Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary)
Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
} else {
acceptCallButton()
}
@ -36,7 +35,9 @@ struct CICallItemView: View {
case .error: missedCallIcon(sent).foregroundColor(.orange)
}
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
chatItem.timestampText
.font(.caption)
.foregroundColor(.secondary)
.padding(.bottom, 8)
.padding(.horizontal, 12)
}
@ -50,28 +51,28 @@ struct CICallItemView: View {
Image(systemName: "phone.connection").foregroundColor(.green)
}
private func endedCallIcon(_ sent: Bool) -> some View {
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
HStack {
Image(systemName: "phone.down")
Text(durationText(duration)).foregroundColor(theme.colors.secondary)
Text(durationText(duration)).foregroundColor(.secondary)
}
}
@ViewBuilder private func acceptCallButton() -> some View {
if case let .direct(contact) = chat.chatInfo {
Label("Answer call", systemImage: "phone.arrow.down.left")
.foregroundColor(theme.colors.primary)
.simultaneousGesture(TapGesture().onEnded {
if let invitation = m.callInvitations[contact.id] {
CallController.shared.answerCall(invitation: invitation)
logger.debug("acceptCallButton call answered")
} else {
AlertManager.shared.showAlertMsg(title: "Call already ended!")
}
})
Button {
if let invitation = m.callInvitations[contact.id] {
CallController.shared.answerCall(invitation: invitation)
logger.debug("acceptCallButton call answered")
} else {
AlertManager.shared.showAlertMsg(title: "Call already ended!")
}
} label: {
Label("Answer call", systemImage: "phone.arrow.down.left")
}
} else {
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
}
}
}

View file

@ -11,17 +11,14 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.revealed) var revealed: Bool
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
@Binding var revealed: Bool
var feature: Feature
var icon: String? = nil
var iconColor: Color
var body: some View {
if !revealed, let fs = mergedFeatures() {
if !revealed, let fs = mergedFeautures() {
HStack {
ForEach(fs, content: featureIconView)
}
@ -50,12 +47,12 @@ struct CIChatFeatureView: View {
}
}
private func mergedFeatures() -> [FeatureInfo]? {
private func mergedFeautures() -> [FeatureInfo]? {
var fs: [FeatureInfo] = []
var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) {
while i < m.reversedChatItems.count,
let f = featureInfo(m.reversedChatItems[i]) {
if !icons.contains(f.icon) {
fs.insert(f, at: 0)
icons.insert(f.icon)
@ -68,10 +65,10 @@ struct CIChatFeatureView: View {
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
switch ci.content {
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
default: nil
}
}
@ -83,7 +80,7 @@ struct CIChatFeatureView: View {
if let param = f.param {
HStack {
i
chatEventText(Text(param), theme.colors.secondary).lineLimit(1)
chatEventText(Text(param)).lineLimit(1)
}
} else {
i
@ -95,7 +92,7 @@ struct CIChatFeatureView: View {
Image(systemName: icon ?? feature.iconFilled)
.foregroundColor(iconColor)
.scaleEffect(feature.iconScale)
chatEventText(chatItem, theme.colors.secondary)
chatEventText(chatItem)
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
@ -106,9 +103,6 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(
chat: Chat.sampleData,
chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
).environment(\.revealed, true)
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
}
}

View file

@ -17,7 +17,6 @@ struct CIEventView: View {
.padding(.horizontal, 6)
.padding(.vertical, 4)
.textSelection(.disabled)
.lineLimit(4)
}
}

View file

@ -11,7 +11,6 @@ import SimpleXChat
struct CIFeaturePreferenceView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var feature: ChatFeature
var allowed: FeatureAllowed
@ -20,15 +19,15 @@ struct CIFeaturePreferenceView: View {
var body: some View {
HStack(alignment: .center, spacing: 4) {
Image(systemName: feature.icon)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
.scaleEffect(feature.iconScale)
if let ct = chat.chatInfo.contact,
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil
featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept")
.simultaneousGesture(TapGesture().onEnded {
.onTapGesture {
allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil)
})
}
} else {
featurePreferenceView()
}
@ -41,17 +40,17 @@ struct CIFeaturePreferenceView: View {
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
.fontWeight(.light)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
if let acceptText {
r = r
+ Text(acceptText)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
+ Text(verbatim: " ")
.foregroundColor(.accentColor)
+ Text(" ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
return r.font(.caption)
}
}

View file

@ -11,19 +11,15 @@ import SimpleXChat
struct CIFileView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme
let file: CIFile?
let edited: Bool
var smallViewSize: CGFloat?
var body: some View {
if smallViewSize != nil {
fileIndicator()
.simultaneousGesture(TapGesture().onEnded(fileAction))
} else {
let metaReserve = edited
? " "
: " "
let metaReserve = edited
? " "
: " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
@ -34,12 +30,12 @@ struct CIFileView: View {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground)
.foregroundColor(.primary)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
}
} else {
Text(metaReserve)
@ -49,45 +45,48 @@ struct CIFileView: View {
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
.simultaneousGesture(TapGesture().onEnded(fileAction))
.disabled(!itemInteractive)
}
.disabled(!itemInteractive)
}
@inline(__always)
private var itemInteractive: Bool {
if let file = file {
switch (file.fileStatus) {
case .sndStored: return file.fileProtocol == .local
case .sndStored: return false
case .sndTransfer: return false
case .sndComplete: return true
case .sndComplete: return false
case .sndCancelled: return false
case .sndError: return true
case .sndWarning: return true
case .sndError: return false
case .rcvInvitation: return true
case .rcvAccepted: return true
case .rcvTransfer: return false
case .rcvAborted: return true
case .rcvComplete: return true
case .rcvCancelled: return false
case .rcvError: return true
case .rcvWarning: return true
case .rcvError: return false
case .invalid: return false
}
}
return false
}
private func fileSizeValid() -> Bool {
if let file = file {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
}
return false
}
private func fileAction() {
logger.debug("CIFileView fileAction")
if let file = file {
switch (file.fileStatus) {
case .rcvInvitation, .rcvAborted:
if fileSizeValid(file) {
case .rcvInvitation:
if fileSizeValid() {
Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task")
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted)
}
}
} else {
@ -109,35 +108,12 @@ struct CIFileView: View {
title: "Waiting for file",
message: "File will be received when your contact is online, please wait or check later!"
)
case .local: ()
}
case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete")
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
}
case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError")
showFileErrorAlert(rcvFileError)
case let .rcvWarning(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvWarning")
showFileErrorAlert(rcvFileError, temporary: true)
case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
}
case .sndComplete:
logger.debug("CIFileView fileAction - in .sndComplete")
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
}
case let .sndError(sndFileError):
logger.debug("CIFileView fileAction - in .sndError")
showFileErrorAlert(sndFileError)
case let .sndWarning(sndFileError):
logger.debug("CIFileView fileAction - in .sndWarning")
showFileErrorAlert(sndFileError, temporary: true)
default: break
}
}
@ -150,21 +126,18 @@ struct CIFileView: View {
switch file.fileProtocol {
case .xftp: progressView()
case .smp: fileIcon("doc.fill")
case .local: fileIcon("doc.fill")
}
case let .sndTransfer(sndProgress, sndTotal):
switch file.fileProtocol {
case .xftp: progressCircle(sndProgress, sndTotal)
case .smp: progressView()
case .local: EmptyView()
}
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
case .rcvInvitation:
if fileSizeValid(file) {
fileIcon("arrow.down.doc.fill", color: theme.colors.primary)
if fileSizeValid() {
fileIcon("arrow.down.doc.fill", color: .accentColor)
} else {
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
}
@ -175,12 +148,9 @@ struct CIFileView: View {
} else {
progressView()
}
case .rcvAborted:
fileIcon("doc.fill", color: theme.colors.primary, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
case .rcvComplete: fileIcon("doc.fill")
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
}
} else {
@ -189,22 +159,21 @@ struct CIFileView: View {
}
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
let size = smallViewSize ?? 30
return ZStack(alignment: .center) {
ZStack(alignment: .center) {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.frame(width: 30, height: 30)
.foregroundColor(color)
if let innerIcon = innerIcon,
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
let innerIconSize = innerIconSize {
Image(systemName: innerIcon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white)
.padding(.top, size / 2.5)
.padding(.top, 12)
}
}
}
@ -225,13 +194,6 @@ struct CIFileView: View {
}
}
func fileSizeValid(_ file: CIFile?) -> Bool {
if let file = file {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
}
return false
}
func saveCryptoFile(_ fileSource: CryptoFile) {
if let cfArgs = fileSource.cryptoArgs {
let url = getAppFilePath(fileSource.filePath)
@ -256,26 +218,6 @@ func saveCryptoFile(_ fileSource: CryptoFile) {
}
}
func showFileErrorAlert(_ err: FileError, temporary: Bool = false) {
let title: String = if temporary {
NSLocalizedString("Temporary file error", comment: "file error alert title")
} else {
NSLocalizedString("File error", comment: "file error alert title")
}
if let btn = err.moreInfoButton {
showAlert(title, message: err.errorInfo) {
[
okAlertAction,
UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in
UIApplication.shared.open(contentModerationPostLink)
})
]
}
} else {
showAlert(title, message: err.errorInfo)
}
}
struct CIFileView_Previews: PreviewProvider {
static var previews: some View {
let sentFile: ChatItem = ChatItem(
@ -293,18 +235,17 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
}
}

View file

@ -11,9 +11,7 @@ import SimpleXChat
struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@ObservedObject var chat: Chat
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var groupInvitation: CIGroupInvitation
var memberRole: GroupMemberRole
@ -22,8 +20,6 @@ struct CIGroupInvitationView: View {
@State private var inProgress = false
@State private var progressByTimeout = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View {
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
let v = ZStack(alignment: .bottomTrailing) {
@ -41,22 +37,16 @@ struct CIGroupInvitationView: View {
VStack(alignment: .leading, spacing: 2) {
groupInvitationText()
.overlay(DetermineWidth())
(
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
.font(.callout)
+ Text(verbatim: " ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
.overlay(DetermineWidth())
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
.foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
.font(.callout)
.padding(.trailing, 60)
.overlay(DetermineWidth())
}
} else {
(
groupInvitationText()
+ Text(verbatim: " ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
.overlay(DetermineWidth())
groupInvitationText()
.padding(.trailing, 60)
.overlay(DetermineWidth())
}
}
.padding(.bottom, 2)
@ -66,11 +56,14 @@ struct CIGroupInvitationView: View {
}
}
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
chatItem.timestampText
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
@ -84,12 +77,12 @@ struct CIGroupInvitationView: View {
}
if action {
v.simultaneousGesture(TapGesture().onEnded {
v.onTapGesture {
inProgress = true
joinGroup(groupInvitation.groupId) {
await MainActor.run { inProgress = false }
}
})
}
.disabled(inProgress)
} else {
v
@ -99,7 +92,7 @@ struct CIGroupInvitationView: View {
private func groupInfoView(_ action: Bool) -> some View {
var color: Color
if action && !inProgress {
color = chatIncognito ? .indigo : theme.colors.primary
color = chatIncognito ? .indigo : .accentColor
} else {
color = Color(uiColor: .tertiaryLabel)
}
@ -107,9 +100,9 @@ struct CIGroupInvitationView: View {
ProfileImage(
imageStr: groupInvitation.groupProfile.image,
iconName: "person.2.circle.fill",
size: 44,
color: color
)
.frame(width: 44, height: 44)
.padding(.trailing, 4)
VStack(alignment: .leading) {
let p = groupInvitation.groupProfile
@ -122,7 +115,7 @@ struct CIGroupInvitationView: View {
}
}
private func groupInvitationText() -> Text {
private func groupInvitationText() -> some View {
Text(groupInvitationStr())
.font(.callout)
}
@ -144,8 +137,8 @@ struct CIGroupInvitationView: View {
struct CIGroupInvitationView_Previews: PreviewProvider {
static var previews: some View {
Group {
CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin)
CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin)
CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin)
CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin)
}
}
}

View file

@ -11,47 +11,34 @@ import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
let chatItem: ChatItem
var scrollToItemId: ((ChatItem.ID) -> Void)? = nil
var preview: UIImage?
let image: String
let maxWidth: CGFloat
var imgWidth: CGFloat?
var smallView: Bool = false
@Binding var showFullScreenImage: Bool
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
@Binding var imgWidth: CGFloat?
@State var scrollProxy: ScrollViewProxy?
@State var metaColor: Color
@State private var showFullScreenImage = false
var body: some View {
let file = chatItem.file
VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, image: uiImage, showView: $showFullScreenImage)
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
}
.if(!blurred) { v in
v.simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true })
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false
}
} else if let preview {
Group {
if smallView {
smallViewImageView(preview)
} else {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
}
}
.simultaneousGesture(TapGesture().onEnded {
.onTapGesture { showFullScreenImage = true }
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
imageView(uiImage)
.onTapGesture {
if let file = file {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
case .rcvInvitation:
Task {
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile)
}
}
case .rcvAccepted:
@ -66,63 +53,33 @@ struct CIImageView: View {
title: "Waiting for image",
message: "Image will be received when your contact is online, please wait or check later!"
)
case .local: ()
}
case .rcvTransfer: () // ?
case .rcvComplete: () // ?
case .rcvCancelled: () // TODO
case let .rcvError(rcvFileError):
showFileErrorAlert(rcvFileError)
case let .rcvWarning(rcvFileError):
showFileErrorAlert(rcvFileError, temporary: true)
case let .sndError(sndFileError):
showFileErrorAlert(sndFileError)
case let .sndWarning(sndFileError):
showFileErrorAlert(sndFileError, temporary: true)
default: ()
}
}
})
}
}
}
.onDisappear {
showFullScreenImage = false
}
}
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : img.imageData == nil ? .infinity : maxWidth
DispatchQueue.main.async { imgWidth = w }
return ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(width: w)
.frame(maxWidth: w)
} else {
SwiftyGif(image: img)
.frame(width: w, height: w * img.size.height / img.size.width)
.scaledToFit()
}
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
loadingIndicator()
}
}
}
private func smallViewImageView(_ img: UIImage) -> some View {
ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
} else {
SwiftyGif(image: img, contentMode: .scaleAspectFill)
.frame(width: maxWidth, height: maxWidth)
}
if chatItem.file?.showStatusIconInSmallView == true {
loadingIndicator()
}
loadingIndicator()
}
}
@ -133,22 +90,18 @@ struct CIImageView: View {
switch file.fileProtocol {
case .xftp: progressView()
case .smp: EmptyView()
case .local: EmptyView()
}
case .sndTransfer: progressView()
case .sndComplete: fileIcon("checkmark", 10, 13)
case .sndCancelled: fileIcon("xmark", 10, 13)
case .sndError: fileIcon("xmark", 10, 13)
case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case .rcvTransfer: progressView()
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvComplete: EmptyView()
case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13)
case .rcvWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView()
}
}
}
@ -156,9 +109,9 @@ struct CIImageView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon)
.resizable()
.invertedForegroundStyle()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.foregroundColor(metaColor)
.padding(padding)
}
@ -169,12 +122,4 @@ struct CIImageView: View {
.tint(.white)
.padding(8)
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
}

View file

@ -7,11 +7,9 @@
//
import SwiftUI
import SimpleXChat
struct CIInvalidJSONView: View {
@EnvironmentObject var theme: AppTheme
var json: Data?
var json: String
@State private var showJSON = false
var body: some View {
@ -23,17 +21,18 @@ struct CIInvalidJSONView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
.simultaneousGesture(TapGesture().onEnded { showJSON = true })
.appSheet(isPresented: $showJSON) {
invalidJSONView(dataToString(json))
.onTapGesture { showJSON = true }
.sheet(isPresented: $showJSON) {
invalidJSONView(json)
}
}
}
func invalidJSONView(_ json: String) -> some View {
VStack(alignment: .leading, spacing: 16) {
Button { // this is used in the sheet, Button works here
Button {
showShareSheet(items: [json])
} label: {
Image(systemName: "square.and.arrow.up")
@ -45,11 +44,10 @@ func invalidJSONView(_ json: String) -> some View {
}
.frame(maxHeight: .infinity)
.padding()
.modifier(ThemedBackground())
}
struct CIInvalidJSONView_Previews: PreviewProvider {
static var previews: some View {
CIInvalidJSONView(json: "{}".data(using: .utf8)!)
CIInvalidJSONView(json: "{}")
}
}

View file

@ -10,59 +10,36 @@ import SwiftUI
import SimpleXChat
struct CILinkView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme
let linkPreview: LinkPreview
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View {
VStack(alignment: .center, spacing: 6) {
if let uiImage = imageFromBase64(linkPreview.image) {
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred))
.if(!blurred) { v in
v.simultaneousGesture(TapGesture().onEnded {
openBrowserAlert(uri: linkPreview.uri)
})
}
}
VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title)
.lineLimit(3)
// if linkPreview.description != "" {
// Text(linkPreview.description)
// .font(.subheadline)
// .lineLimit(12)
// }
Text(linkPreview.uri.absoluteString)
.font(.caption)
.lineLimit(1)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.simultaneousGesture(TapGesture().onEnded {
openBrowserAlert(uri: linkPreview.uri)
})
}
}
}
func openBrowserAlert(uri: URL) {
showAlert(
NSLocalizedString("Open link?", comment: "alert title"),
message: uri.absoluteString,
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .default,
handler: { _ in }
),
UIAlertAction(
title: NSLocalizedString("Open", comment: "alert action"),
style: .default,
handler: { _ in UIApplication.shared.open(uri) }
)
]}
)
}
struct LargeLinkPreview_Previews: PreviewProvider {
static var previews: some View {
let preview = LinkPreview(

View file

@ -11,7 +11,6 @@ import SimpleXChat
struct CIMemberCreatedContactView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var body: some View {
@ -20,11 +19,12 @@ struct CIMemberCreatedContactView: View {
case let .groupRcv(groupMember):
if let contactId = groupMember.memberContactId {
memberCreatedContactView(openText: "Open")
.simultaneousGesture(TapGesture().onEnded {
ItemsModel.shared.loadOpenChat("@\(contactId)") {
dismissAllSheets(animated: true)
.onTapGesture {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
m.chatId = "@\(contactId)"
}
})
}
} else {
memberCreatedContactView()
}
@ -43,12 +43,12 @@ struct CIMemberCreatedContactView: View {
r = r
+ Text(openText)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
+ Text(verbatim: " ")
.foregroundColor(.accentColor)
+ Text(" ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
return r.font(.caption)
}
@ -56,11 +56,11 @@ struct CIMemberCreatedContactView: View {
if let member = chatItem.memberDisplayName {
return Text(member + " " + chatItem.content.text + " ")
.fontWeight(.light)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
} else {
return Text(chatItem.content.text + " ")
.fontWeight(.light)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
}
}
}

View file

@ -11,173 +11,97 @@ import SimpleXChat
struct CIMetaView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem
var metaColor: Color
var paleMetaColor = Color(uiColor: .tertiaryLabel)
var showStatus = true
var showEdited = true
var invertedMaterial = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var metaColor = Color.secondary
var paleMetaColor = Color(UIColor.tertiaryLabel)
var body: some View {
if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
} else {
ZStack {
ciMetaText(
chatItem.meta,
chatTTL: chat.chatInfo.timedMessagesTTL,
encrypted: chatItem.encryptedFile,
color: metaColor,
paleColor: paleMetaColor,
colorMode: invertedMaterial
? .invertedMaterial
: .normal,
showStatus: showStatus,
showEdited: showEdited,
showViaProxy: showSentViaProxy,
showTimesamp: showTimestamp
).invertedForegroundStyle(enabled: invertedMaterial)
if invertedMaterial {
ciMetaText(
chatItem.meta,
chatTTL: chat.chatInfo.timedMessagesTTL,
encrypted: chatItem.encryptedFile,
colorMode: .normal,
onlyOverrides: true,
showStatus: showStatus,
showEdited: showEdited,
showViaProxy: showSentViaProxy,
showTimesamp: showTimestamp
)
let meta = chatItem.meta
let ttl = chat.chatInfo.timedMessagesTTL
let encrypted = chatItem.encryptedFile
switch meta.itemStatus {
case let .sndSent(sndProgress):
switch sndProgress {
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent)
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent)
}
case let .sndRcvd(_, sndProgress):
switch sndProgress {
case .complete:
ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2)
}
case .partial:
ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2)
}
}
default:
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor)
}
}
}
}
enum MetaColorMode {
// Renders provided colours
case normal
// Fully transparent meta - used for reserving space
case transparent
// Renders white on dark backgrounds and black on light ones
case invertedMaterial
func resolve(_ c: Color?) -> Color? {
switch self {
case .normal: c
case .transparent: .clear
case .invertedMaterial: nil
}
}
func statusSpacer(_ sent: Bool) -> Text {
switch self {
case .normal, .transparent:
Text(
sent
? Image("checkmark.wide")
: Image(systemName: "circlebadge.fill")
).foregroundColor(.clear)
case .invertedMaterial: textSpace.kerning(13)
}
}
enum SentCheckmark {
case sent
case rcvd1
case rcvd2
}
func ciMetaText(
_ meta: CIMeta,
chatTTL: Int?,
encrypted: Bool?,
color: Color = .clear, // we use this function to reserve space without rendering meta
paleColor: Color? = nil,
primaryColor: Color = .accentColor,
colorMode: MetaColorMode = .normal,
onlyOverrides: Bool = false, // only render colors that differ from base
showStatus: Bool = true,
showEdited: Bool = true,
showViaProxy: Bool,
showTimesamp: Bool
) -> Text {
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text {
var r = Text("")
var space: Text? = nil
let appendSpace = {
if let sp = space {
r = r + sp
space = nil
}
}
let resolved = colorMode.resolve(color)
if showEdited, meta.itemEdited {
r = r + statusIconText("pencil", resolved)
if meta.itemEdited {
r = r + statusIconText("pencil", color)
}
if meta.disappearing {
r = r + statusIconText("timer", resolved).font(.caption2)
r = r + statusIconText("timer", color).font(.caption2)
let ttl = meta.itemTimed?.ttl
if ttl != chatTTL {
r = r + colored(Text(shortTimeText(ttl)), resolved)
r = r + Text(shortTimeText(ttl)).foregroundColor(color)
}
space = textSpace
r = r + Text(" ")
}
if showViaProxy, meta.sentViaProxy == true {
appendSpace()
r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2)
}
if showStatus {
appendSpace()
if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) {
let metaColor = if onlyOverrides && statusColor == color {
Color.clear
} else {
colorMode.resolve(statusColor)
}
r = r + colored(Text(image), metaColor)
} else if !meta.disappearing {
r = r + colorMode.statusSpacer(meta.itemStatus.sent)
if let (icon, statusColor) = meta.statusIcon(color) {
let t = Text(Image(systemName: icon)).font(.caption2)
let gap = Text(" ").kerning(-1.25)
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
switch sent {
case nil: r = r + t1
case .sent: r = r + t1 + gap
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
case .rcvd2: r = r + gap + t1
}
space = textSpace
r = r + Text(" ")
} else if !meta.disappearing {
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
}
if let enc = encrypted {
appendSpace()
r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
space = textSpace
}
if showTimesamp {
appendSpace()
r = r + colored(meta.timestampText, resolved)
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
}
r = r + meta.timestampText.foregroundColor(color)
return r.font(.caption)
}
@inline(__always)
private func statusIconText(_ icon: String, _ color: Color?) -> Text {
colored(Text(Image(systemName: icon)), color)
}
// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
@inline(__always)
private func colored(_ t: Text, _ color: Color?) -> Text {
if let color {
t.foregroundColor(color)
} else {
t
}
private func statusIconText(_ icon: String, _ color: Color) -> Text {
Text(Image(systemName: icon)).foregroundColor(color)
}
struct CIMetaView_Previews: PreviewProvider {
static let metaColor = Color.secondary
static var previews: some View {
Group {
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor)
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
}
.previewLayout(.fixed(width: 360, height: 100))
}

View file

@ -13,22 +13,18 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
var msgDecryptError: MsgDecryptError
var msgCount: UInt32
var chatItem: ChatItem
@State private var alert: CIRcvDecryptionErrorAlert?
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
enum CIRcvDecryptionErrorAlert: Identifiable {
case syncAllowedAlert(_ syncConnection: () -> Void)
case syncNotSupportedContactAlert
case syncNotSupportedMemberAlert
case decryptionErrorAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String {
switch self {
@ -48,7 +44,7 @@ struct CIRcvDecryptionError: View {
if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir {
do {
let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId)
let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
if let s = stats {
m.updateGroupMemberConnectionStats(groupInfo, member, s)
}
@ -63,46 +59,43 @@ struct CIRcvDecryptionError: View {
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
case let .error(title, error): return mkAlert(title: title, message: error)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
}
}
private func viewBody() -> some View {
Group {
if case let .direct(contact) = chat.chatInfo,
let contactStats = contact.activeConn?.connectionStats {
if contactStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncContactConnection(contact) }
}
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
}
} else {
basicDecryptionErrorItem()
@ViewBuilder private func viewBody() -> some View {
if case let .direct(contact) = chat.chatInfo,
let contactStats = contact.activeConn?.connectionStats {
if contactStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncContactConnection(contact) }
}
} else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
}
} else {
basicDecryptionErrorItem()
}
} else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
}
} else {
basicDecryptionErrorItem()
}
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
}
private func basicDecryptionErrorItem() -> some View {
@ -119,22 +112,24 @@ struct CIRcvDecryptionError: View {
}
(
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.foregroundColor(syncSupported ? .accentColor : .secondary)
.font(.callout)
+ textSpace
+ Text(" ")
+ Text("Fix connection")
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.foregroundColor(syncSupported ? .accentColor : .secondary)
.font(.callout)
+ Text(verbatim: " ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
)
}
.padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12)
}
.simultaneousGesture(TapGesture().onEnded(onClick))
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
}
@ -144,15 +139,17 @@ struct CIRcvDecryptionError: View {
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
+ Text(verbatim: " ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
}
.padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12)
}
.simultaneousGesture(TapGesture().onEnded(onClick))
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
}
@ -161,13 +158,13 @@ struct CIRcvDecryptionError: View {
let why = Text(decryptErrorReason)
switch msgDecryptError {
case .ratchetHeader:
message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
case .tooManySkipped:
message = Text("\(msgCount) messages skipped.") + textNewLine + why
message = Text("\(msgCount) messages skipped.") + Text("\n") + why
case .ratchetEarlier:
message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
case .other:
message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
case .ratchetSync:
message = Text("Encryption re-negotiation failed.")
}

View file

@ -13,167 +13,84 @@ import Combine
struct CIVideoView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
private let chatItem: ChatItem
private let preview: UIImage?
private let image: String
@State private var duration: Int
@State private var progress: Int = 0
@State private var videoPlaying: Bool = false
private let maxWidth: CGFloat
private var videoWidth: CGFloat?
private let smallView: Bool
@Binding private var videoWidth: CGFloat?
@State private var scrollProxy: ScrollViewProxy?
@State private var preview: UIImage? = nil
@State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer?
@State private var url: URL?
@State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false
@Binding private var showFullScreenPlayer: Bool
@State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
self.chatItem = chatItem
self.preview = preview
self.image = image
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
self.videoWidth = videoWidth
self.smallView = smallView
self._showFullScreenPlayer = showFullscreenPlayer
self._videoWidth = videoWidth
self.scrollProxy = scrollProxy
if let url = getLoadedVideo(chatItem.file) {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false))
self._fullPlayer = State(initialValue: AVPlayer(url: url))
self._url = State(initialValue: url)
}
if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
self._preview = State(initialValue: uiImage)
}
}
var body: some View {
let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) {
ZStack {
ZStack(alignment: .topLeading) {
if let file, let preview {
if let urlDecrypted {
if smallView {
smallVideoView(urlDecrypted, file, preview)
} else if let player {
videoView(player, urlDecrypted, file, preview, duration)
}
} else if file.loaded {
if smallView {
smallVideoViewEncrypted(file, preview)
} else {
videoViewEncrypted(file, preview, duration)
}
} else {
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
.simultaneousGesture(TapGesture().onEnded {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
case .rcvAccepted:
switch file.fileProtocol {
case .xftp:
AlertManager.shared.showAlertMsg(
title: "Waiting for video",
message: "Video will be received when your contact completes uploading it."
)
case .smp:
AlertManager.shared.showAlertMsg(
title: "Waiting for video",
message: "Video will be received when your contact is online, please wait or check later!"
)
case .local: ()
}
case .rcvTransfer: () // ?
case .rcvComplete: () // ?
case .rcvCancelled: () // TODO
default: ()
if let file = file, let preview = preview, let player = player, let url = url {
videoView(player, url, file, preview, duration)
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
imageView(uiImage)
.onTapGesture {
if let file = file {
switch file.fileStatus {
case .rcvInvitation:
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
case .rcvAccepted:
switch file.fileProtocol {
case .xftp:
AlertManager.shared.showAlertMsg(
title: "Waiting for video",
message: "Video will be received when your contact completes uploading it."
)
case .smp:
AlertManager.shared.showAlertMsg(
title: "Waiting for video",
message: "Video will be received when your contact is online, please wait or check later!"
)
}
})
case .rcvTransfer: () // ?
case .rcvComplete: () // ?
case .rcvCancelled: () // TODO
default: ()
}
}
}
}
if !smallView {
durationProgress()
}
durationProgress()
}
if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView || !file.showStatusIconInSmallView {
if let file = file, case .rcvInvitation = file.fileStatus {
Button {
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
.simultaneousGesture(TapGesture().onEnded {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
})
}
}
}
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onAppear {
setupPlayer(chatItem.file)
}
.onChange(of: chatItem.file) { file in
// ChatItem can be changed in small view on chat list screen
setupPlayer(file)
}
.onDisappear {
showFullScreenPlayer = false
}
}
private func setupPlayer(_ file: CIFile?) {
let newUrl = getLoadedVideo(file)
if newUrl == url {
return
}
url = nil
urlDecrypted = nil
player = nil
fullPlayer = nil
if let newUrl {
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
urlDecrypted = decrypted
if let decrypted = decrypted {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
url = newUrl
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
private func videoViewEncrypted(_ file: CIFile, _ defaultPreview: UIImage, _ duration: Int) -> some View {
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview)
.simultaneousGesture(TapGesture().onEnded {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
})
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !blurred {
if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
.simultaneousGesture(TapGesture().onEnded {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
}
}
})
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
}
}
}
}
@ -181,9 +98,10 @@ struct CIVideoView: View {
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete
VideoPlayerView(player: player, url: url, showControls: false)
.frame(width: w, height: w * preview.size.height / preview.size.width)
.onChange(of: m.stopPreviousRecPlay) { playingUrl in
@ -192,34 +110,32 @@ struct CIVideoView: View {
videoPlaying = false
}
}
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
.if(!blurred) { v in
v.simultaneousGesture(TapGesture().onEnded {
switch player.timeControlStatus {
case .playing:
player.pause()
videoPlaying = false
case .paused:
if canBePlayed {
showFullScreenPlayer = true
}
default: ()
.fullScreenCover(isPresented: $showFullScreenPlayer) {
fullScreenPlayer(url)
}
.onTapGesture {
switch player.timeControlStatus {
case .playing:
player.pause()
videoPlaying = false
case .paused:
if canBePlayed {
showFullScreenPlayer = true
}
})
default: ()
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !videoPlaying && !blurred {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
.simultaneousGesture(TapGesture().onEnded {
m.stopPreviousRecPlay = url
player.play()
})
.disabled(!canBePlayed)
if !videoPlaying {
Button {
m.stopPreviousRecPlay = url
player.play()
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
.disabled(!canBePlayed)
}
}
fileStatusIcon()
loadingIndicator()
}
.onAppear {
addObserver(player, url)
@ -231,137 +147,70 @@ struct CIVideoView: View {
}
}
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
.onTapGesture { // this is shown in chat list, where onTapGesture works
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if file.showStatusIconInSmallView {
// Show nothing
} else if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} else {
videoDecryptionProgress()
}
}
}
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
.onTapGesture { // this is shown in chat list, where onTapGesture works
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
.frame(width: 12, height: 12)
.foregroundColor(color)
.padding(.leading, smallView ? 0 : 4)
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
.padding(.leading, 4)
.frame(width: 40, height: 40)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView()
.progressViewStyle(.circular)
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
.tint(color)
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
private func durationProgress() -> some View {
HStack {
Text("\(durationText(videoPlaying ? progress : duration))")
.foregroundColor(.white)
.font(.caption)
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
.cornerRadius(10)
.padding([.top, .leading], 6)
private var fileSizeString: String {
if let file = chatItem.file, !videoPlaying {
" " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
} else {
""
if let file = chatItem.file, !videoPlaying {
Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
.foregroundColor(.white)
.font(.caption)
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color.black.opacity(0.35))
.cornerRadius(10)
.padding(.top, 6)
}
}
}
private func durationProgress() -> some View {
Text((durationText(videoPlaying ? progress : duration)) + fileSizeString)
.invertedForegroundStyle()
.font(.caption)
.padding(.vertical, 6)
.padding(.horizontal, 12)
}
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : .infinity
DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(width: w)
.modifier(PrivacyBlur(blurred: $blurred))
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
fileStatusIcon()
}
.frame(maxWidth: w)
loadingIndicator()
}
}
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
ZStack(alignment: .center) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
if file.showStatusIconInSmallView {
fileStatusIcon()
.allowsHitTesting(false)
}
}
}
@ViewBuilder private func fileStatusIcon() -> some View {
@ViewBuilder private func loadingIndicator() -> some View {
if let file = chatItem.file {
switch file.fileStatus {
case .sndStored:
switch file.fileProtocol {
case .xftp: progressView()
case .smp: EmptyView()
case .local: EmptyView()
}
case let .sndTransfer(sndProgress, sndTotal):
switch file.fileProtocol {
case .xftp: progressCircle(sndProgress, sndTotal)
case .smp: progressView()
case .local: EmptyView()
}
case .sndComplete: fileIcon("checkmark", 10, 13)
case .sndCancelled: fileIcon("xmark", 10, 13)
case let .sndError(sndFileError):
fileIcon("xmark", 10, 13)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError)
})
case let .sndWarning(sndFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError, temporary: true)
})
case .sndError: fileIcon("xmark", 10, 13)
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case let .rcvTransfer(rcvProgress, rcvTotal):
@ -370,20 +219,10 @@ struct CIVideoView: View {
} else {
progressView()
}
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvComplete: EmptyView()
case .rcvCancelled: fileIcon("xmark", 10, 13)
case let .rcvError(rcvFileError):
fileIcon("xmark", 10, 13)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError)
})
case let .rcvWarning(rcvFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError, temporary: true)
})
case .rcvError: fileIcon("xmark", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView()
}
}
}
@ -391,10 +230,10 @@ struct CIVideoView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon)
.resizable()
.invertedForegroundStyle()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.padding(smallView ? 0 : padding)
.foregroundColor(.white)
.padding(padding)
}
private func progressView() -> some View {
@ -402,24 +241,26 @@ struct CIVideoView: View {
.progressViewStyle(.circular)
.frame(width: 16, height: 16)
.tint(.white)
.padding(smallView ? 0 : 11)
.padding(11)
}
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
Circle()
.trim(from: 0, to: Double(progress) / Double(total))
.stroke(style: StrokeStyle(lineWidth: 2))
.invertedForegroundStyle()
.stroke(
Color(uiColor: .white),
style: StrokeStyle(lineWidth: 2)
)
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11)
.padding([.trailing, .top], 11)
}
// TODO encrypt: where file size is checked?
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
Task {
if let user = m.currentUser {
await receiveFile(user, file.fileId, false, false)
await receiveFile(user, file.fileId, encrypted, false)
}
}
}
@ -429,7 +270,7 @@ struct CIVideoView: View {
Color.black.edgesIgnoringSafeArea(.all)
VideoPlayer(player: fullPlayer)
.overlay(alignment: .topLeading, content: {
Button(action: { showFullScreenPlayer = false }, // this is used in full screen player, Button works here
Button(action: { showFullScreenPlayer = false },
label: {
Image(systemName: "multiply")
.resizable()
@ -452,8 +293,7 @@ struct CIVideoView: View {
)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) {
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
m.stopPreviousRecPlay = url
if let player = fullPlayer {
player.play()
var played = false
@ -483,24 +323,6 @@ struct CIVideoView: View {
}
}
private func decrypt(file: CIFile, completed: (() -> Void)? = nil) {
if decryptionInProgress { return }
decryptionInProgress = true
Task {
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
await MainActor.run {
if let decrypted = urlDecrypted {
if !smallView {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
}
fullPlayer = AVPlayer(url: decrypted)
}
decryptionInProgress = false
completed?()
}
}
}
private func addObserver(_ player: AVPlayer, _ url: URL) {
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
if let item = player.currentItem {

View file

@ -11,30 +11,18 @@ import SimpleXChat
struct CIVoiceView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
var smallViewSize: CGFloat?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
Group {
if smallViewSize != nil {
HStack(spacing: 10) {
player()
playerTime()
.allowsHitTesting(false)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
} else if chatItem.chatDir.sent {
if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) {
HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
@ -65,13 +53,7 @@ struct CIVoiceView: View {
}
private func player() -> some View {
let sizeMultiplier: CGFloat = if let sz = smallViewSize {
voiceMessageSizeBasedOnSquareSize(sz) / 56
} else {
1
}
return VoiceMessagePlayer(
chat: chat,
VoiceMessagePlayer(
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
@ -80,8 +62,7 @@ struct CIVoiceView: View {
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime,
allowMenu: $allowMenu,
sizeMultiplier: sizeMultiplier
allowMenu: $allowMenu
)
}
@ -91,7 +72,7 @@ struct CIVoiceView: View {
playbackState: $playbackState,
playbackTime: $playbackTime
)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
}
private func playbackSlider() -> some View {
@ -108,11 +89,10 @@ struct CIVoiceView: View {
allowMenu = true
}
}
.tint(theme.colors.primary)
}
private func metaView() -> some View {
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
CIMetaView(chat: chat, chatItem: chatItem)
}
}
@ -137,9 +117,8 @@ struct VoiceMessagePlayerTime: View {
}
struct VoiceMessagePlayer: View {
@ObservedObject var chat: Chat
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var recordingFile: CIFile?
var recordingTime: TimeInterval
@ -149,49 +128,23 @@ struct VoiceMessagePlayer: View {
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
var sizeMultiplier: CGFloat
var body: some View {
ZStack {
if let recordingFile = recordingFile {
switch recordingFile.fileStatus {
case .sndStored:
if recordingFile.fileProtocol == .local {
playbackButton()
} else {
loadingIcon()
}
case .sndTransfer: loadingIcon()
case .sndStored: playbackButton()
case .sndTransfer: playbackButton()
case .sndComplete: playbackButton()
case .sndCancelled: playbackButton()
case let .sndError(sndFileError):
fileStatusIcon("multiply", 14)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError)
})
case let .sndWarning(sndFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError, temporary: true)
})
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .sndError: playbackButton()
case .rcvInvitation: downloadButton(recordingFile)
case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon()
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
case .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case let .rcvError(rcvFileError):
fileStatusIcon("multiply", 14)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError)
})
case let .rcvWarning(rcvFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
.simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError, temporary: true)
})
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
} else {
@ -199,126 +152,84 @@ struct VoiceMessagePlayer: View {
}
}
.onAppear {
if audioPlayer == nil {
let small = sizeMultiplier != 1
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
}
seek = { to in audioPlayer?.seek(to) }
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) {
getAppFilePath(recordingSource.filePath)
} else {
nil
}
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
audioPlayer?.onTimer = {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
}
audioPlayer?.onTimer = { playbackTime = $0 }
audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
stopPlayback()
}
}
.onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
stopPlayback()
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
}
.onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback
// Notify activeContentPreview in ChatPreviewView that playback is finished
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
chatModel.stopPreviousRecPlay = nil
}
}
.onChange(of: chatModel.chatId) { _ in
stopPlayback()
}
.onDisappear {
if sizeMultiplier == 1 && chatModel.chatId == nil {
stopPlayback()
}
}
}
private func playbackButton() -> some View {
let icon = switch playbackState {
case .noPlayback: "play.fill"
case .playing: "pause.fill"
case .paused: "play.fill"
}
return playPauseIcon(icon, theme.colors.primary)
.simultaneousGesture(TapGesture().onEnded { _ in
switch playbackState {
case .noPlayback:
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
case .playing:
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
case .paused:
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
@ViewBuilder private func playbackButton() -> some View {
switch playbackState {
case .noPlayback:
Button {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
})
} label: {
playPauseIcon("play.fill")
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
} label: {
playPauseIcon("pause.fill")
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
} label: {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
ZStack {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
.frame(width: 20, height: 20)
.foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle())
if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime)
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
}
}
}
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
playPauseIcon(icon, theme.colors.primary)
.simultaneousGesture(TapGesture().onEnded {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
private func downloadButton(_ recordingFile: CIFile) -> some View {
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get())
}
})
}
func notifyStateChange() {
if sizeMultiplier != 1 {
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} else {
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
}
} label: {
playPauseIcon("play.fill")
}
}
private struct ProgressCircle: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval
@Binding var progress: TimeInterval?
@ -326,7 +237,7 @@ struct VoiceMessagePlayer: View {
Circle()
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
.stroke(
theme.colors.primary,
Color.accentColor,
style: StrokeStyle(lineWidth: 3)
)
.rotationEffect(.degrees(-90))
@ -334,103 +245,26 @@ struct VoiceMessagePlayer: View {
}
}
private func fileStatusIcon(_ image: String, _ size: CGFloat) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
private func loadingIcon() -> some View {
ProgressView()
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.frame(width: 30, height: 30)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle())
}
private func startPlayback(_ recordingSource: CryptoFile) {
let audioPath = getAppFilePath(recordingSource.filePath)
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
chatModel.stopPreviousRecPlay = audioPath
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
audioPlayer = AudioPlayer(
onTimer: {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
},
onTimer: { playbackTime = $0 },
onFinishPlayback: {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
)
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
playbackState = .playing
notifyStateChange()
}
private func stopPlayback() {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
}
@inline(__always)
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio)
}
class VoiceItemState {
var audioPlayer: AudioPlayer?
var playbackState: VoiceMessagePlaybackState
var playbackTime: TimeInterval?
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
self.audioPlayer = audioPlayer
self.playbackState = playbackState
self.playbackTime = playbackTime
}
@inline(__always)
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)"
}
@inline(__always)
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)"
}
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = smallView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = chatView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static var smallView: [String: VoiceItemState] = [:]
static var chatView: [String: VoiceItemState] = [:]
}
struct CIVoiceView_Previews: PreviewProvider {
@ -455,12 +289,15 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30,
audioPlayer: .constant(nil),
playbackState: .constant(.playing),
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true)
)
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
}
.previewLayout(.fixed(width: 360, height: 360))
}

View file

@ -10,21 +10,22 @@ import SwiftUI
import SimpleXChat
struct DeletedItemView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
Text(chatItem.content.text)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
.italic()
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme))
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled)
}
}

View file

@ -11,7 +11,6 @@ import SimpleXChat
struct EmojiItemView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var body: some View {
@ -19,7 +18,7 @@ struct EmojiItemView: View {
emojiText(chatItem.content.text)
.padding(.top, 8)
.padding(.horizontal, 6)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
CIMetaView(chat: chat, chatItem: chatItem)
.padding(.bottom, 8)
.padding(.horizontal, 12)
}

View file

@ -12,24 +12,21 @@ import SwiftUI
import SimpleXChat
struct FramedCIVoiceView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
HStack {
VoiceMessagePlayer(
chat: chat,
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
@ -38,15 +35,14 @@ struct FramedCIVoiceView: View {
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime,
allowMenu: $allowMenu,
sizeMultiplier: 1
allowMenu: $allowMenu
)
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
playbackState: $playbackState,
playbackTime: $playbackTime
)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
.frame(width: 50, alignment: .leading)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
@ -92,13 +88,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in })
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
}
}

File diff suppressed because one or more lines are too long

View file

@ -14,11 +14,11 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
@State var chatItem: ChatItem
var scrollToItemId: ((ChatItem.ID) -> Void)?
@State var image: UIImage?
@State var player: AVPlayer? = nil
@State var url: URL? = nil
@Binding var showView: Bool
@State var scrollProxy: ScrollViewProxy?
@State private var showNext = false
@State private var nextImage: UIImage?
@State private var nextPlayer: AVPlayer?
@ -71,7 +71,9 @@ struct FullScreenMediaView: View {
let w = abs(t.width)
if t.height > 60 && t.height > w * 2 {
showView = false
scrollToItemId?(chatItem.id)
if let proxy = scrollProxy {
proxy.scrollTo(chatItem.viewId)
}
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0
scrolling = true
@ -126,7 +128,7 @@ struct FullScreenMediaView: View {
.scaledToFit()
}
}
.onTapGesture { showView = false } // this is used in full screen view, onTapGesture works
.onTapGesture { showView = false }
}
private func videoView( _ player: AVPlayer, _ url: URL) -> some View {

View file

@ -11,7 +11,6 @@ import SimpleXChat
struct IntegrityErrorItemView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var msgError: MsgErrorType
var chatItem: ChatItem
@ -31,8 +30,8 @@ struct IntegrityErrorItemView: View {
case .msgBadHash:
AlertManager.shared.showAlert(Alert(
title: Text("Bad message hash"),
message: Text("The hash of the previous message is different.") + textNewLine +
Text(decryptErrorReason) + textNewLine +
message: Text("The hash of the previous message is different.") + Text("\n") +
Text(decryptErrorReason) + Text("\n") +
Text("Please report it to the developers.")
))
case .msgBadId: msgBadIdAlert()
@ -47,7 +46,7 @@ struct IntegrityErrorItemView: View {
message: Text("""
The ID of the next message is incorrect (less or equal to the previous).
It can happen because of some bug or when the connection is compromised.
""") + textNewLine +
""") + Text("\n") +
Text("Please report it to the developers.")
))
}
@ -55,7 +54,6 @@ struct IntegrityErrorItemView: View {
struct CIMsgError: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
var onTap: () -> Void
@ -64,14 +62,15 @@ struct CIMsgError: View {
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
.padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
.simultaneousGesture(TapGesture().onEnded(onTap))
.onTapGesture(perform: onTap)
}
}

View file

@ -11,18 +11,19 @@ import SimpleXChat
struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
@Binding var revealed: Bool
var body: some View {
(Text(mergedMarkedDeletedText).italic() + textSpace + chatItem.timestampText)
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.foregroundColor(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled)
}
@ -32,11 +33,10 @@ struct MarkedDeletedItemView: View {
var i = m.getChatItemIndex(chatItem) {
var moderated = 0
var blocked = 0
var blockedByAdmin = 0
var deleted = 0
var moderatedBy: Set<String> = []
while i < ItemsModel.shared.reversedChatItems.count,
let ci = .some(ItemsModel.shared.reversedChatItems[i]),
while i < m.reversedChatItems.count,
let ci = .some(m.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
@ -44,19 +44,16 @@ struct MarkedDeletedItemView: View {
moderated += 1
moderatedBy.insert(byGroupMember.displayName)
case .blocked: blocked += 1
case .blockedByAdmin: blockedByAdmin += 1
case .deleted: deleted += 1
}
i += 1
}
let total = moderated + blocked + blockedByAdmin + deleted
let total = moderated + blocked + deleted
return total <= 1
? markedDeletedText
: total == moderated
? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))"
: total == blockedByAdmin
? "\(total) messages blocked by admin"
: total == blocked + blockedByAdmin
: total == blocked
? "\(total) messages blocked"
: "\(total) messages marked deleted"
} else {
@ -64,18 +61,11 @@ struct MarkedDeletedItemView: View {
}
}
// same texts are in markedDeletedText in ChatPreviewView, but it returns String;
// can be refactored into a single function if functions calling these are changed to return same type
var markedDeletedText: LocalizedStringKey {
if chatItem.meta.itemDeleted != nil, chatItem.isReport {
"archived report"
} else {
switch chatItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
case .blocked: "blocked"
case .blockedByAdmin: "blocked by admin"
case .deleted, nil: "marked deleted"
}
switch chatItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
case .blocked: "blocked"
default: "marked deleted"
}
}
}
@ -83,10 +73,7 @@ struct MarkedDeletedItemView: View {
struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
MarkedDeletedItemView(
chat: Chat.sampleData,
chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
).environment(\.revealed, true)
MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 200))
}

View file

@ -9,76 +9,47 @@
import SwiftUI
import SimpleXChat
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont.Weight]) -> NSMutableAttributedString {
let res = NSMutableAttributedString()
for w in ws {
res.append(NSAttributedString(string: ".", attributes: [
.font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: w),
.kern: -2 as NSNumber,
.foregroundColor: UIColor(theme.colors.secondary)
]))
}
return res
private let noTyping = Text(" ")
private let typingIndicators: [Text] = [
(typing(.black) + typing() + typing()),
(typing(.bold) + typing(.black) + typing()),
(typing() + typing(.bold) + typing(.black)),
(typing() + typing() + typing(.bold))
]
private func typing(_ w: Font.Weight = .light) -> Text {
Text(".").fontWeight(w)
}
struct MsgContentView: View {
@ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
@Environment(\.containerBackground) var containerBackground: UIColor
@EnvironmentObject var theme: AppTheme
var text: String
var formattedText: [FormattedText]? = nil
var textStyle: UIFont.TextStyle
var sender: String? = nil
var meta: CIMeta? = nil
var mentions: [String: CIMention]? = nil
var userMemberId: String? = nil
var rightToLeft = false
var prefix: NSAttributedString? = nil
@State private var showSecrets: Set<Int> = []
@State private var typingIdx = 0
@State private var timer: Timer?
@State private var typingIndicators: [NSAttributedString] = []
@State private var noTyping = NSAttributedString(string: " ")
@State private var phase: CGFloat = 0
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View {
let v = msgContentView()
if meta?.isLive == true {
v.onAppear {
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
noTyping = NSAttributedString(string: " ", attributes: [
.font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular),
.kern: -2 as NSNumber,
.foregroundColor: UIColor(theme.colors.secondary)
])
switchTyping()
}
msgContentView()
.onAppear { switchTyping() }
.onDisappear(perform: stopTyping)
.onChange(of: meta?.isLive, perform: switchTyping)
.onChange(of: meta?.recent, perform: switchTyping)
} else {
v
msgContentView()
}
}
private func switchTyping(_: Bool? = nil) {
if let meta = meta, meta.isLive && meta.recent {
if typingIndicators.isEmpty {
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
typingIndicators = [
typing(theme, descr, [.black, .light, .light]),
typing(theme, descr, [.bold, .black, .light]),
typing(theme, descr, [.light, .bold, .black]),
typing(theme, descr, [.light, .light, .bold])
]
}
timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in
typingIdx = typingIdx + 1
typingIdx = (typingIdx + 1) % typingIndicators.count
}
} else {
stopTyping()
@ -88,279 +59,92 @@ struct MsgContentView: View {
private func stopTyping() {
timer?.invalidate()
timer = nil
typingIdx = 0
}
@inline(__always)
private func msgContentView() -> some View {
let r = messageText(text, formattedText, textStyle: textStyle, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: containerBackground, prefix: prefix)
let s = r.string
let t: Text
private func msgContentView() -> Text {
var v = messageText(text, formattedText, sender)
if let mt = meta {
if mt.isLive {
s.append(typingIndicator(mt.recent))
v = v + typingIndicator(mt.recent)
}
t = Text(AttributedString(s)) + reserveSpaceForMeta(mt)
} else {
t = Text(AttributedString(s))
v = v + reserveSpaceForMeta(mt)
}
return msgTextResultView(r, t, showSecrets: $showSecrets)
return v
}
@inline(__always)
private func typingIndicator(_ recent: Bool) -> NSAttributedString {
recent && !typingIndicators.isEmpty
? typingIndicators[typingIdx % 4]
: noTyping
private func typingIndicator(_ recent: Bool) -> Text {
return (recent ? typingIndicators[typingIdx] : noTyping)
.font(.body.monospaced())
.kerning(-2)
.foregroundColor(.secondary)
}
@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)
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true)
}
}
func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding<Set<Int>>? = nil) -> some View {
t.if(r.hasSecrets, transform: hiddenSecretsView)
.if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets)) }
}
@inline(__always)
private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding<Set<Int>>? = nil) -> some View {
return GeometryReader { g in
Rectangle()
.fill(Color.clear)
.contentShape(Rectangle())
.simultaneousGesture(DragGesture(minimumDistance: 0).onEnded { event in
let t = event.translation
if t.width * t.width + t.height * t.height > 100 { return }
let framesetter = CTFramesetterCreateWithAttributedString(s as CFAttributedString)
let path = CGPath(rect: CGRect(origin: .zero, size: g.size), transform: nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, s.length), path, nil)
let point = CGPoint(x: event.location.x, y: g.size.height - event.location.y) // Flip y for UIKit
var index: CFIndex?
if let lines = CTFrameGetLines(frame) as? [CTLine] {
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
for i in 0 ..< lines.count {
let bounds = CTLineGetBoundsWithOptions(lines[i], .useOpticalBounds)
if bounds.offsetBy(dx: origins[i].x, dy: origins[i].y).contains(point) {
index = CTLineGetStringIndexForPosition(lines[i], point)
break
}
}
}
if let index, let (url, browser) = attributedStringLink(s, for: index) {
if browser {
openBrowserAlert(uri: url)
} else {
UIApplication.shared.open(url)
}
}
})
}
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (URL, Bool)? {
var linkURL: URL?
var browser: Bool = false
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
if index >= range.location && index < range.location + range.length {
if let url = attrs[linkAttrKey] as? NSURL {
linkURL = url.absoluteURL
browser = attrs[webLinkAttrKey] != nil
} else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
if showSecrets.wrappedValue.contains(i) {
showSecrets.wrappedValue.remove(i)
} else {
showSecrets.wrappedValue.insert(i)
}
}
stop.pointee = true
}
}
return if let linkURL { (linkURL, browser) } else { nil }
}
}
func hiddenSecretsView<V: View>(_ v: V) -> some View {
v.overlay(
GeometryReader { g in
let size = (g.size.width + g.size.height) / 1.4142
Image("vertical_logo")
.resizable(resizingMode: .tile)
.frame(width: size, height: size)
.rotationEffect(.degrees(45), anchor: .center)
.position(x: g.size.width / 2, y: g.size.height / 2)
.clipped()
.saturation(0.65)
.opacity(0.35)
}
.mask(v)
)
}
private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link")
private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink")
private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret")
typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool)
func messageText(
_ text: String,
_ formattedText: [FormattedText]?,
textStyle: UIFont.TextStyle = .body,
sender: String?,
preview: Bool = false,
mentions: [String: CIMention]?,
userMemberId: String?,
showSecrets: Set<Int>?,
backgroundColor: UIColor,
prefix: NSAttributedString? = nil
) -> MsgTextResult {
let res = NSMutableAttributedString()
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
let font = 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
if let sender {
if preview {
res.append(NSAttributedString(string: sender + ": ", attributes: plain))
} else {
var attrs = plain
attrs[.font] = UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.medium]]), size: descr.pointSize)
res.append(NSAttributedString(string: sender, attributes: attrs))
res.append(NSAttributedString(string: ": ", attributes: plain))
}
}
if let prefix {
res.append(prefix)
}
if let fts = formattedText, fts.count > 0 {
var bold: UIFont?
var italic: UIFont?
var snippet: 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 .italic:
italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
attrs[.font] = italic
case .strikeThrough:
attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
case .snippet:
snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular)
attrs[.font] = snippet
case .secret:
if let showSecrets {
if !showSecrets.contains(secretIdx) {
attrs[.foregroundColor] = UIColor.clear
attrs[.backgroundColor] = secretColor
}
attrs[secretAttrKey] = secretIdx
secretIdx += 1
handleTaps = true
} else {
attrs[.foregroundColor] = UIColor.clear
attrs[.backgroundColor] = secretColor
}
hasSecrets = true
case let .colored(color):
if let c = color.uiColor {
attrs[.foregroundColor] = UIColor(c)
}
case .uri:
attrs = linkAttrs()
if !preview {
let s = t.lowercased()
let link = s.hasPrefix("http://") || s.hasPrefix("https://")
? t
: "https://" + t
attrs[linkAttrKey] = NSURL(string: link)
attrs[webLinkAttrKey] = true
handleTaps = true
}
case let .simplexLink(linkType, simplexUri, smpHosts):
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = NSURL(string: simplexUri)
handleTaps = true
}
if case .description = privacySimplexLinkModeDefault.get() {
t = simplexLinkText(linkType, smpHosts)
}
case let .mention(memberName):
if let m = mentions?[memberName] {
mention = mention ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: descr.pointSize)
attrs[.font] = mention
if let ref = m.memberRef {
let name: String = if let alias = ref.localAlias, alias != "" {
"\(alias) (\(ref.displayName))"
} else {
ref.displayName
}
if m.memberId == userMemberId {
attrs[.foregroundColor] = UIColor.tintColor
}
t = mentionText(name)
} else {
t = mentionText(memberName)
}
}
case .email:
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text)
handleTaps = true
}
case .phone:
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: ""))
handleTaps = true
}
case .none: ()
}
res.append(NSAttributedString(string: t, attributes: attrs))
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false) -> Text {
let s = text
var res: Text
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
res = formatText(ft[0], preview)
var i = 1
while i < ft.count {
res = res + formatText(ft[i], preview)
i = i + 1
}
} else {
res.append(NSMutableAttributedString(string: text, attributes: plain))
res = Text(s)
}
return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps)
if let i = icon {
res = Text(Image(systemName: i)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ") + res
}
func linkAttrs() -> [NSAttributedString.Key: Any] {
link = link ?? [
.font: font,
.foregroundColor: uiLinkColor,
.underlineStyle: NSUnderlineStyle.single.rawValue
]
return link!
if let s = sender {
let t = Text(s)
return (preview ? t : t.fontWeight(.medium)) + Text(": ") + res
} else {
return res
}
}
@inline(__always)
private func mentionText(_ name: String) -> String {
name.contains(" @") ? "@'\(name)'" : "@\(name)"
private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
let t = ft.text
if let f = ft.format {
switch (f) {
case .bold: return Text(t).bold()
case .italic: return Text(t).italic()
case .strikeThrough: return Text(t).strikethrough()
case .snippet: return Text(t).font(.body.monospaced())
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
case .uri: return linkText(t, t, preview, prefix: "")
case let .simplexLink(linkType, simplexUri, smpHosts):
switch privacySimplexLinkModeDefault.get() {
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
case .full: return linkText(t, simplexUri, preview, prefix: "")
case .browser: return linkText(t, simplexUri, preview, prefix: "")
}
case .email: return linkText(t, t, preview, prefix: "mailto:")
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
}
} else {
return Text(t)
}
}
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text {
preview
? Text(s).foregroundColor(color).underline(color: color)
: Text(AttributedString(s, attributes: AttributeContainer([
.link: NSURL(string: prefix + link) as Any,
.foregroundColor: uiColor as Any
]))).underline()
}
private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
}
@ -371,7 +155,6 @@ struct MsgContentView_Previews: PreviewProvider {
chat: Chat.sampleData,
text: chatItem.text,
formattedText: chatItem.formattedText,
textStyle: .body,
sender: chatItem.memberDisplayName,
meta: chatItem.meta
)

Some files were not shown because too many files have changed in this diff Show more