Compare commits

..

1 Commits

Author SHA1 Message Date
IC Rainbow 4c92e32dc2 WIP: add batching 2023-12-06 01:31:13 +02:00
1400 changed files with 52726 additions and 222888 deletions
+33 -93
View File
@@ -5,22 +5,11 @@ on:
branches: branches:
- master - master
- stable - stable
- users
tags: tags:
- "v*" - "v*"
- "!*-fdroid" - "!*-fdroid"
- "!*-armv7a"
pull_request: pull_request:
paths-ignore:
- "apps/ios"
- "apps/multiplatform"
- "blog"
- "docs"
- "fastlane"
- "images"
- "packages"
- "website"
- "README.md"
- "PRIVACY.md"
jobs: jobs:
prepare-release: prepare-release:
@@ -53,7 +42,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build: build:
name: build-${{ matrix.os }}-${{ matrix.ghc }} name: build-${{ matrix.os }}
if: always() if: always()
needs: prepare-release needs: prepare-release
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -62,30 +51,18 @@ jobs:
matrix: matrix:
include: include:
- os: ubuntu-20.04 - os: ubuntu-20.04
ghc: "8.10.7"
cache_path: ~/.cabal/store
- os: ubuntu-20.04
ghc: "9.6.3"
cache_path: ~/.cabal/store cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-20_04-x86-64 asset_name: simplex-chat-ubuntu-20_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
- os: ubuntu-22.04 - os: ubuntu-22.04
ghc: "9.6.3"
cache_path: ~/.cabal/store cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-22_04-x86-64 asset_name: simplex-chat-ubuntu-22_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
- os: macos-latest - os: macos-latest
ghc: "9.6.3"
cache_path: ~/.cabal/store
asset_name: simplex-chat-macos-aarch64
desktop_asset_name: simplex-desktop-macos-aarch64.dmg
- os: macos-13
ghc: "9.6.3"
cache_path: ~/.cabal/store cache_path: ~/.cabal/store
asset_name: simplex-chat-macos-x86-64 asset_name: simplex-chat-macos-x86-64
desktop_asset_name: simplex-desktop-macos-x86_64.dmg desktop_asset_name: simplex-desktop-macos-x86_64.dmg
- os: windows-latest - os: windows-latest
ghc: "9.6.3"
cache_path: C:/cabal cache_path: C:/cabal
asset_name: simplex-chat-windows-x86-64 asset_name: simplex-chat-windows-x86-64
desktop_asset_name: simplex-desktop-windows-x86_64.msi desktop_asset_name: simplex-desktop-windows-x86_64.msi
@@ -104,17 +81,16 @@ jobs:
- name: Setup Haskell - name: Setup Haskell
uses: haskell-actions/setup@v2 uses: haskell-actions/setup@v2
with: with:
ghc-version: ${{ matrix.ghc }} ghc-version: "9.6.3"
cabal-version: "3.10.1.0" cabal-version: "3.10.1.0"
- name: Restore cached build - name: Cache dependencies
id: restore_cache uses: actions/cache@v3
uses: actions/cache/restore@v3
with: with:
path: | path: |
${{ matrix.cache_path }} ${{ matrix.cache_path }}
dist-newstyle dist-newstyle
key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
# / Unix # / Unix
@@ -122,37 +98,19 @@ jobs:
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
shell: bash shell: bash
run: | run: |
echo "ignore-project: False" >> cabal.project.local echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo "" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Unix prepare cabal.project.local for Mac
if: matrix.os == 'macos-13'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Install AppImage dependencies - name: Install AppImage dependencies
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
run: sudo apt install -y desktop-file-utils run: sudo apt install -y desktop-file-utils
- name: Install openssl for Mac - name: Install pkg-config for Mac
if: matrix.os == 'macos-latest' || matrix.os == 'macos-13' if: matrix.os == 'macos-latest'
run: brew install openssl@3.0 run: brew install pkg-config
- name: Unix prepare cabal.project.local for Ubuntu - name: Unix prepare cabal.project.local for Ubuntu
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04' if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
@@ -173,7 +131,7 @@ jobs:
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $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: Unix upload CLI binary to release - name: Unix upload CLI binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
@@ -182,7 +140,7 @@ jobs:
tag: ${{ github.ref }} tag: ${{ github.ref }}
- name: Unix update CLI binary hash - name: Unix update CLI binary hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -192,7 +150,7 @@ jobs:
${{ steps.unix_cli_build.outputs.bin_hash }} ${{ steps.unix_cli_build.outputs.bin_hash }}
- name: Setup Java - name: Setup Java
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name if: startsWith(github.ref, 'refs/tags/v')
uses: actions/setup-java@v3 uses: actions/setup-java@v3
with: with:
distribution: 'corretto' distribution: 'corretto'
@@ -201,7 +159,7 @@ jobs:
- name: Linux build desktop - name: Linux build desktop
id: linux_desktop_build id: linux_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
shell: bash shell: bash
run: | run: |
scripts/desktop/build-lib-linux.sh scripts/desktop/build-lib-linux.sh
@@ -210,10 +168,10 @@ jobs:
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb) path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $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: Linux make AppImage - name: Linux make AppImage
id: linux_appimage_build id: linux_appimage_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
shell: bash shell: bash
run: | run: |
scripts/desktop/make-appimage-linux.sh scripts/desktop/make-appimage-linux.sh
@@ -223,7 +181,7 @@ jobs:
- name: Mac build desktop - name: Mac build desktop
id: mac_desktop_build id: mac_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
shell: bash shell: bash
env: env:
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }} APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
@@ -236,7 +194,7 @@ jobs:
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $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: Linux upload desktop package to release - name: Linux upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
@@ -245,7 +203,7 @@ jobs:
tag: ${{ github.ref }} tag: ${{ github.ref }}
- name: Linux update desktop package hash - name: Linux update desktop package hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -255,7 +213,7 @@ jobs:
${{ steps.linux_desktop_build.outputs.package_hash }} ${{ steps.linux_desktop_build.outputs.package_hash }}
- name: Linux upload AppImage to release - name: Linux upload AppImage to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
@@ -264,7 +222,7 @@ jobs:
tag: ${{ github.ref }} tag: ${{ github.ref }}
- name: Linux update AppImage hash - name: Linux update AppImage hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -274,7 +232,7 @@ jobs:
${{ steps.linux_appimage_build.outputs.appimage_hash }} ${{ steps.linux_appimage_build.outputs.appimage_hash }}
- name: Mac upload desktop package to release - name: Mac upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
@@ -283,7 +241,7 @@ jobs:
tag: ${{ github.ref }} tag: ${{ github.ref }}
- name: Mac update desktop package hash - name: Mac update desktop package hash
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -292,18 +250,9 @@ jobs:
body: | body: |
${{ steps.mac_desktop_build.outputs.package_hash }} ${{ steps.mac_desktop_build.outputs.package_hash }}
- name: Cache unix build
uses: actions/cache/save@v3
if: matrix.os != 'windows-latest'
with:
path: |
${{ matrix.cache_path }}
dist-newstyle
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
- name: Unix test - name: Unix test
if: matrix.os != 'windows-latest' if: matrix.os != 'windows-latest'
timeout-minutes: 40 timeout-minutes: 30
shell: bash shell: bash
run: cabal test --test-show-details=direct run: cabal test --test-show-details=direct
@@ -332,9 +281,9 @@ jobs:
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
shell: msys2 {0} shell: msys2 {0}
run: | 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 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 rm cabal.project.local 2>/dev/null || true
echo "ignore-project: False" >> cabal.project.local echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local
@@ -374,14 +323,14 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
shell: msys2 {0} shell: msys2 {0}
run: | 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 scripts/desktop/build-lib-windows.sh
cd apps/multiplatform cd apps/multiplatform
./gradlew packageMsi ./gradlew packageMsi
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $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: Windows upload desktop package to release - name: Windows upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
@@ -401,13 +350,4 @@ jobs:
body: | body: |
${{ steps.windows_desktop_build.outputs.package_hash }} ${{ steps.windows_desktop_build.outputs.package_hash }}
- name: Cache windows build
uses: actions/cache/save@v3
if: matrix.os == 'windows-latest'
with:
path: |
${{ matrix.cache_path }}
dist-newstyle
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
# Windows / # Windows /
+3 -9
View File
@@ -5,20 +5,14 @@ on:
pull_request_target: pull_request_target:
types: [opened, closed, synchronize] types: [opened, closed, synchronize]
permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs: jobs:
CLAssistant: CLAssistant:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "CLA Assistant" - 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 # Beta Release
uses: cla-assistant/github-action@v2.3.0 uses: cla-assistant/github-action@v2.1.3-beta
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret # the below token should have repo scope and must be manually added by you in the repository's secret
@@ -39,4 +33,4 @@ jobs:
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA' #custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.' #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true) #lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA #use-dco-flag: true - If you are using DCO instead of CLA
+1 -1
View File
@@ -4,13 +4,13 @@ on:
push: push:
branches: branches:
- master - master
- stable
paths: paths:
- website/** - website/**
- images/** - images/**
- blog/** - blog/**
- docs/** - docs/**
- .github/workflows/web.yml - .github/workflows/web.yml
- PRIVACY.md
jobs: jobs:
build: build:
-2
View File
@@ -54,14 +54,12 @@ website/translations.json
website/src/img/images/ website/src/img/images/
website/src/images/ website/src/images/
website/src/js/lottie.min.js website/src/js/lottie.min.js
website/src/privacy.md
# Generated files # Generated files
website/package/generated* website/package/generated*
# Ignore build tool output, e.g. code coverage # Ignore build tool output, e.g. code coverage
website/.nyc_output/ website/.nyc_output/
website/coverage/ website/coverage/
result
# Ignore API documentation # Ignore API documentation
website/api-docs/ website/api-docs/
+16 -25
View File
@@ -1,41 +1,32 @@
ARG TAG=22.04 FROM ubuntu:focal AS build
FROM ubuntu:${TAG} AS build # 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
### 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 ghcup # Install ghcup
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
chmod +x /usr/bin/ghcup
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Install ghc
RUN ghcup install ghc 9.6.3
# Install cabal
RUN ghcup install cabal 3.10.1.0
# Set both as default # Set both as default
RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \ RUN ghcup set ghc 9.6.3 && \
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}" ghcup set cabal 3.10.1.0
COPY . /project COPY . /project
WORKDIR /project WORKDIR /project
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Adjust build # Adjust build
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat # Compile simplex-chat
RUN cabal update RUN cabal update
RUN cabal build exe:simplex-chat --constraint 'simplexmq +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 FROM scratch AS export-stage
COPY --from=build /project/simplex-chat / COPY --from=build /root/.cabal/bin/simplex-chat /
+60 -151
View File
@@ -1,225 +1,134 @@
--- # SimpleX Chat Terms & Privacy Policy
layout: layouts/privacy.html
permalink: /privacy/index.html
---
# 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 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).
[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).
## Privacy Policy ## 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. ### Information you provide
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
#### User profiles #### 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. 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.
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.
#### Messages and Files #### 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. 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).
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.
#### Connections with other users #### 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. 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.
#### 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.
#### iOS Push Notifications #### 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. 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 #### 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.
#### User Support 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.
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. ### Information we may share
### Preset Server 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.
Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics. 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.
Preset server operators will not provide general access to their servers or the data on their servers to each other. The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing identified illegal content. This control port access only allows deleting known links and files, and access to aggregate statistics, but does NOT allow enumerating any information on the servers. - To meet any applicable law, regulation, legal process or enforceable governmental request.
- To enforce applicable Terms, including investigation of potential violations.
### 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 detect, prevent, or otherwise address fraud, security, or technical issues. - 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.
At the time of updating this document, the preset server operators have never provided or have been requested the access to the preset relay servers or any information from the servers by 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. 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.
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.
### Updates ### 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; 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.
**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. **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.
**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. **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 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. **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.
**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. **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)
**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. **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.
**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. **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.
**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). **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.
**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. **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.
**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. **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.
**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. **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.
**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. **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.
**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. **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.
**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. Updated August 17, 2023
**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 November 14, 2024
+42 -57
View File
@@ -4,7 +4,7 @@
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat) [![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> <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%"> <img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
@@ -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). 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. 3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat). 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). [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. 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-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%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 - chat bots and automations
- integrations with other apps - 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: 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. You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
@@ -127,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/)|| |🇫🇮 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)| |🇫🇷 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>-||| |🇮🇱 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/)|| |🇮🇹 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/)|| |🇯🇵 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/)|| |🇳🇱 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/)||
@@ -135,7 +134,6 @@ 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/)|| |🇧🇷 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/)||| |🇷🇺 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/)||| |🇹🇭 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/)|| |🇺🇦 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/)|| |🇨🇳 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/)||
@@ -150,7 +148,7 @@ We would love to have you join the development! You can help us with:
- contributing to SimpleX Chat knowledge-base. - contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started. - developing features - please connect to us via chat so we can help you get started.
## Please support us with your donations ## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat! Huge thank you to everybody who donated to SimpleX Chat!
@@ -158,19 +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. 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: It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission). - [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad - [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt - Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg - Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- USDT: - USDT:
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu - Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
- please ask if you want to donate any other coins. - Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
Thank you, Thank you,
@@ -233,22 +232,14 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates: Recent and important updates:
[Nov 25, 2025. Servers operated by Flux - true privacy and decentralization for all users](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.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)
[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.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). [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).
[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). [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).
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.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). [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).
[Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md). [Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
@@ -296,27 +287,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. 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. 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. 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. 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. 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. 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. 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. 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 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. 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). 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. 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. 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. 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. 12. Manual messaging queue rotations to move conversation to another SMP relay.
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. 13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
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. 14. Local files encryption, except videos (to be added later).
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.
We plan to add: 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). 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. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time. 2. Post-quantum resistant key exchange in double ratchet protocol.
3. 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. 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. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party. 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 ## For developers
@@ -380,14 +369,12 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Desktop client. - ✅ Desktop client.
- ✅ Encryption of local files stored in the app. - ✅ Encryption of local files stored in the app.
- ✅ Using mobile profiles from the desktop 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).
- 🏗 Improve stability and reduce battery usage.
- 🏗 Improve experience for the new users. - 🏗 Improve experience for the new users.
- 🏗 Post-quantum resistant key exchange in double ratchet protocol.
- 🏗 Large groups, communities and public channels. - 🏗 Large groups, communities and public channels.
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- Privacy & security slider - a simple way to set all settings at once. - Privacy & security slider - a simple way to set all settings at once.
- Improve sending videos (including encryption of locally stored videos).
- SMP queue redundancy and rotation (manual is supported). - SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address. - 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.). - Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
@@ -405,9 +392,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. [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 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).
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).
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. 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.
@@ -417,13 +402,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. 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 ## 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 ## License
+22 -91
View File
@@ -9,25 +9,39 @@
import Foundation import Foundation
import UIKit import UIKit
import SimpleXChat import SimpleXChat
import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate { class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
logger.debug("AppDelegate: didFinishLaunchingWithOptions") logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications() application.registerForRemoteNotifications()
removePasscodesIfReinstalled() if #available(iOS 17.0, *) { trackKeyboard() }
prepareForLaunch()
deleteOldChatArchive()
return true 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) { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined() let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
let m = ChatModel.shared let m = ChatModel.shared
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token) let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
m.deviceToken = deviceToken m.deviceToken = deviceToken
// savedToken is set in startChat, when it is started before this method is called
if m.savedToken != nil { if m.savedToken != nil {
registerToken(token: deviceToken) registerToken(token: deviceToken)
} }
@@ -66,7 +80,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
} }
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
if m.ntfEnablePeriodic && allowBackgroundRefresh() && BGManager.shared.lastRanLongAgo { if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic {
receiveMessages(completionHandler) receiveMessages(completionHandler)
} else { } else {
completionHandler(.noData) completionHandler(.noData)
@@ -107,23 +121,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
BGManager.shared.receiveMessages(complete) 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) { static func keepScreenOn(_ on: Bool) {
UIApplication.shared.isIdleTimerDisabled = on UIApplication.shared.isIdleTimerDisabled = on
} }
@@ -131,79 +128,13 @@ class AppDelegate: NSObject, UIApplicationDelegate {
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate { class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
var window: UIWindow? var window: UIWindow?
static var windowStatic: UIWindow?
var windowScene: UIWindowScene? var windowScene: UIWindowScene?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
UITableView.appearance().backgroundColor = .clear
guard let windowScene = scene as? UIWindowScene else { return } guard let windowScene = scene as? UIWindowScene else { return }
self.windowScene = windowScene self.windowScene = windowScene
window = windowScene.keyWindow window = windowScene.keyWindow
SceneDelegate.windowStatic = windowScene.keyWindow window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
migrateAccentColorAndTheme() window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
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)
} }
} }
@@ -1,12 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.2.svg",
"idiom": "universal"
}
]
}
@@ -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

@@ -1,12 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.wide.svg",
"idiom": "universal"
}
]
}
@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

+80 -241
View File
@@ -9,48 +9,26 @@ import SwiftUI
import Intents import Intents
import SimpleXChat import SimpleXChat
private enum NoticesSheet: Identifiable {
case whatsNew(updatedConditions: Bool)
case updatedConditions
var id: String {
switch self {
case .whatsNew: return "whatsNew"
case .updatedConditions: return "updatedConditions"
}
}
}
struct ContentView: View { struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared @ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared @ObservedObject var callController = CallController.shared
@ObservedObject var appSheetState = AppSheetState.shared
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme @Binding var doAuthenticate: Bool
@EnvironmentObject var sceneDelegate: SceneDelegate @Binding var userAuthorized: Bool?
@Binding var canConnectCall: Bool
var contentAccessAuthenticationExtended: Bool @Binding var lastSuccessfulUnlock: TimeInterval?
@Binding var showInitializationView: Bool
@Environment(\.scenePhase) var scenePhase
@State private var automaticAuthenticationAttempted = false
@State private var canConnectViewCall = false
@State private var lastSuccessfulUnlock: TimeInterval? = nil
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false @AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
@State private var noticesShown = false @State private var showSettings = false
@State private var noticesSheetItem: NoticesSheet? = nil @State private var showWhatsNew = false
@State private var showChooseLAMode = false @State private var showChooseLAMode = false
@State private var showSetPasscode = false @State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true
@State private var chatListActionSheet: ChatListActionSheet? = nil @State private var chatListActionSheet: ChatListActionSheet? = nil
@State private var chatListUserPickerSheet: UserPickerSheet? = nil
private let callTopPadding: CGFloat = 40
private enum ChatListActionSheet: Identifiable { private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet) case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
@@ -62,53 +40,16 @@ struct ContentView: View {
} }
} }
private var accessAuthenticated: Bool {
chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended
}
var body: some View { var body: some View {
if #available(iOS 16.0, *) {
allViews()
.scrollContentBackground(.hidden)
} else {
// on iOS 15 scroll view background disabled in SceneDelegate
allViews()
}
}
@ViewBuilder func allViews() -> some View {
ZStack { ZStack {
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted contentView()
// 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()
}
}
if chatModel.showCallView, let call = chatModel.activeCall { if chatModel.showCallView, let call = chatModel.activeCall {
callView(call) callView(call)
} }
if !showSettings, let la = chatModel.laRequest {
if chatListUserPickerSheet == nil, let la = chatModel.laRequest {
LocalAuthView(authRequest: la) LocalAuthView(authRequest: la)
.onDisappear {
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
waitingForOrPassedAuth = accessAuthenticated
}
} else if showSetPasscode { } else if showSetPasscode {
SetAppPasscodeView { SetAppPasscodeView {
chatModel.contentViewAccessAuthenticated = true
prefPerformLA = true prefPerformLA = true
showSetPasscode = false showSetPasscode = false
privacyLocalAuthModeDefault.set(.passcode) privacyLocalAuthModeDefault.set(.passcode)
@@ -118,64 +59,31 @@ struct ContentView: View {
showSetPasscode = false showSetPasscode = false
alertManager.showAlert(laPasscodeNotSetAlert()) 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! } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
.sheet(isPresented: $showSettings) {
SettingsView(showSettings: $showSettings)
}
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) { .confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
Button("System authentication") { initialEnableLA() } Button("System authentication") { initialEnableLA() }
Button("Passcode entry") { showSetPasscode = true } 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 { @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) DatabaseErrorView(status: status)
} else if !chatModel.v3DBMigration.startChat { } else if !chatModel.v3DBMigration.startChat {
MigrateToAppGroupView() MigrateToAppGroupView()
@@ -183,11 +91,11 @@ struct ContentView: View {
if case .onboardingComplete = step, if case .onboardingComplete = step,
chatModel.currentUser != nil { chatModel.currentUser != nil {
mainView() mainView()
.actionSheet(item: $chatListActionSheet) { sheet in .actionSheet(item: $chatListActionSheet) { sheet in
switch sheet { switch sheet {
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false) case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
}
} }
}
} else { } else {
OnboardingView(onboarding: step) OnboardingView(onboarding: step)
} }
@@ -198,11 +106,11 @@ struct ContentView: View {
if CallController.useCallKit() { if CallController.useCallKit() {
ActiveCallView(call: call, canConnectCall: Binding.constant(true)) ActiveCallView(call: call, canConnectCall: Binding.constant(true))
.onDisappear { .onDisappear {
if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() } if userAuthorized == false && doAuthenticate { runAuthenticate() }
} }
} else { } else {
ActiveCallView(call: call, canConnectCall: $canConnectViewCall) ActiveCallView(call: call, canConnectCall: $canConnectCall)
if prefPerformLA && !accessAuthenticated { if prefPerformLA && userAuthorized != true {
Rectangle() Rectangle()
.fill(colorScheme == .dark ? .black : .white) .fill(colorScheme == .dark ? .black : .white)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -211,78 +119,31 @@ struct ContentView: View {
} }
} }
@ViewBuilder 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 { private func lockButton() -> some View {
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") }
} }
private func initializationView() -> some View { private func initializationView() -> some View {
VStack { VStack {
ProgressView().scaleEffect(2) ProgressView().scaleEffect(2)
Text("Opening app") Text("Opening database")
.padding() .padding()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity )
.background(
Rectangle()
.fill(theme.colors.background)
)
} }
private func mainView() -> some View { private func mainView() -> some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet) ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
.redacted(reason: appSheetState.redactionReasons(protectScreen))
.onAppear { .onAppear {
requestNtfAuthorization() if !prefPerformLA { requestNtfAuthorization() }
// Local Authentication notice is to be shown on next start after onboarding is complete // 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 prefLANoticeShown = true
alertManager.showAlert(laNoticeAlert()) alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil { } else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !noticesShown { if !showWhatsNew {
let showWhatsNew = shouldShowWhatsNew() showWhatsNew = shouldShowWhatsNew()
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
noticesShown = showWhatsNew || showUpdatedConditions
if showWhatsNew {
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
} else if showUpdatedConditions {
noticesSheetItem = .updatedConditions
}
} }
} }
} }
@@ -290,22 +151,8 @@ struct ContentView: View {
connectViaUrl() connectViaUrl()
} }
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.sheet(item: $noticesSheetItem) { item in .sheet(isPresented: $showWhatsNew) {
switch item { WhatsNewView()
case let .whatsNew(updatedConditions):
WhatsNewView(updatedConditions: updatedConditions)
.modifier(ThemedBackground())
.if(updatedConditions) { v in
v.task { await setConditionsNotified_() }
}
case .updatedConditions:
UsageConditionsView(
currUserServers: Binding.constant([]),
userServers: Binding.constant([])
)
.modifier(ThemedBackground(grouped: true))
.task { await setConditionsNotified_() }
}
} }
if chatModel.setDeliveryReceipts { if chatModel.setDeliveryReceipts {
SetDeliveryReceiptsView() SetDeliveryReceiptsView()
@@ -317,15 +164,6 @@ struct ContentView: View {
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
} }
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) { private func processUserActivity(_ activity: NSUserActivity) {
let intent = activity.interaction?.intent let intent = activity.interaction?.intent
if let intent = intent as? INStartCallIntent { if let intent = intent as? INStartCallIntent {
@@ -342,53 +180,55 @@ struct ContentView: View {
if let contactId = contacts?.first?.personHandle?.value, if let contactId = contacts?.first?.personHandle?.value,
let chat = chatModel.getChat(contactId), let chat = chatModel.getChat(contactId),
case let .direct(contact) = chat.chatInfo { case let .direct(contact) = chat.chatInfo {
let activeCall = chatModel.activeCall logger.debug("callToRecentContact: schedule call")
// This line works when a user clicks on a video button in CallKit UI while in call. DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// The app tries to make another call to the same contact and overwite activeCall instance making its state broken CallController.shared.startCall(contact, mediaType)
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)
}
} }
} }
} }
private func unlockedRecently() -> Bool { private func initAuthenticate() {
if let lastSuccessfulUnlock = lastSuccessfulUnlock { logger.debug("initAuthenticate")
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil {
} else { userAuthorized = false
return false } else if doAuthenticate {
runAuthenticate()
} }
} }
private func authenticateContentViewAccess() { private func runAuthenticate() {
logger.debug("DEBUGGING: authenticateContentViewAccess") logger.debug("DEBUGGING: runAuthenticate")
dismissAllSheets(animated: false) { if !prefPerformLA {
logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback") userAuthorized = true
chatModel.chatId = nil } 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 private func justAuthenticate() {
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") userAuthorized = false
switch (laResult) { let laMode = privacyLocalAuthModeDefault.get()
case .success: authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
chatModel.contentViewAccessAuthenticated = true logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
canConnectViewCall = true switch (laResult) {
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime case .success:
case .failed: userAuthorized = true
chatModel.contentViewAccessAuthenticated = false canConnectCall = true
if privacyLocalAuthModeDefault.get() == .passcode { lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
AlertManager.shared.showAlert(laFailedAlert()) case .failed:
} if laMode == .passcode {
case .unavailable: AlertManager.shared.showAlert(laFailedAlert())
prefPerformLA = false
canConnectViewCall = true
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
} }
case .unavailable:
userAuthorized = true
prefPerformLA = false
canConnectCall = true
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
} }
} }
} }
@@ -419,7 +259,6 @@ struct ContentView: View {
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
switch laResult { switch laResult {
case .success: case .success:
chatModel.contentViewAccessAuthenticated = true
prefPerformLA = true prefPerformLA = true
alertManager.showAlert(laTurnedOnAlert()) alertManager.showAlert(laTurnedOnAlert())
case .failed: case .failed:
+1 -1
View File
@@ -179,7 +179,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
if playback { if playback {
if AVAudioSession.sharedInstance().category != .playback { if AVAudioSession.sharedInstance().category != .playback {
logger.log("AudioSession: 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 { } else {
if AVAudioSession.sharedInstance().category != .soloAmbient { if AVAudioSession.sharedInstance().category != .soloAmbient {
+6 -28
View File
@@ -15,13 +15,7 @@ private let receiveTaskId = "chat.simplex.app.receive"
// TCP timeout + 2 sec // TCP timeout + 2 sec
private let waitForMessages: TimeInterval = 6 private let waitForMessages: TimeInterval = 6
// This is the smallest interval between refreshes, and also target interval in "off" mode private let bgRefreshInterval: TimeInterval = 450
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 maxTimerCount = 9 private let maxTimerCount = 9
@@ -39,14 +33,14 @@ class BGManager {
} }
} }
func schedule(interval: TimeInterval? = nil) { func schedule() {
if !ChatModel.shared.ntfEnableLocal { if !ChatModel.shared.ntfEnableLocal {
logger.debug("BGManager.schedule: disabled") logger.debug("BGManager.schedule: disabled")
return return
} }
logger.debug("BGManager.schedule") logger.debug("BGManager.schedule")
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId) let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
request.earliestBeginDate = Date(timeIntervalSinceNow: interval ?? runInterval) request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval)
do { do {
try BGTaskScheduler.shared.submit(request) try BGTaskScheduler.shared.submit(request)
} catch { } 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) { private func handleRefresh(_ task: BGAppRefreshTask) {
if !ChatModel.shared.ntfEnableLocal { if !ChatModel.shared.ntfEnableLocal {
logger.debug("BGManager.handleRefresh: disabled") logger.debug("BGManager.handleRefresh: disabled")
return return
} }
logger.debug("BGManager.handleRefresh") logger.debug("BGManager.handleRefresh")
let shouldRun_ = lastRanLongAgo schedule()
if allowBackgroundRefresh() && shouldRun_ { if appStateGroupDefault.get().inactive {
schedule()
let completeRefresh = completionHandler { let completeRefresh = completionHandler {
task.setTaskCompleted(success: true) task.setTaskCompleted(success: true)
} }
task.expirationHandler = { completeRefresh("expirationHandler") } task.expirationHandler = { completeRefresh("expirationHandler") }
receiveMessages(completeRefresh) receiveMessages(completeRefresh)
} else { } else {
schedule(interval: shouldRun_ ? bgRefreshInterval : runInterval)
logger.debug("BGManager.completionHandler: already active, not started") logger.debug("BGManager.completionHandler: already active, not started")
task.setTaskCompleted(success: true) task.setTaskCompleted(success: true)
} }
@@ -110,22 +90,20 @@ class BGManager {
} }
self.completed = false self.completed = false
DispatchQueue.main.async { DispatchQueue.main.async {
chatLastBackgroundRunGroupDefault.set(Date.now)
let m = ChatModel.shared let m = ChatModel.shared
if (!m.chatInitialized) { if (!m.chatInitialized) {
setAppState(.bgRefresh)
do { do {
try initializeChat(start: true) try initializeChat(start: true)
} catch let error { } catch let error {
fatalError("Failed to start or load chats: \(responseError(error))") fatalError("Failed to start or load chats: \(responseError(error))")
} }
} }
activateChat(appState: .bgRefresh)
if m.currentUser == nil { if m.currentUser == nil {
completeReceiving("no current user") completeReceiving("no current user")
return return
} }
logger.debug("BGManager.receiveMessages: starting chat") logger.debug("BGManager.receiveMessages: starting chat")
activateChat(appState: .bgRefresh)
let cr = ChatReceiver() let cr = ChatReceiver()
self.chatReceiver = cr self.chatReceiver = cr
cr.start() cr.start()
+146 -342
View File
@@ -43,124 +43,28 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
items.append(item) items.append(item)
} }
class ItemsModel: ObservableObject {
static let shared = ItemsModel()
private let publisher = ObservableObjectPublisher()
private var bag = Set<AnyCancellable>()
var reversedChatItems: [ChatItem] = [] {
willSet { publisher.send() }
}
var itemAdded = false {
willSet { publisher.send() }
}
// Publishes directly to `objectWillChange` publisher,
// this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false
@Published var showLoadingProgress = false
init() {
publisher
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
let navigationTimeout = Task {
do {
try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run {
willNavigate()
ChatModel.shared.chatId = chatId
}
} catch {}
}
let progressTimeout = Task {
do {
try await Task.sleep(nanoseconds: 1500_000000)
await MainActor.run { showLoadingProgress = true }
} catch {}
}
Task {
if let chat = ChatModel.shared.getChat(chatId) {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 5000_000000)
await loadChat(chat: chat)
navigationTimeout.cancel()
progressTimeout.cancel()
await MainActor.run {
self.isLoading = false
self.showLoadingProgress = false
willNavigate()
ChatModel.shared.chatId = chatId
}
}
}
}
}
class NetworkModel: ObservableObject {
// map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
static let shared = NetworkModel()
private init() { }
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] = status
}
}
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] ?? .unknown
} else {
.unknown
}
}
}
/// ChatItemWithMenu can depend on previous or next item for it's appearance
/// This dummy model is used to force an update of all chat items,
/// when they might have changed appearance.
class ChatItemDummyModel: ObservableObject {
static let shared = ChatItemDummyModel()
func sendUpdate() { objectWillChange.send() }
}
final class ChatModel: ObservableObject { final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage? @Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false @Published var setDeliveryReceipts = false
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
@Published var currentUser: User? { @Published var currentUser: User?
didSet {
ThemeManager.applyTheme(currentThemeDefault.get())
}
}
@Published var users: [UserInfo] = [] @Published var users: [UserInfo] = []
@Published var chatInitialized = false @Published var chatInitialized = false
@Published var chatRunning: Bool? @Published var chatRunning: Bool?
@Published var chatDbChanged = false @Published var chatDbChanged = false
@Published var chatDbEncrypted: Bool? @Published var chatDbEncrypted: Bool?
@Published var chatDbStatus: DBMigrationResult? @Published var chatDbStatus: DBMigrationResult?
@Published var ctrlInitInProgress: Bool = false
@Published var notificationResponse: UNNotificationResponse?
// local authentication
@Published var contentViewAccessAuthenticated: Bool = false
@Published var laRequest: LocalAuthRequest? @Published var laRequest: LocalAuthRequest?
// list of chat "previews" // list of chat "previews"
@Published private(set) var chats: [Chat] = [] @Published var chats: [Chat] = []
@Published var deletedChats: Set<String> = [] // map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
// current chat // current chat
@Published var chatId: String? @Published var chatId: String?
@Published var reversedChatItems: [ChatItem] = []
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:] var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String? @Published var chatToTop: String?
@Published var groupMembers: [GMember] = [] @Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
@Published var membersLoaded = false
// items in the terminal view // items in the terminal view
@Published var showingTerminal = false @Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = [] @Published var terminalItems: [TerminalItem] = []
@@ -172,7 +76,6 @@ final class ChatModel: ObservableObject {
@Published var tokenRegistered = false @Published var tokenRegistered = false
@Published var tokenStatus: NtfTknStatus? @Published var tokenStatus: NtfTknStatus?
@Published var notificationMode = NotificationsMode.off @Published var notificationMode = NotificationsMode.off
@Published var notificationServer: String?
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get() @Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
// pending notification actions // pending notification actions
@Published var ntfContactRequest: NTFContactRequest? @Published var ntfContactRequest: NTFContactRequest?
@@ -182,19 +85,16 @@ final class ChatModel: ObservableObject {
@Published var activeCall: Call? @Published var activeCall: Call?
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor() let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
@Published var showCallView = false @Published var showCallView = false
@Published var activeCallViewIsCollapsed = false
// remote desktop // remote desktop
@Published var remoteCtrlSession: RemoteCtrlSession? @Published var remoteCtrlSession: RemoteCtrlSession?
// currently showing invitation // currently showing QR code
@Published var showingInvitation: ShowingInvitation? @Published var connReqInv: String?
@Published var migrationState: MigrationToState? = MigrationToDeviceState.makeMigrationState()
// audio recording and playback // audio recording and playback
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source @Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
@Published var draft: ComposeState? @Published var draft: ComposeState?
@Published var draftChatId: String? @Published var draftChatId: String?
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true) // tracks keyboard height via subscription in AppDelegate
// usage conditions @Published var keyboardHeight: CGFloat = 0
@Published var conditions: ServerOperatorConditions = .empty
var messageDelivery: Dictionary<Int64, () -> Void> = [:] var messageDelivery: Dictionary<Int64, () -> Void> = [:]
@@ -202,14 +102,14 @@ final class ChatModel: ObservableObject {
static let shared = ChatModel() static let shared = ChatModel()
let im = ItemsModel.shared
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true var ntfEnableLocal: Bool {
notificationMode == .off || ntfEnableLocalGroupDefault.get()
}
var ntfEnablePeriodic: Bool { var ntfEnablePeriodic: Bool {
notificationMode != .off notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get()
} }
var activeRemoteCtrl: Bool { var activeRemoteCtrl: Bool {
@@ -236,7 +136,7 @@ final class ChatModel: ObservableObject {
} }
func removeUser(_ user: User) { func removeUser(_ user: User) {
if let i = getUserIndex(user) { if let i = getUserIndex(user), users[i].user.userId != currentUser?.userId {
users.remove(at: i) users.remove(at: i)
} }
} }
@@ -269,47 +169,18 @@ final class ChatModel: ObservableObject {
} }
} }
func populateGroupMembersIndexes() {
groupMembersIndexes.removeAll()
for (i, member) in groupMembers.enumerated() {
groupMembersIndexes[member.groupMemberId] = i
}
}
func getGroupMember(_ groupMemberId: Int64) -> GMember? { func getGroupMember(_ groupMemberId: Int64) -> GMember? {
if let i = groupMembersIndexes[groupMemberId] { groupMembers.first { $0.groupMemberId == groupMemberId }
return groupMembers[i]
}
return nil
}
func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
if chatId == groupInfo.id {
self.groupMembers = groupMembers.map { GMember.init($0) }
self.populateGroupMembersIndexes()
self.membersLoaded = true
updateView()
}
}
} }
private func getChatIndex(_ id: String) -> Int? { private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id }) chats.firstIndex(where: { $0.id == id })
} }
func addChat(_ chat: Chat) { func addChat(_ chat: Chat, at position: Int = 0) {
if chatId == nil { withAnimation {
withAnimation { addChat_(chat, at: 0) } chats.insert(chat, at: position)
} else {
addChat_(chat, at: 0)
} }
popChatCollector.throttlePopChat(chat.chatInfo.id, currentPosition: 0)
}
func addChat_(_ chat: Chat, at position: Int = 0) {
chats.insert(chat, at: position)
} }
func updateChatInfo(_ cInfo: ChatInfo) { func updateChatInfo(_ cInfo: ChatInfo) {
@@ -367,10 +238,26 @@ final class ChatModel: ObservableObject {
} }
} }
func updateChats(_ newChats: [ChatData]) { func updateChats(with newChats: [ChatData]) {
chats = newChats.map { Chat($0) } for i in 0..<newChats.count {
let c = newChats[i]
if let j = getChatIndex(c.id) {
let chat = chats[j]
chat.chatInfo = c.chatInfo
chat.chatItems = c.chatItems
chat.chatStats = c.chatStats
if i != j {
if chatId != c.chatInfo.id {
popChat_(j, to: i)
} else if i == 0 {
chatToTop = c.chatInfo.id
}
}
} else {
addChat(Chat(c), at: i)
}
}
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers()) NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
popChatCollector.clear()
} }
// func addGroup(_ group: SimpleXChat.Group) { // func addGroup(_ group: SimpleXChat.Group) {
@@ -378,12 +265,6 @@ final class ChatModel: ObservableObject {
// } // }
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// mark chat non deleted
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
updateContact(updatedContact)
}
// update previews // update previews
if let i = getChatIndex(cInfo.id) { if let i = getChatIndex(cInfo.id) {
chats[i].chatItems = switch cInfo { chats[i].chatItems = switch cInfo {
@@ -401,9 +282,18 @@ final class ChatModel: ObservableObject {
[cItem] [cItem]
} }
if case .rcvNew = cItem.meta.itemStatus { if case .rcvNew = cItem.meta.itemStatus {
unreadCollector.changeUnreadCounter(cInfo.id, by: 1) chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
increaseUnreadCounter(user: currentUser!)
}
if i > 0 {
if chatId == nil {
withAnimation { popChat_(i) }
} else if chatId == cInfo.id {
chatToTop = cInfo.id
} else {
popChat_(i)
}
} }
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else { } else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
} }
@@ -418,7 +308,7 @@ final class ChatModel: ObservableObject {
var res: Bool var res: Bool
if let chat = getChat(cInfo.id) { if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last { if let pItem = chat.chatItems.last {
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
chat.chatItems = [cItem] chat.chatItems = [cItem]
} }
} else { } else {
@@ -429,26 +319,24 @@ final class ChatModel: ObservableObject {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
res = true res = true
} }
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
// update current chat // update current chat
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
} }
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) { if let i = getChatItemIndex(cItem) {
_updateChatItem(at: i, with: cItem) withAnimation {
ChatItemDummyModel.shared.sendUpdate() _updateChatItem(at: i, with: cItem)
}
return false return false
} else { } else {
var ci = cItem withAnimation(itemAnimation()) {
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { var ci = cItem
ci.meta.itemStatus = status if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
} }
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true return true
} }
@@ -462,7 +350,7 @@ final class ChatModel: ObservableObject {
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) { func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) { if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
withConditionalAnimation { withAnimation {
_updateChatItem(at: i, with: cItem) _updateChatItem(at: i, with: cItem)
} }
} else if let status = status { } else if let status = status {
@@ -471,17 +359,17 @@ final class ChatModel: ObservableObject {
} }
private func _updateChatItem(at i: Int, with cItem: ChatItem) { private func _updateChatItem(at i: Int, with cItem: ChatItem) {
im.reversedChatItems[i] = cItem reversedChatItems[i] = cItem
im.reversedChatItems[i].viewTimestamp = .now reversedChatItems[i].viewTimestamp = .now
} }
func getChatItemIndex(_ cItem: ChatItem) -> Int? { func getChatItemIndex(_ cItem: ChatItem) -> Int? {
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id }) reversedChatItems.firstIndex(where: { $0.id == cItem.id })
} }
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew { if cItem.isRcvNew {
unreadCollector.changeUnreadCounter(cInfo.id, by: -1) decreaseUnreadCounter(cInfo)
} }
// update previews // update previews
if let chat = getChat(cInfo.id) { if let chat = getChat(cInfo.id) {
@@ -493,24 +381,23 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id { if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) { if let i = getChatItemIndex(cItem) {
_ = withAnimation { _ = withAnimation {
im.reversedChatItems.remove(at: i) self.reversedChatItems.remove(at: i)
} }
} }
} }
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
} }
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? { func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil } guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous { if previous {
while i < im.reversedChatItems.count - 1 { while i < reversedChatItems.count - 1 {
i += 1 i += 1
if let res = map(im.reversedChatItems[i]) { return res } if let res = map(reversedChatItems[i]) { return res }
} }
} else { } else {
while i > 0 { while i > 0 {
i -= 1 i -= 1
if let res = map(im.reversedChatItems[i]) { return res } if let res = map(reversedChatItems[i]) { return res }
} }
} }
return nil return nil
@@ -528,21 +415,10 @@ final class ChatModel: ObservableObject {
} }
} }
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
guard var current = currentUser, current.uiThemes != uiThemes else { return }
current.uiThemes = uiThemes
let i = users.firstIndex(where: { $0.user.userId == current.userId })
if let i {
users[i].user = current
}
currentUser = current
}
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem { func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
let cItem = ChatItem.liveDummy(chatInfo.chatType) let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation { withAnimation {
im.reversedChatItems.insert(cItem, at: 0) reversedChatItems.insert(cItem, at: 0)
im.itemAdded = true
} }
return cItem return cItem
} }
@@ -550,22 +426,21 @@ final class ChatModel: ObservableObject {
func removeLiveDummy(animated: Bool = true) { func removeLiveDummy(animated: Bool = true) {
if hasLiveDummy { if hasLiveDummy {
if animated { if animated {
withAnimation { _ = im.reversedChatItems.removeFirst() } withAnimation { _ = reversedChatItems.removeFirst() }
} else { } else {
_ = im.reversedChatItems.removeFirst() _ = reversedChatItems.removeFirst()
} }
} }
} }
private var hasLiveDummy: Bool { private var hasLiveDummy: Bool {
im.reversedChatItems.first?.isLiveDummy == true reversedChatItems.first?.isLiveDummy == true
} }
func markChatItemsRead(_ cInfo: ChatInfo) { func markChatItemsRead(_ cInfo: ChatInfo) {
// update preview // update preview
_updateChat(cInfo.id) { chat in _updateChat(cInfo.id) { chat in
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
self.updateFloatingButtons(unreadCount: 0)
chat.chatStats = ChatStats() chat.chatStats = ChatStats()
} }
// update current chat // update current chat
@@ -576,18 +451,12 @@ final class ChatModel: ObservableObject {
private func markCurrentChatRead(fromIndex i: Int = 0) { private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i var j = i
while j < im.reversedChatItems.count { while j < reversedChatItems.count {
markChatItemRead_(j) markChatItemRead_(j)
j += 1 j += 1
} }
} }
private func updateFloatingButtons(unreadCount: Int) {
let fbm = ChatView.FloatingButtonModel.shared
fbm.totalUnread = unreadCount
fbm.objectWillChange.send()
}
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) { func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
if let cItem = aboveItem { if let cItem = aboveItem {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) { if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
@@ -596,7 +465,7 @@ final class ChatModel: ObservableObject {
var unreadBelow = 0 var unreadBelow = 0
var j = i - 1 var j = i - 1
while j >= 0 { while j >= 0 {
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus { if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
unreadBelow += 1 unreadBelow += 1
} }
j -= 1 j -= 1
@@ -606,7 +475,6 @@ final class ChatModel: ObservableObject {
if markedCount > 0 { if markedCount > 0 {
chat.chatStats.unreadCount -= markedCount chat.chatStats.unreadCount -= markedCount
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
} }
} }
} }
@@ -632,143 +500,51 @@ final class ChatModel: ObservableObject {
// clear current chat // clear current chat
if chatId == cInfo.id { if chatId == cInfo.id {
chatItemStatuses = [:] chatItemStatuses = [:]
im.reversedChatItems = [] reversedChatItems = []
} }
} }
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) { func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if self.chatId == cInfo.id { // update preview
for itemId in itemIds { decreaseUnreadCounter(cInfo)
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) { // update current chat
markChatItemRead_(i) if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
} markChatItemRead_(i)
}
}
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
}
private let unreadCollector = UnreadCollector()
class UnreadCollector {
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var unreadCounts: [ChatId: Int] = [:]
init() {
subject
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink {
let m = ChatModel.shared
for (chatId, count) in self.unreadCounts {
if let i = m.getChatIndex(chatId) {
m.changeUnreadCounter(i, by: count)
}
}
self.unreadCounts = [:]
}
.store(in: &bag)
}
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
if chatId == ChatModel.shared.chatId {
ChatView.FloatingButtonModel.shared.totalUnread += count
}
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
subject.send()
}
}
let popChatCollector = PopChatCollector()
class PopChatCollector {
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var chatsToPop: [ChatId: Date] = [:]
private let popTsComparator = KeyPathComparator<Chat>(\.popTs, order: .reverse)
init() {
subject
.throttle(for: 2, scheduler: DispatchQueue.main, latest: true)
.sink { self.popCollectedChats() }
.store(in: &bag)
}
func throttlePopChat(_ chatId: ChatId, currentPosition: Int) {
let m = ChatModel.shared
if currentPosition > 0 && m.chatId == chatId {
m.chatToTop = chatId
}
if currentPosition > 0 || !chatsToPop.isEmpty {
chatsToPop[chatId] = Date.now
subject.send()
}
}
func clear() {
chatsToPop = [:]
}
func popCollectedChats() {
let m = ChatModel.shared
var ixs: IndexSet = []
var chs: [Chat] = []
// collect chats that received updates
for (chatId, popTs) in self.chatsToPop {
// Currently opened chat is excluded, removing it from the list would navigate out of it
// It will be popped to top later when user exits from the list.
if m.chatId != chatId, let i = m.getChatIndex(chatId) {
ixs.insert(i)
let ch = m.chats[i]
ch.popTs = popTs
chs.append(ch)
}
}
let removeInsert = {
m.chats.remove(atOffsets: ixs)
// sort chats by pop timestamp in descending order
m.chats.insert(contentsOf: chs.sorted(using: self.popTsComparator), at: 0)
}
if m.chatId == nil {
withAnimation { removeInsert() }
} else {
removeInsert()
}
self.chatsToPop = [:]
} }
} }
private func markChatItemRead_(_ i: Int) { private func markChatItemRead_(_ i: Int) {
let meta = im.reversedChatItems[i].meta let meta = reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus { if case .rcvNew = meta.itemStatus {
im.reversedChatItems[i].meta.itemStatus = .rcvRead reversedChatItems[i].meta.itemStatus = .rcvRead
im.reversedChatItems[i].viewTimestamp = .now reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl { if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl) reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
} }
} }
} }
func changeUnreadCounter(_ chatIndex: Int, by count: Int) { func decreaseUnreadCounter(_ cInfo: ChatInfo) {
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count if let i = getChatIndex(cInfo.id) {
changeUnreadCounter(user: currentUser!, by: count) chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
decreaseUnreadCounter(user: currentUser!)
}
} }
func increaseUnreadCounter(user: any UserLike) { func increaseUnreadCounter(user: any UserLike) {
changeUnreadCounter(user: user, by: 1) changeUnreadCounter(user: user, by: 1)
NtfManager.shared.incNtfBadgeCount()
} }
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) { func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
changeUnreadCounter(user: user, by: -by) changeUnreadCounter(user: user, by: -by)
NtfManager.shared.decNtfBadgeCount(by: by)
} }
private func changeUnreadCounter(user: any UserLike, by: Int) { private func changeUnreadCounter(user: any UserLike, by: Int) {
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) { if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
users[i].unreadCount += by users[i].unreadCount += by
} }
NtfManager.shared.changeNtfBadgeCount(by: by)
} }
func totalUnreadCountForAllUsers() -> Int { func totalUnreadCountForAllUsers() -> Int {
@@ -782,8 +558,8 @@ final class ChatModel: ObservableObject {
var ns: [String] = [] var ns: [String] = []
if let ciCategory = chatItem.mergeCategory, if let ciCategory = chatItem.mergeCategory,
var i = getChatItemIndex(chatItem) { var i = getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count { while i < reversedChatItems.count {
let ci = im.reversedChatItems[i] let ci = reversedChatItems[i]
if ci.mergeCategory != ciCategory { break } if ci.mergeCategory != ciCategory { break }
if let m = ci.memberConnected { if let m = ci.memberConnected {
ns.append(m.displayName) ns.append(m.displayName)
@@ -798,7 +574,7 @@ final class ChatModel: ObservableObject {
// returns the index of the passed item and the next item (it has smaller index) // returns the index of the passed item and the next item (it has smaller index)
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) { func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
if let i = getChatItemIndex(ci) { if let i = getChatItemIndex(ci) {
(i, i > 0 ? im.reversedChatItems[i - 1] : nil) (i, i > 0 ? reversedChatItems[i - 1] : nil)
} else { } else {
(nil, nil) (nil, nil)
} }
@@ -808,10 +584,10 @@ final class ChatModel: ObservableObject {
// and the previous visible item with another merge category // and the previous visible item with another merge category
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) { func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
guard var i = ciIndex else { return (nil, nil) } guard var i = ciIndex else { return (nil, nil) }
let fst = im.reversedChatItems.count - 1 let fst = reversedChatItems.count - 1
while i < fst { while i < fst {
i = i + 1 i = i + 1
let ci = im.reversedChatItems[i] let ci = reversedChatItems[i]
if ciCategory == nil || ciCategory != ci.mergeCategory { if ciCategory == nil || ciCategory != ci.mergeCategory {
return (i - 1, ci) return (i - 1, ci)
} }
@@ -824,7 +600,7 @@ final class ChatModel: ObservableObject {
var prevMember: GroupMember? = nil var prevMember: GroupMember? = nil
var memberIds: Set<Int64> = [] var memberIds: Set<Int64> = []
for i in range { for i in range {
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir { if case let .groupRcv(m) = reversedChatItems[i].chatDir {
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
memberIds.insert(m.groupMemberId) memberIds.insert(m.groupMemberId)
} }
@@ -834,7 +610,6 @@ final class ChatModel: ObservableObject {
func popChat(_ id: String) { func popChat(_ id: String) {
if let i = getChatIndex(id) { if let i = getChatIndex(id) {
// no animation here, for it not to look like it just moved when leaving the chat
popChat_(i) popChat_(i)
} }
} }
@@ -845,16 +620,14 @@ final class ChatModel: ObservableObject {
} }
func dismissConnReqView(_ id: String) { func dismissConnReqView(_ id: String) {
if id == showingInvitation?.pcc.id { if let connReqInv = connReqInv,
markShowingInvitationUsed() let c = getChat(id),
case let .contactConnection(contactConnection) = c.chatInfo,
connReqInv == contactConnection.connReqInv {
dismissAllSheets() dismissAllSheets()
} }
} }
func markShowingInvitationUsed() {
showingInvitation?.connChatUsed = true
}
func removeChat(_ id: String) { func removeChat(_ id: String) {
withAnimation { withAnimation {
chats.removeAll(where: { $0.id == id }) chats.removeAll(where: { $0.id == id })
@@ -869,17 +642,14 @@ final class ChatModel: ObservableObject {
} }
// update current chat // update current chat
if chatId == groupInfo.id { if chatId == groupInfo.id {
if let i = groupMembersIndexes[member.groupMemberId] { if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
withAnimation(.default) { withAnimation(.default) {
self.groupMembers[i].wrapped = member self.groupMembers[i].wrapped = member
self.groupMembers[i].created = Date.now self.groupMembers[i].created = Date.now
} }
return false return false
} else { } else {
withAnimation { withAnimation { groupMembers.append(GMember(member)) }
groupMembers.append(GMember(member))
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
}
return true return true
} }
} else { } else {
@@ -895,11 +665,43 @@ final class ChatModel: ObservableObject {
_ = upsertGroupMember(groupInfo, updatedMember) _ = upsertGroupMember(groupInfo, updatedMember)
} }
} }
}
struct ShowingInvitation { func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
var pcc: PendingContactConnection var i = 0
var connChatUsed: Bool var totalBelow = 0
var unreadBelow = 0
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
totalBelow += 1
if reversedChatItems[i].isRcvNew {
unreadBelow += 1
}
i += 1
}
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
}
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
let maxIx = reversedChatItems.count - 1
var i = 0
let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
while i < maxIx && !inView(i) { i += 1 }
while i < maxIx && inView(i) { i += 1 }
return reversedChatItems[min(i - 1, maxIx)]
}
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] = status
}
}
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] ?? .unknown
} else {
.unknown
}
}
} }
struct NTFContactRequest { struct NTFContactRequest {
@@ -907,12 +709,16 @@ struct NTFContactRequest {
var chatId: String var chatId: String
} }
final class Chat: ObservableObject, Identifiable, ChatLike { struct UnreadChatItemCounts {
var totalBelow: Int
var unreadBelow: Int
}
final class Chat: ObservableObject, Identifiable {
@Published var chatInfo: ChatInfo @Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem] @Published var chatItems: [ChatItem]
@Published var chatStats: ChatStats @Published var chatStats: ChatStats
var created = Date.now var created = Date.now
fileprivate var popTs: Date?
init(_ cData: ChatData) { init(_ cData: ChatData) {
self.chatInfo = cData.chatInfo self.chatInfo = cData.chatInfo
@@ -940,8 +746,6 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
case let .group(groupInfo): case let .group(groupInfo):
let m = groupInfo.membership let m = groupInfo.membership
return m.memberActive && m.memberRole >= .member return m.memberActive && m.memberRole >= .member
case .local:
return true
default: return false default: return false
} }
} }
@@ -7,19 +7,18 @@
// //
import Foundation import Foundation
import SimpleXChat
import SwiftUI import SwiftUI
import AVKit import AVKit
import SwiftyGif
import LinkPresentation
public func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? { func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
if let file = file, file.loaded { if let file = file, file.loaded {
return file.fileSource return file.fileSource
} }
return nil return nil
} }
public func getLoadedImage(_ file: CIFile?) -> UIImage? { func getLoadedImage(_ file: CIFile?) -> UIImage? {
if let fileSource = getLoadedFileSource(file) { if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath) let filePath = getAppFilePath(fileSource.filePath)
do { do {
@@ -38,7 +37,7 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? {
return nil return nil
} }
public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data { func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
if let cfArgs = cfArgs { if let cfArgs = cfArgs {
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs) return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
} else { } 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) { if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath) let filePath = getAppFilePath(fileSource.filePath)
if FileManager.default.fileExists(atPath: filePath.path) { if FileManager.default.fileExists(atPath: filePath.path) {
@@ -56,13 +55,13 @@ public func getLoadedVideo(_ file: CIFile?) -> URL? {
return nil return nil
} }
public func saveAnimImage(_ image: UIImage) -> CryptoFile? { func saveAnimImage(_ image: UIImage) -> CryptoFile? {
let fileName = generateNewFileName("IMG", "gif") let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil } guard let imageData = image.imageData else { return nil }
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get()) return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
} }
public func saveImage(_ uiImage: UIImage) -> CryptoFile? { func saveImage(_ uiImage: UIImage) -> CryptoFile? {
let hasAlpha = imageHasAlpha(uiImage) let hasAlpha = imageHasAlpha(uiImage)
let ext = hasAlpha ? "png" : "jpg" let ext = hasAlpha ? "png" : "jpg"
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) { if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
@@ -72,7 +71,7 @@ public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
return nil return nil
} }
public func cropToSquare(_ image: UIImage) -> UIImage { func cropToSquare(_ image: UIImage) -> UIImage {
let size = image.size let size = image.size
let side = min(size.width, size.height) let side = min(size.width, size.height)
let newSize = CGSize(width: side, height: side) 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)) 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 img = image
var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0 var dataSize = data?.count ?? 0
@@ -100,7 +99,7 @@ public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha
return data return data
} }
public func resizeImageToStrSizeSync(_ image: UIImage, maxDataSize: Int64) -> String? { func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
var img = image var img = image
let hasAlpha = imageHasAlpha(image) let hasAlpha = imageHasAlpha(image)
var str = compressImageStr(img, hasAlpha: hasAlpha) var str = compressImageStr(img, hasAlpha: hasAlpha)
@@ -116,15 +115,7 @@ public func resizeImageToStrSizeSync(_ image: UIImage, maxDataSize: Int64) -> St
return str return str
} }
public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) async -> String? { func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> 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: ", ")
// }
let ext = hasAlpha ? "png" : "jpg" let ext = hasAlpha ? "png" : "jpg"
if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) { if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
return "data:image/\(ext);base64,\(data.base64EncodedString())" 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) 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() let format = UIGraphicsImageRendererFormat()
format.scale = 1.0 format.scale = 1.0
format.opaque = !hasAlpha 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 { if let cgImage = img.cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB() let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@@ -167,36 +158,7 @@ public func imageHasAlpha(_ img: UIImage) -> Bool {
return false return false
} }
/// Reduces image size, while consuming less RAM func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? {
///
/// 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()
let savedFile: CryptoFile? let savedFile: CryptoFile?
if url.startAccessingSecurityScopedResource() { if url.startAccessingSecurityScopedResource() {
do { do {
@@ -221,75 +183,30 @@ public func saveFileFromURL(_ url: URL) -> CryptoFile? {
return savedFile return savedFile
} }
public func moveTempFileFromURL(_ url: URL) -> CryptoFile? { func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
do { do {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let fileName = uniqueCombine(url.lastPathComponent) let fileName = uniqueCombine(url.lastPathComponent)
let savedFile: CryptoFile? try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
if encrypted { ChatModel.shared.filesToDelete.remove(url)
let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: getAppFilePath(fileName).path) return CryptoFile.plain(fileName)
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
} catch { } catch {
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)") logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
return nil return nil
} }
} }
public func saveWallpaperFile(url: URL) -> String? { func generateNewFileName(_ prefix: String, _ ext: String) -> String {
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true)) uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
do {
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
return destFile.lastPathComponent
} catch {
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
return nil
}
} }
public func saveWallpaperFile(image: UIImage) -> String? { private func uniqueCombine(_ fileName: String) -> 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(atPath: getWallpaperDirectory().path).forEach {
if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) }
}
} catch {
logger.error("FileUtils.removeWallpaperFile error: \(error.localizedDescription)")
}
if let fileName {
WallpaperType.cachedImages.removeValue(forKey: fileName)
}
}
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 {
func tryCombine(_ fileName: String, _ n: Int) -> String { func tryCombine(_ fileName: String, _ n: Int) -> String {
let ns = fileName as NSString let ns = fileName as NSString
let name = ns.deletingPathExtension let name = ns.deletingPathExtension
let ext = ns.pathExtension let ext = ns.pathExtension
let suffix = (n == 0) ? "" : "_\(n)" let suffix = (n == 0) ? "" : "_\(n)"
let f = "\(name)\(suffix).\(ext)" 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) return tryCombine(fileName, 0)
} }
@@ -310,7 +227,7 @@ private func getTimestamp() -> String {
return df.string(from: Date()) 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,") dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
} }
@@ -318,23 +235,8 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s 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 { extension AVAsset {
public func generatePreview() -> (UIImage, Int)? { func generatePreview() -> (UIImage, Int)? {
let generator = AVAssetImageGenerator(asset: self) let generator = AVAssetImageGenerator(asset: self)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
var actualTime = CMTimeMake(value: 0, timescale: 0) var actualTime = CMTimeMake(value: 0, timescale: 0)
@@ -346,7 +248,7 @@ extension AVAsset {
} }
extension UIImage { extension UIImage {
public func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage { func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
if let cgImage = cgImage { if let cgImage = cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB() let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@@ -392,92 +294,3 @@ extension UIImage {
return self 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
}
}
-83
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)
}
}
}
}
@@ -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))")
}
}
}
}
+17 -38
View File
@@ -26,37 +26,20 @@ enum NtfCallAction {
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
static let shared = NtfManager() static let shared = NtfManager()
public var navigatingToChat = false
private var granted = false private var granted = false
private var prevNtfTime: Dictionary<ChatId, Date> = [:] private var prevNtfTime: Dictionary<ChatId, Date> = [:]
override init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
// Handle notification when app is in background // Handle notification when app is in background
func userNotificationCenter(_ center: UNUserNotificationCenter, func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
withCompletionHandler handler: () -> Void) { withCompletionHandler handler: () -> Void) {
logger.debug("NtfManager.userNotificationCenter: didReceive") logger.debug("NtfManager.userNotificationCenter: didReceive")
if appStateGroupDefault.get() == .active { let content = response.notification.request.content
processNotificationResponse(response)
} else {
logger.debug("NtfManager.userNotificationCenter: remember response in model")
ChatModel.shared.notificationResponse = response
}
handler()
}
func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
let chatModel = ChatModel.shared let chatModel = ChatModel.shared
let content = ntfResponse.notification.request.content let action = response.actionIdentifier
let action = ntfResponse.actionIdentifier logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
logger.debug("NtfManager.processNotificationResponse: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
if let userId = content.userInfo["userId"] as? Int64, if let userId = content.userInfo["userId"] as? Int64,
userId != chatModel.currentUser?.userId { userId != chatModel.currentUser?.userId {
logger.debug("NtfManager.processNotificationResponse changeActiveUser")
changeActiveUser(userId, viewPwd: nil) changeActiveUser(userId, viewPwd: nil)
} }
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito), if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
@@ -74,13 +57,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
chatModel.ntfCallInvitationAction = (chatId, ntfAction) chatModel.ntfCallInvitationAction = (chatId, ntfAction)
} }
} else { } else {
if let chatId = content.targetContentIdentifier { chatModel.chatId = content.targetContentIdentifier
self.navigatingToChat = true
ItemsModel.shared.loadOpenChat(chatId) {
self.navigatingToChat = false
}
}
} }
handler()
} }
private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? { private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? {
@@ -95,6 +74,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
return nil return nil
} }
// Handle notification when the app is in foreground // Handle notification when the app is in foreground
func userNotificationCenter(_ center: UNUserNotificationCenter, func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification, willPresent notification: UNNotification,
@@ -203,12 +183,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
actions: [], actions: [],
intentIdentifiers: [], intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification") 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) { func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest") logger.debug("NtfManager.notifyContactRequest")
addNotification(createContactRequestNtf(user, contactRequest, 0)) addNotification(createContactRequestNtf(user, contactRequest))
} }
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) { func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected") logger.debug("NtfManager.notifyContactConnected")
addNotification(createContactConnectedNtf(user, contact, 0)) addNotification(createContactConnectedNtf(user, contact))
} }
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) { func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived") logger.debug("NtfManager.notifyMessageReceived")
if cInfo.ntfsEnabled { if cInfo.ntfsEnabled {
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0)) addNotification(createMessageReceivedNtf(user, cInfo, cItem))
} }
} }
func notifyCallInvitation(_ invitation: RcvCallInvitation) { func notifyCallInvitation(_ invitation: RcvCallInvitation) {
logger.debug("NtfManager.notifyCallInvitation") logger.debug("NtfManager.notifyCallInvitation")
addNotification(createCallInvitationNtf(invitation, 0)) addNotification(createCallInvitationNtf(invitation))
} }
func setNtfBadgeCount(_ count: Int) { func setNtfBadgeCount(_ count: Int) {
@@ -263,8 +238,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
ntfBadgeCountGroupDefault.set(count) ntfBadgeCountGroupDefault.set(count)
} }
func changeNtfBadgeCount(by count: Int = 1) { func decNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count)) setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
}
func incNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
} }
private func addNotification(_ content: UNMutableNotificationContent) { private func addNotification(_ content: UNMutableNotificationContent) {
File diff suppressed because it is too large Load Diff
+24 -99
View File
@@ -9,42 +9,27 @@
import Foundation import Foundation
import UIKit import UIKit
import SimpleXChat import SimpleXChat
import SwiftUI
private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock") private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock")
let appSuspendTimeout: Int = 15 // seconds
let bgSuspendTimeout: Int = 5 // seconds let bgSuspendTimeout: Int = 5 // seconds
let terminationTimeout: Int = 3 // seconds let terminationTimeout: Int = 3 // seconds
let activationDelay: TimeInterval = 1.5
let nseSuspendTimeout: TimeInterval = 5
private func _suspendChat(timeout: Int) { private func _suspendChat(timeout: Int) {
// this is a redundant check to prevent logical errors, like the one fixed in this PR // 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 { 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 { } else if ChatModel.ok {
AppChatState.shared.set(.suspending) appStateGroupDefault.set(.suspending)
apiSuspendChat(timeoutMicroseconds: timeout * 1000000) apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
let endTask = beginBGTask(chatSuspended) let endTask = beginBGTask(chatSuspended)
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask) DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
} else { } else {
AppChatState.shared.set(.suspended) appStateGroupDefault.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() }
}
} }
} }
@@ -56,16 +41,18 @@ func suspendChat() {
func suspendBgRefresh() { func suspendBgRefresh() {
suspendLockQueue.sync { suspendLockQueue.sync {
if case .bgRefresh = AppChatState.shared.value { if case .bgRefresh = appStateGroupDefault.get() {
_suspendChat(timeout: bgSuspendTimeout) _suspendChat(timeout: bgSuspendTimeout)
} }
} }
} }
private var terminating = false
func terminateChat() { func terminateChat() {
logger.debug("terminateChat") logger.debug("terminateChat")
suspendLockQueue.sync { suspendLockQueue.sync {
switch AppChatState.shared.value { switch appStateGroupDefault.get() {
case .suspending: case .suspending:
// suspend instantly if already suspending // suspend instantly if already suspending
_chatSuspended() _chatSuspended()
@@ -77,6 +64,7 @@ func terminateChat() {
case .stopped: case .stopped:
chatCloseStore() chatCloseStore()
default: default:
terminating = true
// the store will be closed in _chatSuspended when event is received // the store will be closed in _chatSuspended when event is received
_suspendChat(timeout: terminationTimeout) _suspendChat(timeout: terminationTimeout)
} }
@@ -85,7 +73,7 @@ func terminateChat() {
func chatSuspended() { func chatSuspended() {
suspendLockQueue.sync { suspendLockQueue.sync {
if case .suspending = AppChatState.shared.value { if case .suspending = appStateGroupDefault.get() {
_chatSuspended() _chatSuspended()
} }
} }
@@ -93,111 +81,48 @@ func chatSuspended() {
private func _chatSuspended() { private func _chatSuspended() {
logger.debug("_chatSuspended") logger.debug("_chatSuspended")
AppChatState.shared.set(.suspended) appStateGroupDefault.set(.suspended)
if ChatModel.shared.chatRunning == true { if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.stop() ChatReceiver.shared.stop()
} }
chatCloseStore() if terminating {
} chatCloseStore()
func setAppState(_ appState: AppState) {
suspendLockQueue.sync {
AppChatState.shared.set(appState)
} }
} }
func activateChat(appState: AppState = .active) { func activateChat(appState: AppState = .active) {
logger.debug("DEBUGGING: activateChat") logger.debug("DEBUGGING: activateChat")
terminating = false
suspendLockQueue.sync { suspendLockQueue.sync {
AppChatState.shared.set(appState) appStateGroupDefault.set(appState)
if ChatModel.ok { apiActivateChat() } if ChatModel.ok { apiActivateChat() }
logger.debug("DEBUGGING: activateChat: after apiActivateChat") logger.debug("DEBUGGING: activateChat: after apiActivateChat")
} }
} }
func initChatAndMigrate(refreshInvitations: Bool = true) { func initChatAndMigrate(refreshInvitations: Bool = true) {
terminating = false
let m = ChatModel.shared let m = ChatModel.shared
if (!m.chatInitialized) { 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 { 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 { } catch let error {
AlertManager.shared.showAlertMsg( fatalError("Failed to start or load chats: \(responseError(error))")
title: start ? "Error starting chat" : "Error opening chat",
message: "Please contact developers.\nError: \(responseError(error))"
)
} }
} }
} }
func startChatForCall() { func startChatAndActivate() {
logger.debug("DEBUGGING: startChatForCall") terminating = false
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) {
logger.debug("DEBUGGING: startChatAndActivate") logger.debug("DEBUGGING: startChatAndActivate")
if ChatModel.shared.chatRunning == true { if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.start() ChatReceiver.shared.start()
logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start") logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start")
} }
if case .active = AppChatState.shared.value { if .active != appStateGroupDefault.get() {
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() {
logger.debug("DEBUGGING: startChatAndActivate: before activateChat") logger.debug("DEBUGGING: startChatAndActivate: before activateChat")
activateChat() activateChat()
completion()
logger.debug("DEBUGGING: startChatAndActivate: after activateChat") 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
}
}
+52 -57
View File
@@ -16,9 +16,14 @@ struct SimpleXApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var chatModel = ChatModel.shared @StateObject private var chatModel = ChatModel.shared
@ObservedObject var alertManager = AlertManager.shared @ObservedObject var alertManager = AlertManager.shared
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil @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() { init() {
DispatchQueue.global(qos: .background).sync { DispatchQueue.global(qos: .background).sync {
@@ -34,70 +39,53 @@ struct SimpleXApp: App {
} }
var body: some Scene { var body: some Scene {
WindowGroup { return WindowGroup {
// contentAccessAuthenticationExtended has to be passed to ContentView on view initialization, ContentView(
// so that it's computed by the time view renders, and not on event after rendering doAuthenticate: $doAuthenticate,
ContentView(contentAccessAuthenticationExtended: !authenticationExpired()) userAuthorized: $userAuthorized,
canConnectCall: $canConnectCall,
lastSuccessfulUnlock: $lastSuccessfulUnlock,
showInitializationView: $showInitializationView
)
.environmentObject(chatModel) .environmentObject(chatModel)
.environmentObject(AppTheme.shared)
.onOpenURL { url in .onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)") logger.debug("ContentView.onOpenURL: \(url)")
chatModel.appOpenUrl = url chatModel.appOpenUrl = url
} }
.onAppear() { .onAppear() {
// Present screen for continue migration if it wasn't finished yet showInitializationView = true
if chatModel.migrationState != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// It's important, otherwise, user may be locked in undefined state initChatAndMigrate()
onboardingStageDefault.set(.step1_SimpleXInfo)
chatModel.onboardingStage = onboardingStageDefault.get()
} else if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
initChatAndMigrate()
}
} }
} }
.onChange(of: scenePhase) { phase in .onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
AppSheetState.shared.scenePhaseActive = phase == .active
switch (phase) { switch (phase) {
case .background: 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 { if CallController.useCallKit() && chatModel.activeCall != nil {
CallController.shared.shouldSuspendChat = true CallController.shared.shouldSuspendChat = true
} else { } else {
suspendChat() suspendChat()
BGManager.shared.schedule() BGManager.shared.schedule()
} }
if userAuthorized == true {
enteredBackground = ProcessInfo.processInfo.systemUptime
}
doAuthenticate = false
canConnectCall = false
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
case .active: case .active:
CallController.shared.shouldSuspendChat = false CallController.shared.shouldSuspendChat = false
let appState = AppChatState.shared.value let appState = appStateGroupDefault.get()
startChatAndActivate()
if appState != .stopped { if appState.inactive && chatModel.chatRunning == true {
startChatAndActivate { updateChats()
if chatModel.chatRunning == true { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
if let ntfResponse = chatModel.notificationResponse { updateCallInvitations()
chatModel.notificationResponse = nil
NtfManager.shared.processNotificationResponse(ntfResponse)
}
if appState.inactive {
Task {
await updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
}
}
}
}
} }
} }
doAuthenticate = authenticationExpired()
canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently()
default: default:
break break
} }
@@ -115,12 +103,12 @@ struct SimpleXApp: App {
if legacyDatabase, case .documents = dbContainerGroupDefault.get() { if legacyDatabase, case .documents = dbContainerGroupDefault.get() {
dbContainerGroupDefault.set(.documents) dbContainerGroupDefault.set(.documents)
setMigrationState(.offer) 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 { } else {
dbContainerGroupDefault.set(.group) dbContainerGroupDefault.set(.group)
setMigrationState(.ready) setMigrationState(.ready)
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath())*.db") logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db")
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not") present") logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present")
} }
} }
@@ -130,25 +118,32 @@ struct SimpleXApp: App {
} }
private func authenticationExpired() -> Bool { private func authenticationExpired() -> Bool {
if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated { if let enteredBackground = enteredBackground {
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY)) let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay
} else { } else {
return true 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 { do {
let chats = try await apiGetChatsAsync() let chats = try apiGetChats()
await MainActor.run { chatModel.updateChats(chats) } chatModel.updateChats(with: chats)
if let id = chatModel.chatId, if let id = chatModel.chatId,
let chat = chatModel.getChat(id), let chat = chatModel.getChat(id) {
!NtfManager.shared.navigatingToChat { loadChat(chat: chat)
Task { await loadChat(chat: chat, clearItems: false) }
} }
if let ncr = chatModel.ntfContactRequest { if let ncr = chatModel.ntfContactRequest {
await MainActor.run { chatModel.ntfContactRequest = nil } chatModel.ntfContactRequest = nil
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo { if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) } Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
} }
@@ -158,9 +153,9 @@ struct SimpleXApp: App {
} }
} }
private func updateCallInvitations() async { private func updateCallInvitations() {
do { do {
try await refreshCallInvitations() try refreshCallInvitations()
} catch let error { } catch let error {
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))") logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
} }
-199
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
)
}
}
-303
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)
}
}
+108 -289
View File
@@ -9,100 +9,57 @@
import SwiftUI import SwiftUI
import WebKit import WebKit
import SimpleXChat import SimpleXChat
import AVFoundation
struct ActiveCallView: View { struct ActiveCallView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var call: Call @ObservedObject var call: Call
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State private var client: WebRTCClient? = nil @State private var client: WebRTCClient? = nil
@State private var activeCall: WebRTCClient.Call? = nil
@State private var localRendererAspectRatio: CGFloat? = nil @State private var localRendererAspectRatio: CGFloat? = nil
@State var remoteContentMode: UIView.ContentMode = .scaleAspectFill
@Binding var canConnectCall: Bool @Binding var canConnectCall: Bool
@State var prevColorScheme: ColorScheme = .dark
@State var pipShown = false
@State var wasConnected = false
var body: some View { var body: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .bottom) {
ZStack(alignment: .bottom) { if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
if let client = client, call.hasVideo { GeometryReader { g in
GeometryReader { g in let width = g.size.width * 0.3
let width = g.size.width * 0.3 ZStack(alignment: .topTrailing) {
ZStack(alignment: .topTrailing) { CallViewRemote(client: client, activeCall: $activeCall)
ZStack(alignment: .center) { CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio)
// 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)
}
}
.cornerRadius(10) .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) .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 { .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) AppDelegate.keepScreenOn(true)
Task {
await askRequiredPermissions()
}
createWebRTCClient() createWebRTCClient()
dismissAllSheets() dismissAllSheets()
hideKeyboard()
prevColorScheme = colorScheme
} }
.onChange(of: canConnectCall) { _ in .onChange(of: canConnectCall) { _ in
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)") logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)")
createWebRTCClient() createWebRTCClient()
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
hideKeyboard()
}
.onDisappear { .onDisappear {
logger.debug("ActiveCallView: disappear") logger.debug("ActiveCallView: disappear")
Task { await m.callCommand.setClient(nil) } Task { await m.callCommand.setClient(nil) }
AppDelegate.keepScreenOn(false) AppDelegate.keepScreenOn(false)
client?.endCall() client?.endCall()
CallSoundsPlayer.shared.stop()
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient)
if (wasConnected) {
CallSoundsPlayer.shared.vibrate(long: true)
}
} }
.background(m.activeCallViewIsCollapsed ? .clear : .black) .background(.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(.dark)
.preferredColorScheme(m.activeCallViewIsCollapsed || CallController.useCallKit() ? prevColorScheme : .dark)
} }
private func createWebRTCClient() { private func createWebRTCClient() {
if client == nil && canConnectCall { 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)
Task { Task {
await m.callCommand.setClient(client) await m.callCommand.setClient(client)
} }
@@ -112,12 +69,12 @@ struct ActiveCallView: View {
@MainActor @MainActor
private func processRtcMessage(msg: WVAPIMessage) { private func processRtcMessage(msg: WVAPIMessage) {
if call == m.activeCall, if call == m.activeCall,
let call = m.activeCall, let call = m.activeCall,
let client = client { let client = client {
logger.debug("ActiveCallView: response \(msg.resp.respType)") logger.debug("ActiveCallView: response \(msg.resp.respType)")
switch msg.resp { switch msg.resp {
case let .capabilities(capabilities): case let .capabilities(capabilities):
let callType = CallType(media: call.initialCallType, capabilities: capabilities) let callType = CallType(media: call.localMedia, capabilities: capabilities)
Task { Task {
do { do {
try await apiSendCallInvitation(call.contact, callType) try await apiSendCallInvitation(call.contact, callType)
@@ -128,17 +85,12 @@ struct ActiveCallView: View {
call.callState = .invitationSent call.callState = .invitationSent
call.localCapabilities = capabilities 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): case let .offer(offer, iceCandidates, capabilities):
Task { Task {
do { do {
try await apiSendCallOffer(call.contact, offer, iceCandidates, try await apiSendCallOffer(call.contact, offer, iceCandidates,
media: call.initialCallType, capabilities: capabilities) media: call.localMedia, capabilities: capabilities)
} catch { } catch {
logger.error("apiSendCallOffer \(responseError(error))") logger.error("apiSendCallOffer \(responseError(error))")
} }
@@ -156,7 +108,6 @@ struct ActiveCallView: View {
} }
await MainActor.run { await MainActor.run {
call.callState = .negotiated call.callState = .negotiated
CallSoundsPlayer.shared.stop()
} }
} }
case let .ice(iceCandidates): case let .ice(iceCandidates):
@@ -171,22 +122,13 @@ struct ActiveCallView: View {
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
case .connected = callStatus { case .connected = callStatus {
call.direction == .outgoing call.direction == .outgoing
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil) : CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
call.callState = .connected call.callState = .connected
call.connectedAt = .now
if !wasConnected {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
}
} }
if state.connectionState == "closed" { if state.connectionState == "closed" {
closeCallView(client) closeCallView(client)
if let callUUID = m.activeCall?.callUUID {
CallController.shared.endCall(callUUID: callUUID)
}
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
} }
Task { Task {
do { do {
@@ -198,23 +140,10 @@ struct ActiveCallView: View {
case let .connected(connectionInfo): case let .connected(connectionInfo):
call.callState = .connected call.callState = .connected
call.connectionInfo = connectionInfo 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: case .ended:
closeCallView(client) closeCallView(client)
call.callState = .ended call.callState = .ended
if let uuid = call.callUUID { if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: uuid) CallController.shared.endCall(callUUID: uuid)
} }
case .ok: case .ok:
@@ -224,7 +153,6 @@ struct ActiveCallView: View {
case .end: case .end:
closeCallView(client) closeCallView(client)
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
default: () default: ()
} }
case let .error(message): case let .error(message):
@@ -237,44 +165,6 @@ struct ActiveCallView: View {
} }
} }
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 .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) { private func closeCallView(_ client: WebRTCClient) {
if m.activeCall != nil { if m.activeCall != nil {
m.showCallView = false m.showCallView = false
@@ -286,76 +176,71 @@ struct ActiveCallOverlay: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var call: Call @ObservedObject var call: Call
var client: WebRTCClient var client: WebRTCClient
@ObservedObject private var deviceManager = CallAudioDeviceManager.shared
var body: some View { var body: some View {
VStack { VStack {
switch call.hasVideo { switch call.localMedia {
case true: case .video:
videoCallInfoView(call) callInfoView(call, .leading)
.foregroundColor(.white) .foregroundColor(.white)
.opacity(0.8) .opacity(0.8)
.padding(.horizontal) .padding()
// 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)
}
}
Spacer() Spacer()
HStack { HStack {
toggleMicButton() toggleAudioButton()
Spacer() Spacer()
audioDeviceButton() Color.clear.frame(width: 40, height: 40)
Spacer() Spacer()
endCallButton() endCallButton()
Spacer() Spacer()
if call.localMediaSources.camera { if call.videoEnabled {
flipCameraButton() flipCameraButton()
} else { } else {
Color.clear.frame(width: 60, height: 60) 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() 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) .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 { VStack {
Text(call.contact.chatViewName) Text(call.contact.chatViewName)
.lineLimit(1) .lineLimit(1)
.font(.title) .font(.title)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: alignment)
Group { Group {
Text(call.callState.text) Text(call.callState.text)
HStack { HStack {
@@ -366,157 +251,91 @@ struct ActiveCallOverlay: View {
} }
} }
.font(.subheadline) .font(.subheadline)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: alignment)
}
}
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("(") + Text(connInfo.text) + Text(")")
}
}
}
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }
private func endCallButton() -> some View { private func endCallButton() -> some View {
let cc = CallController.shared let cc = CallController.shared
return callButton("phone.down.fill", .red, padding: 10) { return callButton("phone.down.fill", width: 60, height: 60) {
if let uuid = call.callUUID { if let uuid = call.callkitUUID {
cc.endCall(callUUID: uuid) cc.endCall(callUUID: uuid)
} else { } else {
cc.endCall(call: call) {} cc.endCall(call: call) {}
} }
} }
.foregroundColor(.red)
} }
private func toggleMicButton() -> some View { private func toggleAudioButton() -> some View {
controlButton(call, call.localMediaSources.mic ? "mic.fill" : "mic.slash", padding: 14) { controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
Task { Task {
if await WebRTCClient.isAuthorized(for: .audio) { client.setAudioEnabled(!call.audioEnabled)
client.setAudioEnabled(!call.localMediaSources.mic) DispatchQueue.main.async {
} else { WebRTCClient.showUnauthorizedAlert(for: .audio) } call.audioEnabled = !call.audioEnabled
} }
}
}
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
} }
} }
} }
private func toggleSpeakerButton() -> some View { 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) { controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker Task {
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled) client.setSpeakerEnabledAndConfigureSession(!call.speakerEnabled)
call.speakerEnabled = !speakerEnabled DispatchQueue.main.async {
} call.speakerEnabled = !call.speakerEnabled
.onAppear { }
deviceManager.call = call }
//call.speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
} }
} }
private func toggleCameraButton() -> some View { private func toggleVideoButton() -> some View {
controlButton(call, call.localMediaSources.camera ? "video.fill" : "video.slash", padding: call.localMediaSources.camera ? 16 : 14) { controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
Task { Task {
if await WebRTCClient.isAuthorized(for: .video) { client.setVideoEnabled(!call.videoEnabled)
client.setCameraEnabled(!call.localMediaSources.camera) DispatchQueue.main.async {
} else { WebRTCClient.showUnauthorizedAlert(for: .video) } call.videoEnabled = !call.videoEnabled
}
} }
} }
.disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true)
} }
@ViewBuilder private func flipCameraButton() -> some View { @ViewBuilder private func flipCameraButton() -> some View {
controlButton(call, "arrow.triangle.2.circlepath", padding: 12) { controlButton(call, "arrow.triangle.2.circlepath") {
Task { Task {
if await WebRTCClient.isAuthorized(for: .video) { client.flipCamera()
client.flipCamera()
}
} }
} }
} }
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View { @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform) if call.hasMedia {
callButton(imageName, width: 50, height: 38, perform)
.foregroundColor(.white)
.opacity(0.85)
} else {
Color.clear.frame(width: 50, height: 38)
}
} }
@ViewBuilder private func audioDevicePickerButton() -> some View { private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> 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 {
Button { Button {
perform() perform()
} label: { } label: {
Image(systemName: imageName) Image(systemName: imageName)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.padding(padding) .frame(maxWidth: width, maxHeight: height)
.frame(width: 60, height: 60)
.background(background)
} }
.foregroundColor(whiteColorWithAlpha)
.clipShape(.circle)
}
private var whiteColorWithAlpha: Color {
get { Color(red: 204 / 255, green: 204 / 255, blue: 204 / 255) }
} }
} }
struct ActiveCallOverlay_Previews: PreviewProvider { struct ActiveCallOverlay_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group{ 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) .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) .background(.black)
} }
} }
@@ -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) {
}
}
@@ -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()
}
}
+46 -125
View File
@@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXStartCallAction) { func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
logger.debug("CallController.provider CXStartCallAction") logger.debug("CallController.provider CXStartCallAction")
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) { if callManager.startOutgoingCall(callUUID: action.callUUID) {
action.fulfill() action.fulfill()
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil) provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
} else { } else {
@@ -61,30 +61,12 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
logger.debug("CallController.provider CXAnswerCallAction") logger.debug("CallController.provider CXAnswerCallAction")
Task { if callManager.answerIncomingCall(callUUID: action.callUUID) {
let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500) // WebRTC call should be in connected state to fulfill.
logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))") // Otherwise no audio and mic working on lockscreen
if !chatIsReady { fulfillOnConnect = action
action.fail() } else {
return action.fail()
}
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()
}
}
} }
} }
@@ -93,7 +75,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
// Should be nil here if connection was in connected state // Should be nil here if connection was in connected state
fulfillOnConnect?.fail() fulfillOnConnect?.fail()
fulfillOnConnect = nil fulfillOnConnect = nil
callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in callManager.endCall(callUUID: action.callUUID) { ok in
if ok { if ok {
action.fulfill() action.fulfill()
} else { } else {
@@ -104,7 +86,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
} }
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { 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() action.fulfill()
} else { } else {
action.fail() action.fail()
@@ -121,23 +103,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = true RTCAudioSession.sharedInstance().isAudioEnabled = true
do { do {
let hasVideo = ChatModel.shared.activeCall?.hasVideo == true try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers)
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) }
}
}
}
logger.debug("audioSession category set") logger.debug("audioSession category set")
try audioSession.setActive(true) try audioSession.setActive(true)
logger.debug("audioSession activated") 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 // The delay allows to accept the second call before suspending a chat
// see `.onChange(of: scenePhase)` in SimpleXApp // see `.onChange(of: scenePhase)` in SimpleXApp
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in 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 { if ChatModel.shared.activeCall == nil && self?.shouldSuspendChat == true {
self?.shouldSuspendChat = false self?.shouldSuspendChat = false
suspendChat() 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:) @objc(pushRegistry:didUpdatePushCredentials:forType:)
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { 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) { 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 { if type != .voIP {
completion() completion()
return return
} }
if AppChatState.shared.value == .stopped { logger.debug("CallController: initializing chat")
self.reportExpiredCall(payload: payload, completion) if (!ChatModel.shared.chatInitialized) {
return 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 // Extract the call information from the push notification payload
let m = ChatModel.shared let m = ChatModel.shared
if let contactId = payload.dictionaryPayload["contactId"] as? String, if let contactId = payload.dictionaryPayload["contactId"] as? String,
let displayName = payload.dictionaryPayload["displayName"] as? String, let invitation = m.callInvitations[contactId] {
let callUUID = payload.dictionaryPayload["callUUID"] as? String, let update = cxCallUpdate(invitation: invitation)
let uuid = UUID(uuidString: callUUID), if let uuid = invitation.callkitUUID {
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 {
logger.debug("CallController: report pushkit call via CallKit") 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 { if error != nil {
m.callInvitations.removeValue(forKey: contactId) m.callInvitations.removeValue(forKey: contactId)
} }
@@ -223,31 +176,11 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
completion() completion()
} }
} else { } else {
logger.debug("CallController will expire call 1") reportExpiredCall(update: update, completion)
self.reportExpiredCall(update: update, completion)
} }
} else { } else {
logger.debug("CallController will expire call 2") reportExpiredCall(payload: payload, completion)
self.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, // 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) { func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))") logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let uuid = invitation.callkitUUID {
if invitation.callTs.timeIntervalSinceNow >= -180 { if invitation.callTs.timeIntervalSinceNow >= -180 {
let update = cxCallUpdate(invitation: invitation) let update = cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
@@ -299,14 +232,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
return update 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?) { func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting incoming call connected") logger.debug("CallController: reporting incoming call connected")
if CallController.useCallKit() { if CallController.useCallKit() {
@@ -318,14 +243,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting outgoing call connected") 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) provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
} }
} }
func reportCallRemoteEnded(invitation: RcvCallInvitation) { func reportCallRemoteEnded(invitation: RcvCallInvitation) {
logger.debug("CallController: reporting remote ended") 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) provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
} else if invitation.contact.id == activeCallInvitation?.contact.id { } else if invitation.contact.id == activeCallInvitation?.contact.id {
activeCallInvitation = nil activeCallInvitation = nil
@@ -334,17 +259,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportCallRemoteEnded(call: Call) { func reportCallRemoteEnded(call: Call) {
logger.debug("CallController: reporting remote ended") 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) provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
} }
} }
func startCall(_ contact: Contact, _ media: CallMediaType) { func startCall(_ contact: Contact, _ media: CallMediaType) {
logger.debug("CallController.startCall") logger.debug("CallController.startCall")
let callUUID = callManager.newOutgoingCall(contact, media) let uuid = callManager.newOutgoingCall(contact, media)
guard let uuid = UUID(uuidString: callUUID) else {
return
}
if CallController.useCallKit() { if CallController.useCallKit() {
let handle = CXHandle(type: .generic, value: contact.id) let handle = CXHandle(type: .generic, value: contact.id)
let action = CXStartCallAction(call: uuid, handle: handle) let action = CXStartCallAction(call: uuid, handle: handle)
@@ -356,17 +278,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
update.localizedCallerName = contact.displayName update.localizedCallerName = contact.displayName
self.provider.reportCall(with: uuid, updated: update) self.provider.reportCall(with: uuid, updated: update)
} }
} else if callManager.startOutgoingCall(callUUID: callUUID) { } else if callManager.startOutgoingCall(callUUID: uuid) {
logger.debug("CallController.startCall: call started") if callManager.startOutgoingCall(callUUID: uuid) {
} else { logger.debug("CallController.startCall: call started")
logger.error("CallController.startCall: no active call") } else {
logger.error("CallController.startCall: no active call")
}
} }
} }
func answerCall(invitation: RcvCallInvitation) { func answerCall(invitation: RcvCallInvitation) {
logger.debug("CallController: answering a call") logger.debug("CallController: answering a call")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
requestTransaction(with: CXAnswerCallAction(call: uuid)) requestTransaction(with: CXAnswerCallAction(call: callUUID))
} else { } else {
callManager.answerIncomingCall(invitation: invitation) callManager.answerIncomingCall(invitation: invitation)
} }
@@ -375,13 +299,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
} }
} }
func endCall(callUUID: String) { func endCall(callUUID: UUID) {
let uuid = UUID(uuidString: callUUID) logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
logger.debug("CallController: ending the call with UUID \(callUUID)")
if CallController.useCallKit() { if CallController.useCallKit() {
if let uuid { requestTransaction(with: CXEndCallAction(call: callUUID))
requestTransaction(with: CXEndCallAction(call: uuid))
}
} else { } else {
callManager.endCall(callUUID: callUUID) { ok in callManager.endCall(callUUID: callUUID) { ok in
if ok { if ok {
@@ -429,7 +350,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) { private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) {
controller.request(CXTransaction(action: action)) { error in controller.request(CXTransaction(action: action)) { error in
if let error = error { 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 { } else {
logger.debug("CallController.requestTransaction requested transaction successfully") logger.debug("CallController.requestTransaction requested transaction successfully")
onSuccess() onSuccess()
+16 -18
View File
@@ -10,25 +10,25 @@ import Foundation
import SimpleXChat import SimpleXChat
class CallManager { class CallManager {
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String { func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
let uuid = UUID().uuidString.lowercased() let uuid = UUID()
let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, initialCallType: media) let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
call.speakerEnabled = media == .video call.speakerEnabled = media == .video
ChatModel.shared.activeCall = call ChatModel.shared.activeCall = call
return uuid return uuid
} }
func startOutgoingCall(callUUID: String) -> Bool { func startOutgoingCall(callUUID: UUID) -> Bool {
let m = ChatModel.shared let m = ChatModel.shared
if let call = m.activeCall, call.callUUID == callUUID { if let call = m.activeCall, call.callkitUUID == callUUID {
m.showCallView = true m.showCallView = true
Task { await m.callCommand.processCommand(.capabilities(media: call.initialCallType)) } Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
return true return true
} }
return false return false
} }
func answerIncomingCall(callUUID: String) -> Bool { func answerIncomingCall(callUUID: UUID) -> Bool {
if let invitation = getCallInvitation(callUUID) { if let invitation = getCallInvitation(callUUID) {
answerIncomingCall(invitation: invitation) answerIncomingCall(invitation: invitation)
return true return true
@@ -42,9 +42,9 @@ class CallManager {
let call = Call( let call = Call(
direction: .incoming, direction: .incoming,
contact: invitation.contact, contact: invitation.contact,
callUUID: invitation.callUUID, callkitUUID: invitation.callkitUUID,
callState: .invitationAccepted, callState: .invitationAccepted,
initialCallType: invitation.callType.media, localMedia: invitation.callType.media,
sharedKey: invitation.sharedKey sharedKey: invitation.sharedKey
) )
call.speakerEnabled = invitation.callType.media == .video call.speakerEnabled = invitation.callType.media == .video
@@ -68,17 +68,17 @@ class CallManager {
} }
} }
func enableMedia(source: CallMediaSource, enable: Bool, callUUID: String) -> Bool { func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID { if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
let m = ChatModel.shared let m = ChatModel.shared
Task { await m.callCommand.processCommand(.media(source: source, enable: enable)) } Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
return true return true
} }
return false return false
} }
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) { func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID { if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
endCall(call: call) { completed(true) } endCall(call: call) { completed(true) }
} else if let invitation = getCallInvitation(callUUID) { } else if let invitation = getCallInvitation(callUUID) {
endCall(invitation: invitation) { completed(true) } endCall(invitation: invitation) { completed(true) }
@@ -92,7 +92,6 @@ class CallManager {
if case .ended = call.callState { if case .ended = call.callState {
logger.debug("CallManager.endCall: call ended") logger.debug("CallManager.endCall: call ended")
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
m.showCallView = false m.showCallView = false
completed() completed()
} else { } else {
@@ -101,7 +100,6 @@ class CallManager {
await m.callCommand.processCommand(.end) await m.callCommand.processCommand(.end)
await MainActor.run { await MainActor.run {
m.activeCall = nil m.activeCall = nil
m.activeCallViewIsCollapsed = false
m.showCallView = false m.showCallView = false
completed() completed()
} }
@@ -126,8 +124,8 @@ class CallManager {
} }
} }
private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? { private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) { if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
return invitation return invitation
} }
return nil return nil
@@ -6,313 +6,70 @@
import SwiftUI import SwiftUI
import WebRTC import WebRTC
import SimpleXChat import SimpleXChat
import AVKit
struct CallViewRemote: UIViewRepresentable { struct CallViewRemote: UIViewRepresentable {
var client: WebRTCClient var client: WebRTCClient
@ObservedObject var call: Call var activeCall: Binding<WebRTCClient.Call?>
@State var enablePip: (Bool) -> Void = {_ in }
@Binding var activeCallViewIsCollapsed: Bool init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>) {
@Binding var contentMode: UIView.ContentMode self.client = client
@Binding var pipShown: Bool self.activeCall = activeCall
}
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> UIView {
let view = UIView() let view = UIView()
let remoteCameraRenderer = RTCMTLVideoView(frame: view.frame) if let call = activeCall.wrappedValue {
remoteCameraRenderer.videoContentMode = contentMode let remoteRenderer = RTCMTLVideoView(frame: view.frame)
remoteCameraRenderer.tag = 0 remoteRenderer.videoContentMode = .scaleAspectFill
client.addRemoteRenderer(call, remoteRenderer)
let screenVideo = call.peerMediaSources.screenVideo addSubviewAndResize(remoteRenderer, into: view)
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)
} }
return 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) { func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView remote") 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 { struct CallViewLocal: UIViewRepresentable {
var client: WebRTCClient var client: WebRTCClient
var activeCall: Binding<WebRTCClient.Call?>
var localRendererAspectRatio: Binding<CGFloat?> 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.client = client
self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio self.localRendererAspectRatio = localRendererAspectRatio
self._pipShown = pipShown
} }
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> UIView {
let view = UIView() let view = UIView()
let localRenderer = RTCEAGLVideoView(frame: .zero) if let call = activeCall.wrappedValue {
context.coordinator.renderer = localRenderer let localRenderer = RTCEAGLVideoView(frame: .zero)
client.addLocalRenderer(localRenderer) client.addLocalRenderer(call, localRenderer)
addSubviewAndResize(localRenderer, nil, into: view) client.startCaptureLocalVideo(call)
DispatchQueue.main.async { addSubviewAndResize(localRenderer, into: view)
pipStateChanged = { shown in
localRenderer.isHidden = shown
}
} }
return view return view
} }
func makeCoordinator() -> Coordinator {
Coordinator(client)
}
func updateUIView(_ view: UIView, context: Context) { func updateUIView(_ view: UIView, context: Context) {
logger.debug("CallView.updateUIView local") 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) { private func addSubviewAndResize(_ view: UIView, into containerView: UIView) {
if containerView.subviews.firstIndex(of: fullscreen) == 0 && ((end == nil && containerView.subviews.count == 1) || (end != nil && containerView.subviews.firstIndex(of: end!) == 1)) { containerView.addSubview(view)
// Nothing to do, elements on their places view.translatesAutoresizingMaskIntoConstraints = false
return containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|",
}
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]|",
options: [], options: [],
metrics: nil, metrics: nil,
views: ["fullscreen": fullscreen] views: ["view": view]))
)
let constraintFullscreenH = NSLayoutConstraint.constraints( containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|",
withVisualFormat: "H:|[fullscreen]|",
options: [], options: [],
metrics: nil, metrics: nil,
views: ["fullscreen": fullscreen] views: ["view": view]))
)
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)
}
containerView.layoutIfNeeded() containerView.layoutIfNeeded()
} }
@@ -11,7 +11,6 @@ import SimpleXChat
struct IncomingCallView: View { struct IncomingCallView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var cc = CallController.shared @ObservedObject var cc = CallController.shared
var body: some View { var body: some View {
@@ -31,21 +30,21 @@ struct IncomingCallView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack { HStack {
if m.users.count > 1 { 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) Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green)
Text(invitation.callTypeText) Text(invitation.callTypeText)
} }
HStack { HStack {
ProfilePreview(profileOf: invitation.contact, color: .white) ProfilePreview(profileOf: invitation.contact, color: .white)
.padding(.vertical, 6)
Spacer() Spacer()
callButton("Reject", "phone.down.fill", .red) { callButton("Reject", "phone.down.fill", .red) {
cc.endCall(invitation: invitation) cc.endCall(invitation: invitation)
} }
callButton("Ignore", "multiply", .primary) { callButton("Ignore", "multiply", .accentColor) {
cc.activeCallInvitation = nil cc.activeCallInvitation = nil
} }
@@ -65,7 +64,7 @@ struct IncomingCallView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.modifier(ThemedBackground()) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.onAppear { dismissAllSheets() } .onAppear { dismissAllSheets() }
} }
@@ -78,7 +77,7 @@ struct IncomingCallView: View {
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
Text(text) Text(text)
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
.frame(minWidth: 44) .frame(minWidth: 44)
}) })
@@ -8,7 +8,6 @@
import Foundation import Foundation
import AVFoundation import AVFoundation
import UIKit
class SoundPlayer { class SoundPlayer {
static let shared = SoundPlayer() static let shared = SoundPlayer()
@@ -44,63 +43,3 @@ class SoundPlayer {
audioPlayer = nil 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()
}
}
}
+22 -47
View File
@@ -18,49 +18,47 @@ class Call: ObservableObject, Equatable {
var direction: CallDirection var direction: CallDirection
var contact: Contact var contact: Contact
var callUUID: String? var callkitUUID: UUID?
var initialCallType: CallMediaType var localMedia: CallMediaType
@Published var localMediaSources: CallMediaSources
@Published var callState: CallState @Published var callState: CallState
@Published var localCapabilities: CallCapabilities? @Published var localCapabilities: CallCapabilities?
@Published var peerMediaSources: CallMediaSources = CallMediaSources() @Published var peerMedia: CallMediaType?
@Published var sharedKey: String? @Published var sharedKey: String?
@Published var audioEnabled = true
@Published var speakerEnabled = false @Published var speakerEnabled = false
@Published var videoEnabled: Bool
@Published var connectionInfo: ConnectionInfo? @Published var connectionInfo: ConnectionInfo?
@Published var connectedAt: Date? = nil
init( init(
direction: CallDirection, direction: CallDirection,
contact: Contact, contact: Contact,
callUUID: String?, callkitUUID: UUID?,
callState: CallState, callState: CallState,
initialCallType: CallMediaType, localMedia: CallMediaType,
sharedKey: String? = nil sharedKey: String? = nil
) { ) {
self.direction = direction self.direction = direction
self.contact = contact self.contact = contact
self.callUUID = callUUID self.callkitUUID = callkitUUID
self.callState = callState self.callState = callState
self.initialCallType = initialCallType self.localMedia = localMedia
self.sharedKey = sharedKey self.sharedKey = sharedKey
self.localMediaSources = CallMediaSources( self.videoEnabled = localMedia == .video
mic: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized,
camera: initialCallType == .video && AVCaptureDevice.authorizationStatus(for: .video) == .authorized)
} }
var encrypted: Bool { get { localEncrypted && sharedKey != nil } } 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 { var encryptionStatus: LocalizedStringKey {
get { get {
switch callState { switch callState {
case .waitCapabilities: return "" case .waitCapabilities: return ""
case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption" case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption"
case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption" case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted" default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
} }
} }
} }
var hasVideo: Bool { get { localMediaSources.hasVideo || peerMediaSources.hasVideo } } var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
} }
enum CallDirection { enum CallDirection {
@@ -105,28 +103,18 @@ struct WVAPIMessage: Equatable, Decodable, Encodable {
var command: WCallCommand? 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 { enum WCallCommand: Equatable, Encodable, Decodable {
case capabilities(media: CallMediaType) case capabilities(media: CallMediaType)
case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) 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 offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case answer(answer: String, iceCandidates: String) case answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String) case ice(iceCandidates: String)
case media(source: CallMediaSource, enable: Bool) case media(media: CallMediaType, enable: Bool)
case end case end
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case type case type
case media case media
case source
case aesKey case aesKey
case offer case offer
case answer case answer
@@ -177,9 +165,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
case let .ice(iceCandidates): case let .ice(iceCandidates):
try container.encode("ice", forKey: .type) try container.encode("ice", forKey: .type)
try container.encode(iceCandidates, forKey: .iceCandidates) 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("media", forKey: .type)
try container.encode(source, forKey: .media) try container.encode(media, forKey: .media)
try container.encode(enable, forKey: .enable) try container.encode(enable, forKey: .enable)
case .end: case .end:
try container.encode("end", forKey: .type) 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) let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates) self = .ice(iceCandidates: iceCandidates)
case "media": 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) let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
self = .media(source: source, enable: enable) self = .media(media: media, enable: enable)
case "end": case "end":
self = .end self = .end
default: default:
@@ -234,7 +222,6 @@ enum WCallResponse: Equatable, Decodable {
case ice(iceCandidates: String) case ice(iceCandidates: String)
case connection(state: ConnectionState) case connection(state: ConnectionState)
case connected(connectionInfo: ConnectionInfo) case connected(connectionInfo: ConnectionInfo)
case peerMedia(source: CallMediaSource, enabled: Bool)
case ended case ended
case ok case ok
case error(message: String) case error(message: String)
@@ -249,8 +236,6 @@ enum WCallResponse: Equatable, Decodable {
case state case state
case connectionInfo case connectionInfo
case message case message
case source
case enabled
} }
var respType: String { var respType: String {
@@ -262,7 +247,6 @@ enum WCallResponse: Equatable, Decodable {
case .ice: return "ice" case .ice: return "ice"
case .connection: return "connection" case .connection: return "connection"
case .connected: return "connected" case .connected: return "connected"
case .peerMedia: return "peerMedia"
case .ended: return "ended" case .ended: return "ended"
case .ok: return "ok" case .ok: return "ok"
case .error: return "error" case .error: return "error"
@@ -297,10 +281,6 @@ enum WCallResponse: Equatable, Decodable {
case "connected": case "connected":
let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo) let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo)
self = .connected(connectionInfo: 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": case "ended":
self = .ended self = .ended
case "ok": case "ok":
@@ -342,10 +322,6 @@ extension WCallResponse: Encodable {
case let .connected(connectionInfo): case let .connected(connectionInfo):
try container.encode("connected", forKey: .type) try container.encode("connected", forKey: .type)
try container.encode(connectionInfo, forKey: .connectionInfo) 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: case .ended:
try container.encode("ended", forKey: .type) try container.encode("ended", forKey: .type)
case .ok: case .ok:
@@ -398,7 +374,7 @@ actor WebRTCCommandProcessor {
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool { func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
switch c { switch c {
case .capabilities, .start, .offer, .end: true case .capabilities, .start, .offer, .end: true
default: client.activeCall != nil default: client.activeCall.wrappedValue != nil
} }
} }
} }
@@ -453,18 +429,17 @@ struct RTCIceServer: Codable, Equatable {
} }
// the servers are expected in this format: // the servers are expected in this format:
// stuns:stun.simplex.im:443?transport=tcp // stun:stun.simplex.im:443?transport=tcp
// turns:private2:Hxuq2QxUjnhj96Zq2r4HjqHRj@turn.simplex.im:443?transport=tcp // turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp
func parseRTCIceServer(_ str: String) -> RTCIceServer? { func parseRTCIceServer(_ str: String) -> RTCIceServer? {
var s = replaceScheme(str, "stun:") var s = replaceScheme(str, "stun:")
s = replaceScheme(s, "stuns:")
s = replaceScheme(s, "turn:") s = replaceScheme(s, "turn:")
s = replaceScheme(s, "turns:") s = replaceScheme(s, "turns:")
if let u: URL = URL(string: s), if let u: URL = URL(string: s),
let scheme = u.scheme, let scheme = u.scheme,
let host = u.host, let host = u.host,
let port = u.port, 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 ?? "") let query = u.query == nil || u.query == "" ? "" : "?" + (u.query ?? "")
return RTCIceServer( return RTCIceServer(
urls: ["\(scheme):\(host):\(port)\(query)"], urls: ["\(scheme):\(host):\(port)\(query)"],
+105 -456
View File
@@ -18,29 +18,19 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}() }()
private static let ivTagBytes: Int = 28 private static let ivTagBytes: Int = 28
private static let enableEncryption: Bool = true private static let enableEncryption: Bool = true
private var chat_ctrl = getChatCtrl()
struct Call { struct Call {
var connection: RTCPeerConnection var connection: RTCPeerConnection
var iceCandidates: IceCandidates var iceCandidates: IceCandidates
var localMedia: CallMediaType
var localCamera: RTCVideoCapturer? var localCamera: RTCVideoCapturer?
var localAudioTrack: RTCAudioTrack? var localVideoSource: RTCVideoSource?
var localVideoTrack: RTCVideoTrack? var localStream: RTCVideoTrack?
var remoteAudioTrack: RTCAudioTrack? var remoteStream: RTCVideoTrack?
var remoteVideoTrack: RTCVideoTrack? var device: AVCaptureDevice.Position = .front
var remoteScreenAudioTrack: RTCAudioTrack?
var remoteScreenVideoTrack: RTCVideoTrack?
var device: AVCaptureDevice.Position
var aesKey: String? var aesKey: String?
var frameEncryptor: RTCFrameEncryptor? var frameEncryptor: RTCFrameEncryptor?
var frameDecryptor: RTCFrameDecryptor? var frameDecryptor: RTCFrameDecryptor?
var peerHasOldVersion: Bool
}
struct NotConnectedCall {
var audioTrack: RTCAudioTrack?
var localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)?
var device: AVCaptureDevice.Position = .front
} }
actor IceCandidates { actor IceCandidates {
@@ -58,77 +48,68 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
private let rtcAudioSession = RTCAudioSession.sharedInstance() private let rtcAudioSession = RTCAudioSession.sharedInstance()
private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio") private let audioQueue = DispatchQueue(label: "audio")
private var sendCallResponse: (WVAPIMessage) async -> Void private var sendCallResponse: (WVAPIMessage) async -> Void
var activeCall: Call? var activeCall: Binding<Call?>
var notConnectedCall: NotConnectedCall?
private var localRendererAspectRatio: Binding<CGFloat?> private var localRendererAspectRatio: Binding<CGFloat?>
var cameraRenderers: [RTCVideoRenderer] = []
var screenRenderers: [RTCVideoRenderer] = []
@available(*, unavailable) @available(*, unavailable)
override init() { override init() {
fatalError("Unimplemented") fatalError("Unimplemented")
} }
required init(_ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding<CGFloat?>) { required init(_ activeCall: Binding<Call?>, _ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding<CGFloat?>) {
self.sendCallResponse = sendCallResponse self.sendCallResponse = sendCallResponse
self.activeCall = activeCall
self.localRendererAspectRatio = localRendererAspectRatio self.localRendererAspectRatio = localRendererAspectRatio
rtcAudioSession.useManualAudio = CallController.useCallKit() rtcAudioSession.useManualAudio = CallController.useCallKit()
rtcAudioSession.isAudioEnabled = !CallController.useCallKit() rtcAudioSession.isAudioEnabled = !CallController.useCallKit()
logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)") logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)}")
super.init() super.init()
} }
let defaultIceServers: [WebRTC.RTCIceServer] = [ let defaultIceServers: [WebRTC.RTCIceServer] = [
WebRTC.RTCIceServer(urlStrings: ["stuns:stun.simplex.im:443"]), WebRTC.RTCIceServer(urlStrings: ["stun:stun.simplex.im:443"]),
//WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=udp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"), WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"), WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
] ]
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay) let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
connection.delegate = self connection.delegate = self
let device = notConnectedCall?.device ?? .front createAudioSender(connection)
var localStream: RTCVideoTrack? = nil
var remoteStream: RTCVideoTrack? = nil
var localCamera: RTCVideoCapturer? = nil var localCamera: RTCVideoCapturer? = nil
var localAudioTrack: RTCAudioTrack? = nil var localVideoSource: RTCVideoSource? = nil
var localVideoTrack: RTCVideoTrack? = nil if mediaType == .video {
if let localCameraAndTrack = notConnectedCall?.localCameraAndTrack { (localStream, remoteStream, localCamera, localVideoSource) = createVideoSender(connection)
(localCamera, localVideoTrack) = localCameraAndTrack
} else if notConnectedCall == nil && mediaType == .video {
(localCamera, localVideoTrack) = createVideoTrackAndStartCapture(device)
} }
if let audioTrack = notConnectedCall?.audioTrack {
localAudioTrack = audioTrack
} else if notConnectedCall == nil {
localAudioTrack = createAudioTrack()
}
notConnectedCall?.localCameraAndTrack = nil
notConnectedCall?.audioTrack = nil
var frameEncryptor: RTCFrameEncryptor? = nil var frameEncryptor: RTCFrameEncryptor? = nil
var frameDecryptor: RTCFrameDecryptor? = nil var frameDecryptor: RTCFrameDecryptor? = nil
if aesKey != nil { if aesKey != nil {
let encryptor = RTCFrameEncryptor.init(sizeChange: Int32(WebRTCClient.ivTagBytes)) let encryptor = RTCFrameEncryptor.init(sizeChange: Int32(WebRTCClient.ivTagBytes))
encryptor.delegate = self encryptor.delegate = self
frameEncryptor = encryptor frameEncryptor = encryptor
connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes)) let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes))
decryptor.delegate = self decryptor.delegate = self
frameDecryptor = decryptor frameDecryptor = decryptor
// Has no video receiver in outgoing call if applied here, see [peerConnection(_ connection: RTCPeerConnection, didChange newState]
// connection.receivers.forEach { $0.setRtcFrameDecryptor(decryptor) }
} }
return Call( return Call(
connection: connection, connection: connection,
iceCandidates: IceCandidates(), iceCandidates: IceCandidates(),
localMedia: mediaType,
localCamera: localCamera, localCamera: localCamera,
localAudioTrack: localAudioTrack, localVideoSource: localVideoSource,
localVideoTrack: localVideoTrack, localStream: localStream,
device: device, remoteStream: remoteStream,
aesKey: aesKey, aesKey: aesKey,
frameEncryptor: frameEncryptor, frameEncryptor: frameEncryptor,
frameDecryptor: frameDecryptor, frameDecryptor: frameDecryptor
peerHasOldVersion: false
) )
} }
@@ -169,24 +150,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func sendCallCommand(command: WCallCommand) async { func sendCallCommand(command: WCallCommand) async {
var resp: WCallResponse? = nil var resp: WCallResponse? = nil
let pc = activeCall?.connection let pc = activeCall.wrappedValue?.connection
switch command { switch command {
case let .capabilities(media): // outgoing case .capabilities:
let localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)? = media == .video
? createVideoTrackAndStartCapture(.front)
: nil
notConnectedCall = NotConnectedCall(audioTrack: createAudioTrack(), localCameraAndTrack: localCameraAndTrack, device: .front)
resp = .capabilities(capabilities: CallCapabilities(encryption: WebRTCClient.enableEncryption)) resp = .capabilities(capabilities: CallCapabilities(encryption: WebRTCClient.enableEncryption))
case let .start(media: media, aesKey, iceServers, relay): // incoming case let .start(media: media, aesKey, iceServers, relay):
logger.debug("starting incoming call - create webrtc session") logger.debug("starting incoming call - create webrtc session")
if activeCall != nil { endCall() } if activeCall.wrappedValue != nil { endCall() }
let encryption = WebRTCClient.enableEncryption let encryption = WebRTCClient.enableEncryption
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay) let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
activeCall = call activeCall.wrappedValue = call
setupLocalTracks(true, call)
let (offer, error) = await call.connection.offer() let (offer, error) = await call.connection.offer()
if let offer = offer { if let offer = offer {
setupEncryptionForLocalTracks(call)
resp = .offer( resp = .offer(
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))), offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())), iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
@@ -196,24 +171,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} else { } else {
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")") resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
} }
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): // outgoing case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
if activeCall != nil { if activeCall.wrappedValue != nil {
resp = .error(message: "accept: call already started") resp = .error(message: "accept: call already started")
} else if !WebRTCClient.enableEncryption && aesKey != nil { } else if !WebRTCClient.enableEncryption && aesKey != nil {
resp = .error(message: "accept: encryption is not supported") resp = .error(message: "accept: encryption is not supported")
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)), } else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay) let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
activeCall = call activeCall.wrappedValue = call
let pc = call.connection let pc = call.connection
if let type = offer.type, let sdp = offer.sdp { if let type = offer.type, let sdp = offer.sdp {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil { if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
setupLocalTracks(false, call)
setupEncryptionForLocalTracks(call)
pc.transceivers.forEach { transceiver in
transceiver.setDirection(.sendRecv, error: nil)
}
await adaptToOldVersion(pc.transceivers.count <= 2)
let (answer, error) = await pc.answer() let (answer, error) = await pc.answer()
if let answer = answer { if let answer = answer {
self.addIceCandidates(pc, remoteIceCandidates) self.addIceCandidates(pc, remoteIceCandidates)
@@ -230,7 +199,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
} }
} }
case let .answer(answer, iceCandidates): // incoming case let .answer(answer, iceCandidates):
if pc == nil { if pc == nil {
resp = .error(message: "answer: call not started") resp = .error(message: "answer: call not started")
} else if pc?.localDescription == nil { } else if pc?.localDescription == nil {
@@ -242,9 +211,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
let type = answer.type, let sdp = answer.sdp, let type = answer.type, let sdp = answer.sdp,
let pc = pc { let pc = pc {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil { if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
var currentDirection: RTCRtpTransceiverDirection = .sendOnly
pc.transceivers[2].currentDirection(&currentDirection)
await adaptToOldVersion(currentDirection == .sendOnly)
addIceCandidates(pc, remoteIceCandidates) addIceCandidates(pc, remoteIceCandidates)
resp = .ok resp = .ok
} else { } else {
@@ -259,11 +225,13 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} else { } else {
resp = .error(message: "ice: call not started") resp = .error(message: "ice: call not started")
} }
case let .media(source, enable): case let .media(media, enable):
if activeCall == nil { if activeCall.wrappedValue == nil {
resp = .error(message: "media: call not started") resp = .error(message: "media: call not started")
} else if activeCall.wrappedValue?.localMedia == .audio && media == .video {
resp = .error(message: "media: no video")
} else { } else {
await enableMedia(source, enable) enableMedia(media, enable)
resp = .ok resp = .ok
} }
case .end: case .end:
@@ -278,7 +246,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func getInitialIceCandidates() async -> [RTCIceCandidate] { func getInitialIceCandidates() async -> [RTCIceCandidate] {
await untilIceComplete(timeoutMs: 750, stepMs: 150) {} await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
let candidates = await activeCall?.iceCandidates.getAndClear() ?? [] let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)") logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
return candidates return candidates
} }
@@ -286,7 +254,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func waitForMoreIceCandidates() { func waitForMoreIceCandidates() {
Task { Task {
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) { await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
let candidates = await self.activeCall?.iceCandidates.getAndClear() ?? [] let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
if candidates.count > 0 { if candidates.count > 0 {
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)") logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
await self.sendIceCandidates(candidates) await self.sendIceCandidates(candidates)
@@ -303,202 +271,25 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
) )
} }
func setupMuteUnmuteListener(_ transceiver: RTCRtpTransceiver, _ track: RTCMediaStreamTrack) { func enableMedia(_ media: CallMediaType, _ enable: Bool) {
// logger.log("Setting up mute/unmute listener in the call without encryption for mid = \(transceiver.mid)") logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
Task { media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
var lastBytesReceived: Int64 = 0
// muted initially
var mutedSeconds = 4
while let call = self.activeCall, transceiver.receiver.track?.readyState == .live {
let stats: RTCStatisticsReport = await call.connection.statistics(for: transceiver.receiver)
let stat = stats.statistics.values.first(where: { stat in stat.type == "inbound-rtp"})
if let stat {
//logger.debug("Stat \(stat.debugDescription)")
let bytes = stat.values["bytesReceived"] as! Int64
if bytes <= lastBytesReceived {
mutedSeconds += 1
if mutedSeconds == 3 {
await MainActor.run {
self.onMediaMuteUnmute(transceiver.mid, true)
}
}
} else {
if mutedSeconds >= 3 {
await MainActor.run {
self.onMediaMuteUnmute(transceiver.mid, false)
}
}
lastBytesReceived = bytes
mutedSeconds = 0
}
}
try? await Task.sleep(nanoseconds: 1000_000000)
}
}
} }
@MainActor func addLocalRenderer(_ activeCall: Call, _ renderer: RTCEAGLVideoView) {
func onMediaMuteUnmute(_ transceiverMid: String?, _ mute: Bool) { activeCall.localStream?.add(renderer)
guard let activeCall = ChatModel.shared.activeCall else { return }
let source = mediaSourceFromTransceiverMid(transceiverMid)
logger.log("Mute/unmute \(source.rawValue) track = \(mute) with mid = \(transceiverMid ?? "nil")")
if source == .mic && activeCall.peerMediaSources.mic == mute {
activeCall.peerMediaSources.mic = !mute
} else if (source == .camera && activeCall.peerMediaSources.camera == mute) {
activeCall.peerMediaSources.camera = !mute
} else if (source == .screenAudio && activeCall.peerMediaSources.screenAudio == mute) {
activeCall.peerMediaSources.screenAudio = !mute
} else if (source == .screenVideo && activeCall.peerMediaSources.screenVideo == mute) {
activeCall.peerMediaSources.screenVideo = !mute
}
}
@MainActor
func enableMedia(_ source: CallMediaSource, _ enable: Bool) {
logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)")
source == .camera ? setCameraEnabled(enable) : setAudioEnabled(enable)
}
@MainActor
func adaptToOldVersion(_ peerHasOldVersion: Bool) {
activeCall?.peerHasOldVersion = peerHasOldVersion
if peerHasOldVersion {
logger.debug("The peer has an old version. Remote audio track is nil = \(self.activeCall?.remoteAudioTrack == nil), video = \(self.activeCall?.remoteVideoTrack == nil)")
onMediaMuteUnmute("0", false)
if activeCall?.remoteVideoTrack != nil {
onMediaMuteUnmute("1", false)
}
if ChatModel.shared.activeCall?.localMediaSources.camera == true && ChatModel.shared.activeCall?.peerMediaSources.camera == false {
logger.debug("Stopping video track for the old version")
activeCall?.connection.senders[1].track = nil
ChatModel.shared.activeCall?.localMediaSources.camera = false
(activeCall?.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
activeCall?.localCamera = nil
activeCall?.localVideoTrack = nil
}
}
}
func addLocalRenderer(_ renderer: RTCEAGLVideoView) {
if let activeCall {
if let track = activeCall.localVideoTrack {
track.add(renderer)
}
} else if let notConnectedCall {
if let track = notConnectedCall.localCameraAndTrack?.1 {
track.add(renderer)
}
}
// To get width and height of a frame, see videoView(videoView:, didChangeVideoSize) // To get width and height of a frame, see videoView(videoView:, didChangeVideoSize)
renderer.delegate = self renderer.delegate = self
} }
func removeLocalRenderer(_ renderer: RTCEAGLVideoView) {
if let activeCall {
if let track = activeCall.localVideoTrack {
track.remove(renderer)
}
} else if let notConnectedCall {
if let track = notConnectedCall.localCameraAndTrack?.1 {
track.remove(renderer)
}
}
renderer.delegate = nil
}
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) { func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
guard size.height > 0 else { return } guard size.height > 0 else { return }
localRendererAspectRatio.wrappedValue = size.width / size.height localRendererAspectRatio.wrappedValue = size.width / size.height
} }
func setupLocalTracks(_ incomingCall: Bool, _ call: Call) {
let pc = call.connection
let transceivers = call.connection.transceivers
let audioTrack = call.localAudioTrack
let videoTrack = call.localVideoTrack
if incomingCall {
let micCameraInit = RTCRtpTransceiverInit()
// streamIds required for old versions which adds tracks from stream, not from track property
micCameraInit.streamIds = ["micCamera"]
let screenAudioVideoInit = RTCRtpTransceiverInit()
screenAudioVideoInit.streamIds = ["screenAudioVideo"]
// incoming call, no transceivers yet. But they should be added in order: mic, camera, screen audio, screen video
// mid = 0, mic
if let audioTrack {
pc.addTransceiver(with: audioTrack, init: micCameraInit)
} else {
pc.addTransceiver(of: .audio, init: micCameraInit)
}
// mid = 1, camera
if let videoTrack {
pc.addTransceiver(with: videoTrack, init: micCameraInit)
} else {
pc.addTransceiver(of: .video, init: micCameraInit)
}
// mid = 2, screenAudio
pc.addTransceiver(of: .audio, init: screenAudioVideoInit)
// mid = 3, screenVideo
pc.addTransceiver(of: .video, init: screenAudioVideoInit)
} else {
// new version
if transceivers.count > 2 {
// Outgoing call. All transceivers are ready. Don't addTrack() because it will create new transceivers, replace existing (nil) tracks
transceivers
.first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .mic })?
.sender.track = audioTrack
transceivers
.first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .camera })?
.sender.track = videoTrack
} else {
// old version, only two transceivers
if let audioTrack {
pc.add(audioTrack, streamIds: ["micCamera"])
} else {
// it's important to have any track in order to be able to turn it on again (currently it's off)
let sender = pc.add(createAudioTrack(), streamIds: ["micCamera"])
sender?.track = nil
}
if let videoTrack {
pc.add(videoTrack, streamIds: ["micCamera"])
} else {
// it's important to have any track in order to be able to turn it on again (currently it's off)
let localVideoSource = WebRTCClient.factory.videoSource()
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
let sender = pc.add(localVideoTrack, streamIds: ["micCamera"])
sender?.track = nil
}
}
}
}
func mediaSourceFromTransceiverMid(_ mid: String?) -> CallMediaSource {
switch mid {
case "0":
return .mic
case "1":
return .camera
case "2":
return .screenAudio
case "3":
return .screenVideo
default:
return .unknown
}
}
// Should be called after local description set
func setupEncryptionForLocalTracks(_ call: Call) {
if let encryptor = call.frameEncryptor {
call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
}
}
func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? { func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? {
guard encrypted.count > 0 else { return nil } guard encrypted.count > 0 else { return nil }
if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8), if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) { let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) {
memcpy(pointer, (encrypted as NSData).bytes, encrypted.count) memcpy(pointer, (encrypted as NSData).bytes, encrypted.count)
let isKeyFrame = encrypted[0] & 1 == 0 let isKeyFrame = encrypted[0] & 1 == 0
@@ -512,12 +303,12 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? { func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? {
guard unencrypted.count > 0 else { return nil } guard unencrypted.count > 0 else { return nil }
if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8), if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) { let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) {
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count) memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
let isKeyFrame = unencrypted[0] & 1 == 0 let isKeyFrame = unencrypted[0] & 1 == 0
let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3 let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3
logCrypto("encrypt", chat_encrypt_media(chat_ctrl, &key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize))) logCrypto("encrypt", chat_encrypt_media(&key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize)))
return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes) return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes)
} else { } else {
return nil return nil
@@ -535,42 +326,14 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
} }
func addRemoteCameraRenderer(_ renderer: RTCVideoRenderer) { func addRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
if activeCall?.remoteVideoTrack != nil { activeCall.remoteStream?.add(renderer)
activeCall?.remoteVideoTrack?.add(renderer)
} else {
cameraRenderers.append(renderer)
}
} }
func removeRemoteCameraRenderer(_ renderer: RTCVideoRenderer) { func startCaptureLocalVideo(_ activeCall: Call) {
if activeCall?.remoteVideoTrack != nil {
activeCall?.remoteVideoTrack?.remove(renderer)
} else {
cameraRenderers.removeAll(where: { $0.isEqual(renderer) })
}
}
func addRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
if activeCall?.remoteScreenVideoTrack != nil {
activeCall?.remoteScreenVideoTrack?.add(renderer)
} else {
screenRenderers.append(renderer)
}
}
func removeRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
if activeCall?.remoteScreenVideoTrack != nil {
activeCall?.remoteScreenVideoTrack?.remove(renderer)
} else {
screenRenderers.removeAll(where: { $0.isEqual(renderer) })
}
}
func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
guard guard
let capturer = (activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCFileVideoCapturer let capturer = activeCall.localCamera as? RTCFileVideoCapturer
else { else {
logger.error("Unable to work with a file capturer") logger.error("Unable to work with a file capturer")
return return
@@ -580,10 +343,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
capturer.startCapturing(fromFileNamed: "sounds/video.mp4") capturer.startCapturing(fromFileNamed: "sounds/video.mp4")
#else #else
guard guard
let capturer = capturer as? RTCCameraVideoCapturer, let capturer = activeCall.localCamera as? RTCCameraVideoCapturer,
let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == device }) let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == activeCall.device })
else { else {
logger.error("Unable to find a camera or local track") logger.error("Unable to find a camera")
return return
} }
@@ -609,6 +372,19 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
#endif #endif
} }
private func createAudioSender(_ connection: RTCPeerConnection) {
let streamId = "stream"
let audioTrack = createAudioTrack()
connection.add(audioTrack, streamIds: [streamId])
}
private func createVideoSender(_ connection: RTCPeerConnection) -> (RTCVideoTrack?, RTCVideoTrack?, RTCVideoCapturer?, RTCVideoSource?) {
let streamId = "stream"
let (localVideoTrack, localCamera, localVideoSource) = createVideoTrack()
connection.add(localVideoTrack, streamIds: [streamId])
return (localVideoTrack, connection.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack, localCamera, localVideoSource)
}
private func createAudioTrack() -> RTCAudioTrack { private func createAudioTrack() -> RTCAudioTrack {
let audioConstrains = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) let audioConstrains = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
let audioSource = WebRTCClient.factory.audioSource(with: audioConstrains) let audioSource = WebRTCClient.factory.audioSource(with: audioConstrains)
@@ -616,7 +392,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
return audioTrack return audioTrack
} }
private func createVideoTrackAndStartCapture(_ device: AVCaptureDevice.Position) -> (RTCVideoCapturer, RTCVideoTrack) { private func createVideoTrack() -> (RTCVideoTrack, RTCVideoCapturer, RTCVideoSource) {
let localVideoSource = WebRTCClient.factory.videoSource() let localVideoSource = WebRTCClient.factory.videoSource()
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
@@ -626,30 +402,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
#endif #endif
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0") let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
startCaptureLocalVideo(device, localCamera) return (localVideoTrack, localCamera, localVideoSource)
return (localCamera, localVideoTrack)
} }
func endCall() { func endCall() {
if #available(iOS 16.0, *) { guard let call = activeCall.wrappedValue else { return }
_endCall()
} else {
// Fixes `connection.close()` getting locked up in iOS15
DispatchQueue.global(qos: .utility).async { self._endCall() }
}
}
private func _endCall() {
(notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
guard let call = activeCall else { return }
logger.debug("WebRTCClient: ending the call") logger.debug("WebRTCClient: ending the call")
activeCall.wrappedValue = nil
call.connection.close() call.connection.close()
call.connection.delegate = nil call.connection.delegate = nil
call.frameEncryptor?.delegate = nil call.frameEncryptor?.delegate = nil
call.frameDecryptor?.delegate = nil call.frameDecryptor?.delegate = nil
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
audioSessionToDefaults() audioSessionToDefaults()
activeCall = nil
} }
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async { func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
@@ -658,7 +422,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000) _ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
t += stepMs t += stepMs
await action() await action()
} while t < timeoutMs && activeCall?.connection.iceGatheringState != .complete } while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
} }
} }
@@ -719,40 +483,11 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
logger.debug("Connection should negotiate") logger.debug("Connection should negotiate")
} }
func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {
if let track = transceiver.receiver.track {
DispatchQueue.main.async {
// Doesn't work for outgoing video call (audio in video call works ok still, same as incoming call)
// if let decryptor = self.activeCall?.frameDecryptor {
// transceiver.receiver.setRtcFrameDecryptor(decryptor)
// }
let source = self.mediaSourceFromTransceiverMid(transceiver.mid)
switch source {
case .mic: self.activeCall?.remoteAudioTrack = track as? RTCAudioTrack
case .camera:
self.activeCall?.remoteVideoTrack = track as? RTCVideoTrack
self.cameraRenderers.forEach({ renderer in
self.activeCall?.remoteVideoTrack?.add(renderer)
})
self.cameraRenderers.removeAll()
case .screenAudio: self.activeCall?.remoteScreenAudioTrack = track as? RTCAudioTrack
case .screenVideo:
self.activeCall?.remoteScreenVideoTrack = track as? RTCVideoTrack
self.screenRenderers.forEach({ renderer in
self.activeCall?.remoteScreenVideoTrack?.add(renderer)
})
self.screenRenderers.removeAll()
case .unknown: ()
}
}
self.setupMuteUnmuteListener(transceiver, track)
}
}
func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)") debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)")
guard let connectionStateString = newState.toString(), guard let call = activeCall.wrappedValue,
let connectionStateString = newState.toString(),
let iceConnectionStateString = connection.iceConnectionState.toString(), let iceConnectionStateString = connection.iceConnectionState.toString(),
let iceGatheringStateString = connection.iceGatheringState.toString(), let iceGatheringStateString = connection.iceGatheringState.toString(),
let signalingStateString = connection.signalingState.toString() let signalingStateString = connection.signalingState.toString()
@@ -773,14 +508,18 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
switch newState { switch newState {
case .checking: case .checking:
if let frameDecryptor = activeCall?.frameDecryptor { if let frameDecryptor = activeCall.wrappedValue?.frameDecryptor {
connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) } connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) }
} }
let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true let enableSpeaker: Bool
switch call.localMedia {
case .video: enableSpeaker = true
default: enableSpeaker = false
}
setSpeakerEnabledAndConfigureSession(enableSpeaker) setSpeakerEnabledAndConfigureSession(enableSpeaker)
case .connected: sendConnectedEvent(connection) case .connected: sendConnectedEvent(connection)
case .disconnected, .failed: endCall() case .disconnected, .failed: endCall()
default: () default: do {}
} }
} }
} }
@@ -792,7 +531,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) { func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
// logger.debug("Connection generated candidate \(candidate.debugDescription)") // logger.debug("Connection generated candidate \(candidate.debugDescription)")
Task { Task {
await self.activeCall?.iceCandidates.append(candidate.toCandidate(nil, nil)) await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
} }
} }
@@ -847,42 +586,11 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
} }
extension WebRTCClient { extension WebRTCClient {
static func isAuthorized(for type: AVMediaType) async -> Bool { func setAudioEnabled(_ enabled: Bool) {
let status = AVCaptureDevice.authorizationStatus(for: type) setTrackEnabled(RTCAudioTrack.self, enabled)
var isAuthorized = status == .authorized
if status == .notDetermined {
isAuthorized = await AVCaptureDevice.requestAccess(for: type)
}
return isAuthorized
} }
static func showUnauthorizedAlert(for type: AVMediaType) { func setSpeakerEnabledAndConfigureSession( _ enabled: Bool) {
if type == .audio {
AlertManager.shared.showAlert(Alert(
title: Text("No permission to record speech"),
message: Text("To record speech please grant permission to use Microphone."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
))
} else if type == .video {
AlertManager.shared.showAlert(Alert(
title: Text("No permission to record video"),
message: Text("To record video please grant permission to use Camera."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
))
}
}
func setSpeakerEnabledAndConfigureSession( _ enabled: Bool, skipExternalDevice: Bool = false) {
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)") logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)")
audioQueue.async { [weak self] in audioQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -891,23 +599,9 @@ extension WebRTCClient {
self.rtcAudioSession.unlockForConfiguration() self.rtcAudioSession.unlockForConfiguration()
} }
do { do {
let hasExternalAudioDevice = self.rtcAudioSession.session.hasExternalAudioDevice() try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue)
if enabled { try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) try self.rtcAudioSession.overrideOutputAudioPort(enabled ? .speaker : .none)
try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue)
if hasExternalAudioDevice && !skipExternalDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
try self.rtcAudioSession.setPreferredInput(preferred)
} else {
try self.rtcAudioSession.overrideOutputAudioPort(.speaker)
}
} else {
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
try self.rtcAudioSession.overrideOutputAudioPort(.none)
}
if hasExternalAudioDevice && !skipExternalDevice {
logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker")
}
try self.rtcAudioSession.setActive(true) try self.rtcAudioSession.setActive(true)
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success") logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success")
} catch let error { } catch let error {
@@ -936,70 +630,25 @@ extension WebRTCClient {
} }
} }
@MainActor func setVideoEnabled(_ enabled: Bool) {
func setAudioEnabled(_ enabled: Bool) { setTrackEnabled(RTCVideoTrack.self, enabled)
if activeCall != nil {
activeCall?.localAudioTrack = enabled ? createAudioTrack() : nil
activeCall?.connection.transceivers.first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .mic })?.sender.track = activeCall?.localAudioTrack
} else if notConnectedCall != nil {
notConnectedCall?.audioTrack = enabled ? createAudioTrack() : nil
}
ChatModel.shared.activeCall?.localMediaSources.mic = enabled
}
@MainActor
func setCameraEnabled(_ enabled: Bool) {
if let call = activeCall {
if enabled {
if call.localVideoTrack == nil {
let device = activeCall?.device ?? notConnectedCall?.device ?? .front
let (camera, track) = createVideoTrackAndStartCapture(device)
activeCall?.localCamera = camera
activeCall?.localVideoTrack = track
}
} else {
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
activeCall?.localCamera = nil
activeCall?.localVideoTrack = nil
}
call.connection.transceivers
.first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .camera })?
.sender.track = activeCall?.localVideoTrack
ChatModel.shared.activeCall?.localMediaSources.camera = activeCall?.localVideoTrack != nil
} else if let call = notConnectedCall {
if enabled {
let device = activeCall?.device ?? notConnectedCall?.device ?? .front
notConnectedCall?.localCameraAndTrack = createVideoTrackAndStartCapture(device)
} else {
(call.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
notConnectedCall?.localCameraAndTrack = nil
}
ChatModel.shared.activeCall?.localMediaSources.camera = notConnectedCall?.localCameraAndTrack != nil
}
} }
func flipCamera() { func flipCamera() {
let device = activeCall?.device ?? notConnectedCall?.device switch activeCall.wrappedValue?.device {
if activeCall != nil { case .front: activeCall.wrappedValue?.device = .back
activeCall?.device = device == .front ? .back : .front case .back: activeCall.wrappedValue?.device = .front
} else { default: ()
notConnectedCall?.device = device == .front ? .back : .front }
if let call = activeCall.wrappedValue {
startCaptureLocalVideo(call)
} }
startCaptureLocalVideo(
activeCall?.device ?? notConnectedCall?.device,
(activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCCameraVideoCapturer
)
}
}
extension AVAudioSession {
func hasExternalAudioDevice() -> Bool {
availableInputs?.allSatisfy({ $0.portType == .builtInMic }) != true
} }
func preferredInputDevice() -> AVAudioSessionPortDescription? { private func setTrackEnabled<T: RTCMediaStreamTrack>(_ type: T.Type, _ enabled: Bool) {
// logger.debug("Preferred input device: \(String(describing: self.availableInputs?.filter({ $0.portType != .builtInMic })))") activeCall.wrappedValue?.connection.transceivers
return availableInputs?.filter({ $0.portType != .builtInMic }).last .compactMap { $0.sender.track as? T }
.forEach { $0.isEnabled = enabled }
} }
} }
@@ -9,9 +9,10 @@
import SwiftUI import SwiftUI
import SimpleXChat 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 { struct ChatInfoToolbar: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var imageSize: CGFloat = 32 var imageSize: CGFloat = 32
@@ -24,30 +25,30 @@ struct ChatInfoToolbar: View {
} }
ChatInfoImage( ChatInfoImage(
chat: chat, chat: chat,
size: imageSize, color: colorScheme == .dark
color: Color(uiColor: .tertiaryLabel) ? chatImageColorDark
: chatImageColorLight
) )
.frame(width: imageSize, height: imageSize)
.padding(.trailing, 4) .padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline) VStack {
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t) let t = Text(cInfo.displayName).font(.headline)
.lineLimit(1) (cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in .lineLimit(1)
VStack(spacing: 0) { if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
v Text(cInfo.fullName).font(.subheadline)
Text(cInfo.fullName).font(.subheadline) .lineLimit(1)
.lineLimit(1)
.padding(.top, -2)
}
} }
}
} }
.foregroundColor(theme.colors.onBackground) .foregroundColor(.primary)
.frame(width: 220) .frame(width: 220)
} }
private var contactVerifiedShield: Text { private var contactVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + textSpace) (Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.baselineOffset(1) .baselineOffset(1)
.kerning(-2) .kerning(-2)
} }
@@ -56,6 +57,5 @@ struct ChatInfoToolbar: View {
struct ChatInfoToolbar_Previews: PreviewProvider { struct ChatInfoToolbar_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
.environmentObject(CurrentColors.toAppTheme())
} }
} }
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,6 @@ import SwiftUI
class AnimatedImageView: UIView { class AnimatedImageView: UIView {
var image: UIImage? = nil var image: UIImage? = nil
var imageView: UIImageView? = nil var imageView: UIImageView? = nil
var cMode: UIView.ContentMode = .scaleAspectFit
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@@ -19,12 +18,11 @@ class AnimatedImageView: UIView {
fatalError("Not implemented") fatalError("Not implemented")
} }
convenience init(image: UIImage, contentMode: UIView.ContentMode) { convenience init(image: UIImage) {
self.init() self.init()
self.image = image self.image = image
self.cMode = contentMode
imageView = UIImageView(gifImage: image) imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!) self.addSubview(imageView!)
} }
@@ -37,7 +35,7 @@ class AnimatedImageView: UIView {
if let subview = self.subviews.first as? UIImageView { if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData { if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image) imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!) self.addSubview(imageView!)
subview.removeFromSuperview() subview.removeFromSuperview()
} }
@@ -49,15 +47,13 @@ class AnimatedImageView: UIView {
struct SwiftyGif: UIViewRepresentable { struct SwiftyGif: UIViewRepresentable {
private let image: UIImage private let image: UIImage
private let contentMode: UIView.ContentMode
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) { init(image: UIImage) {
self.image = image self.image = image
self.contentMode = contentMode
} }
func makeUIView(context: Context) -> AnimatedImageView { func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image, contentMode: contentMode) AnimatedImageView(image: image)
} }
func updateUIView(_ imageView: AnimatedImageView, context: Context) { func updateUIView(_ imageView: AnimatedImageView, context: Context) {
@@ -11,7 +11,6 @@ import SimpleXChat
struct CICallItemView: View { struct CICallItemView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var status: CICallStatus var status: CICallStatus
@@ -23,7 +22,7 @@ struct CICallItemView: View {
switch status { switch status {
case .pending: case .pending:
if sent { if sent {
Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary) Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
} else { } else {
acceptCallButton() acceptCallButton()
} }
@@ -36,7 +35,9 @@ struct CICallItemView: View {
case .error: missedCallIcon(sent).foregroundColor(.orange) 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(.bottom, 8)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
@@ -53,7 +54,7 @@ struct CICallItemView: View {
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View { @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
HStack { HStack {
Image(systemName: "phone.down") Image(systemName: "phone.down")
Text(durationText(duration)).foregroundColor(theme.colors.secondary) Text(durationText(duration)).foregroundColor(.secondary)
} }
} }
@@ -71,7 +72,7 @@ struct CICallItemView: View {
Label("Answer call", systemImage: "phone.arrow.down.left") Label("Answer call", systemImage: "phone.arrow.down.left")
} }
} else { } else {
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary) Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
} }
} }
} }
@@ -11,17 +11,14 @@ import SimpleXChat
struct CIChatFeatureView: View { struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel @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 var chatItem: ChatItem
@Binding var revealed: Bool
var feature: Feature var feature: Feature
var icon: String? = nil var icon: String? = nil
var iconColor: Color var iconColor: Color
var body: some View { var body: some View {
if !revealed, let fs = mergedFeatures() { if !revealed, let fs = mergedFeautures() {
HStack { HStack {
ForEach(fs, content: featureIconView) ForEach(fs, content: featureIconView)
} }
@@ -50,12 +47,12 @@ struct CIChatFeatureView: View {
} }
} }
private func mergedFeatures() -> [FeatureInfo]? { private func mergedFeautures() -> [FeatureInfo]? {
var fs: [FeatureInfo] = [] var fs: [FeatureInfo] = []
var icons: Set<String> = [] var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) { if var i = m.getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count, while i < m.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) { let f = featureInfo(m.reversedChatItems[i]) {
if !icons.contains(f.icon) { if !icons.contains(f.icon) {
fs.insert(f, at: 0) fs.insert(f, at: 0)
icons.insert(f.icon) icons.insert(f.icon)
@@ -68,10 +65,10 @@ struct CIChatFeatureView: View {
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? { private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
switch ci.content { switch ci.content {
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.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(theme.colors.secondary), param) case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, 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 .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, 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 .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
default: nil default: nil
} }
} }
@@ -83,7 +80,7 @@ struct CIChatFeatureView: View {
if let param = f.param { if let param = f.param {
HStack { HStack {
i i
chatEventText(Text(param), theme.colors.secondary).lineLimit(1) chatEventText(Text(param)).lineLimit(1)
} }
} else { } else {
i i
@@ -95,7 +92,7 @@ struct CIChatFeatureView: View {
Image(systemName: icon ?? feature.iconFilled) Image(systemName: icon ?? feature.iconFilled)
.foregroundColor(iconColor) .foregroundColor(iconColor)
.scaleEffect(feature.iconScale) .scaleEffect(feature.iconScale)
chatEventText(chatItem, theme.colors.secondary) chatEventText(chatItem)
} }
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 4) .padding(.vertical, 4)
@@ -106,9 +103,6 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider { struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false) let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView( CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
chat: Chat.sampleData,
chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
).environment(\.revealed, true)
} }
} }
@@ -17,7 +17,6 @@ struct CIEventView: View {
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 4) .padding(.vertical, 4)
.textSelection(.disabled) .textSelection(.disabled)
.lineLimit(4)
} }
} }
@@ -11,7 +11,6 @@ import SimpleXChat
struct CIFeaturePreferenceView: View { struct CIFeaturePreferenceView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
var feature: ChatFeature var feature: ChatFeature
var allowed: FeatureAllowed var allowed: FeatureAllowed
@@ -20,7 +19,7 @@ struct CIFeaturePreferenceView: View {
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
Image(systemName: feature.icon) Image(systemName: feature.icon)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.scaleEffect(feature.iconScale) .scaleEffect(feature.iconScale)
if let ct = chat.chatInfo.contact, if let ct = chat.chatInfo.contact,
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) { allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
@@ -41,17 +40,17 @@ struct CIFeaturePreferenceView: View {
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View { private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ") var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
if let acceptText { if let acceptText {
r = r r = r
+ Text(acceptText) + Text(acceptText)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
+ Text(verbatim: " ") + Text(" ")
} }
r = r + chatItem.timestampText r = r + chatItem.timestampText
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
return r.font(.caption) return r.font(.caption)
} }
} }
@@ -11,83 +11,82 @@ import SimpleXChat
struct CIFileView: View { struct CIFileView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let file: CIFile? let file: CIFile?
let edited: Bool let edited: Bool
var smallViewSize: CGFloat?
var body: some View { var body: some View {
if smallViewSize != nil { let metaReserve = edited
fileIndicator() ? " "
.onTapGesture(perform: fileAction) : " "
} else { Button(action: fileAction) {
let metaReserve = edited HStack(alignment: .bottom, spacing: 6) {
? " " fileIndicator()
: " " .padding(.top, 5)
Button(action: fileAction) { .padding(.bottom, 3)
HStack(alignment: .bottom, spacing: 6) { if let file = file {
fileIndicator() let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
.padding(.top, 5) VStack(alignment: .leading, spacing: 2) {
.padding(.bottom, 3) Text(file.fileName)
if let file = file { .lineLimit(1)
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary) .multilineTextAlignment(.leading)
VStack(alignment: .leading, spacing: 2) { .foregroundColor(.primary)
Text(file.fileName) Text(prettyFileSize + metaReserve)
.lineLimit(1) .font(.caption)
.multilineTextAlignment(.leading) .lineLimit(1)
.foregroundColor(theme.colors.onBackground) .multilineTextAlignment(.leading)
Text(prettyFileSize + metaReserve) .foregroundColor(.secondary)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
} }
} else {
Text(metaReserve)
} }
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
} }
.disabled(!itemInteractive) .padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
} }
.disabled(!itemInteractive)
} }
private var itemInteractive: Bool { private var itemInteractive: Bool {
if let file = file { if let file = file {
switch (file.fileStatus) { switch (file.fileStatus) {
case .sndStored: return file.fileProtocol == .local case .sndStored: return false
case .sndTransfer: return false case .sndTransfer: return false
case .sndComplete: return true case .sndComplete: return false
case .sndCancelled: return false case .sndCancelled: return false
case .sndError: return true case .sndError: return false
case .sndWarning: return true
case .rcvInvitation: return true case .rcvInvitation: return true
case .rcvAccepted: return true case .rcvAccepted: return true
case .rcvTransfer: return false case .rcvTransfer: return false
case .rcvAborted: return true
case .rcvComplete: return true case .rcvComplete: return true
case .rcvCancelled: return false case .rcvCancelled: return false
case .rcvError: return true case .rcvError: return false
case .rcvWarning: return true
case .invalid: return false case .invalid: return false
} }
} }
return false return false
} }
private func fileSizeValid() -> Bool {
if let file = file {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
}
return false
}
private func fileAction() { private func fileAction() {
logger.debug("CIFileView fileAction") logger.debug("CIFileView fileAction")
if let file = file { if let file = file {
switch (file.fileStatus) { switch (file.fileStatus) {
case .rcvInvitation, .rcvAborted: case .rcvInvitation:
if fileSizeValid(file) { if fileSizeValid() {
Task { Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
if let user = m.currentUser { 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 { } else {
@@ -109,47 +108,12 @@ struct CIFileView: View {
title: "Waiting for file", title: "Waiting for file",
message: "File will be received when your contact is online, please wait or check later!" message: "File will be received when your contact is online, please wait or check later!"
) )
case .local: ()
} }
case .rcvComplete: case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete") logger.debug("CIFileView fileAction - in .rcvComplete")
if let fileSource = getLoadedFileSource(file) { if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource) saveCryptoFile(fileSource)
} }
case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError")
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
case let .rcvWarning(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvWarning")
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
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")
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
case let .sndWarning(sndFileError):
logger.debug("CIFileView fileAction - in .sndWarning")
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
default: break default: break
} }
} }
@@ -162,21 +126,18 @@ struct CIFileView: View {
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: progressView() case .xftp: progressView()
case .smp: fileIcon("doc.fill") case .smp: fileIcon("doc.fill")
case .local: fileIcon("doc.fill")
} }
case let .sndTransfer(sndProgress, sndTotal): case let .sndTransfer(sndProgress, sndTotal):
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: progressCircle(sndProgress, sndTotal) case .xftp: progressCircle(sndProgress, sndTotal)
case .smp: progressView() case .smp: progressView()
case .local: EmptyView()
} }
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10) case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .sndError: 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: case .rcvInvitation:
if fileSizeValid(file) { if fileSizeValid() {
fileIcon("arrow.down.doc.fill", color: theme.colors.primary) fileIcon("arrow.down.doc.fill", color: .accentColor)
} else { } else {
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
} }
@@ -187,12 +148,9 @@ struct CIFileView: View {
} else { } else {
progressView() progressView()
} }
case .rcvAborted:
fileIcon("doc.fill", color: theme.colors.primary, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
case .rcvComplete: fileIcon("doc.fill") case .rcvComplete: fileIcon("doc.fill")
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvError: 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) case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
} }
} else { } else {
@@ -201,22 +159,21 @@ struct CIFileView: View {
} }
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View { private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
let size = smallViewSize ?? 30 ZStack(alignment: .center) {
return ZStack(alignment: .center) {
Image(systemName: icon) Image(systemName: icon)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: 30, height: 30)
.foregroundColor(color) .foregroundColor(color)
if let innerIcon = innerIcon, if let innerIcon = innerIcon,
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) { let innerIconSize = innerIconSize {
Image(systemName: innerIcon) Image(systemName: innerIcon)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(maxHeight: 16) .frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize) .frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.top, size / 2.5) .padding(.top, 12)
} }
} }
} }
@@ -237,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) { func saveCryptoFile(_ fileSource: CryptoFile) {
if let cfArgs = fileSource.cryptoArgs { if let cfArgs = fileSource.cryptoArgs {
let url = getAppFilePath(fileSource.filePath) let url = getAppFilePath(fileSource.filePath)
@@ -285,18 +235,17 @@ struct CIFileView_Previews: PreviewProvider {
file: nil file: nil
) )
Group { Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentFile) ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample()) 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)) 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)) 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))) 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)) 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)) 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)) 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)) 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) ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
} }
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }
} }
@@ -11,9 +11,7 @@ import SimpleXChat
struct CIGroupInvitationView: View { struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var groupInvitation: CIGroupInvitation var groupInvitation: CIGroupInvitation
var memberRole: GroupMemberRole var memberRole: GroupMemberRole
@@ -22,8 +20,6 @@ struct CIGroupInvitationView: View {
@State private var inProgress = false @State private var inProgress = false
@State private var progressByTimeout = false @State private var progressByTimeout = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View { var body: some View {
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
let v = ZStack(alignment: .bottomTrailing) { let v = ZStack(alignment: .bottomTrailing) {
@@ -41,22 +37,16 @@ struct CIGroupInvitationView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
groupInvitationText() groupInvitationText()
.overlay(DetermineWidth()) .overlay(DetermineWidth())
( Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
Text(chatIncognito ? "Tap to join incognito" : "Tap to join") .foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary) .font(.callout)
.font(.callout) .padding(.trailing, 60)
+ Text(verbatim: " ") .overlay(DetermineWidth())
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
.overlay(DetermineWidth())
} }
} else { } else {
( groupInvitationText()
groupInvitationText() .padding(.trailing, 60)
+ Text(verbatim: " ") .overlay(DetermineWidth())
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
)
.overlay(DetermineWidth())
} }
} }
.padding(.bottom, 2) .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(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in .onChange(of: inProgress) { inProgress in
@@ -99,7 +92,7 @@ struct CIGroupInvitationView: View {
private func groupInfoView(_ action: Bool) -> some View { private func groupInfoView(_ action: Bool) -> some View {
var color: Color var color: Color
if action && !inProgress { if action && !inProgress {
color = chatIncognito ? .indigo : theme.colors.primary color = chatIncognito ? .indigo : .accentColor
} else { } else {
color = Color(uiColor: .tertiaryLabel) color = Color(uiColor: .tertiaryLabel)
} }
@@ -107,9 +100,9 @@ struct CIGroupInvitationView: View {
ProfileImage( ProfileImage(
imageStr: groupInvitation.groupProfile.image, imageStr: groupInvitation.groupProfile.image,
iconName: "person.2.circle.fill", iconName: "person.2.circle.fill",
size: 44,
color: color color: color
) )
.frame(width: 44, height: 44)
.padding(.trailing, 4) .padding(.trailing, 4)
VStack(alignment: .leading) { VStack(alignment: .leading) {
let p = groupInvitation.groupProfile let p = groupInvitation.groupProfile
@@ -122,7 +115,7 @@ struct CIGroupInvitationView: View {
} }
} }
private func groupInvitationText() -> Text { private func groupInvitationText() -> some View {
Text(groupInvitationStr()) Text(groupInvitationStr())
.font(.callout) .font(.callout)
} }
@@ -144,8 +137,8 @@ struct CIGroupInvitationView: View {
struct CIGroupInvitationView_Previews: PreviewProvider { struct CIGroupInvitationView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin) CIGroupInvitationView(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(status: .accepted), memberRole: .admin)
} }
} }
} }
@@ -11,44 +11,34 @@ import SimpleXChat
struct CIImageView: View { struct CIImageView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
let chatItem: ChatItem let chatItem: ChatItem
var preview: UIImage? let image: String
let maxWidth: CGFloat let maxWidth: CGFloat
var imgWidth: CGFloat? @Binding var imgWidth: CGFloat?
var smallView: Bool = false @State var scrollProxy: ScrollViewProxy?
@Binding var showFullScreenImage: Bool @State var metaColor: Color
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0 @State private var showFullScreenImage = false
var body: some View { var body: some View {
let file = chatItem.file let file = chatItem.file
VStack(alignment: .center, spacing: 6) { VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) { if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } } imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) { .fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage) FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
} }
.onTapGesture { showFullScreenImage = true } .onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in } else if let data = Data(base64Encoded: dropImagePrefix(image)),
showFullScreenImage = false let uiImage = UIImage(data: data) {
} imageView(uiImage)
} else if let preview {
Group {
if smallView {
smallViewImageView(preview)
} else {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
}
}
.onTapGesture { .onTapGesture {
if let file = file { if let file = file {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation, .rcvAborted: case .rcvInvitation:
Task { Task {
if let user = m.currentUser { if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId) await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile)
} }
} }
case .rcvAccepted: case .rcvAccepted:
@@ -63,75 +53,33 @@ struct CIImageView: View {
title: "Waiting for image", title: "Waiting for image",
message: "Image will be received when your contact is online, please wait or check later!" message: "Image will be received when your contact is online, please wait or check later!"
) )
case .local: ()
} }
case .rcvTransfer: () // ? case .rcvTransfer: () // ?
case .rcvComplete: () // ? case .rcvComplete: () // ?
case .rcvCancelled: () // TODO case .rcvCancelled: () // TODO
case let .rcvError(rcvFileError):
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
case let .rcvWarning(rcvFileError):
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
case let .sndError(sndFileError):
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
case let .sndWarning(sndFileError):
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
default: () default: ()
} }
} }
} }
} }
} }
.onDisappear {
showFullScreenImage = false
}
} }
private func imageView(_ img: UIImage) -> some View { 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) { return ZStack(alignment: .topTrailing) {
if img.imageData == nil { if img.imageData == nil {
Image(uiImage: img) Image(uiImage: img)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: w) .frame(maxWidth: w)
} else { } else {
SwiftyGif(image: img) SwiftyGif(image: img)
.frame(width: w, height: w * img.size.height / img.size.width) .frame(width: w, height: w * img.size.height / img.size.width)
.scaledToFit() .scaledToFit()
} }
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) { loadingIndicator()
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()
}
} }
} }
@@ -142,22 +90,18 @@ struct CIImageView: View {
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: progressView() case .xftp: progressView()
case .smp: EmptyView() case .smp: EmptyView()
case .local: EmptyView()
} }
case .sndTransfer: progressView() case .sndTransfer: progressView()
case .sndComplete: fileIcon("checkmark", 10, 13) case .sndComplete: fileIcon("checkmark", 10, 13)
case .sndCancelled: fileIcon("xmark", 10, 13) case .sndCancelled: fileIcon("xmark", 10, 13)
case .sndError: 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 .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11) case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case .rcvTransfer: progressView() case .rcvTransfer: progressView()
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvComplete: EmptyView()
case .rcvCancelled: fileIcon("xmark", 10, 13) case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13) case .rcvError: fileIcon("xmark", 10, 13)
case .rcvWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13) case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView()
} }
} }
} }
@@ -165,9 +109,9 @@ struct CIImageView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View { private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon) Image(systemName: icon)
.resizable() .resizable()
.invertedForegroundStyle()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(metaColor)
.padding(padding) .padding(padding)
} }
@@ -178,12 +122,4 @@ struct CIImageView: View {
.tint(.white) .tint(.white)
.padding(8) .padding(8)
} }
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
} }
@@ -9,7 +9,6 @@
import SwiftUI import SwiftUI
struct CIInvalidJSONView: View { struct CIInvalidJSONView: View {
@EnvironmentObject var theme: AppTheme
var json: String var json: String
@State private var showJSON = false @State private var showJSON = false
@@ -22,9 +21,10 @@ struct CIInvalidJSONView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground)) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
.onTapGesture { showJSON = true } .onTapGesture { showJSON = true }
.appSheet(isPresented: $showJSON) { .sheet(isPresented: $showJSON) {
invalidJSONView(json) invalidJSONView(json)
} }
} }
@@ -44,7 +44,6 @@ func invalidJSONView(_ json: String) -> some View {
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.padding() .padding()
.modifier(ThemedBackground())
} }
struct CIInvalidJSONView_Previews: PreviewProvider { struct CIInvalidJSONView_Previews: PreviewProvider {
@@ -10,17 +10,16 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CILinkView: View { struct CILinkView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let linkPreview: LinkPreview let linkPreview: LinkPreview
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View { var body: some View {
VStack(alignment: .center, spacing: 6) { 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) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred))
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title) Text(linkPreview.title)
@@ -33,7 +32,7 @@ struct CILinkView: View {
Text(linkPreview.uri.absoluteString) Text(linkPreview.uri.absoluteString)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -11,7 +11,6 @@ import SimpleXChat
struct CIMemberCreatedContactView: View { struct CIMemberCreatedContactView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
@@ -23,7 +22,7 @@ struct CIMemberCreatedContactView: View {
.onTapGesture { .onTapGesture {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
DispatchQueue.main.async { DispatchQueue.main.async {
ItemsModel.shared.loadOpenChat("@\(contactId)") m.chatId = "@\(contactId)"
} }
} }
} else { } else {
@@ -44,12 +43,12 @@ struct CIMemberCreatedContactView: View {
r = r r = r
+ Text(openText) + Text(openText)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
+ Text(verbatim: " ") + Text(" ")
} }
r = r + chatItem.timestampText r = r + chatItem.timestampText
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
return r.font(.caption) return r.font(.caption)
} }
@@ -57,11 +56,11 @@ struct CIMemberCreatedContactView: View {
if let member = chatItem.memberDisplayName { if let member = chatItem.memberDisplayName {
return Text(member + " " + chatItem.content.text + " ") return Text(member + " " + chatItem.content.text + " ")
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} else { } else {
return Text(chatItem.content.text + " ") return Text(chatItem.content.text + " ")
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
} }
@@ -11,171 +11,97 @@ import SimpleXChat
struct CIMetaView: View { struct CIMetaView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem var chatItem: ChatItem
var metaColor: Color var metaColor = Color.secondary
var paleMetaColor = Color(UIColor.tertiaryLabel) 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 body: some View { var body: some View {
if chatItem.isDeletedContent { if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor) chatItem.timestampText.font(.caption).foregroundColor(metaColor)
} else { } else {
ZStack { let meta = chatItem.meta
ciMetaText( let ttl = chat.chatInfo.timedMessagesTTL
chatItem.meta, let encrypted = chatItem.encryptedFile
chatTTL: chat.chatInfo.timedMessagesTTL, switch meta.itemStatus {
encrypted: chatItem.encryptedFile, case let .sndSent(sndProgress):
color: metaColor, switch sndProgress {
paleColor: paleMetaColor, case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent)
colorMode: invertedMaterial case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent)
? .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
)
} }
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 { enum SentCheckmark {
// Renders provided colours case sent
case normal case rcvd1
// Fully transparent meta - used for reserving space case rcvd2
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)
}
}
} }
func ciMetaText( func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text {
_ 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 {
var r = Text("") var r = Text("")
var space: Text? = nil if meta.itemEdited {
let appendSpace = { r = r + statusIconText("pencil", color)
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.disappearing { if meta.disappearing {
r = r + statusIconText("timer", resolved).font(.caption2) r = r + statusIconText("timer", color).font(.caption2)
let ttl = meta.itemTimed?.ttl let ttl = meta.itemTimed?.ttl
if ttl != chatTTL { 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 { if let (icon, statusColor) = meta.statusIcon(color) {
appendSpace() let t = Text(Image(systemName: icon)).font(.caption2)
r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2) let gap = Text(" ").kerning(-1.25)
} let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
if showStatus { switch sent {
appendSpace() case nil: r = r + t1
if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) { case .sent: r = r + t1 + gap
let metaColor = if onlyOverrides && statusColor == color { case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
Color.clear case .rcvd2: r = r + gap + t1
} else {
colorMode.resolve(statusColor)
}
r = r + colored(Text(image), metaColor)
} else if !meta.disappearing {
r = r + colorMode.statusSpacer(meta.itemStatus.sent)
} }
space = textSpace r = r + Text(" ")
} else if !meta.disappearing {
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
} }
if let enc = encrypted { if let enc = encrypted {
appendSpace() r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
space = textSpace
}
if showTimesamp {
appendSpace()
r = r + colored(meta.timestampText, resolved)
} }
r = r + meta.timestampText.foregroundColor(color)
return r.font(.caption) return r.font(.caption)
} }
private func statusIconText(_ icon: String, _ color: Color?) -> Text { private func statusIconText(_ icon: String, _ color: Color) -> Text {
colored(Text(Image(systemName: icon)), color) Text(Image(systemName: icon)).foregroundColor(color)
}
// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
private func colored(_ t: Text, _ color: Color?) -> Text {
if let color {
t.foregroundColor(color)
} else {
t
}
} }
struct CIMetaView_Previews: PreviewProvider { struct CIMetaView_Previews: PreviewProvider {
static let metaColor = Color.secondary
static var previews: some View { static var previews: some View {
Group { 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: .complete)))
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", .sndSent(sndProgress: .partial)))
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: .complete)))
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: .ok, sndProgress: .partial)))
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", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
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.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
} }
.previewLayout(.fixed(width: 360, height: 100)) .previewLayout(.fixed(width: 360, height: 100))
} }
@@ -13,22 +13,18 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
struct CIRcvDecryptionError: View { struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
var msgDecryptError: MsgDecryptError var msgDecryptError: MsgDecryptError
var msgCount: UInt32 var msgCount: UInt32
var chatItem: ChatItem var chatItem: ChatItem
@State private var alert: CIRcvDecryptionErrorAlert? @State private var alert: CIRcvDecryptionErrorAlert?
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
enum CIRcvDecryptionErrorAlert: Identifiable { enum CIRcvDecryptionErrorAlert: Identifiable {
case syncAllowedAlert(_ syncConnection: () -> Void) case syncAllowedAlert(_ syncConnection: () -> Void)
case syncNotSupportedContactAlert case syncNotSupportedContactAlert
case syncNotSupportedMemberAlert case syncNotSupportedMemberAlert
case decryptionErrorAlert case decryptionErrorAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String { var id: String {
switch self { switch self {
@@ -48,7 +44,7 @@ struct CIRcvDecryptionError: View {
if case let .group(groupInfo) = chat.chatInfo, if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir { case let .groupRcv(groupMember) = chatItem.chatDir {
do { do {
let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId) let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
if let s = stats { if let s = stats {
m.updateGroupMemberConnectionStats(groupInfo, member, s) 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 .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 .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), 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))
} }
} }
} }
@ViewBuilder private func viewBody() -> some View { @ViewBuilder private func viewBody() -> some View {
Group { if case let .direct(contact) = chat.chatInfo,
if case let .direct(contact) = chat.chatInfo, let contactStats = contact.activeConn?.connectionStats {
let contactStats = contact.activeConn?.connectionStats { if contactStats.ratchetSyncAllowed {
if contactStats.ratchetSyncAllowed { decryptionErrorItemFixButton(syncSupported: true) {
decryptionErrorItemFixButton(syncSupported: true) { alert = .syncAllowedAlert { syncContactConnection(contact) }
alert = .syncAllowedAlert { syncContactConnection(contact) }
}
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
}
} else {
basicDecryptionErrorItem()
} }
} else if case let .group(groupInfo) = chat.chatInfo, } else if !contactStats.ratchetSyncSupported {
case let .groupRcv(groupMember) = chatItem.chatDir, decryptionErrorItemFixButton(syncSupported: false) {
let mem = m.getGroupMember(groupMember.groupMemberId), alert = .syncNotSupportedContactAlert
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 { } else {
basicDecryptionErrorItem() 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 { private func basicDecryptionErrorItem() -> some View {
@@ -119,22 +112,24 @@ struct CIRcvDecryptionError: View {
} }
( (
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath")) Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? .accentColor : .secondary)
.font(.callout) .font(.callout)
+ textSpace + Text(" ")
+ Text("Fix connection") + Text("Fix connection")
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? .accentColor : .secondary)
.font(.callout) .font(.callout)
+ Text(verbatim: " ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
) )
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -144,15 +139,17 @@ struct CIRcvDecryptionError: View {
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
+ Text(verbatim: " ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -13,54 +13,56 @@ import Combine
struct CIVideoView: View { struct CIVideoView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
private let chatItem: ChatItem private let chatItem: ChatItem
private let preview: UIImage? private let image: String
@State private var duration: Int @State private var duration: Int
@State private var progress: Int = 0 @State private var progress: Int = 0
@State private var videoPlaying: Bool = false @State private var videoPlaying: Bool = false
private let maxWidth: CGFloat private let maxWidth: CGFloat
private var videoWidth: CGFloat? @Binding private var videoWidth: CGFloat?
private let smallView: Bool @State private var scrollProxy: ScrollViewProxy?
@State private var preview: UIImage? = nil
@State private var player: AVPlayer? @State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer? @State private var fullPlayer: AVPlayer?
@State private var url: URL? @State private var url: URL?
@State private var urlDecrypted: URL? @State private var showFullScreenPlayer = false
@State private var decryptionInProgress: Bool = false
@Binding private var showFullScreenPlayer: Bool
@State private var timeObserver: Any? = nil @State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil @State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = 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.chatItem = chatItem
self.preview = preview self.image = image
self._duration = State(initialValue: duration) self._duration = State(initialValue: duration)
self.maxWidth = maxWidth self.maxWidth = maxWidth
self.videoWidth = videoWidth self._videoWidth = videoWidth
self.smallView = smallView self.scrollProxy = scrollProxy
self._showFullScreenPlayer = showFullscreenPlayer 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 { var body: some View {
let file = chatItem.file let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) { ZStack {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView { if let file = file, let preview = preview, let player = player, let url = url {
smallVideoView(decrypted, file, preview) videoView(player, url, file, preview, duration)
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted { } else if let data = Data(base64Encoded: dropImagePrefix(image)),
videoView(player, decrypted, file, preview, duration) let uiImage = UIImage(data: data) {
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView { imageView(uiImage)
smallVideoViewEncrypted(file, defaultPreview) .onTapGesture {
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil { if let file = file {
videoViewEncrypted(file, defaultPreview, duration)
} else if let preview, let file {
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
.onTapGesture {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation, .rcvAborted: case .rcvInvitation:
receiveFileIfValidSize(file: file, receiveFile: receiveFile) receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
case .rcvAccepted: case .rcvAccepted:
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: case .xftp:
@@ -73,7 +75,6 @@ struct CIVideoView: View {
title: "Waiting for video", title: "Waiting for video",
message: "Video will be received when your contact is online, please wait or check later!" message: "Video will be received when your contact is online, please wait or check later!"
) )
case .local: ()
} }
case .rcvTransfer: () // ? case .rcvTransfer: () // ?
case .rcvComplete: () // ? case .rcvComplete: () // ?
@@ -81,100 +82,15 @@ struct CIVideoView: View {
default: () default: ()
} }
} }
}
if !smallView {
durationProgress()
}
}
if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
} }
} else if !file.showStatusIconInSmallView { }
durationProgress()
}
if let file = file, case .rcvInvitation = file.fileStatus {
Button {
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill") playPauseIcon("play.fill")
.onTapGesture {
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)
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !blurred {
if !decryptionInProgress {
Button {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
}
}
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
}
} }
} }
} }
@@ -182,9 +98,10 @@ struct CIVideoView: View {
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some 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 let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) { return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) { 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) VideoPlayerView(player: player, url: url, showControls: false)
.frame(width: w, height: w * preview.size.height / preview.size.width) .frame(width: w, height: w * preview.size.height / preview.size.width)
.onChange(of: m.stopPreviousRecPlay) { playingUrl in .onChange(of: m.stopPreviousRecPlay) { playingUrl in
@@ -193,7 +110,9 @@ struct CIVideoView: View {
videoPlaying = false videoPlaying = false
} }
} }
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred)) .fullScreenCover(isPresented: $showFullScreenPlayer) {
fullScreenPlayer(url)
}
.onTapGesture { .onTapGesture {
switch player.timeControlStatus { switch player.timeControlStatus {
case .playing: case .playing:
@@ -206,10 +125,7 @@ struct CIVideoView: View {
default: () default: ()
} }
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in if !videoPlaying {
showFullScreenPlayer = false
}
if !videoPlaying && !blurred {
Button { Button {
m.stopPreviousRecPlay = url m.stopPreviousRecPlay = url
player.play() player.play()
@@ -219,7 +135,7 @@ struct CIVideoView: View {
.disabled(!canBePlayed) .disabled(!canBePlayed)
} }
} }
fileStatusIcon() loadingIndicator()
} }
.onAppear { .onAppear {
addObserver(player, url) addObserver(player, url)
@@ -231,143 +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 {
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 {
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 { private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .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) .foregroundColor(color)
.padding(.leading, smallView ? 0 : 4) .padding(.leading, 4)
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier) .frame(width: 40, height: 40)
.background(Color.black.opacity(0.35)) .background(Color.black.opacity(0.35))
.clipShape(Circle()) .clipShape(Circle())
} }
private func videoDecryptionProgress(_ color: Color = .white) -> some View { private func durationProgress() -> some View {
ProgressView() HStack {
.progressViewStyle(.circular) Text("\(durationText(videoPlaying ? progress : duration))")
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12) .foregroundColor(.white)
.tint(color) .font(.caption)
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40) .padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color.black.opacity(0.35)) .background(Color.black.opacity(0.35))
.clipShape(Circle()) .cornerRadius(10)
} .padding([.top, .leading], 6)
private var fileSizeString: String { if let file = chatItem.file, !videoPlaying {
if let file = chatItem.file, !videoPlaying { Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
" " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary) .foregroundColor(.white)
} else { .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 { 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) { return ZStack(alignment: .topTrailing) {
Image(uiImage: img) Image(uiImage: img)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: w) .frame(maxWidth: w)
.modifier(PrivacyBlur(blurred: $blurred)) loadingIndicator()
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
fileStatusIcon()
}
} }
} }
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View { @ViewBuilder private func loadingIndicator() -> 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 {
if let file = chatItem.file { if let file = chatItem.file {
switch file.fileStatus { switch file.fileStatus {
case .sndStored: case .sndStored:
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: progressView() case .xftp: progressView()
case .smp: EmptyView() case .smp: EmptyView()
case .local: EmptyView()
} }
case let .sndTransfer(sndProgress, sndTotal): case let .sndTransfer(sndProgress, sndTotal):
switch file.fileProtocol { switch file.fileProtocol {
case .xftp: progressCircle(sndProgress, sndTotal) case .xftp: progressCircle(sndProgress, sndTotal)
case .smp: progressView() case .smp: progressView()
case .local: EmptyView()
} }
case .sndComplete: fileIcon("checkmark", 10, 13) case .sndComplete: fileIcon("checkmark", 10, 13)
case .sndCancelled: fileIcon("xmark", 10, 13) case .sndCancelled: fileIcon("xmark", 10, 13)
case let .sndError(sndFileError): case .sndError: fileIcon("xmark", 10, 13)
fileIcon("xmark", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
}
case let .sndWarning(sndFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
}
case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11) case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case let .rcvTransfer(rcvProgress, rcvTotal): case let .rcvTransfer(rcvProgress, rcvTotal):
@@ -376,26 +219,10 @@ struct CIVideoView: View {
} else { } else {
progressView() progressView()
} }
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvComplete: EmptyView()
case .rcvCancelled: fileIcon("xmark", 10, 13) case .rcvCancelled: fileIcon("xmark", 10, 13)
case let .rcvError(rcvFileError): case .rcvError: fileIcon("xmark", 10, 13)
fileIcon("xmark", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
}
case let .rcvWarning(rcvFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
}
case .invalid: fileIcon("questionmark", 10, 13) case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView()
} }
} }
} }
@@ -403,10 +230,10 @@ struct CIVideoView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View { private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon) Image(systemName: icon)
.resizable() .resizable()
.invertedForegroundStyle()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: size, height: size)
.padding(smallView ? 0 : padding) .foregroundColor(.white)
.padding(padding)
} }
private func progressView() -> some View { private func progressView() -> some View {
@@ -414,24 +241,26 @@ struct CIVideoView: View {
.progressViewStyle(.circular) .progressViewStyle(.circular)
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.tint(.white) .tint(.white)
.padding(smallView ? 0 : 11) .padding(11)
} }
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View { private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
Circle() Circle()
.trim(from: 0, to: Double(progress) / Double(total)) .trim(from: 0, to: Double(progress) / Double(total))
.stroke(style: StrokeStyle(lineWidth: 2)) .stroke(
.invertedForegroundStyle() Color(uiColor: .white),
style: StrokeStyle(lineWidth: 2)
)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11) .padding([.trailing, .top], 11)
} }
// TODO encrypt: where file size is checked? // 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 { Task {
if let user = m.currentUser { if let user = m.currentUser {
await receiveFile(user, file.fileId, false, false) await receiveFile(user, file.fileId, encrypted, false)
} }
} }
} }
@@ -464,8 +293,7 @@ struct CIVideoView: View {
) )
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) { DispatchQueue.main.asyncAfter(deadline: .now()) {
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+ m.stopPreviousRecPlay = url
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
if let player = fullPlayer { if let player = fullPlayer {
player.play() player.play()
var played = false var played = false
@@ -495,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) { private func addObserver(_ player: AVPlayer, _ url: URL) {
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
if let item = player.currentItem { if let item = player.currentItem {
@@ -11,30 +11,18 @@ import SimpleXChat
struct CIVoiceView: View { struct CIVoiceView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
let recordingFile: CIFile? let recordingFile: CIFile?
let duration: Int let duration: Int
@State var audioPlayer: AudioPlayer? = nil @Binding var audioPlayer: AudioPlayer?
@State var playbackState: VoiceMessagePlaybackState = .noPlayback @Binding var playbackState: VoiceMessagePlaybackState
@State var playbackTime: TimeInterval? = nil @Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
var smallViewSize: CGFloat?
@State private var seek: (TimeInterval) -> Void = { _ in } @State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View { var body: some View {
Group { Group {
if smallViewSize != nil { if chatItem.chatDir.sent {
HStack(spacing: 10) {
player()
playerTime()
.allowsHitTesting(false)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
} else if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) { VStack (alignment: .trailing, spacing: 6) {
HStack { HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu { if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
@@ -65,13 +53,7 @@ struct CIVoiceView: View {
} }
private func player() -> some View { private func player() -> some View {
let sizeMultiplier: CGFloat = if let sz = smallViewSize { VoiceMessagePlayer(
voiceMessageSizeBasedOnSquareSize(sz) / 56
} else {
1
}
return VoiceMessagePlayer(
chat: chat,
chatItem: chatItem, chatItem: chatItem,
recordingFile: recordingFile, recordingFile: recordingFile,
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
@@ -80,8 +62,7 @@ struct CIVoiceView: View {
audioPlayer: $audioPlayer, audioPlayer: $audioPlayer,
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime, playbackTime: $playbackTime,
allowMenu: $allowMenu, allowMenu: $allowMenu
sizeMultiplier: sizeMultiplier
) )
} }
@@ -91,7 +72,7 @@ struct CIVoiceView: View {
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime playbackTime: $playbackTime
) )
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
private func playbackSlider() -> some View { private func playbackSlider() -> some View {
@@ -108,11 +89,10 @@ struct CIVoiceView: View {
allowMenu = true allowMenu = true
} }
} }
.tint(theme.colors.primary)
} }
private func metaView() -> some View { 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 { struct VoiceMessagePlayer: View {
@ObservedObject var chat: Chat
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem var chatItem: ChatItem
var recordingFile: CIFile? var recordingFile: CIFile?
var recordingTime: TimeInterval var recordingTime: TimeInterval
@@ -149,61 +128,23 @@ struct VoiceMessagePlayer: View {
@Binding var audioPlayer: AudioPlayer? @Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval? @Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
var sizeMultiplier: CGFloat
var body: some View { var body: some View {
ZStack { ZStack {
if let recordingFile = recordingFile { if let recordingFile = recordingFile {
switch recordingFile.fileStatus { switch recordingFile.fileStatus {
case .sndStored: case .sndStored: playbackButton()
if recordingFile.fileProtocol == .local { case .sndTransfer: playbackButton()
playbackButton()
} else {
loadingIcon()
}
case .sndTransfer: loadingIcon()
case .sndComplete: playbackButton() case .sndComplete: playbackButton()
case .sndCancelled: playbackButton() case .sndCancelled: playbackButton()
case let .sndError(sndFileError): case .sndError: playbackButton()
fileStatusIcon("multiply", 14) case .rcvInvitation: downloadButton(recordingFile)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
}
case let .sndWarning(sndFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
}
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .rcvAccepted: loadingIcon() case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon() case .rcvTransfer: loadingIcon()
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
case .rcvComplete: playbackButton() case .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case let .rcvError(rcvFileError): case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
fileStatusIcon("multiply", 14)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
}
case let .rcvWarning(rcvFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
}
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
} }
} else { } else {
@@ -211,170 +152,84 @@ struct VoiceMessagePlayer: View {
} }
} }
.onAppear { .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) } seek = { to in audioPlayer?.seek(to) }
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) { audioPlayer?.onTimer = { playbackTime = $0 }
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?.onFinishPlayback = { audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback playbackState = .noPlayback
playbackTime = TimeInterval(0) 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 .onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath, if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) { chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
stopPlayback() audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} }
} }
.onChange(of: playbackState) { state in .onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback 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()
}
} }
} }
@ViewBuilder private func playbackButton() -> some View { @ViewBuilder private func playbackButton() -> some View {
if sizeMultiplier != 1 { switch playbackState {
switch playbackState { case .noPlayback:
case .noPlayback: Button {
playPauseIcon("play.fill", theme.colors.primary) if let recordingSource = getLoadedFileSource(recordingFile) {
.onTapGesture { startPlayback(recordingSource)
if let recordingSource = getLoadedFileSource(recordingFile) { }
startPlayback(recordingSource) } label: {
} playPauseIcon("play.fill")
}
case .playing:
playPauseIcon("pause.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
}
case .paused:
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
}
} }
} else { case .playing:
switch playbackState { Button {
case .noPlayback: audioPlayer?.pause()
Button { playbackState = .paused
if let recordingSource = getLoadedFileSource(recordingFile) { } label: {
startPlayback(recordingSource) playPauseIcon("pause.fill")
} }
} label: { case .paused:
playPauseIcon("play.fill", theme.colors.primary) Button {
} audioPlayer?.play()
case .playing: playbackState = .playing
Button { } label: {
audioPlayer?.pause() playPauseIcon("play.fill")
playbackState = .paused
notifyStateChange()
} label: {
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
} }
} }
} }
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View { private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
ZStack { ZStack {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier) .frame(width: 20, height: 20)
.foregroundColor(color) .foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0) .padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier) .frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear) .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle()) .clipShape(Circle())
if recordingTime > 0 { if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime) 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 { private func downloadButton(_ recordingFile: CIFile) -> some View {
Group { Button {
if sizeMultiplier != 1 { Task {
playPauseIcon(icon, theme.colors.primary) if let user = chatModel.currentUser {
.onTapGesture { await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get())
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
}
} else {
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
} label: {
playPauseIcon(icon, theme.colors.primary)
} }
} }
} } label: {
} playPauseIcon("play.fill")
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)
} }
} }
private struct ProgressCircle: View { private struct ProgressCircle: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval var length: TimeInterval
@Binding var progress: TimeInterval? @Binding var progress: TimeInterval?
@@ -382,7 +237,7 @@ struct VoiceMessagePlayer: View {
Circle() Circle()
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length)) .trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
.stroke( .stroke(
theme.colors.primary, Color.accentColor,
style: StrokeStyle(lineWidth: 3) style: StrokeStyle(lineWidth: 3)
) )
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
@@ -390,100 +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 { private func loadingIcon() -> some View {
ProgressView() ProgressView()
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier) .frame(width: 30, height: 30)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier) .frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear) .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle()) .clipShape(Circle())
} }
private func startPlayback(_ recordingSource: CryptoFile) { private func startPlayback(_ recordingSource: CryptoFile) {
let audioPath = getAppFilePath(recordingSource.filePath) chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
chatModel.stopPreviousRecPlay = audioPath
audioPlayer = AudioPlayer( audioPlayer = AudioPlayer(
onTimer: { onTimer: { playbackTime = $0 },
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()
}
},
onFinishPlayback: { onFinishPlayback: {
playbackState = .noPlayback playbackState = .noPlayback
playbackTime = TimeInterval(0) playbackTime = TimeInterval(0)
notifyStateChange()
} }
) )
audioPlayer?.start(fileSource: recordingSource, at: playbackTime) audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
playbackState = .playing playbackState = .playing
notifyStateChange()
} }
private func stopPlayback() {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
}
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
}
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)"
}
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 { struct CIVoiceView_Previews: PreviewProvider {
@@ -508,12 +289,15 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(), chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30, duration: 30,
audioPlayer: .constant(nil),
playbackState: .constant(.playing),
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true) allowMenu: Binding.constant(true)
) )
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, 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(), allowMenu: .constant(true)) 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)), allowMenu: .constant(true)) 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, allowMenu: .constant(true)) 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)) .previewLayout(.fixed(width: 360, height: 360))
} }
@@ -10,21 +10,22 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct DeletedItemView: View { struct DeletedItemView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
HStack(alignment: .bottom, spacing: 0) { HStack(alignment: .bottom, spacing: 0) {
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.italic() .italic()
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.padding(.leading, 12) .padding(.leading, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme)) .background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
} }
@@ -11,7 +11,6 @@ import SimpleXChat
struct EmojiItemView: View { struct EmojiItemView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
@@ -19,7 +18,7 @@ struct EmojiItemView: View {
emojiText(chatItem.content.text) emojiText(chatItem.content.text)
.padding(.top, 8) .padding(.top, 8)
.padding(.horizontal, 6) .padding(.horizontal, 6)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.bottom, 8) .padding(.bottom, 8)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
@@ -12,24 +12,21 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct FramedCIVoiceView: View { struct FramedCIVoiceView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
let recordingFile: CIFile? let recordingFile: CIFile?
let duration: Int let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var seek: (TimeInterval) -> Void = { _ in } @State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View { var body: some View {
HStack { HStack {
VoiceMessagePlayer( VoiceMessagePlayer(
chat: chat,
chatItem: chatItem, chatItem: chatItem,
recordingFile: recordingFile, recordingFile: recordingFile,
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
@@ -38,15 +35,14 @@ struct FramedCIVoiceView: View {
audioPlayer: $audioPlayer, audioPlayer: $audioPlayer,
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime, playbackTime: $playbackTime,
allowMenu: $allowMenu, allowMenu: $allowMenu
sizeMultiplier: 1
) )
VoiceMessagePlayerTime( VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime playbackTime: $playbackTime
) )
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.frame(width: 50, alignment: .leading) .frame(width: 50, alignment: .leading)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu { if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider() playbackSlider()
@@ -92,13 +88,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete) file: CIFile.getSample(fileStatus: .sndComplete)
) )
Group { Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage) ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there")) 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))) 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.")) 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) ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
} }
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }
} }
File diff suppressed because one or more lines are too long
@@ -13,12 +13,12 @@ import AVKit
struct FullScreenMediaView: View { struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var scrollModel: ReverseListScrollModel
@State var chatItem: ChatItem @State var chatItem: ChatItem
@State var image: UIImage? @State var image: UIImage?
@State var player: AVPlayer? = nil @State var player: AVPlayer? = nil
@State var url: URL? = nil @State var url: URL? = nil
@Binding var showView: Bool @Binding var showView: Bool
@State var scrollProxy: ScrollViewProxy?
@State private var showNext = false @State private var showNext = false
@State private var nextImage: UIImage? @State private var nextImage: UIImage?
@State private var nextPlayer: AVPlayer? @State private var nextPlayer: AVPlayer?
@@ -71,7 +71,9 @@ struct FullScreenMediaView: View {
let w = abs(t.width) let w = abs(t.width)
if t.height > 60 && t.height > w * 2 { if t.height > 60 && t.height > w * 2 {
showView = false showView = false
scrollModel.scrollToItem(id: chatItem.id) if let proxy = scrollProxy {
proxy.scrollTo(chatItem.viewId)
}
} else if w > 60 && w > abs(t.height) * 2 && !scrolling { } else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0 let previous = t.width > 0
scrolling = true scrolling = true
@@ -11,7 +11,6 @@ import SimpleXChat
struct IntegrityErrorItemView: View { struct IntegrityErrorItemView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var msgError: MsgErrorType var msgError: MsgErrorType
var chatItem: ChatItem var chatItem: ChatItem
@@ -55,7 +54,6 @@ struct IntegrityErrorItemView: View {
struct CIMsgError: View { struct CIMsgError: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
var onTap: () -> Void var onTap: () -> Void
@@ -64,12 +62,13 @@ struct CIMsgError: View {
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.padding(.leading, 12) .padding(.leading, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
.onTapGesture(perform: onTap) .onTapGesture(perform: onTap)
} }
@@ -11,18 +11,19 @@ import SimpleXChat
struct MarkedDeletedItemView: View { struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool
var body: some View { var body: some View {
(Text(mergedMarkedDeletedText).italic() + textSpace + chatItem.timestampText) (Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -32,11 +33,10 @@ struct MarkedDeletedItemView: View {
var i = m.getChatItemIndex(chatItem) { var i = m.getChatItemIndex(chatItem) {
var moderated = 0 var moderated = 0
var blocked = 0 var blocked = 0
var blockedByAdmin = 0
var deleted = 0 var deleted = 0
var moderatedBy: Set<String> = [] var moderatedBy: Set<String> = []
while i < ItemsModel.shared.reversedChatItems.count, while i < m.reversedChatItems.count,
let ci = .some(ItemsModel.shared.reversedChatItems[i]), let ci = .some(m.reversedChatItems[i]),
ci.mergeCategory == ciCategory, ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted { let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted { switch itemDeleted {
@@ -44,19 +44,16 @@ struct MarkedDeletedItemView: View {
moderated += 1 moderated += 1
moderatedBy.insert(byGroupMember.displayName) moderatedBy.insert(byGroupMember.displayName)
case .blocked: blocked += 1 case .blocked: blocked += 1
case .blockedByAdmin: blockedByAdmin += 1
case .deleted: deleted += 1 case .deleted: deleted += 1
} }
i += 1 i += 1
} }
let total = moderated + blocked + blockedByAdmin + deleted let total = moderated + blocked + deleted
return total <= 1 return total <= 1
? markedDeletedText ? markedDeletedText
: total == moderated : total == moderated
? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))" ? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))"
: total == blockedByAdmin : total == blocked
? "\(total) messages blocked by admin"
: total == blocked + blockedByAdmin
? "\(total) messages blocked" ? "\(total) messages blocked"
: "\(total) messages marked deleted" : "\(total) messages marked deleted"
} else { } else {
@@ -64,14 +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 { var markedDeletedText: LocalizedStringKey {
switch chatItem.meta.itemDeleted { switch chatItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
case .blocked: "blocked" case .blocked: "blocked"
case .blockedByAdmin: "blocked by admin" default: "marked deleted"
case .deleted, nil: "marked deleted"
} }
} }
} }
@@ -79,10 +73,7 @@ struct MarkedDeletedItemView: View {
struct MarkedDeletedItemView_Previews: PreviewProvider { struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
MarkedDeletedItemView( MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
chat: Chat.sampleData,
chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
).environment(\.revealed, true)
} }
.previewLayout(.fixed(width: 360, height: 200)) .previewLayout(.fixed(width: 360, height: 200))
} }
@@ -9,9 +9,9 @@
import SwiftUI import SwiftUI
import SimpleXChat 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 let noTyping = Text(verbatim: " ") private let noTyping = Text(" ")
private let typingIndicators: [Text] = [ private let typingIndicators: [Text] = [
(typing(.black) + typing() + typing()), (typing(.black) + typing() + typing()),
@@ -26,19 +26,14 @@ private func typing(_ w: Font.Weight = .light) -> Text {
struct MsgContentView: View { struct MsgContentView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
@EnvironmentObject var theme: AppTheme
var text: String var text: String
var formattedText: [FormattedText]? = nil var formattedText: [FormattedText]? = nil
var sender: String? = nil var sender: String? = nil
var meta: CIMeta? = nil var meta: CIMeta? = nil
var rightToLeft = false var rightToLeft = false
var showSecrets: Bool
@State private var typingIdx = 0 @State private var typingIdx = 0
@State private var timer: Timer? @State private var timer: Timer?
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View { var body: some View {
if meta?.isLive == true { if meta?.isLive == true {
msgContentView() msgContentView()
@@ -67,7 +62,7 @@ struct MsgContentView: View {
} }
private func msgContentView() -> Text { private func msgContentView() -> Text {
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary) var v = messageText(text, formattedText, sender)
if let mt = meta { if let mt = meta {
if mt.isLive { if mt.isLive {
v = v + typingIndicator(mt.recent) v = v + typingIndicator(mt.recent)
@@ -81,22 +76,22 @@ struct MsgContentView: View {
return (recent ? typingIndicators[typingIdx] : noTyping) return (recent ? typingIndicators[typingIdx] : noTyping)
.font(.body.monospaced()) .font(.body.monospaced())
.kerning(-2) .kerning(-2)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? Text("\n") : 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 messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text { func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false) -> Text {
let s = text let s = text
var res: Text var res: Text
if let ft = formattedText, ft.count > 0 && ft.count <= 200 { if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
res = formatText(ft[0], preview, showSecret: showSecrets) res = formatText(ft[0], preview)
var i = 1 var i = 1
while i < ft.count { while i < ft.count {
res = res + formatText(ft[i], preview, showSecret: showSecrets) res = res + formatText(ft[i], preview)
i = i + 1 i = i + 1
} }
} else { } else {
@@ -104,7 +99,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
} }
if let i = icon { if let i = icon {
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res res = Text(Image(systemName: i)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ") + res
} }
if let s = sender { if let s = sender {
@@ -115,7 +110,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
} }
} }
private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text { private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
let t = ft.text let t = ft.text
if let f = ft.format { if let f = ft.format {
switch (f) { switch (f) {
@@ -123,13 +118,7 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool)
case .italic: return Text(t).italic() case .italic: return Text(t).italic()
case .strikeThrough: return Text(t).strikethrough() case .strikeThrough: return Text(t).strikethrough()
case .snippet: return Text(t).font(.body.monospaced()) case .snippet: return Text(t).font(.body.monospaced())
case .secret: return case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
showSecret
? Text(t)
: Text(AttributedString(t, attributes: AttributeContainer([
.foregroundColor: UIColor.clear as Any,
.backgroundColor: UIColor.secondarySystemFill as Any
])))
case let .colored(color): return Text(t).foregroundColor(color.uiColor) case let .colored(color): return Text(t).foregroundColor(color.uiColor)
case .uri: return linkText(t, t, preview, prefix: "") case .uri: return linkText(t, t, preview, prefix: "")
case let .simplexLink(linkType, simplexUri, smpHosts): case let .simplexLink(linkType, simplexUri, smpHosts):
@@ -155,7 +144,7 @@ private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: Stri
]))).underline() ]))).underline()
} }
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String { private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
linkType.description + " " + "(via \(smpHosts.first ?? "?"))" linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
} }
@@ -167,8 +156,7 @@ struct MsgContentView_Previews: PreviewProvider {
text: chatItem.text, text: chatItem.text,
formattedText: chatItem.formattedText, formattedText: chatItem.formattedText,
sender: chatItem.memberDisplayName, sender: chatItem.memberDisplayName,
meta: chatItem.meta, meta: chatItem.meta
showSecrets: false
) )
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
} }
@@ -1,132 +0,0 @@
//
// ChatItemForwardingView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 12.04.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ChatItemForwardingView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss
var chatItems: [ChatItem]
var fromChatInfo: ChatInfo
@Binding var composeState: ComposeState
@State private var searchText: String = ""
@State private var alert: SomeAlert?
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
var body: some View {
NavigationView {
forwardListView()
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .principal) {
Text("Forward")
.bold()
}
}
}
.modifier(ThemedBackground())
.alert(item: $alert) { $0.alert }
}
@ViewBuilder private func forwardListView() -> some View {
VStack(alignment: .leading) {
if !chatsToForwardTo.isEmpty {
List {
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
ForEach(chats) { chat in
forwardListChatView(chat)
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
.modifier(ThemedBackground(grouped: true))
} else {
ZStack {
emptyList()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(ThemedBackground())
}
}
}
private func emptyList() -> some View {
Text("No filtered chats")
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = chatItems.map { ci in
chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false
)
}.contains(true)
Button {
if prohibited {
alert = SomeAlert(
alert: mkAlert(
title: "Cannot forward message",
message: "Selected chat preferences prohibit this message."
),
id: "forward prohibited by preferences"
)
} else {
dismiss()
if chat.id == fromChatInfo.id {
composeState = ComposeState(
message: composeState.message,
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo)
)
} else {
composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id)
}
}
} label: {
HStack {
ChatInfoImage(chat: chat, size: 30)
.padding(.trailing, 2)
Text(chat.chatInfo.chatViewName)
.foregroundColor(prohibited ? theme.colors.secondary : theme.colors.onBackground)
.lineLimit(1)
if chat.chatInfo.incognito {
Spacer()
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.foregroundColor(theme.colors.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
#Preview {
ChatItemForwardingView(
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")],
fromChatInfo: .direct(contact: Contact.sampleData),
composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme())
}
+43 -178
View File
@@ -11,20 +11,16 @@ import SimpleXChat
struct ChatItemInfoView: View { struct ChatItemInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
var ci: ChatItem var ci: ChatItem
@Binding var chatItemInfo: ChatItemInfo? @Binding var chatItemInfo: ChatItemInfo?
@State private var selection: CIInfoTab = .history @State private var selection: CIInfoTab = .history
@State private var alert: CIInfoViewAlert? = nil @State private var alert: CIInfoViewAlert? = nil
@State private var messageStatusLimited: Bool = true
@State private var fileStatusLimited: Bool = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum CIInfoTab { enum CIInfoTab {
case history case history
case quote case quote
case forwarded
case delivery case delivery
} }
@@ -57,9 +53,7 @@ struct ChatItemInfoView: View {
} }
private var title: String { private var title: String {
ci.localNote ci.chatDir.sent
? NSLocalizedString("Saved message", comment: "message info title")
: ci.chatDir.sent
? NSLocalizedString("Sent message", comment: "message info title") ? NSLocalizedString("Sent message", comment: "message info title")
: NSLocalizedString("Received message", comment: "message info title") : NSLocalizedString("Received message", comment: "message info title")
} }
@@ -72,20 +66,9 @@ struct ChatItemInfoView: View {
if ci.quotedItem != nil { if ci.quotedItem != nil {
numTabs += 1 numTabs += 1
} }
if chatItemInfo?.forwardedFromChatItem != nil {
numTabs += 1
}
return numTabs return numTabs
} }
private var local: Bool {
switch ci.chatDir {
case .localSnd: true
case .localRcv: true
default: false
}
}
@ViewBuilder private func itemInfoView() -> some View { @ViewBuilder private func itemInfoView() -> some View {
if numTabs > 1 { if numTabs > 1 {
TabView(selection: $selection) { TabView(selection: $selection) {
@@ -101,22 +84,12 @@ struct ChatItemInfoView: View {
Label("History", systemImage: "clock") Label("History", systemImage: "clock")
} }
.tag(CIInfoTab.history) .tag(CIInfoTab.history)
.modifier(ThemedBackground())
if let qi = ci.quotedItem { if let qi = ci.quotedItem {
quoteTab(qi) quoteTab(qi)
.tabItem { .tabItem {
Label("In reply to", systemImage: "arrowshape.turn.up.left") Label("In reply to", systemImage: "arrowshape.turn.up.left")
} }
.tag(CIInfoTab.quote) .tag(CIInfoTab.quote)
.modifier(ThemedBackground())
}
if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem {
forwardedFromTab(forwardedFromItem)
.tabItem {
Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward")
}
.tag(CIInfoTab.forwarded)
.modifier(ThemedBackground())
} }
} }
.onAppear { .onAppear {
@@ -126,7 +99,6 @@ struct ChatItemInfoView: View {
} }
} else { } else {
historyTab() historyTab()
.modifier(ThemedBackground())
} }
} }
@@ -138,11 +110,7 @@ struct ChatItemInfoView: View {
.bold() .bold()
.padding(.bottom) .padding(.bottom)
if ci.localNote { infoRow("Sent at", localTimestamp(meta.itemTs))
infoRow("Created at", localTimestamp(meta.itemTs))
} else {
infoRow("Sent at", localTimestamp(meta.itemTs))
}
if !ci.chatDir.sent { if !ci.chatDir.sent {
infoRow("Received at", localTimestamp(meta.createdAt)) infoRow("Received at", localTimestamp(meta.createdAt))
} }
@@ -163,35 +131,6 @@ struct ChatItemInfoView: View {
if developerTools { if developerTools {
infoRow("Database ID", "\(meta.itemId)") infoRow("Database ID", "\(meta.itemId)")
infoRow("Record updated at", localTimestamp(meta.updatedAt)) infoRow("Record updated at", localTimestamp(meta.updatedAt))
let msv = infoRow("Message status", ci.meta.itemStatus.id)
Group {
if messageStatusLimited {
msv.lineLimit(1)
} else {
msv
}
}
.onTapGesture {
withAnimation {
messageStatusLimited.toggle()
}
}
if let file = ci.file {
let fsv = infoRow("File status", file.fileStatus.id)
Group {
if fileStatusLimited {
fsv.lineLimit(1)
} else {
fsv
}
}
.onTapGesture {
withAnimation {
fileStatusLimited.toggle()
}
}
}
} }
} }
} }
@@ -216,7 +155,7 @@ struct ChatItemInfoView: View {
} }
else { else {
Text("No history") Text("No history")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
@@ -229,10 +168,11 @@ struct ChatItemInfoView: View {
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { @ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil) textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
.allowsHitTesting(false)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(ci, theme)) .background(chatItemFrameColor(ci, colorScheme))
.modifier(ChatItemClipped()) .cornerRadius(18)
.contextMenu { .contextMenu {
if itemVersion.msgContent.text != "" { if itemVersion.msgContent.text != "" {
Button { Button {
@@ -258,23 +198,11 @@ struct ChatItemInfoView: View {
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View { @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
if text != "" { if text != "" {
TextBubble(text: text, formattedText: formattedText, sender: sender) messageText(text, formattedText, sender)
} else { } else {
Text("no text") Text("no text")
.italic() .italic()
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
}
}
private struct TextBubble: View {
@EnvironmentObject var theme: AppTheme
var text: String
var formattedText: [FormattedText]?
var sender: String? = nil
@State private var showSecrets = false
var body: some View {
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
} }
} }
@@ -299,10 +227,11 @@ struct ChatItemInfoView: View {
@ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { @ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
textBubble(qi.text, qi.formattedText, qi.getSender(nil)) textBubble(qi.text, qi.formattedText, qi.getSender(nil))
.allowsHitTesting(false)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, theme)) .background(quotedMsgFrameColor(qi, colorScheme))
.modifier(ChatItemClipped()) .cornerRadius(18)
.contextMenu { .contextMenu {
if qi.text != "" { if qi.text != "" {
Button { Button {
@@ -325,78 +254,10 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading) .frame(maxWidth: maxWidth, alignment: .leading)
} }
func quotedMsgFrameColor(_ qi: CIQuote, _ theme: AppTheme) -> Color { func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
(qi.chatDir?.sent ?? false) (qi.chatDir?.sent ?? false)
? theme.appColors.sentMessage ? (colorScheme == .light ? sentColorLight : sentColorDark)
: theme.appColors.receivedMessage : Color(uiColor: .tertiarySystemGroupedBackground)
}
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
details()
Divider().padding(.vertical)
Text(local ? "Saved from" : "Forwarded from")
.font(.title2)
.padding(.bottom, 4)
forwardedFromView(forwardedFromItem)
}
.padding()
}
.frame(maxHeight: .infinity, alignment: .top)
}
private func forwardedFromView(_ forwardedFromItem: AChatItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
Button {
Task {
await MainActor.run {
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
dismiss()
}
}
} label: {
forwardedFromSender(forwardedFromItem)
}
if !local {
Divider().padding(.top, 32)
Text("Recipient(s) can't see who this message is from.")
.font(.caption)
.foregroundColor(theme.colors.secondary)
}
}
}
@ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
HStack {
ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48)
.padding(.trailing, 6)
if forwardedFromItem.chatItem.chatDir.sent {
VStack(alignment: .leading) {
Text("you")
.italic()
.foregroundColor(theme.colors.onBackground)
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
}
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
VStack(alignment: .leading) {
Text(groupMember.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
}
} else {
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
}
}
} }
@ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
@@ -415,43 +276,56 @@ struct ChatItemInfoView: View {
} }
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
LazyVStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
let mss = membersStatuses(memberDeliveryStatuses) let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty { if !mss.isEmpty {
ForEach(mss, id: \.0.groupMemberId) { memberStatus in ForEach(mss, id: \.0.groupMemberId) { memberStatus in
memberDeliveryStatusView(memberStatus.0, memberStatus.1, memberStatus.2) memberDeliveryStatusView(memberStatus.0, memberStatus.1)
} }
} else { } else {
Text("No delivery information") Text("No delivery information")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
} }
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] { private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
memberDeliveryStatuses.compactMap({ mds in memberDeliveryStatuses.compactMap({ mds in
if let mem = chatModel.getGroupMember(mds.groupMemberId) { if let mem = chatModel.getGroupMember(mds.groupMemberId) {
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy) return (mem.wrapped, mds.memberDeliveryStatus)
} else { } else {
return nil return nil
} }
}) })
} }
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View { private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View {
HStack{ HStack{
MemberProfileImage(member, size: 30) ProfileImage(imageStr: member.image)
.frame(width: 30, height: 30)
.padding(.trailing, 2) .padding(.trailing, 2)
Text(member.chatViewName) Text(member.chatViewName)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer()
if sentViaProxy == true {
Image(systemName: "arrow.forward")
.foregroundColor(theme.colors.secondary).opacity(0.67)
}
let v = Group { let v = Group {
let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) if let (icon, statusColor) = status.statusIcon(Color.secondary) {
image.foregroundColor(statusColor) switch status {
case .sndRcvd:
ZStack(alignment: .trailing) {
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
}
default:
Image(systemName: icon)
.foregroundColor(statusColor)
}
} else {
Image(systemName: "ellipsis")
.foregroundColor(Color.secondary)
}
} }
if let (title, text) = status.statusInfo { if let (title, text) = status.statusInfo {
@@ -467,12 +341,7 @@ struct ChatItemInfoView: View {
private func itemInfoShareText() -> String { private func itemInfoShareText() -> String {
let meta = ci.meta let meta = ci.meta
var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""] var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""]
shareText += [String.localizedStringWithFormat( shareText += [String.localizedStringWithFormat(NSLocalizedString("Sent at: %@", comment: "copied message info"), localTimestamp(meta.itemTs))]
ci.localNote
? NSLocalizedString("Created at: %@", comment: "copied message info")
: NSLocalizedString("Sent at: %@", comment: "copied message info"),
localTimestamp(meta.itemTs))
]
if !ci.chatDir.sent { if !ci.chatDir.sent {
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))] shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
} }
@@ -493,12 +362,8 @@ struct ChatItemInfoView: View {
if developerTools { if developerTools {
shareText += [ shareText += [
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId), String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt)), String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt))
String.localizedStringWithFormat(NSLocalizedString("Message status: %@", comment: "copied message info"), meta.itemStatus.id)
] ]
if let file = ci.file {
shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)]
}
} }
if let qi = ci.quotedItem { if let qi = ci.quotedItem {
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")] shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
+61 -113
View File
@@ -9,59 +9,50 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
extension EnvironmentValues {
struct ShowTimestamp: EnvironmentKey {
static let defaultValue: Bool = true
}
struct Revealed: EnvironmentKey {
static let defaultValue: Bool = true
}
var showTimestamp: Bool {
get { self[ShowTimestamp.self] }
set { self[ShowTimestamp.self] = newValue }
}
var revealed: Bool {
get { self[Revealed.self] }
set { self[Revealed.self] = newValue }
}
}
struct ChatItemView: View { struct ChatItemView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@Environment(\.revealed) var revealed: Bool
var chatItem: ChatItem var chatItem: ChatItem
var maxWidth: CGFloat = .infinity var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@Binding var revealed: Bool
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
init( init(
chat: Chat, chat: Chat,
chatItem: ChatItem, chatItem: ChatItem,
showMember: Bool = false, showMember: Bool = false,
maxWidth: CGFloat = .infinity, maxWidth: CGFloat = .infinity,
allowMenu: Binding<Bool> = .constant(false) scrollProxy: ScrollViewProxy? = nil,
revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false),
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
playbackTime: Binding<TimeInterval?> = .constant(nil)
) { ) {
self.chat = chat self.chat = chat
self.chatItem = chatItem self.chatItem = chatItem
self.maxWidth = maxWidth self.maxWidth = maxWidth
_scrollProxy = .init(initialValue: scrollProxy)
_revealed = revealed
_allowMenu = allowMenu _allowMenu = allowMenu
_audioPlayer = audioPlayer
_playbackState = playbackState
_playbackTime = playbackTime
} }
var body: some View { var body: some View {
let ci = chatItem let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
MarkedDeletedItemView(chat: chat, chatItem: chatItem) MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { } else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci) EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu) CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil { } else if ci.content.msgContent == nil {
ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else { } else {
framedItemView() framedItemView()
} }
@@ -71,40 +62,15 @@ struct ChatItemView: View {
} }
private func framedItemView() -> some View { private func framedItemView() -> some View {
let preview = chatItem.content.msgContent FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.flatMap {
switch $0 {
case let .image(_, image): image
case let .video(_, image, _): image
default: nil
}
}
.flatMap { imageFromBase64($0) }
let adjustedMaxWidth = {
if let preview, preview.size.width <= preview.size.height {
maxWidth * 0.75
} else {
maxWidth
}
}()
return FramedItemView(
chat: chat,
chatItem: chatItem,
preview: preview,
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth,
allowMenu: $allowMenu
)
} }
} }
struct ChatItemContentView<Content: View>: View { struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool
var msgContentView: () -> Content var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -131,23 +97,18 @@ struct ChatItemContentView<Content: View>: View {
case .sndGroupEvent: eventItemView() case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView() case .rcvConnEvent: eventItemView()
case .sndConnEvent: eventItemView() case .sndConnEvent: eventItemView()
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary)) case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary)) case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .rcvChatPreference(feature, allowed, param): case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _): case let .sndChatPreference(feature, _, _):
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary) CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red) case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
case .sndModerated: deletedItemView() case .sndModerated: deletedItemView()
case .rcvModerated: deletedItemView() case .rcvModerated: deletedItemView()
case .rcvBlocked: deletedItemView()
case let .sndDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo))
case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo))
case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
case let .invalidJSON(json): CIInvalidJSONView(json: json) case let .invalidJSON(json): CIInvalidJSONView(json: json)
} }
} }
@@ -161,29 +122,29 @@ struct ChatItemContentView<Content: View>: View {
} }
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View { private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
CIGroupInvitationView(chat: chat, chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito) CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito)
} }
private func eventItemView() -> some View { private func eventItemView() -> some View {
CIEventView(eventText: eventItemViewText(theme.colors.secondary)) return CIEventView(eventText: eventItemViewText())
} }
private func eventItemViewText(_ secondaryColor: Color) -> Text { private func eventItemViewText() -> Text {
if !revealed, let t = mergedGroupEventText { if !revealed, let t = mergedGroupEventText {
return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor) return chatEventText(t + Text(" ") + chatItem.timestampText)
} else if let member = chatItem.memberDisplayName { } else if let member = chatItem.memberDisplayName {
return Text(member + " ") return Text(member + " ")
.font(.caption) .font(.caption)
.foregroundColor(secondaryColor) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
+ chatEventText(chatItem, secondaryColor) + chatEventText(chatItem)
} else { } else {
return chatEventText(chatItem, secondaryColor) return chatEventText(chatItem)
} }
} }
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor) CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
} }
private var mergedGroupEventText: Text? { private var mergedGroupEventText: Text? {
@@ -203,58 +164,41 @@ struct ChatItemContentView<Content: View>: View {
} else if ns.count == 0 { } else if ns.count == 0 {
Text("\(count) group events") Text("\(count) group events")
} else if count > ns.count { } else if count > ns.count {
Text(members) + textSpace + Text("and \(count - ns.count) other events") Text(members) + Text(" ") + Text("and \(count - ns.count) other events")
} else { } else {
Text(members) Text(members)
} }
} }
private func directE2EEInfoText(_ info: E2EEInfo) -> Text {
info.pqEnabled
? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
.font(.caption)
.foregroundColor(theme.colors.secondary)
.fontWeight(.light)
: e2eeInfoNoPQText()
}
private func e2eeInfoNoPQText() -> Text {
Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
.font(.caption)
.foregroundColor(theme.colors.secondary)
.fontWeight(.light)
}
} }
func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text { func chatEventText(_ text: Text) -> Text {
text text
.font(.caption) .font(.caption)
.foregroundColor(secondaryColor) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
} }
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text { func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
chatEventText(Text(eventText) + textSpace + ts, secondaryColor) chatEventText(Text(eventText) + Text(" ") + ts)
} }
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { func chatEventText(_ ci: ChatItem) -> Text {
chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor) chatEventText("\(ci.content.text)", ci.timestampText)
} }
struct ChatItemView_Previews: PreviewProvider { struct ChatItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group{ Group{
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello")) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too")) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂")) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂")) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂")) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
} }
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70)) .previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
} }
@@ -272,7 +216,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)), content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
quotedItem: nil, quotedItem: nil,
file: nil file: nil
) ),
revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chat: Chat.sampleData,
@@ -282,7 +227,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2), content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
quotedItem: nil, quotedItem: nil,
file: nil file: nil
) ),
revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chat: Chat.sampleData,
@@ -292,7 +238,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin), content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
quotedItem: nil, quotedItem: nil,
file: nil file: nil
) ),
revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chat: Chat.sampleData,
@@ -302,7 +249,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)), content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
quotedItem: nil, quotedItem: nil,
file: nil file: nil
) ),
revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chat: Chat.sampleData, chat: Chat.sampleData,
@@ -312,10 +260,10 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: ciFeatureContent, content: ciFeatureContent,
quotedItem: nil, quotedItem: nil,
file: nil file: nil
) ),
revealed: Binding.constant(true)
) )
} }
.environment(\.revealed, true)
.previewLayout(.fixed(width: 360, height: 70)) .previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
} }
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@
import SwiftUI import SwiftUI
struct ComposeFileView: View { struct ComposeFileView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let fileName: String let fileName: String
let cancelFile: (() -> Void) let cancelFile: (() -> Void)
let cancelEnabled: Bool let cancelEnabled: Bool
@@ -32,8 +32,9 @@ struct ComposeFileView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.padding(.trailing, 12) .padding(.trailing, 12)
.frame(height: 54) .frame(height: 50)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
} }
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ComposeImageView: View { struct ComposeImageView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let images: [String] let images: [String]
let cancelImage: (() -> Void) let cancelImage: (() -> Void)
let cancelEnabled: Bool let cancelEnabled: Bool
@@ -18,7 +18,10 @@ struct ComposeImageView: View {
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
let imgs: [UIImage] = images.compactMap { image in let imgs: [UIImage] = images.compactMap { image in
imageFromBase64(image) if let data = Data(base64Encoded: dropImagePrefix(image)) {
return UIImage(data: data)
}
return nil
} }
if imgs.count == 0 { if imgs.count == 0 {
ProgressView() ProgressView()
@@ -45,9 +48,9 @@ struct ComposeImageView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.padding(.trailing, 12) .padding(.trailing, 12)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
} }
@@ -10,8 +10,37 @@ import SwiftUI
import LinkPresentation import LinkPresentation
import SimpleXChat import SimpleXChat
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 = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
cb(nil)
}
}
}
struct ComposeLinkView: View { struct ComposeLinkView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let linkPreview: LinkPreview? let linkPreview: LinkPreview?
var cancelPreview: (() -> Void)? = nil var cancelPreview: (() -> Void)? = nil
let cancelEnabled: Bool let cancelEnabled: Bool
@@ -33,14 +62,15 @@ struct ComposeLinkView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.padding(.trailing, 12) .padding(.trailing, 12)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View { private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
if let uiImage = imageFromBase64(linkPreview.image) { if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
@@ -52,10 +82,10 @@ struct ComposeLinkView: View {
Text(linkPreview.uri.absoluteString) Text(linkPreview.uri.absoluteString)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
.padding(.vertical, 5) .padding(.vertical, 5)
.frame(maxWidth: .infinity, minHeight: 60) .frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
} }
} }
} }
@@ -23,7 +23,6 @@ enum ComposeContextItem {
case noContextItem case noContextItem
case quotedItem(chatItem: ChatItem) case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem)
case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
} }
enum VoiceMessageRecordingState { enum VoiceMessageRecordingState {
@@ -73,13 +72,6 @@ struct ComposeState {
} }
} }
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
self.message = ""
self.preview = .noPreview
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
self.voiceMessageRecordingState = .noRecording
}
func copy( func copy(
message: String? = nil, message: String? = nil,
liveMessage: LiveMessage? = nil, liveMessage: LiveMessage? = nil,
@@ -110,19 +102,12 @@ struct ComposeState {
} }
} }
var forwarding: Bool {
switch contextItem {
case .forwardingItems: return true
default: return false
}
}
var sendEnabled: Bool { var sendEnabled: Bool {
switch preview { switch preview {
case let .mediaPreviews(media): return !media.isEmpty case .mediaPreviews: return true
case .voicePreview: return voiceMessageRecordingState == .finished case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true case .filePreview: return true
default: return !message.isEmpty || forwarding || liveMessage != nil default: return !message.isEmpty || liveMessage != nil
} }
} }
@@ -167,15 +152,8 @@ struct ComposeState {
} }
} }
var manyMediaPreviews: Bool {
switch preview {
case let .mediaPreviews(mediaPreviews): return mediaPreviews.count > 1
default: return false
}
}
var attachmentDisabled: Bool { var attachmentDisabled: Bool {
if editing || forwarding || liveMessage != nil || inProgress { return true } if editing || liveMessage != nil || inProgress { return true }
switch preview { switch preview {
case .noPreview: return false case .noPreview: return false
case .linkPreview: return false case .linkPreview: return false
@@ -183,16 +161,6 @@ struct ComposeState {
} }
} }
var attachmentPreview: Bool {
switch preview {
case .noPreview: false
case .linkPreview: false
case let .mediaPreviews(mediaPreviews): !mediaPreviews.isEmpty
case .voicePreview: false
case .filePreview: true
}
}
var empty: Bool { var empty: Bool {
message == "" && noPreview message == "" && noPreview
} }
@@ -261,13 +229,11 @@ enum UploadContent: Equatable {
struct ComposeView: View { struct ComposeView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@Binding var keyboardVisible: Bool @Binding var keyboardVisible: Bool
@State var linkUrl: URL? = nil @State var linkUrl: URL? = nil
@State var hasSimplexLink: Bool = false
@State var prevLinkUrl: URL? = nil @State var prevLinkUrl: URL? = nil
@State var pendingLinkUrl: URL? = nil @State var pendingLinkUrl: URL? = nil
@State var cancelledLinks: Set<String> = [] @State var cancelledLinks: Set<String> = []
@@ -287,29 +253,12 @@ struct ComposeView: View {
// this is a workaround to fire an explicit event in certain cases // this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false @State private var stopPlayback: Bool = false
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Divider()
if chat.chatInfo.contact?.nextSendGrpInv ?? false { if chat.chatInfo.contact?.nextSendGrpInv ?? false {
ContextInvitingContactMemberView() ContextInvitingContactMemberView()
Divider()
}
// preference checks should match checks in forwarding list
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
if simplexLinkProhibited {
msgNotAllowedView("SimpleX links not allowed", icon: "link")
Divider()
} else if fileProhibited {
msgNotAllowedView("Files and media not allowed", icon: "doc")
Divider()
} else if voiceProhibited {
msgNotAllowedView("Voice messages not allowed", icon: "mic")
Divider()
} }
contextItemView() contextItemView()
switch (composeState.editing, composeState.preview) { switch (composeState.editing, composeState.preview) {
@@ -328,9 +277,8 @@ struct ComposeView: View {
.frame(width: 25, height: 25) .frame(width: 25, height: 25)
.padding(.bottom, 12) .padding(.bottom, 12)
.padding(.leading, 12) .padding(.leading, 12)
.tint(theme.colors.primary)
if case let .group(g) = chat.chatInfo, if case let .group(g) = chat.chatInfo,
!g.fullGroupPreferences.files.on(for: g.membership) { !g.fullGroupPreferences.files.on {
b.disabled(true).onTapGesture { b.disabled(true).onTapGesture {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Files and media prohibited!", title: "Files and media prohibited!",
@@ -347,7 +295,7 @@ struct ComposeView: View {
sendMessage(ttl: ttl) sendMessage(ttl: ttl)
resetLinkPreview() resetLinkPreview()
}, },
sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil, sendLiveMessage: sendLiveMessage,
updateLiveMessage: updateLiveMessage, updateLiveMessage: updateLiveMessage,
cancelLiveMessage: { cancelLiveMessage: {
composeState.liveMessage = nil composeState.liveMessage = nil
@@ -355,7 +303,6 @@ struct ComposeView: View {
}, },
nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false, nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
disableSendButton: simplexLinkProhibited || fileProhibited || voiceProhibited,
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
startVoiceMessageRecording: { startVoiceMessageRecording: {
Task { Task {
@@ -369,15 +316,16 @@ struct ComposeView: View {
keyboardVisible: $keyboardVisible, keyboardVisible: $keyboardVisible,
sendButtonColor: chat.chatInfo.incognito sendButtonColor: chat.chatInfo.incognito
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7) ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
: theme.colors.primary : .accentColor
) )
.padding(.trailing, 12) .padding(.trailing, 12)
.background(.background)
.disabled(!chat.userCanSend) .disabled(!chat.userCanSend)
if chat.userIsObserver { if chat.userIsObserver {
Text("you are observer") Text("you are observer")
.italic() .italic()
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.onTapGesture { .onTapGesture {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -389,23 +337,13 @@ struct ComposeView: View {
} }
} }
} }
.background { .onChange(of: composeState.message) { _ in
Color.clear
.overlay(ToolbarMaterial.material(toolbarMaterial))
.ignoresSafeArea(.all, edges: .bottom)
}
.onChange(of: composeState.message) { msg in
if composeState.linkPreviewAllowed { if composeState.linkPreviewAllowed {
if msg.count > 0 { if composeState.message.count > 0 {
showLinkPreview(msg) showLinkPreview(composeState.message)
} else { } else {
resetLinkPreview() resetLinkPreview()
hasSimplexLink = false
} }
} else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
(_, hasSimplexLink) = parseMessage(msg)
} else {
hasSimplexLink = false
} }
} }
.onChange(of: chat.userCanSend) { canSend in .onChange(of: chat.userCanSend) { canSend in
@@ -446,10 +384,10 @@ struct ComposeView: View {
} }
} }
.sheet(isPresented: $showMediaPicker) { .sheet(isPresented: $showMediaPicker) {
LibraryMediaListPicker(addMedia: addMediaContent, selectionLimit: 10, finishedPreprocessing: finishedPreprocessingMediaContent) { itemsSelected in LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in
await MainActor.run { showMediaPicker = false
showMediaPicker = false if itemsSelected {
if itemsSelected { DispatchQueue.main.async {
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: [])) composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
} }
} }
@@ -459,7 +397,7 @@ struct ComposeView: View {
Task { Task {
var media: [(String, UploadContent)] = [] var media: [(String, UploadContent)] = []
for content in selected { for content in selected {
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
media.append((img, content)) media.append((img, content))
await MainActor.run { await MainActor.run {
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media)) composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
@@ -550,30 +488,6 @@ struct ComposeView: View {
} }
} }
private func addMediaContent(_ content: UploadContent) async {
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
var newMedia: [(String, UploadContent?)] = []
if case var .mediaPreviews(media) = composeState.preview {
media.append((img, content))
newMedia = media
} else {
newMedia = [(img, content)]
}
await MainActor.run {
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: newMedia))
}
}
}
// When error occurs while converting video, remove media preview
private func finishedPreprocessingMediaContent() {
if case let .mediaPreviews(media) = composeState.preview, media.isEmpty {
DispatchQueue.main.async {
composeState = composeState.copy(preview: .noPreview)
}
}
}
private var maxFileSize: Int64 { private var maxFileSize: Int64 {
getMaxFileSize(.xftp) getMaxFileSize(.xftp)
} }
@@ -642,7 +556,6 @@ struct ComposeView: View {
cancelPreview: cancelLinkPreview, cancelPreview: cancelLinkPreview,
cancelEnabled: !composeState.inProgress cancelEnabled: !composeState.inProgress
) )
Divider()
case let .mediaPreviews(mediaPreviews: media): case let .mediaPreviews(mediaPreviews: media):
ComposeImageView( ComposeImageView(
images: media.map { (img, _) in img }, images: media.map { (img, _) in img },
@@ -651,7 +564,6 @@ struct ComposeView: View {
chosenMedia = [] chosenMedia = []
}, },
cancelEnabled: !composeState.editing && !composeState.inProgress) cancelEnabled: !composeState.editing && !composeState.inProgress)
Divider()
case let .voicePreview(recordingFileName, _): case let .voicePreview(recordingFileName, _):
ComposeVoiceView( ComposeVoiceView(
recordingFileName: recordingFileName, recordingFileName: recordingFileName,
@@ -664,7 +576,6 @@ struct ComposeView: View {
cancelEnabled: !composeState.editing && !composeState.inProgress, cancelEnabled: !composeState.editing && !composeState.inProgress,
stopPlayback: $stopPlayback stopPlayback: $stopPlayback
) )
Divider()
case let .filePreview(fileName, _): case let .filePreview(fileName, _):
ComposeFileView( ComposeFileView(
fileName: fileName, fileName: fileName,
@@ -672,21 +583,9 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview) composeState = composeState.copy(preview: .noPreview)
}, },
cancelEnabled: !composeState.editing && !composeState.inProgress) cancelEnabled: !composeState.editing && !composeState.inProgress)
Divider()
} }
} }
private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
HStack {
Image(systemName: icon).foregroundColor(theme.colors.secondary)
Text(reason).italic()
}
.padding(12)
.frame(minHeight: 54)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
}
@ViewBuilder private func contextItemView() -> some View { @ViewBuilder private func contextItemView() -> some View {
switch composeState.contextItem { switch composeState.contextItem {
case .noContextItem: case .noContextItem:
@@ -694,27 +593,17 @@ struct ComposeView: View {
case let .quotedItem(chatItem: quotedItem): case let .quotedItem(chatItem: quotedItem):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItems: [quotedItem], contextItem: quotedItem,
contextIcon: "arrowshape.turn.up.left", contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
) )
Divider()
case let .editingItem(chatItem: editingItem): case let .editingItem(chatItem: editingItem):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItems: [editingItem], contextItem: editingItem,
contextIcon: "pencil", contextIcon: "pencil",
cancelContextItem: { clearState() } cancelContextItem: { clearState() }
) )
Divider()
case let .forwardingItems(chatItems, _):
ContextItemView(
chat: chat,
contextItems: chatItems,
contextIcon: "arrowshape.turn.up.forward",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
Divider()
} }
} }
@@ -736,12 +625,6 @@ struct ComposeView: View {
} }
if chat.chatInfo.contact?.nextSendGrpInv ?? false { if chat.chatInfo.contact?.nextSendGrpInv ?? false {
await sendMemberContactInvitation() await sendMemberContactInvitation()
} else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem {
// Composed text is send as a reply to the last forwarded item
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
if !composeState.message.isEmpty {
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
}
} else if case let .editingItem(ci) = composeState.contextItem { } else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live) sent = await updateMessage(ci, live: live)
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
@@ -757,63 +640,39 @@ struct ComposeView: View {
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl) sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
case .linkPreview: case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl) sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
case let .mediaPreviews(media): case let .mediaPreviews(mediaPreviews: media):
let last = media.count - 1 let last = media.count - 1
var msgs: [ComposedMessage] = []
if last >= 0 { if last >= 0 {
for i in 0..<last { for i in 0..<last {
if i > 0 { if case (_, .video(_, _, _)) = media[i] {
// Sleep to allow `progressByTimeout` update be rendered sent = await sendVideo(media[i], ttl: ttl)
try? await Task.sleep(nanoseconds: 100_000000) } else {
} sent = await sendImage(media[i], ttl: ttl)
if let (fileSource, msgContent) = mediaContent(media[i], text: "") {
msgs.append(ComposedMessage(fileSource: fileSource, msgContent: msgContent))
} }
_ = try? await Task.sleep(nanoseconds: 100_000000)
} }
if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) { if case (_, .video(_, _, _)) = media[last] {
msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent)) sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
} else {
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
} }
} }
if msgs.isEmpty { if sent == nil {
msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))] sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
} }
sent = await send(msgs, live: live, ttl: ttl).last
case let .voicePreview(recordingFileName, duration): case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle() stopPlayback.toggle()
let file = voiceCryptoFile(recordingFileName) let file = voiceCryptoFile(recordingFileName)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl) sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
case let .filePreview(_, file): case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file) { if let savedFile = saveFileFromURL(file, encrypted: privacyEncryptLocalFilesGroupDefault.get()) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl) sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
} }
} }
} }
await MainActor.run { await MainActor.run { clearState(live: live) }
let wasForwarding = composeState.forwarding
clearState(live: live)
if wasForwarding,
chatModel.draftChatId == chat.chatInfo.id,
let draft = chatModel.draft {
composeState = draft
}
}
return sent return sent
func mediaContent(_ media: (String, UploadContent?), text: String) -> (CryptoFile?, MsgContent)? {
let (previewImage, uploadContent) = media
return switch uploadContent {
case let .simpleImage(image):
(saveImage(image), .image(text: text, image: previewImage))
case let .animatedImage(image):
(saveAnimImage(image), .image(text: text, image: previewImage))
case let .video(_, url, duration):
(moveTempFileFromURL(url), .video(text: text, image: previewImage, duration: duration))
case .none:
nil
}
}
func sending() async { func sending() async {
await MainActor.run { composeState.inProgress = true } await MainActor.run { composeState.inProgress = true }
} }
@@ -877,6 +736,22 @@ struct ComposeView: View {
} }
} }
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
if let data = data, let savedFile = saveAnyImage(data) {
return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
}
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
}
func voiceCryptoFile(_ fileName: String) -> CryptoFile? { func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
if !privacyEncryptLocalFilesGroupDefault.get() { if !privacyEncryptLocalFilesGroupDefault.get() {
return CryptoFile.plain(fileName) return CryptoFile.plain(fileName)
@@ -893,72 +768,31 @@ struct ComposeView: View {
} }
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
await send( if let chatItem = await apiSendMessage(
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)], type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
file: file,
quotedItemId: quoted,
msg: mc,
live: live, live: live,
ttl: ttl ttl: ttl
).first
}
func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] {
if let chatItems = chat.chatInfo.chatType == .local
? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs)
: await apiSendMessages(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
live: live,
ttl: ttl,
composedMessages: msgs
) {
await MainActor.run {
chatModel.removeLiveDummy(animated: false)
for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
}
return chatItems
}
for msg in msgs {
if let file = msg.fileSource {
removeFile(file.filePath)
}
}
return []
}
func forwardItems(_ forwardedItems: [ChatItem], _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> [ChatItem] {
if let chatItems = await apiForwardChatItems(
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
itemIds: forwardedItems.map { $0.id },
ttl: ttl
) { ) {
await MainActor.run { await MainActor.run {
for chatItem in chatItems { chatModel.removeLiveDummy(animated: false)
chatModel.addChatItem(chat.chatInfo, chatItem) chatModel.addChatItem(chat.chatInfo, chatItem)
}
if forwardedItems.count != chatItems.count {
showAlert(
String.localizedStringWithFormat(
NSLocalizedString("%d messages not forwarded", comment: "alert title"),
forwardedItems.count - chatItems.count
),
message: NSLocalizedString("Messages were deleted after you selected them.", comment: "alert message")
)
}
} }
return chatItems return chatItem
} else {
return []
} }
if let file = file {
removeFile(file.filePath)
}
return nil
} }
func checkLinkPreview() -> MsgContent { func checkLinkPreview() -> MsgContent {
switch (composeState.preview) { switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview): case let .linkPreview(linkPreview: linkPreview):
if let url = parseMessage(msgText).url, if let url = parseMessage(msgText),
let linkPreview = linkPreview, let linkPreview = linkPreview,
url == linkPreview.uri { url == linkPreview.uri {
return .link(text: msgText, preview: linkPreview) return .link(text: msgText, preview: linkPreview)
@@ -969,6 +803,14 @@ struct ComposeView: View {
return .text(msgText) return .text(msgText)
} }
} }
func saveAnyImage(_ img: UploadContent) -> CryptoFile? {
switch img {
case let .simpleImage(image): return saveImage(image)
case let .animatedImage(image): return saveAnimImage(image)
default: return nil
}
}
} }
private func startVoiceMessageRecording() async { private func startVoiceMessageRecording() async {
@@ -1079,7 +921,7 @@ struct ComposeView: View {
private func showLinkPreview(_ s: String) { private func showLinkPreview(_ s: String) {
prevLinkUrl = linkUrl prevLinkUrl = linkUrl
(linkUrl, hasSimplexLink) = parseMessage(s) linkUrl = parseMessage(s)
if let url = linkUrl { if let url = linkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl { if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
pendingLinkUrl = url pendingLinkUrl = url
@@ -1096,17 +938,13 @@ struct ComposeView: View {
} }
} }
private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) { private func parseMessage(_ msg: String) -> URL? {
guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) } let parsedMsg = parseSimpleXMarkdown(msg)
let url: URL? = if let uri = parsedMsg.first(where: { ft in let uri = parsedMsg?.first(where: { ft in
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
}) { })
URL(string: uri.text) if let uri = uri { return URL(string: uri.text) }
} else { else { return nil }
nil
}
let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
return (url, simplexLink)
} }
private func isSimplexLink(_ link: String) -> Bool { private func isSimplexLink(_ link: String) -> Bool {
@@ -1114,9 +952,6 @@ struct ComposeView: View {
} }
private func cancelLinkPreview() { private func cancelLinkPreview() {
if let pendingLink = pendingLinkUrl?.absoluteString {
cancelledLinks.insert(pendingLink)
}
if let uri = composeState.linkPreview?.uri.absoluteString { if let uri = composeState.linkPreview?.uri.absoluteString {
cancelledLinks.insert(uri) cancelledLinks.insert(uri)
} }
@@ -25,7 +25,7 @@ func voiceMessageTime_(_ time: TimeInterval?) -> String {
struct ComposeVoiceView: View { struct ComposeVoiceView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
var recordingFileName: String var recordingFileName: String
@Binding var recordingTime: TimeInterval? @Binding var recordingTime: TimeInterval?
@Binding var recordingState: VoiceMessageRecordingState @Binding var recordingState: VoiceMessageRecordingState
@@ -50,9 +50,9 @@ struct ComposeVoiceView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.frame(height: ComposeVoiceView.previewHeight) .frame(height: ComposeVoiceView.previewHeight)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
private func recordingMode() -> some View { private func recordingMode() -> some View {
@@ -80,7 +80,7 @@ struct ComposeVoiceView: View {
Button { Button {
startPlayback() startPlayback()
} label: { } label: {
playPauseIcon("play.fill", theme.colors.primary) playPauseIcon("play.fill")
} }
Text(voiceMessageTime_(recordingTime)) Text(voiceMessageTime_(recordingTime))
case .playing: case .playing:
@@ -88,7 +88,7 @@ struct ComposeVoiceView: View {
audioPlayer?.pause() audioPlayer?.pause()
playbackState = .paused playbackState = .paused
} label: { } label: {
playPauseIcon("pause.fill", theme.colors.primary) playPauseIcon("pause.fill")
} }
Text(voiceMessageTime_(playbackTime)) Text(voiceMessageTime_(playbackTime))
case .paused: case .paused:
@@ -96,7 +96,7 @@ struct ComposeVoiceView: View {
audioPlayer?.play() audioPlayer?.play()
playbackState = .playing playbackState = .playing
} label: { } label: {
playPauseIcon("play.fill", theme.colors.primary) playPauseIcon("play.fill")
} }
Text(voiceMessageTime_(playbackTime)) Text(voiceMessageTime_(playbackTime))
} }
@@ -131,7 +131,7 @@ struct ComposeVoiceView: View {
} }
} }
private func playPauseIcon(_ image: String, _ color: Color) -> some View { private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
@@ -147,11 +147,9 @@ struct ComposeVoiceView: View {
} label: { } label: {
Image(systemName: "multiply") Image(systemName: "multiply")
} }
.tint(theme.colors.primary)
} }
struct SliderBar: View { struct SliderBar: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval var length: TimeInterval
@Binding var progress: TimeInterval? @Binding var progress: TimeInterval?
var seek: (TimeInterval) -> Void var seek: (TimeInterval) -> Void
@@ -160,12 +158,10 @@ struct ComposeVoiceView: View {
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length) Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 4) .frame(height: 4)
.tint(theme.colors.primary)
} }
} }
private struct ProgressBar: View { private struct ProgressBar: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval var length: TimeInterval
@Binding var progress: TimeInterval? @Binding var progress: TimeInterval?
@@ -173,7 +169,7 @@ struct ComposeVoiceView: View {
GeometryReader { geometry in GeometryReader { geometry in
ZStack { ZStack {
Rectangle() Rectangle()
.fill(theme.colors.primary) .fill(Color.accentColor)
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4) .frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
.animation(.linear, value: progress) .animation(.linear, value: progress)
} }
@@ -9,18 +9,19 @@
import SwiftUI import SwiftUI
struct ContextInvitingContactMemberView: View { struct ContextInvitingContactMemberView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
var body: some View { var body: some View {
HStack { HStack {
Image(systemName: "message") Image(systemName: "message")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
Text("Send direct message to connect") Text("Send direct message to connect")
} }
.padding(12) .padding(12)
.frame(minHeight: 54) .frame(minHeight: 50)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.padding(.top, 8)
} }
} }

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