diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index 6256823471..a6ffb6d328 100644
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -1,7 +1,7 @@
name: Bug
description: File a bug report/issue
title: "[Bug]: "
-labels: ["type:bug", "type:triage"]
+labels: ["bug", "triage"]
body:
- type: checkboxes
attributes:
diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml
index 84b8ac5dcc..936d1dcaf5 100644
--- a/.github/ISSUE_TEMPLATE/feature.yml
+++ b/.github/ISSUE_TEMPLATE/feature.yml
@@ -1,7 +1,7 @@
name: Feature
description: Suggest your feature
title: "[Feature]: "
-labels: ["type:enhancement", "type:triage"]
+labels: ["enhancement", "triage"]
body:
- type: checkboxes
attributes:
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
index b69aa7a3ab..8506c69e0a 100644
--- a/.github/ISSUE_TEMPLATE/question.yml
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -1,7 +1,7 @@
name: Question
description: Ask your question
title: "[Q]: "
-labels: ["type:question", "type:triage"]
+labels: ["question", "triage"]
body:
- type: markdown
attributes:
diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml
new file mode 100644
index 0000000000..ce75b7a57c
--- /dev/null
+++ b/.github/actions/prepare-build/action.yml
@@ -0,0 +1,47 @@
+name: "Prebuilt steps for build"
+description: "Reusable steps for multiple jobs"
+inputs:
+ java_ver:
+ required: true
+ description: "Java version to install"
+ ghc_ver:
+ required: true
+ description: "GHC version to install"
+ github_ref:
+ required: true
+ description: "Git reference"
+ os:
+ required: true
+ description: "Target OS"
+ cache_path:
+ required: false
+ default: "~/.cabal/store"
+ description: "Cache path"
+ cabal_ver:
+ required: false
+ default: 3.10.1.0
+ description: "GHC version to install"
+runs:
+ using: "composite"
+ steps:
+ - name: Setup Haskell
+ uses: simplex-chat/setup-haskell-action@v2
+ with:
+ ghc-version: ${{ inputs.ghc_ver }}
+ cabal-version: ${{ inputs.cabal_ver }}
+
+ - name: Setup Java
+ if: startsWith(inputs.github_ref, 'refs/tags/v')
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'corretto'
+ java-version: ${{ inputs.java_ver }}
+ cache: 'gradle'
+
+ - name: Restore cached build
+ uses: actions/cache@v4
+ with:
+ path: |
+ ${{ inputs.cache_path }}
+ dist-newstyle
+ key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
diff --git a/.github/actions/prepare-release/action.yml b/.github/actions/prepare-release/action.yml
new file mode 100644
index 0000000000..e0d32bd596
--- /dev/null
+++ b/.github/actions/prepare-release/action.yml
@@ -0,0 +1,39 @@
+name: "Upload binary and update hash"
+description: "Reusable steps for multiple jobs"
+inputs:
+ bin_path:
+ required: true
+ description: "Path to binary to upload"
+ bin_name:
+ required: true
+ description: "Name of uploaded binary"
+ bin_hash:
+ required: true
+ description: "Message with SHA to include in release"
+ github_ref:
+ required: true
+ description: "Github reference"
+ github_token:
+ required: true
+ description: "Github token"
+runs:
+ using: "composite"
+ steps:
+ - name: Upload file with specific name
+ if: startsWith(inputs.github_ref, 'refs/tags/v')
+ uses: simplex-chat/upload-release-action@v2
+ with:
+ repo_token: ${{ inputs.github_token }}
+ file: ${{ inputs.bin_path }}
+ asset_name: ${{ inputs.bin_name }}
+ tag: ${{ inputs.github_ref }}
+
+ - name: Add hash to release notes
+ if: startsWith(inputs.github_ref, 'refs/tags/v')
+ uses: simplex-chat/action-gh-release@v2
+ env:
+ GITHUB_TOKEN: ${{ inputs.github_token }}
+ with:
+ append_body: true
+ body: |
+ ${{ inputs.bin_hash }}
diff --git a/.github/actions/swap/action.yml b/.github/actions/swap/action.yml
new file mode 100644
index 0000000000..87d670b147
--- /dev/null
+++ b/.github/actions/swap/action.yml
@@ -0,0 +1,44 @@
+name: 'Set Swap Space'
+description: 'Add moar swap'
+branding:
+ icon: 'crop'
+ color: 'orange'
+inputs:
+ swap-size-gb:
+ description: 'Swap space to create, in Gigabytes.'
+ required: false
+ default: '10'
+runs:
+ using: "composite"
+ steps:
+ - name: Swap space report before modification
+ shell: bash
+ run: |
+ echo "Memory and swap:"
+ free -h
+ echo
+ swapon --show
+ echo
+ - name: Set Swap
+ shell: bash
+ run: |
+ export SWAP_FILE=$(swapon --show=NAME | tail -n 1)
+ echo "Swap file: $SWAP_FILE"
+ if [ -z "$SWAP_FILE" ]; then
+ SWAP_FILE=/opt/swapfile
+ else
+ sudo swapoff $SWAP_FILE
+ sudo rm $SWAP_FILE
+ fi
+ sudo fallocate -l ${{ inputs.swap-size-gb }}G $SWAP_FILE
+ sudo chmod 600 $SWAP_FILE
+ sudo mkswap $SWAP_FILE
+ sudo swapon $SWAP_FILE
+ - name: Swap space report after modification
+ shell: bash
+ run: |
+ echo "Memory and swap:"
+ free -h
+ echo
+ swapon --show
+ echo
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 46a38d2f47..ca1bc79510 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,22 +5,75 @@ on:
branches:
- master
- stable
- - users
tags:
- "v*"
+ - "!*-fdroid"
+ - "!*-armv7a"
pull_request:
+ paths-ignore:
+ - "apps/ios"
+ - "apps/multiplatform"
+ - "blog"
+ - "docs"
+ - "fastlane"
+ - "images"
+ - "packages"
+ - "website"
+ - "README.md"
+ - "PRIVACY.md"
+
+# This workflow uses custom actions (prepare-build and prepare-release) defined in:
+#
+# .github/actions/
+# ├── prepare-build
+# │ └── action.yml
+# └── prepare-release
+# └── action.yml
+
+# Important!
+# Do not use always(), it makes build unskippable.
+# See: https://github.com/actions/runner/issues/1846#issuecomment-1246102753
jobs:
- prepare-release:
- if: startsWith(github.ref, 'refs/tags/v')
+
+# =============================
+# Global variables
+# =============================
+
+# That is the only and less hacky way to setup global variables
+# to use in strategy matrix (env:/YAML anchors doesn't work).
+# See: https://github.com/orgs/community/discussions/56787#discussioncomment-6041789
+# https://github.com/actions/runner/issues/1182
+# https://stackoverflow.com/a/77549656
+
+ variables:
+ runs-on: ubuntu-latest
+ outputs:
+ GHC_VER: 9.6.3
+ JAVA_VER: 17
+ steps:
+ - name: Dummy job when we have just simple variables
+ if: false
+ run: echo
+
+# =============================
+# Create release
+# =============================
+
+# Create release, but only if it's triggered by tag push.
+# On pull requests/commits push, this job will always complete.
+
+ maybe-release:
runs-on: ubuntu-latest
steps:
- name: Clone project
- uses: actions/checkout@v2
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: actions/checkout@v3
- name: Build changelog
id: build_changelog
- uses: mikepenz/release-changelog-builder-action@v1
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: simplex-chat/release-changelog-builder-action@v5
with:
configuration: .github/changelog_conf.json
failOnError: true
@@ -30,7 +83,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
- uses: softprops/action-gh-release@v1
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: simplex-chat/action-gh-release@v2
with:
body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: true
@@ -40,124 +94,439 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- build:
- name: build-${{ matrix.os }}
- if: always()
- needs: prepare-release
- runs-on: ${{ matrix.os }}
+# =========================
+# Linux Build
+# =========================
+
+ build-linux:
+ name: "ubuntu-${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
+ needs: [maybe-release, variables]
+ runs-on: ubuntu-${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- - os: ubuntu-20.04
- cache_path: ~/.cabal/store
- asset_name: simplex-chat-ubuntu-20_04-x86-64
- - os: ubuntu-22.04
- cache_path: ~/.cabal/store
- asset_name: simplex-chat-ubuntu-22_04-x86-64
- - os: macos-latest
- cache_path: ~/.cabal/store
- asset_name: simplex-chat-macos-x86-64
- - os: windows-latest
- cache_path: C:/cabal
- asset_name: simplex-chat-windows-x86-64
+ - os: 22.04
+ ghc: "8.10.7"
+ should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }}
+ - os: 22.04
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ cli_asset_name: simplex-chat-ubuntu-22_04-x86-64
+ desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
+ should_run: true
+ - os: 24.04
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ cli_asset_name: simplex-chat-ubuntu-24_04-x86-64
+ desktop_asset_name: simplex-desktop-ubuntu-24_04-x86_64.deb
+ should_run: true
steps:
- - name: Configure pagefile (Windows)
- if: matrix.os == 'windows-latest'
- uses: al-cheb/configure-pagefile-action@v1.3
+ - name: Checkout Code
+ if: matrix.should_run == true
+ uses: actions/checkout@v3
+
+ - name: Setup swap
+ if: matrix.ghc == '8.10.7' && matrix.should_run == true
+ uses: ./.github/actions/swap
with:
- minimum-size: 16GB
- maximum-size: 16GB
- disk-root: "C:"
+ swap-size-gb: 30
- - name: Clone project
- uses: actions/checkout@v2
+ # Otherwise we run out of disk space with Docker build
+ - name: Free disk space
+ if: matrix.should_run == true
+ shell: bash
+ run: ./scripts/ci/linux_util_free_space.sh
- - name: Setup Haskell
- uses: haskell/actions/setup@v1
- with:
- ghc-version: "8.10.7"
- cabal-version: "latest"
-
- - name: Cache dependencies
- uses: actions/cache@v2
+ - name: Restore cached build
+ if: matrix.should_run == true
+ uses: actions/cache@v4
with:
path: |
- ${{ matrix.cache_path }}
+ ~/.cabal/store
dist-newstyle
- key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
+ key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
- # / Unix
+ - name: Set up Docker Buildx
+ if: matrix.should_run == true
+ uses: simplex-chat/docker-setup-buildx-action@v3
- - name: Unix prepare cabal.project.local for Mac
- if: matrix.os == 'macos-latest'
+ - name: Build and cache Docker image
+ if: matrix.should_run == true
+ uses: simplex-chat/docker-build-push-action@v6
+ with:
+ context: .
+ load: true
+ file: Dockerfile.build
+ tags: build/${{ matrix.os }}:latest
+ build-args: |
+ TAG=${{ matrix.os }}
+ GHC=${{ matrix.ghc }}
+
+ # Docker needs these flags for AppImage build:
+ # --device /dev/fuse
+ # --cap-add SYS_ADMIN
+ # --security-opt apparmor:unconfined
+ - name: Start container
+ if: matrix.should_run == true
shell: bash
run: |
- echo "ignore-project: False" >> cabal.project.local
- echo "package direct-sqlcipher" >> cabal.project.local
- echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
- echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
- echo " flags: +openssl" >> cabal.project.local
+ docker run -t -d \
+ --device /dev/fuse \
+ --cap-add SYS_ADMIN \
+ --security-opt apparmor:unconfined \
+ --name builder \
+ -v ~/.cabal:/root/.cabal \
+ -v /home/runner/work/_temp:/home/runner/work/_temp \
+ -v ${{ github.workspace }}:/project \
+ build/${{ matrix.os }}:latest
- - name: Install pkg-config for Mac
- if: matrix.os == 'macos-latest'
- run: brew install pkg-config
-
- - name: Unix prepare cabal.project.local for Ubuntu
- if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
+ - name: Prepare cabal.project.local
+ if: matrix.should_run == true
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- - name: Unix build
- id: unix_build
- if: matrix.os != 'windows-latest'
+ # chmod/git commands are used to workaround permission issues when cache is restored
+ - name: Build CLI
+ if: matrix.should_run == true
+ shell: docker exec -t builder sh -eu {0}
+ run: |
+ chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*'
+ cabal clean
+ cabal update
+ cabal build -j --enable-tests
+ mkdir -p /out
+ for i in simplex-chat simplex-chat-test; do
+ bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
+ chmod +x "$bin"
+ mv "$bin" /out/
+ done
+ strip /out/simplex-chat
+
+ - name: Copy tests from container
+ if: matrix.should_run == true
shell: bash
run: |
- cabal build --enable-tests
- echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
+ docker cp builder:/out/simplex-chat-test .
- - name: Unix test
- if: matrix.os != 'windows-latest'
- timeout-minutes: 30
+ - name: Copy CLI from container and prepare it
+ id: linux_cli_prepare
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
- run: cabal test --test-show-details=direct
-
- - name: Unix upload binary to release
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
- uses: svenstaro/upload-release-action@v2
- with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.unix_build.outputs.bin_path }}
- asset_name: ${{ matrix.asset_name }}
- tag: ${{ github.ref }}
-
- # Unix /
-
- # / Windows
-
- # * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
- # * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
-
- - name: Windows build
- id: windows_build
- if: matrix.os == 'windows-latest'
- shell: cmd
run: |
- cabal build --enable-tests
- cabal list-bin simplex-chat > tmp_bin_path
- set /p bin_path= < tmp_bin_path
- echo ::set-output name=bin_path::%bin_path%
+ docker cp builder:/out/simplex-chat ./${{ matrix.cli_asset_name }}
+ path="${{ github.workspace }}/${{ matrix.cli_asset_name }}"
+ echo "bin_path=$path" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- - name: Windows upload binary to release
- if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
- uses: svenstaro/upload-release-action@v2
+ - name: Upload CLI
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ uses: ./.github/actions/prepare-release
with:
- repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.windows_build.outputs.bin_path }}
- asset_name: ${{ matrix.asset_name }}
- tag: ${{ github.ref }}
+ bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }}
+ bin_name: ${{ matrix.cli_asset_name }}
+ bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
- # Windows /
+ - name: Build Desktop
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ shell: docker exec -t builder sh -eu {0}
+ run: |
+ scripts/desktop/build-lib-linux.sh
+ cd apps/multiplatform
+ ./gradlew packageDeb
+
+ - name: Prepare Desktop
+ id: linux_desktop_build
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ shell: bash
+ run: |
+ path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb )
+ echo "package_path=$path" >> $GITHUB_OUTPUT
+ echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Upload Desktop
+ uses: ./.github/actions/prepare-release
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ with:
+ bin_path: ${{ steps.linux_desktop_build.outputs.package_path }}
+ bin_name: ${{ matrix.desktop_asset_name }}
+ bin_hash: ${{ steps.linux_desktop_build.outputs.package_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build AppImage
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
+ shell: docker exec -t builder sh -eu {0}
+ run: |
+ scripts/desktop/make-appimage-linux.sh
+
+ - name: Prepare AppImage
+ id: linux_appimage_build
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
+ shell: bash
+ run: |
+ path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/*imple*.AppImage)
+ echo "appimage_path=$path" >> $GITHUB_OUTPUT
+ echo "appimage_hash=$(echo SHA2-256\(simplex-desktop-x86_64.AppImage\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Upload AppImage
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
+ uses: ./.github/actions/prepare-release
+ with:
+ bin_path: ${{ steps.linux_appimage_build.outputs.appimage_path }}
+ bin_name: "simplex-desktop-x86_64.AppImage"
+ bin_hash: ${{ steps.linux_appimage_build.outputs.appimage_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Fix permissions for cache
+ if: matrix.should_run == true
+ shell: bash
+ run: |
+ sudo chmod -R 777 dist-newstyle ~/.cabal
+ sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal
+
+ - name: Run tests
+ if: matrix.should_run == true
+ timeout-minutes: 120
+ shell: bash
+ run: |
+ i=1
+ attempts=1
+ ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
+ while [ "$i" -le "$attempts" ]; do
+ if ./simplex-chat-test; then
+ break
+ else
+ echo "Attempt $i failed, retrying..."
+ i=$((i + 1))
+ sleep 1
+ fi
+ done
+ if [ "$i" -gt "$attempts" ]; then
+ echo "All "$attempts" attempts failed."
+ exit 1
+ fi
+
+# =========================
+# MacOS Build
+# =========================
+
+ build-macos:
+ name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
+ needs: [maybe-release, variables]
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: macos-latest
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ cli_asset_name: simplex-chat-macos-aarch64
+ desktop_asset_name: simplex-desktop-macos-aarch64.dmg
+ openssl_dir: "/opt/homebrew/opt"
+ - os: macos-13
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ cli_asset_name: simplex-chat-macos-x86-64
+ desktop_asset_name: simplex-desktop-macos-x86_64.dmg
+ openssl_dir: "/usr/local/opt"
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v3
+
+ - name: Prepare build
+ uses: ./.github/actions/prepare-build
+ with:
+ java_ver: ${{ needs.variables.outputs.JAVA_VER }}
+ ghc_ver: ${{ matrix.ghc }}
+ os: ${{ matrix.os }}
+ github_ref: ${{ github.ref }}
+
+ - name: Install OpenSSL
+ run: brew install openssl@3.0
+
+ - name: Prepare cabal.project.local
+ shell: bash
+ run: |
+ echo "ignore-project: False" >> cabal.project.local
+ echo "package simplexmq" >> cabal.project.local
+ echo " extra-include-dirs: ${{ matrix.opnessl_dir }}/openssl@3.0/include" >> cabal.project.local
+ echo " extra-lib-dirs: ${{ matrix.openssl_dir}}/openssl@3.0/lib" >> cabal.project.local
+ echo "" >> cabal.project.local
+ echo "package direct-sqlcipher" >> cabal.project.local
+ echo " extra-include-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/include" >> cabal.project.local
+ echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local
+ echo " flags: +openssl" >> cabal.project.local
+
+ - name: Build CLI
+ id: mac_cli_build
+ shell: bash
+ run: |
+ cabal build -j --enable-tests
+ path=$(cabal list-bin simplex-chat)
+ echo "bin_path=$path" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Upload CLI
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
+ with:
+ bin_path: ${{ steps.mac_cli_build.outputs.bin_path }}
+ bin_name: ${{ matrix.cli_asset_name }}
+ bin_hash: ${{ steps.mac_cli_build.outputs.bin_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build Desktop
+ id: mac_desktop_build
+ if: startsWith(github.ref, 'refs/tags/v')
+ shell: bash
+ env:
+ APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
+ APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
+ APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
+ run: |
+ scripts/ci/build-desktop-mac.sh
+ path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
+ echo "package_path=$path" >> $GITHUB_OUTPUT
+ echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Upload Desktop
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
+ with:
+ bin_path: ${{ steps.mac_desktop_build.outputs.package_path }}
+ bin_name: ${{ matrix.desktop_asset_name }}
+ bin_hash: ${{ steps.mac_desktop_build.outputs.package_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Run tests
+ timeout-minutes: 120
+ shell: bash
+ run: |
+ i=1
+ attempts=1
+ ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
+ while [ "$i" -le "$attempts" ]; do
+ if cabal test --test-show-details=direct; then
+ break
+ else
+ echo "Attempt $i failed, retrying..."
+ i=$((i + 1))
+ sleep 1
+ fi
+ done
+ if [ "$i" -gt "$attempts" ]; then
+ echo "All "$attempts" attempts failed."
+ exit 1
+ fi
+
+# =========================
+# Windows Build
+# =========================
+
+ build-windows:
+ name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
+ needs: [maybe-release, variables]
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ ghc: ${{ needs.variables.outputs.GHC_VER }}
+ cli_asset_name: simplex-chat-windows-x86-64
+ desktop_asset_name: simplex-desktop-windows-x86_64.msi
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v3
+
+ - name: Prepare build
+ uses: ./.github/actions/prepare-build
+ with:
+ java_ver: ${{ needs.variables.outputs.JAVA_VER }}
+ ghc_ver: ${{ matrix.ghc }}
+ os: ${{ matrix.os }}
+ cache_path: "C:/cabal"
+ github_ref: ${{ github.ref }}
+
+ - name: Configure pagefile (Windows)
+ uses: simplex-chat/configure-pagefile-action@v1.4
+ with:
+ minimum-size: 16GB
+ maximum-size: 16GB
+ disk-root: "C:"
+
+ - name: 'Setup MSYS2'
+ uses: simplex-chat/setup-msys2@v2
+ with:
+ msystem: ucrt64
+ update: true
+ install: >-
+ git
+ perl
+ make
+ pacboy: >-
+ toolchain:p
+ cmake:p
+
+ # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
+ - name: Build CLI
+ id: windows_cli_build
+ shell: msys2 {0}
+ run: |
+ export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
+ 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')
+ rm cabal.project.local 2>/dev/null || true
+ echo "ignore-project: False" >> cabal.project.local
+ echo "package direct-sqlcipher" >> cabal.project.local
+ echo " flags: +openssl" >> cabal.project.local
+ echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local
+ echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local
+
+ rm -rf dist-newstyle/src/direct-sq*
+ sed -i "s/, unix /--, unix /" simplex-chat.cabal
+ cabal build -j --enable-tests
+ rm -rf dist-newstyle/src/direct-sq*
+ path=$(cabal list-bin simplex-chat | tail -n 1)
+ echo "bin_path=$path" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Upload CLI
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
+ with:
+ bin_path: ${{ steps.windows_cli_build.outputs.bin_path }}
+ bin_name: ${{ matrix.cli_asset_name }}
+ bin_hash: ${{ steps.windows_cli_build.outputs.bin_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build Desktop
+ id: windows_desktop_build
+ if: startsWith(github.ref, 'refs/tags/v')
+ shell: msys2 {0}
+ run: |
+ export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
+ scripts/desktop/build-lib-windows.sh
+ cd apps/multiplatform
+ ./gradlew packageMsi
+ rm -rf dist-newstyle/src/direct-sq*
+ path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
+ echo "package_path=$path" >> $GITHUB_OUTPUT
+ echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Upload Desktop
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: ./.github/actions/prepare-release
+ with:
+ bin_path: ${{ steps.windows_desktop_build.outputs.package_path }}
+ bin_name: ${{ matrix.desktop_asset_name }}
+ bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }}
+ github_ref: ${{ github.ref }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
index 72a7cf2b94..b396c9a289 100644
--- a/.github/workflows/cla.yml
+++ b/.github/workflows/cla.yml
@@ -5,14 +5,20 @@ on:
pull_request_target:
types: [opened, closed, synchronize]
+permissions:
+ actions: write
+ contents: write
+ pull-requests: write
+ statuses: write
+
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
- if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request'
+ 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'
# Beta Release
- uses: cla-assistant/github-action@v2.1.3-beta
+ uses: cla-assistant/github-action@v2.3.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
@@ -33,4 +39,4 @@ jobs:
#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.'
#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
\ No newline at end of file
+ #use-dco-flag: true - If you are using DCO instead of CLA
diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml
new file mode 100644
index 0000000000..7de44addc7
--- /dev/null
+++ b/.github/workflows/reproduce-schedule.yml
@@ -0,0 +1,45 @@
+name: Reproduce latest release
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '0 2 * * *' # every day at 02:00 night
+
+jobs:
+ reproduce:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Get latest release
+ shell: bash
+ run: |
+ curl --proto '=https' \
+ --tlsv1.2 \
+ -sSf -L \
+ 'https://api.github.com/repos/simplex-chat/simplex-chat/releases/latest' \
+ 2>/dev/null | \
+ grep -i "tag_name" | \
+ awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV
+
+ - name: Execute reproduce script
+ run: |
+ ${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG"
+
+ - name: Check if build has been reproduced
+ env:
+ url: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_URL }}
+ user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }}
+ pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }}
+ run: |
+ if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then
+ exit 0
+ else
+ curl --proto '=https' --tlsv1.2 -sSf \
+ -u "${user}:${pass}" \
+ -H 'Content-Type: application/json' \
+ -d '{"title": "👾 GitHub: Runner", "description": "⛔️ '"$TAG"' did not reproduce."}' \
+ "$url"
+ exit 1
+ fi
diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml
index f29f118ecc..5fbe8293bc 100644
--- a/.github/workflows/web.yml
+++ b/.github/workflows/web.yml
@@ -4,12 +4,13 @@ on:
push:
branches:
- master
- - stable
paths:
- website/**
- images/**
- blog/**
+ - docs/**
- .github/workflows/web.yml
+ - PRIVACY.md
jobs:
build:
@@ -17,7 +18,7 @@ jobs:
strategy:
matrix:
- node-version: [12.x]
+ node-version: [16.x]
steps:
- uses: actions/checkout@v2
@@ -32,7 +33,7 @@ jobs:
./website/web.sh
- name: Deploy
- uses: peaceiris/actions-gh-pages@v3
+ uses: simplex-chat/actions-gh-pages@v3
with:
publish_dir: ./website/_site
github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 44fd61e359..645b55ec9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,12 +53,15 @@ website/src/docs/
website/translations.json
website/src/img/images/
website/src/images/
+website/src/js/lottie.min.js
+website/src/privacy.md
# Generated files
website/package/generated*
# Ignore build tool output, e.g. code coverage
website/.nyc_output/
website/coverage/
+result
# Ignore API documentation
website/api-docs/
diff --git a/Dockerfile b/Dockerfile
index b78e2f2448..cdcbc40d7d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,32 +1,41 @@
-FROM ubuntu:focal AS build
+ARG TAG=22.04
-# 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
+FROM ubuntu:${TAG} AS build
+
+### Build stage
+
+# Install curl and git and simplex-chat dependencies
+RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
+
+# Specify bootstrap Haskell versions
+ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
+ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
# Install ghcup
-RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
- chmod +x /usr/bin/ghcup
-
-# Install ghc
-RUN ghcup install ghc 8.10.7
-# Install cabal
-RUN ghcup install cabal
-# Set both as default
-RUN ghcup set ghc 8.10.7 && \
- ghcup set cabal
-
-COPY . /project
-WORKDIR /project
+RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
+# Set both as default
+RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
+ ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
+
+COPY . /project
+WORKDIR /project
+
# Adjust build
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
-RUN cabal install
+RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library'
+# Strip the binary from debug symbols to reduce size
+RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
+ mv "$bin" ./ && \
+ strip ./simplex-chat
+
+# Copy compiled app from build stage
FROM scratch AS export-stage
-COPY --from=build /root/.cabal/bin/simplex-chat /
+COPY --from=build /project/simplex-chat /
diff --git a/Dockerfile.build b/Dockerfile.build
new file mode 100644
index 0000000000..76bb1127f2
--- /dev/null
+++ b/Dockerfile.build
@@ -0,0 +1,92 @@
+# syntax=docker/dockerfile:1.7.0-labs
+ARG TAG=24.04
+FROM ubuntu:${TAG} AS build
+
+### Build stage
+
+ARG GHC=9.6.3
+ARG CABAL=3.10.1.0
+ARG JAVA=17
+
+ENV TZ=Etc/UTC \
+ DEBIAN_FRONTEND=noninteractive
+
+# Install curl, git and and simplex-chat dependencies
+RUN apt-get update && \
+ apt-get install -y curl \
+ libpq-dev \
+ git \
+ sqlite3 \
+ libsqlite3-dev \
+ build-essential \
+ libgmp3-dev \
+ zlib1g-dev \
+ llvm \
+ cmake \
+ llvm-dev \
+ libnuma-dev \
+ libssl-dev \
+ desktop-file-utils \
+ patchelf \
+ ca-certificates \
+ zip \
+ wget \
+ fuse3 \
+ file \
+ appstream \
+ gpg \
+ unzip &&\
+ ln -s /bin/fusermount /bin/fusermount3 || :
+
+# Install Java Coretto
+# Required, because official Java in Ubuntu
+# depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final
+# /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin
+# to fix this :(
+RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\
+ echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\
+ apt update &&\
+ apt install -y java-${JAVA}-amazon-corretto-jdk
+
+# Specify bootstrap Haskell versions
+ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC}
+ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL}
+
+# Do not install Stack
+ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true
+ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
+
+# Install ghcup
+RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
+
+# Adjust PATH
+ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
+
+# Set both as default
+RUN ghcup set ghc "${GHC}" && \
+ ghcup set cabal "${CABAL}"
+
+#=====================
+# Install Android SDK
+#=====================
+ARG SDK_VERSION=13114758
+
+ENV SDK_VERSION=$SDK_VERSION \
+ ANDROID_HOME=/root
+
+RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \
+ unzip tools.zip && rm tools.zip && \
+ mv cmdline-tools tools && mkdir "$ANDROID_HOME/cmdline-tools" && mv tools "$ANDROID_HOME/cmdline-tools/" && \
+ ln -s "$ANDROID_HOME/cmdline-tools/tools" "$ANDROID_HOME/cmdline-tools/latest"
+
+ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin"
+
+# https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded
+RUN mkdir -p ~/.android ~/.gradle && \
+ touch ~/.android/repositories.cfg && \
+ echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\
+ yes | sdkmanager --licenses >/dev/null
+
+ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools
+
+WORKDIR /project
diff --git a/PRIVACY.md b/PRIVACY.md
index b19363dbf3..18e5539726 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,96 +1,246 @@
-# SimpleX Chat Terms & Privacy Policy
+---
+layout: layouts/privacy.html
+permalink: /privacy/index.html
+---
-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.
+# SimpleX Chat Operators Privacy Policy and Conditions of Use
-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).
+## Summary
+
+[Introduction](#introduction) and [General principles](#general-principles) cover SimpleX Chat network design, the network operators, and the principles of privacy and security provided by SimpleX network.
+
+[Privacy policy](#privacy-policy) covers:
+- data stored only on your device - [your profiles](#user-profiles), delivered [messages and files](#messages-and-files). You can transfer this information to another device, and you are responsible for its preservation - if you delete the app it will be lost.
+- [private message delivery](#private-message-delivery) that protects your IP address and connection graph from the destination servers.
+- [undelivered messages and files](#storage-of-messages-and-files-on-the-servers) stored on the servers.
+- [how users connect](#connections-with-other-users) without any user profile identifiers.
+- [iOS push notifications](#ios-push-notifications) privacy limitations.
+- [user support](#user-support), [SimpleX directory](#simplex-directory) and [any other data](#another-information-stored-on-the-servers) that may be stored on the servers.
+- [preset server operators](#preset-server-operators) and the [information they may share](#information-preset-server-operators-may-share).
+- [source code license](#source-code-license) and [updates to this document](#updates).
+
+[Conditions of Use](#conditions-of-use-of-software-and-infrastructure) are the conditions you need to accept to use SimpleX Chat applications and the relay servers of preset operators. Their purpose is to protect the users and preset server operators.
+
+*Please note*: this summary and any links in this document are provided for information only - they are not a part of the Privacy Policy and Conditions of Use.
+
+## Introduction
+
+SimpleX Chat (also referred to as SimpleX) is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
+
+SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts.
+
+Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
+
+If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Privacy Policy
-SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection 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.
+### General principles
-SimpleX Chat security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+SimpleX 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).
-### Information you provide
+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.
-We do not store user profiles. The profile you create in the app is local to your device. 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.
+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.
-Messages. SimpleX Chat cannot decrypt or otherwise access the content or even size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are offline, these messages are permanently removed as soon as they are delivered. Your message history is stored only on your own devices.
+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.
-Connections with other users. When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on our servers, or on 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). 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 a set of anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. The exception to that is when you choose to use instant push notifications in our iOS app, because the design of push notifications requires storing the device token on notification server, and the server can observe how many messaging queues your device uses, and approximate how many messages are sent to each queue. It does not allow though to determine the actual addresses of these queues, as a separate address is used to subscibe to the notifications (unless notification and messaging servers exchange information), and 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. It also does not allow to see message content or sizes, as the actual messages are not sent via the notification service, 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 see 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).
+SimpleX network design is based on the principles of users and data sovereignty, and device and operator portability.
-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.
+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).
-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 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).
-### Information we may share
+### Your information
-We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
+#### User profiles
-We use Third party to provide email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according their privacy policies and terms of service.
+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.
-The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
+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.
-- To meet any applicable law, regulation, legal process or enforceable governmental request.
-- To enforce applicable Terms, including investigation of potential violations.
+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
+
+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.
+
+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.
+
+#### Private message delivery
+
+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.
+
+In case you use preset servers of more than one operator, the app will prefer to use a server of an operator different from the operator of the destination server to forward messages, preventing destination server to correlate messages as belonging to one client.
+
+You can additionally use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by you.
+
+*Please note*: the clients allow changing configuration to connect to the destination servers directly. It is not recommended - if you make such change, your IP address will be visible to the destination servers.
+
+#### Storage of messages and files on the servers
+
+The messages are removed from the relay servers as soon as all messages of the file they were stored in are delivered and saving new messages switches to another file, as long as these servers use unmodified published code. Undelivered messages are also marked as delivered after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
+
+The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers).
+
+The encrypted messages can be stored for some time after they are delivered or expired (because servers use append-only logs for message storage). This time varies, and may be longer in connections with fewer messages, but it is usually limited to 1 month, including any backup storage.
+
+#### 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.
+
+Preset and unmodified SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and operators to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
+
+#### Connection links privacy
+
+When you create a connection with another user, the app generates a link/QR code that can be shared with the user to establish the connection via any channel (email, any other messenger, or a video call). This link is safe to share via insecure channels, as long as you can identify the recipient and also trust that this channel did not replace this link (to mitigate the latter risk you can validate the security code via the app).
+
+While the connection "links" contain SimpleX Chat Ltd domain name `simplex.chat`, this site is never accessed by the app, and is only used for these purposes:
+- to direct the new users to the app download instructions,
+- to show connection QR code that can be scanned via the app,
+- to "namespace" these links,
+- to open links directly in the installed app when it is clicked outside of the app.
+
+You can always safely replace the initial part of the link `https://simplex.chat/` either with `simplex:/` (which is a URI scheme provisionally registered with IANA) or with any other domain name where you can self-host the app download instructions and show the connection QR code (but in case it is your domain, it will not open in the app). Also, while the page renders QR code, all the information needed to render it is only available to the browser, as the part of the "link" after `#` symbol is not sent to the website server.
+
+#### iOS Push Notifications
+
+This section applies only to the notification servers operated by SimpleX Chat Ltd.
+
+When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
+
+Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers.
+
+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).
+
+#### 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.
+
+#### SimpleX Directory
+
+This section applies only to the experimental group directory operated by SimpleX Chat Ltd.
+
+[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).
+
+#### Public groups and content channels
+
+You may participate in a public group and receive content from a public channel (Group). In case you send messages or comments to the Group, you grant a license:
+- to all recipients:
+ - to share your messages with the new Group members and outside of the group, e.g. via quoting (replying), forwarding and copy-pasting your message. When your message is deleted or marked as deleted, the copies of your message will not be deleted.
+ - to retain a copy of your messages according to the Group settings (e.g., the Group may allow irreversible message deletion from the recipient devices for a limited period of time, or it may only allow to edit and mark messages as deleted on recipient devices). Deleting message from the recipient devices or marking message as deleted revokes the license to share the message.
+- to Group owners: to share your messages with the new Group members as history of the Group. Currently, the Group history shared with the new members is limited to 100 messages.
+
+Group owners may use chat relays or automated bots (Chat Relays) to re-broadcast member messages to all members, for efficiency. The Chat Relays may be operated by the group owners, by preset operators or by 3rd parties. The Chat Relays have access to and will retain messages in line with Group settings, for technical functioning of the Group. Neither you nor group owners grant any content license to Chat Relay operators.
+
+#### User Support
+
+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.
+
+### Preset Server Operators
+
+Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics.
+
+Preset server operators must not provide general access to their servers or the data on their servers to each other.
+
+Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing illegal content identified in publicly accessible resources (contact and group addresses, and downloadable files). This control port access only allows deleting known links and files, and accessing aggregate server-wide statistics, but does NOT allow enumerating any information on the servers or accessing statistics related to specific users.
+
+### Information Preset Server Operators May Share
+
+The preset server operators use third parties. While they do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via the servers. Hosting and network providers can also store IP addresses and other transport information as part of their logs.
+
+SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
+
+The cases when the preset server operators may share the data temporarily stored on the servers:
+
+- To meet any applicable law, or enforceable governmental request or court order.
+- To enforce applicable terms, including investigation of potential violations.
- To detect, prevent, or otherwise address fraud, security, or technical issues.
-- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law.
+- 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.
-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.
+By the time of updating this document, the preset server operators were not served with any enforceable requests and did not provide any information from the servers to any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
+
+Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
+
+### Source code license
+
+As this software is fully open-source and provided under AGPLv3 license, all infrastructure owners and operators, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the servers.
+
+In addition to the AGPLv3 license terms, the preset relay server operators are committed to the software users that these servers will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
### Updates
-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 applies to SimpleX Chat Ltd and all other preset server operators you use in the app.
-Please also read our Terms of Service.
+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.
-If you have questions about our Privacy Policy please contact us at chat@simplex.chat.
+Please also read The Conditions of Use of Software and Infrastructure below.
-## Terms of Service
+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).
-You accept to our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
+## Conditions of Use of Software and Infrastructure
-**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.
+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.
-**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 do 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 cyphertext 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.
+**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.
-**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.
+**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.
-**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.
+**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.
-**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.
+**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.
-**Traffic and device costs**. You are solely responsible for the traffic and device costs on which you use our Services, and any associated taxes.
+**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.
-**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.
+**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.
-**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.
+**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.
-**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.
+**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.
-**Storing the messages on the device**. Currently the messages are stored in the database on your device without encryption. It means that if you make a backup of the app and store it unecrypted, the backup provider may be able to access the messages.
+**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.
-**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.
+**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software or by using a 3rd party client applications that satisfies the requirements of the Conditions of use (see the next section); 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
-**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.
+**3rd party client applications**. You may use a 3rd party application (App) to access preset operators' Infrastructure or systems, provided that this App:
+- is compatible with the protocol specifications not older than 1 year,
+- provides user-to-user messaging only or enables automated chat bots sending messages requested by users (in case of bots, it must be made clear to the users that these are automated bots),
+- implements the same limits, rules and restrictions as Software,
+- requires that the users accept the same Conditions of use of preset operators' Infrastructure as in Software prior to providing access to this Infrastructure,
+- displays the notice that it is the App for using SimpleX network,
+- provides its source code under open-source license accessible to the users via the App interface. In case the App uses the source code of Software, the App's source code must be provided under AGPLv3 license, and in case it is developed without using Software code its source code must be provided under any widely recognized free open-source license,
+- does NOT use the branding of SimpleX Chat Ltd without the permission,
+- does NOT pretend to be Software,
+- complies with these Conditions of use.
-**Your Rights**. You own the mesasges and 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.
+**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.
-**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 licence](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
+**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.
-**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.
+**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.
-**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.
+**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.
-**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.
+**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.
-**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.
+**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.
-**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.
+**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).
-**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.
+**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.
-**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.
+**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.
-**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.
+**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.
-Updated November 8, 2022
+**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.
+
+**Resolving disputes**. You agree to resolve any Claim you have with SimpleX Chat Ltd and/or preset server operators relating to or arising from these Conditions, them, or the Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern these Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd (or preset server operators) and you, without regard to conflict of law provisions.
+
+**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. The updated conditions have to be accepted within 30 days. Even if you fail to accept updated conditions, your continued use of SimpleX Chat Applications confirms your acceptance of the updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. These Conditions cover the entire agreement between you and SimpleX Chat Ltd, and any preset server operators where applicable, regarding SimpleX Chat Applications. If you do not agree with these Conditions, you should stop using the Applications.
+
+**Enforcing the conditions**. If SimpleX Chat Ltd or preset server operators fail to enforce any of these Conditions, that does not mean they waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from the Conditions and shall not affect the enforceability of the remaining provisions. The Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject SimpleX Chat Ltd to any regulations in another country. SimpleX Chat Ltd reserve the right to limit the access to the Applications in any country. Preset operators reserve the right to limit access to their servers in any country. If you have specific questions about these Conditions, please contact SimpleX Chat Ltd at chat@simplex.chat.
+
+**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators.
+
+Updated March 3, 2025
diff --git a/README.md b/README.md
index c27637dd43..554c6068d9 100644
--- a/README.md
+++ b/README.md
@@ -2,23 +2,23 @@
[](https://github.com/simplex-chat/simplex-chat/releases)
[](https://github.com/simplex-chat/simplex-chat/releases)
[](https://www.reddit.com/r/SimpleXChat)
-[](https://mastodon.social/@simplex)
+
-| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
+| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md), [PL](/docs/lang/pl/README.md) |
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
-[](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
+[](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [](https://www.whonix.org/wiki/Chat#Recommendation) [](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
## Welcome to SimpleX Chat!
1. 📲 [Install the app](#install-the-app).
-2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups).
+2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
-5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
+5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
[Learn more about SimpleX Chat](#contents).
@@ -40,21 +40,29 @@
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
-## Connect to the team via the app
+## Connect to the team
+
+You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](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). Please connect to:
- to ask any questions
- to suggest any improvements
- to share anything relevant
+We are replying the questions manually, so it is not instant – it can take up to 24 hours.
+
+If you are interested in helping us to integrate open-source language models, and in [joining our team](./docs/JOIN_TEAM.md), please get in touch.
+
## Join user groups
+You can join the groups created by other users via the new [directory service](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). We are not responsible for the content shared in these groups.
+
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
You also can:
- criticize the app, and make comparisons with other messengers.
- share new messengers you think could be interesting for privacy, as long as you don't spam.
- share some privacy related publications, infrequently.
-- having preliminary approved with the admin in direct message, share the link to a group you created.
+- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it.
You must:
- be polite to other users
@@ -64,14 +72,30 @@ You must:
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
-You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-3](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fp-j-D_PrY2UMDchFHEUtbSES0nmzCnvD%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA3gBfMjB_GDEmKQwjNdqGbnX91yfuZ7nRJgQijsx5Khc%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%2262MvNZ_Ec2mmlS8V0QNtLQ%3D%3D%22%7D)
+You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FiBkJE72asZX1NUZaYFIeKRVk6oVjb-iv%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAinqu3j74AMjODLoIRR487ZW6ysip_dlpD6Zxk18SPFY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22groupLinkId%22%3A%223wAFGCLygQHR5AwynZOHlQ%3D%3D%22%7D)
+
+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:
+
+- chat bots and automations
+- integrations with other apps
+- social apps and services
+- etc.
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-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-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).
+[\#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).
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
+## Follow our updates
+
+We publish our updates and releases via:
+
+- [Reddit](https://www.reddit.com/r/SimpleXChat/), [Twitter](https://twitter.com/SimpleXChat), [Lemmy](https://lemmy.ml/c/simplex), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://snort.social/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828).
+- SimpleX Chat [team profile](#connect-to-the-team).
+- [blog](https://simplex.chat/blog/) and [RSS feed](https://simplex.chat/feed.rss).
+- [mailing list](https://simplex.chat/#join-simplex), very rarely.
+
## Make a private connection
You need to share a link with your friend or scan a QR code from their phone, in person or during a video call, to make a connection and start messaging.
@@ -86,6 +110,15 @@ After you connect, you can [verify connection security code](./blog/20230103-sim
Read about the app features and settings in the new [User guide](./docs/guide/README.md).
+## Contribute
+
+We would love to have you join the development! You can help us with:
+
+- [share the color theme](./docs/THEMES.md) you use in Android app!
+- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
+- contributing to SimpleX Chat knowledge-base.
+- developing features - please connect to us via chat so we can help you get started.
+
## Help translating SimpleX Chat
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
@@ -95,28 +128,29 @@ Join our translators to help SimpleX grow!
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|🇬🇧 en|English | |✓|✓|✓|✓|
-|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
+|ar|العربية |[jermanuts](https://github.com/jermanuts)|[](https://hosted.weblate.org/projects/simplex-chat/android/ar/) -|[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
+|🇧🇬 bg|Български | |[](https://hosted.weblate.org/projects/simplex-chat/android/bg/) [](https://hosted.weblate.org/projects/simplex-chat/ios/bg/)|||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/) [](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/) [](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/) [](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
+|🇫🇮 fi|Suomi | |[](https://hosted.weblate.org/projects/simplex-chat/android/fi/) [](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/) [](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
+|🇮🇱 he|עִברִית | |[](https://hosted.weblate.org/projects/simplex-chat/android/he/) -|||
+|🇭🇺 hu|Magyar | |[](https://hosted.weblate.org/projects/simplex-chat/android/hu/) -|||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/) [](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
+|🇯🇵 ja|日本語 | |[](https://hosted.weblate.org/projects/simplex-chat/android/ja/) [](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/) [](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
-|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/ru/) [](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
+|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/pl/) [](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
+|🇧🇷 pt-BR|Português||[](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/) -|[](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/) [](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
+|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[](https://hosted.weblate.org/projects/simplex-chat/android/th/) [](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
+|🇹🇷 tr|Türkçe | |[](https://hosted.weblate.org/projects/simplex-chat/android/tr/) [](https://hosted.weblate.org/projects/simplex-chat/ios/tr/)|||
+|🇺🇦 uk|Українська| |[](https://hosted.weblate.org/projects/simplex-chat/android/uk/) [](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)
[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
-## Contribute
-
-We would love to have you join the development! You can help us with:
-
-- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
-- contributing to SimpleX Chat knowledge-base.
-- developing features - please connect to us via chat so we can help you get started.
-
-## Help us with donations
+## Please support us with your donations
Huge thank you to everybody who donated to SimpleX Chat!
@@ -124,17 +158,21 @@ We are prioritizing users privacy and security - it would be impossible without
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
-Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
+Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us.
It is possible to donate via:
-- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
-- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
-- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
-- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
-- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
-- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
-- Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
+- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
+- BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u
+- XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
+- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
+- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
+- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
+- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg
+- ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq
+- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf
+- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
+- please ask if you want to donate any other coins.
Thank you,
@@ -154,7 +192,7 @@ SimpleX Chat founder
- [News and updates](#news-and-updates)
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
- [SimpleX Platform design](#simplex-platform-design)
-- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
+- [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations)
- [For developers](#for-developers)
- [Roadmap](#roadmap)
- [Disclaimers, Security contact, License](#disclaimers)
@@ -195,24 +233,30 @@ You can use SimpleX with your own servers and still communicate with people usin
## News and updates
-Recent updates:
+Recent and important updates:
+
+[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md)
+
+[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
+
+[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
+
+[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
+
+[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
+
+[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
+
+[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
+
+[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
[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 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.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).
-[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
-
-[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
-
-[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
-
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
-[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
-
[All updates](./blog)
## :zap: Quick installation of a terminal app
@@ -241,29 +285,39 @@ See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/stable/p
See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of messages sent between chat clients over [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md).
-## Privacy: technical details and limitations
+## Privacy and security: technical details and limitations
SimpleX Chat is a work in progress – we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
+We compiled a [glossary of terms](./docs/GLOSSARY.md) used to describe communication systems to help understand some terms below and to help compare advantages and disadvantages of various communication systems.
+
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 (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 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](https://signal.org/docs/specifications/doubleratchet/) 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 (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
-4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
-5. Several levels of content padding to frustrate message size attacks.
-6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
-7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
-8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
-9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
-10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
-11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
+1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
+2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
+3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
+4. [Post-quantum resistant key exchange](./docs/GLOSSARY.md#post-quantum-cryptography) in double ratchet protocol *on every ratchet step*. Read more in [this post](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) and also see this [publication by Apple]( https://security.apple.com/blog/imessage-pq3/) explaining the need for post-quantum key rotation.
+5. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
+6. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
+7. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
+8. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
+9. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
+10. To protect your IP address from unknown messaging relays, and for per-message transport anonymity (compared with Tor/VPN per-connection anonymity), from v6.0 all SimpleX Chat clients use private message routing by default. Read more in [this post](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md#private-message-routing).
+11. To protect your IP address from unknown file relays, when SOCKS proxy is not enabled SimpleX Chat clients ask for a confirmation before downloading the files from unknown servers.
+12. To protect your IP address from known servers all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
+13. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
+14. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
+15. Manual messaging queue rotations to move conversation to another SMP relay.
+16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
+17. Local files encryption.
+18. [Reproducible server builds](./docs/SERVER.md#reproduce-builds).
-We plan to add soon:
+We plan to add:
-1. Automatic message queue rotation. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
-2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
-3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
+1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
+2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
+3. Reproducible clients builds – this is a complex problem, but we are aiming to have it in 2025 at least partially.
+4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
## For developers
@@ -276,6 +330,8 @@ You can:
If you are considering developing with SimpleX platform please get in touch for any advice and support.
+Please also join [#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) group to ask any questions and share your success stories.
+
## Roadmap
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
@@ -316,33 +372,45 @@ If you are considering developing with SimpleX platform please get in touch for
- ✅ Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
- ✅ Video messages.
- ✅ App access passcode.
-- 🏗 Improved Android app UI design.
-- 🏗 SMP queue redundancy and rotation (manual is supported).
-- 🏗 Reduced battery and traffic usage in large groups.
+- ✅ Improved Android app UI design.
+- ✅ Optional alternative access password.
+- ✅ Message reactions
+- ✅ Message editing history
+- ✅ Reduced battery and traffic usage in large groups.
+- ✅ Message delivery confirmation (with sender opt-out per contact).
+- ✅ Desktop client.
+- ✅ Encryption of local files stored in the app.
+- ✅ Using mobile profiles from the desktop app.
+- ✅ Private notes.
+- ✅ Improve sending videos (including encryption of locally stored videos).
+- ✅ Post-quantum resistant key exchange in double ratchet protocol.
+- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
+- ✅ Support multiple network operators in the app.
+- 🏗 Large groups, communities and public channels.
+- 🏗 Short links to connect and join groups.
+- 🏗 Improve stability and reduce battery usage.
+- 🏗 Improve experience for the new users.
+- Privacy & security slider - a simple way to set all settings at once.
+- SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address.
-- Ephemeral/disappearing/OTR conversations with the existing contacts.
-- Optional alternative access password.
-- Local app files encryption.
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
-- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
-- Privately share your location.
- Feeds/broadcasts.
+- Ephemeral/disappearing/OTR conversations with the existing contacts.
+- Privately share your location.
- Web widgets for custom interactivity in the chats.
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
-- Supporting the same profile on multiple devices.
-- Desktop client.
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
- keep all your contacts and groups even if you lose the domain.
- the server doesn't have information about your contacts and groups.
-- Hosting server for large groups, communities and public channels.
-- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- High capacity multi-node SMP relays.
## Disclaimers
[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 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 implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about) – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+
+The cryptographic review of SimpleX protocols was done in July 2024 by Trail of Bits – see [the announcement](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
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.
@@ -352,13 +420,13 @@ We have never provided or have been requested access to our servers or any infor
We do not log IP addresses of the users and we do not perform any traffic correlation on our servers. If transport level security is critical you must use Tor or some other similar network to access messaging servers. We will be improving the client applications to reduce the opportunities for traffic correlation.
-Please read more in [Terms & privacy policy](./PRIVACY.md).
+Please read more in [Privacy Policy](./PRIVACY.md).
## Security contact
-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 see our [Security Policy](./docs/SECURITY.md) on how to report security vulnerabilities to us. We will coordinate the fix and disclosure.
-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.
+Please do NOT report security vulnerabilities via GitHub issues.
## License
diff --git a/apps/android/.gitignore b/apps/android/.gitignore
deleted file mode 100644
index 9ddf412fc2..0000000000
--- a/apps/android/.gitignore
+++ /dev/null
@@ -1,20 +0,0 @@
-*.iml
-.gradle
-/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
-/.idea/deploymentTargetDropDown.xml
-/.idea/misc.xml
-/.idea/uiDesigner.xml
-/.idea/kotlinc.xml
-.DS_Store
-/build
-/captures
-.externalNativeBuild
-.cxx
-local.properties
-app/src/main/cpp/libs/
diff --git a/apps/android/.idea/.name b/apps/android/.idea/.name
deleted file mode 100644
index ccb58e52e1..0000000000
--- a/apps/android/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-SimpleX
\ No newline at end of file
diff --git a/apps/android/.idea/compiler.xml b/apps/android/.idea/compiler.xml
deleted file mode 100644
index fb7f4a8a46..0000000000
--- a/apps/android/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/apps/android/.idea/gradle.xml b/apps/android/.idea/gradle.xml
deleted file mode 100644
index a2d7c21338..0000000000
--- a/apps/android/.idea/gradle.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/apps/android/.idea/inspectionProfiles/Project_Default.xml b/apps/android/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 28422375b6..0000000000
--- a/apps/android/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/apps/android/.idea/vcs.xml b/apps/android/.idea/vcs.xml
deleted file mode 100644
index b2bdec2d71..0000000000
--- a/apps/android/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle
deleted file mode 100644
index 80b44bc00a..0000000000
--- a/apps/android/app/build.gradle
+++ /dev/null
@@ -1,242 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'org.jetbrains.kotlin.android'
- id 'org.jetbrains.kotlin.plugin.serialization'
-}
-
-android {
- compileSdk 32
-
- defaultConfig {
- applicationId "chat.simplex.app"
- minSdk 26
- targetSdk 32
- // !!!
- // skip version code after release to F-Droid, as it uses two version codes
- versionCode 121
- versionName "5.1"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- vectorDrawables {
- useSupportLibrary true
- }
- externalNativeBuild {
- cmake {
- cppFlags ''
- }
- }
- manifestPlaceholders.app_name = "@string/app_name"
- manifestPlaceholders.provider_authorities = "chat.simplex.app.provider"
- manifestPlaceholders.extract_native_libs = compression_level != "0"
- }
-
- buildTypes {
- debug {
- applicationIdSuffix "$application_id_suffix"
- debuggable new Boolean("$enable_debuggable")
- manifestPlaceholders.app_name = "$app_name"
- // Provider can't be the same for different apps on the same device
- manifestPlaceholders.provider_authorities = "chat.simplex.app${application_id_suffix}.provider"
- }
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = '1.8'
- freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi"
- freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
- freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi"
- freeCompilerArgs += "-opt-in=androidx.compose.material.ExperimentalMaterialApi"
- freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets"
- freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi"
- freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi"
- freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
- }
- externalNativeBuild {
- cmake {
- path file('src/main/cpp/CMakeLists.txt')
- }
- }
- buildFeatures {
- compose true
- }
- composeOptions {
- kotlinCompilerExtensionVersion compose_version
- }
- packagingOptions {
- resources {
- excludes += '/META-INF/{AL2.0,LGPL2.1}'
- }
- jniLibs.useLegacyPackaging = compression_level != "0"
- }
- def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
- def isBundle = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("bundle") }) != null
-// if (isRelease) {
- // Comma separated list of languages that will be included in the apk
- android.defaultConfig.resConfigs(
- "en",
- "cs",
- "de",
- "es",
- "fr",
- "it",
- "ja",
- "nl",
- "pl",
- "pt-rBR",
- "ru",
- "zh-rCN"
- )
-// }
- if (isBundle) {
- defaultConfig.ndk.abiFilters 'arm64-v8a', 'armeabi-v7a'
- } else {
- splits {
- abi {
- enable true
- reset()
- if (isRelease) {
- include 'arm64-v8a', 'armeabi-v7a'
- } else {
- include 'arm64-v8a', 'armeabi-v7a'
- universalApk false
- }
- }
- }
- }
-}
-
-dependencies {
- implementation 'androidx.core:core-ktx:1.7.0'
- implementation "androidx.compose.ui:ui:$compose_version"
- implementation "androidx.compose.material:material:$compose_version"
- implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
- implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
- implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
- implementation 'androidx.activity:activity-compose:1.4.0'
- implementation 'androidx.fragment:fragment:1.4.1'
- implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
- implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
- implementation 'com.charleskorn.kaml:kaml:0.43.0'
- //implementation "androidx.compose.material:material-icons-extended:$compose_version"
- implementation "androidx.compose.ui:ui-util:$compose_version"
- implementation "androidx.navigation:navigation-compose:2.4.1"
- implementation "com.google.accompanist:accompanist-insets:0.23.0"
- implementation 'androidx.webkit:webkit:1.4.0'
- implementation "com.godaddy.android.colorpicker:compose-color-picker:0.4.2"
-
- def work_version = "2.7.1"
- implementation "androidx.work:work-runtime-ktx:$work_version"
- implementation "androidx.work:work-multiprocess:$work_version"
-
- def camerax_version = "1.1.0-beta01"
- implementation "androidx.camera:camera-core:${camerax_version}"
- implementation "androidx.camera:camera-camera2:${camerax_version}"
- implementation "androidx.camera:camera-lifecycle:${camerax_version}"
- implementation "androidx.camera:camera-view:${camerax_version}"
-
- //Barcode
- implementation 'org.boofcv:boofcv-android:0.40.1'
- implementation 'org.boofcv:boofcv-core:0.40.1'
-
- //Camera Permission
- implementation "com.google.accompanist:accompanist-permissions:0.23.0"
- implementation "com.google.accompanist:accompanist-pager:0.25.1"
-
- // Link Previews
- implementation 'org.jsoup:jsoup:1.13.1'
-
- // Biometric authentication
- implementation 'androidx.biometric:biometric:1.2.0-alpha04'
-
- // GIFs support
- implementation "io.coil-kt:coil-compose:2.1.0"
- implementation "io.coil-kt:coil-gif:2.1.0"
-
- // Video support
- implementation "com.google.android.exoplayer:exoplayer:2.17.1"
-
- // Wheel picker
- implementation 'com.github.zj565061763:compose-wheel-picker:1.0.0-alpha10'
-
- testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.3'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
- androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
- debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
-}
-
-// Don't do anything if no compression is needed
-if (compression_level != "0") {
- tasks.whenTaskAdded { task ->
- if (task.name == 'packageDebug') {
- task.finalizedBy compressApk
- } else if (task.name == 'packageRelease') {
- task.finalizedBy compressApk
- }
- }
-}
-
-tasks.register("compressApk") {
- doLast {
- def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
- def buildType
- if (isRelease) {
- buildType = "release"
- } else {
- buildType = "debug"
- }
- def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
- def sdkDir = android.getSdkDirectory().getAbsolutePath()
- def keyAlias = ""
- def keyPassword = ""
- def storeFile = ""
- def storePassword = ""
- if (project.properties['android.injected.signing.key.alias'] != null) {
- keyAlias = project.properties['android.injected.signing.key.alias']
- keyPassword = project.properties['android.injected.signing.key.password']
- storeFile = project.properties['android.injected.signing.store.file']
- storePassword = project.properties['android.injected.signing.store.password']
- } else if (android.signingConfigs.hasProperty(buildType)) {
- def gradleConfig = android.signingConfigs[buildType]
- keyAlias = gradleConfig.keyAlias
- keyPassword = gradleConfig.keyPassword
- storeFile = gradleConfig.storeFile
- storePassword = gradleConfig.storePassword
- } else {
- // There is no signing config for current build type, can't sign the apk
- println("No signing configs for this build type: $buildType")
- return
- }
-
- def outputDir = tasks["package${buildType.capitalize()}"].outputs.files.last()
-
- exec {
- workingDir '../../../scripts/android'
- setEnvironment(['JAVA_HOME': "$javaHome"])
- commandLine './compress-and-sign-apk.sh', \
- "$compression_level", \
- "$outputDir", \
- "$sdkDir", \
- "$storeFile", \
- "$storePassword", \
- "$keyAlias", \
- "$keyPassword"
- }
-
- if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
- new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
- new File(outputDir, "app-armeabi-v7a-release.apk").renameTo(new File(outputDir, "simplex-armv7a.apk"))
- new File(outputDir, "app-arm64-v8a-release.apk").renameTo(new File(outputDir, "simplex.apk"))
- }
-
- // View all gradle properties set
- // project.properties.each { k, v -> println "$k -> $v" }
- }
-}
diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js
deleted file mode 100644
index c7cf4a9324..0000000000
--- a/apps/android/app/src/main/assets/www/call.js
+++ /dev/null
@@ -1,672 +0,0 @@
-"use strict";
-// Inspired by
-// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption
-var CallMediaType;
-(function (CallMediaType) {
- CallMediaType["Audio"] = "audio";
- CallMediaType["Video"] = "video";
-})(CallMediaType || (CallMediaType = {}));
-var VideoCamera;
-(function (VideoCamera) {
- VideoCamera["User"] = "user";
- VideoCamera["Environment"] = "environment";
-})(VideoCamera || (VideoCamera = {}));
-// for debugging
-// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
-var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
-// Global object with cryptrographic/encoding functions
-const callCrypto = callCryptoFunction();
-var TransformOperation;
-(function (TransformOperation) {
- TransformOperation["Encrypt"] = "encrypt";
- TransformOperation["Decrypt"] = "decrypt";
-})(TransformOperation || (TransformOperation = {}));
-let activeCall;
-let answerTimeout = 30000;
-const processCommand = (function () {
- const defaultIceServers = [
- { urls: ["stun:stun.simplex.im:443"] },
- { urls: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
- { urls: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
- ];
- function getCallConfig(encodedInsertableStreams, iceServers, relay) {
- return {
- peerConnectionConfig: {
- iceServers: iceServers !== null && iceServers !== void 0 ? iceServers : defaultIceServers,
- iceCandidatePoolSize: 10,
- encodedInsertableStreams,
- iceTransportPolicy: relay ? "relay" : "all",
- },
- iceCandidates: {
- delay: 3000,
- extrasInterval: 2000,
- extrasTimeout: 8000,
- },
- };
- }
- function getIceCandidates(conn, config) {
- return new Promise((resolve, _) => {
- let candidates = [];
- let resolved = false;
- let extrasInterval;
- let extrasTimeout;
- const delay = setTimeout(() => {
- if (!resolved) {
- resolveIceCandidates();
- extrasInterval = setInterval(() => {
- sendIceCandidates();
- }, config.iceCandidates.extrasInterval);
- extrasTimeout = setTimeout(() => {
- clearInterval(extrasInterval);
- sendIceCandidates();
- }, config.iceCandidates.extrasTimeout);
- }
- }, config.iceCandidates.delay);
- conn.onicecandidate = ({ candidate: c }) => c && candidates.push(c);
- conn.onicegatheringstatechange = () => {
- if (conn.iceGatheringState == "complete") {
- if (resolved) {
- if (extrasInterval)
- clearInterval(extrasInterval);
- if (extrasTimeout)
- clearTimeout(extrasTimeout);
- sendIceCandidates();
- }
- else {
- resolveIceCandidates();
- }
- }
- };
- function resolveIceCandidates() {
- if (delay)
- clearTimeout(delay);
- resolved = true;
- const iceCandidates = serialize(candidates);
- candidates = [];
- resolve(iceCandidates);
- }
- function sendIceCandidates() {
- if (candidates.length === 0)
- return;
- const iceCandidates = serialize(candidates);
- candidates = [];
- sendMessageToNative({ resp: { type: "ice", iceCandidates } });
- }
- });
- }
- async function initializeCall(config, mediaType, aesKey, useWorker) {
- const pc = new RTCPeerConnection(config.peerConnectionConfig);
- const remoteStream = new MediaStream();
- const localCamera = VideoCamera.User;
- const localStream = await getLocalMediaStream(mediaType, localCamera);
- const iceCandidates = getIceCandidates(pc, config);
- const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker };
- await setupMediaStreams(call);
- let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
- pc.addEventListener("connectionstatechange", connectionStateChange);
- return call;
- async function connectionStateChange() {
- // "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView)
- // See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70)
- if (pc.connectionState !== "failed")
- connectionHandler();
- }
- async function connectionHandler() {
- sendMessageToNative({
- resp: {
- type: "connection",
- state: {
- connectionState: pc.connectionState,
- iceConnectionState: pc.iceConnectionState,
- iceGatheringState: pc.iceGatheringState,
- signalingState: pc.signalingState,
- },
- },
- });
- if (pc.connectionState == "disconnected" || pc.connectionState == "failed") {
- clearConnectionTimeout();
- pc.removeEventListener("connectionstatechange", connectionStateChange);
- if (activeCall) {
- setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
- }
- endCall();
- }
- else if (pc.connectionState == "connected") {
- clearConnectionTimeout();
- const stats = (await pc.getStats());
- for (const stat of stats.values()) {
- const { type, state } = stat;
- if (type === "candidate-pair" && state === "succeeded") {
- const iceCandidatePair = stat;
- const resp = {
- type: "connected",
- connectionInfo: {
- iceCandidatePair,
- localCandidate: stats.get(iceCandidatePair.localCandidateId),
- remoteCandidate: stats.get(iceCandidatePair.remoteCandidateId),
- },
- };
- setTimeout(() => sendMessageToNative({ resp }), 500);
- break;
- }
- }
- }
- }
- function clearConnectionTimeout() {
- if (connectionTimeout) {
- clearTimeout(connectionTimeout);
- connectionTimeout = undefined;
- }
- }
- }
- function serialize(x) {
- return LZString.compressToBase64(JSON.stringify(x));
- }
- function parse(s) {
- return JSON.parse(LZString.decompressFromBase64(s));
- }
- async function processCommand(body) {
- const { corrId, command } = body;
- const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection;
- let resp;
- try {
- switch (command.type) {
- case "capabilities":
- console.log("starting outgoing call - capabilities");
- if (activeCall)
- endCall();
- // This request for local media stream is made to prompt for camera/mic permissions on call start
- if (command.media)
- await getLocalMediaStream(command.media, VideoCamera.User);
- const encryption = supportsInsertableStreams(command.useWorker);
- resp = { type: "capabilities", capabilities: { encryption } };
- break;
- case "start": {
- console.log("starting incoming call - create webrtc session");
- if (activeCall)
- endCall();
- const { media, useWorker, iceServers, relay } = command;
- const encryption = supportsInsertableStreams(useWorker);
- const aesKey = encryption ? command.aesKey : undefined;
- activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker);
- const pc = activeCall.connection;
- const offer = await pc.createOffer();
- await pc.setLocalDescription(offer);
- // for debugging, returning the command for callee to use
- // resp = {
- // type: "offer",
- // offer: serialize(offer),
- // iceCandidates: await activeCall.iceCandidates,
- // capabilities: {encryption},
- // media,
- // iceServers,
- // relay,
- // aesKey,
- // useWorker,
- // }
- resp = {
- type: "offer",
- offer: serialize(offer),
- iceCandidates: await activeCall.iceCandidates,
- capabilities: { encryption },
- };
- break;
- }
- case "offer":
- if (activeCall) {
- resp = { type: "error", message: "accept: call already started" };
- }
- else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) {
- resp = { type: "error", message: "accept: encryption is not supported" };
- }
- else {
- const offer = parse(command.offer);
- const remoteIceCandidates = parse(command.iceCandidates);
- const { media, aesKey, useWorker, iceServers, relay } = command;
- activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker);
- const pc = activeCall.connection;
- await pc.setRemoteDescription(new RTCSessionDescription(offer));
- const answer = await pc.createAnswer();
- await pc.setLocalDescription(answer);
- addIceCandidates(pc, remoteIceCandidates);
- // same as command for caller to use
- resp = {
- type: "answer",
- answer: serialize(answer),
- iceCandidates: await activeCall.iceCandidates,
- };
- }
- break;
- case "answer":
- if (!pc) {
- resp = { type: "error", message: "answer: call not started" };
- }
- else if (!pc.localDescription) {
- resp = { type: "error", message: "answer: local description is not set" };
- }
- else if (pc.currentRemoteDescription) {
- resp = { type: "error", message: "answer: remote description already set" };
- }
- else {
- const answer = parse(command.answer);
- const remoteIceCandidates = parse(command.iceCandidates);
- await pc.setRemoteDescription(new RTCSessionDescription(answer));
- addIceCandidates(pc, remoteIceCandidates);
- resp = { type: "ok" };
- }
- break;
- case "ice":
- if (pc) {
- const remoteIceCandidates = parse(command.iceCandidates);
- addIceCandidates(pc, remoteIceCandidates);
- resp = { type: "ok" };
- }
- else {
- resp = { type: "error", message: "ice: call not started" };
- }
- break;
- case "media":
- if (!activeCall) {
- resp = { type: "error", message: "media: call not started" };
- }
- else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) {
- resp = { type: "error", message: "media: no video" };
- }
- else {
- enableMedia(activeCall.localStream, command.media, command.enable);
- resp = { type: "ok" };
- }
- break;
- case "camera":
- if (!activeCall || !pc) {
- resp = { type: "error", message: "camera: call not started" };
- }
- else {
- await replaceMedia(activeCall, command.camera);
- resp = { type: "ok" };
- }
- break;
- case "end":
- endCall();
- resp = { type: "ok" };
- break;
- default:
- resp = { type: "error", message: "unknown command" };
- break;
- }
- }
- catch (e) {
- resp = { type: "error", message: `${command.type}: ${e.message}` };
- }
- const apiResp = { corrId, resp, command };
- sendMessageToNative(apiResp);
- return apiResp;
- }
- function endCall() {
- var _a;
- try {
- (_a = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection) === null || _a === void 0 ? void 0 : _a.close();
- }
- catch (e) {
- console.log(e);
- }
- activeCall = undefined;
- resetVideoElements();
- }
- function addIceCandidates(conn, iceCandidates) {
- for (const c of iceCandidates) {
- conn.addIceCandidate(new RTCIceCandidate(c));
- }
- }
- async function setupMediaStreams(call) {
- const videos = getVideoElements();
- if (!videos)
- throw Error("no video elements");
- await setupEncryptionWorker(call);
- setupLocalStream(call);
- setupRemoteStream(call);
- setupCodecPreferences(call);
- // setupVideoElement(videos.local)
- // setupVideoElement(videos.remote)
- videos.local.srcObject = call.localStream;
- videos.remote.srcObject = call.remoteStream;
- }
- async function setupEncryptionWorker(call) {
- if (call.aesKey) {
- if (!call.key)
- call.key = await callCrypto.decodeAesKey(call.aesKey);
- if (call.useWorker && !call.worker) {
- const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`;
- call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" })));
- call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message }));
- call.worker.onmessage = ({ data }) => console.log(JSON.stringify({ message: data }));
- }
- }
- }
- function setupLocalStream(call) {
- const videos = getVideoElements();
- if (!videos)
- throw Error("no video elements");
- const pc = call.connection;
- let { localStream } = call;
- for (const track of localStream.getTracks()) {
- pc.addTrack(track, localStream);
- }
- if (call.aesKey && call.key) {
- console.log("set up encryption for sending");
- for (const sender of pc.getSenders()) {
- setupPeerTransform(TransformOperation.Encrypt, sender, call.worker, call.aesKey, call.key);
- }
- }
- }
- function setupRemoteStream(call) {
- // Pull tracks from remote stream as they arrive add them to remoteStream video
- const pc = call.connection;
- pc.ontrack = (event) => {
- try {
- if (call.aesKey && call.key) {
- console.log("set up decryption for receiving");
- setupPeerTransform(TransformOperation.Decrypt, event.receiver, call.worker, call.aesKey, call.key);
- }
- for (const stream of event.streams) {
- for (const track of stream.getTracks()) {
- call.remoteStream.addTrack(track);
- }
- }
- console.log(`ontrack success`);
- }
- catch (e) {
- console.log(`ontrack error: ${e.message}`);
- }
- };
- }
- function setupCodecPreferences(call) {
- // We assume VP8 encoding in the decode/encode stages to get the initial
- // bytes to pass as plaintext so we enforce that here.
- // VP8 is supported by all supports of webrtc.
- // Use of VP8 by default may also reduce depacketisation issues.
- // We do not encrypt the first couple of bytes of the payload so that the
- // video elements can work by determining video keyframes and the opus mode
- // being used. This appears to be necessary for any video feed at all.
- // For VP8 this is the content described in
- // https://tools.ietf.org/html/rfc6386#section-9.1
- // which is 10 bytes for key frames and 3 bytes for delta frames.
- // For opus (where encodedFrame.type is not set) this is the TOC byte from
- // https://tools.ietf.org/html/rfc6716#section-3.1
- var _a;
- const capabilities = RTCRtpSender.getCapabilities("video");
- if (capabilities) {
- const { codecs } = capabilities;
- const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8");
- const selectedCodec = codecs[selectedCodecIndex];
- codecs.splice(selectedCodecIndex, 1);
- codecs.unshift(selectedCodec);
- for (const t of call.connection.getTransceivers()) {
- if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video") {
- t.setCodecPreferences(codecs);
- }
- }
- }
- }
- async function replaceMedia(call, camera) {
- const videos = getVideoElements();
- if (!videos)
- throw Error("no video elements");
- const pc = call.connection;
- for (const t of call.localStream.getTracks())
- t.stop();
- call.localCamera = camera;
- const localStream = await getLocalMediaStream(call.localMedia, camera);
- replaceTracks(pc, localStream.getVideoTracks());
- replaceTracks(pc, localStream.getAudioTracks());
- call.localStream = localStream;
- videos.local.srcObject = localStream;
- }
- function replaceTracks(pc, tracks) {
- if (!tracks.length)
- return;
- const sender = pc.getSenders().find((s) => { var _a; return ((_a = s.track) === null || _a === void 0 ? void 0 : _a.kind) === tracks[0].kind; });
- if (sender)
- for (const t of tracks)
- sender.replaceTrack(t);
- }
- function setupPeerTransform(operation, peer, worker, aesKey, key) {
- if (worker && "RTCRtpScriptTransform" in window) {
- console.log(`${operation} with worker & RTCRtpScriptTransform`);
- peer.transform = new RTCRtpScriptTransform(worker, { operation, aesKey });
- }
- else if ("createEncodedStreams" in peer) {
- const { readable, writable } = peer.createEncodedStreams();
- if (worker) {
- console.log(`${operation} with worker`);
- worker.postMessage({ operation, readable, writable, aesKey }, [readable, writable]);
- }
- else {
- console.log(`${operation} without worker`);
- const transform = callCrypto.transformFrame[operation](key);
- readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable);
- }
- }
- else {
- console.log(`no ${operation}`);
- }
- }
- function getLocalMediaStream(mediaType, facingMode) {
- const constraints = callMediaConstraints(mediaType, facingMode);
- return navigator.mediaDevices.getUserMedia(constraints);
- }
- function callMediaConstraints(mediaType, facingMode) {
- switch (mediaType) {
- case CallMediaType.Audio:
- return { audio: true, video: false };
- case CallMediaType.Video:
- return {
- audio: true,
- video: {
- frameRate: 24,
- width: {
- min: 480,
- ideal: 720,
- max: 1280,
- },
- aspectRatio: 1.33,
- facingMode,
- },
- };
- }
- }
- function supportsInsertableStreams(useWorker) {
- return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) ||
- (!!useWorker && "RTCRtpScriptTransform" in window));
- }
- function resetVideoElements() {
- const videos = getVideoElements();
- if (!videos)
- return;
- videos.local.srcObject = null;
- videos.remote.srcObject = null;
- }
- function getVideoElements() {
- const local = document.getElementById("local-video-stream");
- const remote = document.getElementById("remote-video-stream");
- if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement))
- return;
- return { local, remote };
- }
- // function setupVideoElement(video: HTMLElement) {
- // // TODO use display: none
- // video.style.opacity = "0"
- // video.onplaying = () => {
- // video.style.opacity = "1"
- // }
- // }
- function enableMedia(s, media, enable) {
- const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks();
- for (const t of tracks)
- t.enabled = enable;
- }
- return processCommand;
-})();
-// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
-function callCryptoFunction() {
- const initialPlainTextRequired = {
- key: 10,
- delta: 3,
- empty: 1,
- };
- const IV_LENGTH = 12;
- function encryptFrame(key) {
- return async (frame, controller) => {
- const data = new Uint8Array(frame.data);
- const n = initialPlainTextRequired[frame.type] || 1;
- const iv = randomIV();
- const initial = data.subarray(0, n);
- const plaintext = data.subarray(n, data.byteLength);
- try {
- const ciphertext = plaintext.length
- ? new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext))
- : new Uint8Array(0);
- frame.data = concatN(initial, ciphertext, iv).buffer;
- controller.enqueue(frame);
- }
- catch (e) {
- console.log(`encryption error ${e}`);
- throw e;
- }
- };
- }
- function decryptFrame(key) {
- return async (frame, controller) => {
- const data = new Uint8Array(frame.data);
- const n = initialPlainTextRequired[frame.type] || 1;
- const initial = data.subarray(0, n);
- const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH);
- const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength);
- try {
- const plaintext = ciphertext.length
- ? new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext))
- : new Uint8Array(0);
- frame.data = concatN(initial, plaintext).buffer;
- controller.enqueue(frame);
- }
- catch (e) {
- console.log(`decryption error ${e}`);
- throw e;
- }
- };
- }
- function decodeAesKey(aesKey) {
- const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(aesKey));
- return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
- }
- function concatN(...bs) {
- const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0));
- bs.reduce((offset, b) => {
- a.set(b, offset);
- return offset + b.byteLength;
- }, 0);
- return a;
- }
- function randomIV() {
- return crypto.getRandomValues(new Uint8Array(IV_LENGTH));
- }
- const base64urlChars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("").map((c) => c.charCodeAt(0)));
- const base64urlLookup = new Array(256);
- base64urlChars.forEach((c, i) => (base64urlLookup[c] = i));
- const char_equal = "=".charCodeAt(0);
- function encodeAscii(s) {
- const a = new Uint8Array(s.length);
- let i = s.length;
- while (i--)
- a[i] = s.charCodeAt(i);
- return a;
- }
- function decodeAscii(a) {
- let s = "";
- for (let i = 0; i < a.length; i++)
- s += String.fromCharCode(a[i]);
- return s;
- }
- function encodeBase64url(a) {
- const len = a.length;
- const b64len = Math.ceil(len / 3) * 4;
- const b64 = new Uint8Array(b64len);
- let j = 0;
- for (let i = 0; i < len; i += 3) {
- b64[j++] = base64urlChars[a[i] >> 2];
- b64[j++] = base64urlChars[((a[i] & 3) << 4) | (a[i + 1] >> 4)];
- b64[j++] = base64urlChars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)];
- b64[j++] = base64urlChars[a[i + 2] & 63];
- }
- if (len % 3)
- b64[b64len - 1] = char_equal;
- if (len % 3 === 1)
- b64[b64len - 2] = char_equal;
- return b64;
- }
- function decodeBase64url(b64) {
- let len = b64.length;
- if (len % 4)
- return;
- let bLen = (len * 3) / 4;
- if (b64[len - 1] === char_equal) {
- len--;
- bLen--;
- if (b64[len - 1] === char_equal) {
- len--;
- bLen--;
- }
- }
- const bytes = new Uint8Array(bLen);
- let i = 0;
- let pos = 0;
- while (i < len) {
- const enc1 = base64urlLookup[b64[i++]];
- const enc2 = i < len ? base64urlLookup[b64[i++]] : 0;
- const enc3 = i < len ? base64urlLookup[b64[i++]] : 0;
- const enc4 = i < len ? base64urlLookup[b64[i++]] : 0;
- if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined)
- return;
- bytes[pos++] = (enc1 << 2) | (enc2 >> 4);
- bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2);
- bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63);
- }
- return bytes;
- }
- return {
- transformFrame: { encrypt: encryptFrame, decrypt: decryptFrame },
- decodeAesKey,
- encodeAscii,
- decodeAscii,
- encodeBase64url,
- decodeBase64url,
- };
-}
-// If the worker is used for decryption, this function code (as string) is used to load the worker via Blob
-// We have to use worker optionally, as it crashes in Android web view, regardless of how it is loaded
-function workerFunction() {
- // encryption with createEncodedStreams support
- self.addEventListener("message", async ({ data }) => {
- await setupTransform(data);
- });
- // encryption using RTCRtpScriptTransform.
- if ("RTCTransformEvent" in self) {
- self.addEventListener("rtctransform", async ({ transformer }) => {
- try {
- const { operation, aesKey } = transformer.options;
- const { readable, writable } = transformer;
- await setupTransform({ operation, aesKey, readable, writable });
- self.postMessage({ result: "setupTransform success" });
- }
- catch (e) {
- self.postMessage({ message: `setupTransform error: ${e.message}` });
- }
- });
- }
- async function setupTransform({ operation, aesKey, readable, writable }) {
- const key = await callCrypto.decodeAesKey(aesKey);
- const transform = callCrypto.transformFrame[operation](key);
- readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable);
- }
-}
-//# sourceMappingURL=call.js.map
\ No newline at end of file
diff --git a/apps/android/app/src/main/assets/www/lz-string.min.js b/apps/android/app/src/main/assets/www/lz-string.min.js
deleted file mode 100644
index 2d1900a0d3..0000000000
--- a/apps/android/app/src/main/assets/www/lz-string.min.js
+++ /dev/null
@@ -1 +0,0 @@
-var LZString=function(){function o(o,r){if(!t[o]){t[o]={};for(var n=0;ne;e++){var s=r.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null===o||void 0===o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;t>e;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(o){return null==o?"":i._compress(o,6,function(o){return e.charAt(o)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(n){return o(e,r.charAt(n))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(o,r,n){if(null==o)return"";var e,t,i,s={},p={},u="",c="",a="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;ie;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++),s[c]=f++,a=String(u)}if(""!==a){if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==r-1){d.push(n(m));break}v++}return d.join("")},decompress:function(o){return null==o?"":""==o?null:i._decompress(o.length,32768,function(r){return o.charCodeAt(r)})},_decompress:function(o,n,e){var t,i,s,p,u,c,a,l,f=[],h=4,d=4,m=3,v="",w=[],A={val:e(0),position:n,index:1};for(i=0;3>i;i+=1)f[i]=i;for(p=0,c=Math.pow(2,2),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(t=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 2:return""}for(f[3]=l,s=l,w.push(l);;){if(A.index>o)return"";for(p=0,c=Math.pow(2,m),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(l=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 2:return w.join("")}if(0==h&&(h=Math.pow(2,m),m++),f[l])v=f[l];else{if(l!==d)return null;v=s+s.charAt(0)}w.push(v),f[d++]=s+v.charAt(0),h--,s=v,0==h&&(h=Math.pow(2,m),m++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module&&(module.exports=LZString);
diff --git a/apps/android/app/src/main/cpp/simplex-api.c b/apps/android/app/src/main/cpp/simplex-api.c
deleted file mode 100644
index 9a3c9c48ab..0000000000
--- a/apps/android/app/src/main/cpp/simplex-api.c
+++ /dev/null
@@ -1,111 +0,0 @@
-#include
-
-// from the RTS
-void hs_init(int * argc, char **argv[]);
-
-// from android-support
-void setLineBuffering(void);
-int pipe_std_to_socket(const char * name);
-
-extern void __svfscanf(void){};
-extern void __vfwscanf(void){};
-extern void __memset_chk_fail(void){};
-extern void __strcpy_chk_generic(void){};
-extern void __strcat_chk_generic(void){};
-extern void __libc_globals(void){};
-extern void __rel_iplt_start(void){};
-
-// Android 9 only, not 13
-extern void reallocarray(void){};
-
-JNIEXPORT jint JNICALL
-Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
- const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
- int ret = pipe_std_to_socket(name);
- (*env)->ReleaseStringUTFChars(env, socket_name, name);
- return ret;
-}
-
-JNIEXPORT void JNICALL
-Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
- hs_init(NULL, NULL);
- setLineBuffering();
-}
-
-// from simplex-chat
-typedef long* chat_ctrl;
-
-extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
-extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
-extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
-extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
-extern char *chat_parse_markdown(const char *str);
-extern char *chat_parse_server(const char *str);
-extern char *chat_password_hash(const char *pwd, const char *salt);
-
-JNIEXPORT jobjectArray JNICALL
-Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
- const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
- const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
- const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
- jlong _ctrl = (jlong) 0;
- jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
- (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
- (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
- (*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
-
- // Creating array of Object's (boxed values can be passed, eg. Long instead of long)
- jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
- // Java's String
- (*env)->SetObjectArrayElement(env, ret, 0, res);
- // Java's Long
- (*env)->SetObjectArrayElement(env, ret, 1,
- (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Long"),
- (*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Long"), "", "(J)V"),
- _ctrl));
- return ret;
-}
-
-JNIEXPORT jstring JNICALL
-Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
- const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
- jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
- (*env)->ReleaseStringUTFChars(env, msg, _msg);
- return res;
-}
-
-JNIEXPORT jstring JNICALL
-Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
- return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
-}
-
-JNIEXPORT jstring JNICALL
-Java_chat_simplex_app_SimplexAppKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) {
- return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait));
-}
-
-JNIEXPORT jstring JNICALL
-Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) {
- const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
- jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str));
- (*env)->ReleaseStringUTFChars(env, str, _str);
- return res;
-}
-
-JNIEXPORT jstring JNICALL
-Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
- const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
- jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
- (*env)->ReleaseStringUTFChars(env, str, _str);
- return res;
-}
-
-JNIEXPORT jstring JNICALL
-Java_chat_simplex_app_SimplexAppKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
- const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
- const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
- jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
- (*env)->ReleaseStringUTFChars(env, pwd, _pwd);
- (*env)->ReleaseStringUTFChars(env, salt, _salt);
- return res;
-}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/BackupAgent.kt b/apps/android/app/src/main/java/chat/simplex/app/BackupAgent.kt
deleted file mode 100644
index bbe4e8318c..0000000000
--- a/apps/android/app/src/main/java/chat/simplex/app/BackupAgent.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package chat.simplex.app
-
-import android.app.backup.BackupAgentHelper
-import android.app.backup.FullBackupDataOutput
-import android.content.Context
-import chat.simplex.app.model.AppPreferences
-import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
-
-class BackupAgent: BackupAgentHelper() {
- override fun onFullBackup(data: FullBackupDataOutput?) {
- if (applicationContext
- .getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE)
- .getBoolean(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
- ) {
- super.onFullBackup(data)
- }
- }
-}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
deleted file mode 100644
index 8d70506f26..0000000000
--- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
+++ /dev/null
@@ -1,719 +0,0 @@
-package chat.simplex.app
-
-import android.app.Application
-import android.content.Intent
-import android.net.Uri
-import android.os.*
-import android.os.SystemClock.elapsedRealtime
-import android.util.Log
-import android.view.WindowManager
-import androidx.activity.compose.setContent
-import androidx.activity.viewModels
-import androidx.compose.animation.core.*
-import androidx.compose.foundation.layout.*
-import androidx.compose.material.*
-import androidx.compose.runtime.*
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.fragment.app.FragmentActivity
-import androidx.lifecycle.*
-import chat.simplex.app.MainActivity.Companion.enteredBackground
-import chat.simplex.app.model.*
-import chat.simplex.app.model.NtfManager.Companion.getUserIdFromIntent
-import chat.simplex.app.ui.theme.*
-import chat.simplex.app.views.SplashView
-import chat.simplex.app.views.call.ActiveCallView
-import chat.simplex.app.views.call.IncomingCallAlertView
-import chat.simplex.app.views.chat.ChatView
-import chat.simplex.app.views.chat.group.ProgressIndicator
-import chat.simplex.app.views.chatlist.*
-import chat.simplex.app.views.database.DatabaseErrorView
-import chat.simplex.app.views.helpers.*
-import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
-import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword
-import chat.simplex.app.views.localauth.SetAppPasscodeView
-import chat.simplex.app.views.newchat.*
-import chat.simplex.app.views.onboarding.*
-import chat.simplex.app.views.usersettings.LAMode
-import kotlinx.coroutines.*
-import kotlinx.coroutines.flow.distinctUntilChanged
-
-class MainActivity: FragmentActivity() {
- companion object {
- /**
- * We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user
- * clicks on new message in notification. In this case savedInstanceState will be null (this prevents restoring the values)
- * See [SimplexService.onTaskRemoved] for another part of the logic which nullifies the values when app closed by the user
- * */
- val userAuthorized = mutableStateOf(null)
- val enteredBackground = mutableStateOf(null)
- // Remember result and show it after orientation change
- private val laFailed = mutableStateOf(false)
-
- fun clearAuthState() {
- userAuthorized.value = null
- enteredBackground.value = null
- }
- }
- private val vm by viewModels()
- private val destroyedAfterBackPress = mutableStateOf(false)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- // testJson()
- val m = vm.chatModel
- applyAppLocale(m.controller.appPrefs.appLanguage)
- // When call ended and orientation changes, it re-process old intent, it's unneeded.
- // Only needed to be processed on first creation of activity
- if (savedInstanceState == null) {
- processNotificationIntent(intent, m)
- processIntent(intent, m)
- processExternalIntent(intent, m)
- }
- if (m.controller.appPrefs.privacyProtectScreen.get()) {
- Log.d(TAG, "onCreate: set FLAG_SECURE")
- window.setFlags(
- WindowManager.LayoutParams.FLAG_SECURE,
- WindowManager.LayoutParams.FLAG_SECURE
- )
- }
- setContent {
- SimpleXTheme {
- Surface(color = MaterialTheme.colors.background) {
- MainPage(
- m,
- userAuthorized,
- laFailed,
- destroyedAfterBackPress,
- ::runAuthenticate,
- ::setPerformLA,
- showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown, this) }
- )
- }
- }
- }
- SimplexApp.context.schedulePeriodicServiceRestartWorker()
- SimplexApp.context.schedulePeriodicWakeUp()
- }
-
- override fun onNewIntent(intent: Intent?) {
- super.onNewIntent(intent)
- processIntent(intent, vm.chatModel)
- processExternalIntent(intent, vm.chatModel)
- }
-
- override fun onResume() {
- super.onResume()
- val enteredBackgroundVal = enteredBackground.value
- val delay = vm.chatModel.controller.appPrefs.laLockDelay.get()
- if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
- if (userAuthorized.value != false) {
- /** [runAuthenticate] will be called in [MainPage] if needed. Making like this prevents double showing of passcode on start */
- setAuthState()
- } else if (!vm.chatModel.activeCallViewIsVisible.value) {
- runAuthenticate()
- }
- }
- }
-
- override fun onPause() {
- super.onPause()
- /**
- * When new activity is created after a click on notification, the old one receives onPause before
- * recreation but receives onStop after recreation. So using both (onPause and onStop) to prevent
- * unwanted multiple auth dialogs from [runAuthenticate]
- * */
- enteredBackground.value = elapsedRealtime()
- }
-
- override fun onStop() {
- super.onStop()
- VideoPlayer.stopAll()
- enteredBackground.value = elapsedRealtime()
- }
-
- override fun onBackPressed() {
- if (
- onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
- || Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
- || isTaskRoot // there are still other tasks after we reach the main (home) activity
- ) {
- // https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06
- super.onBackPressed()
- }
-
- if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
- // When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
- clearAuthState()
- laFailed.value = true
- destroyedAfterBackPress.value = true
- }
- if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
- // Drop shared content
- SimplexApp.context.chatModel.sharedContent.value = null
- }
- }
-
- private fun setAuthState() {
- userAuthorized.value = !vm.chatModel.controller.appPrefs.performLA.get()
- }
-
- private fun runAuthenticate() {
- val m = vm.chatModel
- setAuthState()
- if (userAuthorized.value == false) {
- // To make Main thread free in order to allow to Compose to show blank view that hiding content underneath of it faster on slow devices
- CoroutineScope(Dispatchers.Default).launch {
- delay(50)
- withContext(Dispatchers.Main) {
- authenticate(
- if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
- generalGetString(R.string.auth_unlock)
- else
- generalGetString(R.string.la_enter_app_passcode),
- if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
- generalGetString(R.string.auth_log_in_using_credential)
- else
- generalGetString(R.string.auth_unlock),
- selfDestruct = true,
- this@MainActivity,
- completed = { laResult ->
- when (laResult) {
- LAResult.Success ->
- userAuthorized.value = true
- is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
- is LAResult.Error -> {
- laFailed.value = true
- if (m.controller.appPrefs.laMode.get() == LAMode.PASSCODE) {
- laFailedAlert()
- }
- }
- is LAResult.Unavailable -> {
- userAuthorized.value = true
- m.performLA.value = false
- m.controller.appPrefs.performLA.set(false)
- laUnavailableTurningOffAlert()
- }
- }
- }
- )
- }
- }
- }
- }
-
- private fun showLANotice(laNoticeShown: SharedPreference, activity: FragmentActivity) {
- Log.d(TAG, "showLANotice")
- if (!laNoticeShown.get()) {
- laNoticeShown.set(true)
- AlertManager.shared.showAlertDialog(
- title = generalGetString(R.string.la_notice_title_simplex_lock),
- text = generalGetString(R.string.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
- confirmText = generalGetString(R.string.la_notice_turn_on),
- onConfirm = {
- withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager
- showChooseLAMode(laNoticeShown, activity)
- }
- }
- )
- }
- }
-
- private fun showChooseLAMode(laNoticeShown: SharedPreference, activity: FragmentActivity) {
- Log.d(TAG, "showLANotice")
- laNoticeShown.set(true)
- AlertManager.shared.showAlertDialogStacked(
- title = generalGetString(R.string.la_lock_mode),
- text = null,
- confirmText = generalGetString(R.string.la_lock_mode_passcode),
- dismissText = generalGetString(R.string.la_lock_mode_system),
- onConfirm = {
- AlertManager.shared.hideAlert()
- setPasscode()
- },
- onDismiss = {
- AlertManager.shared.hideAlert()
- initialEnableLA(activity)
- }
- )
- }
-
- private fun initialEnableLA(activity: FragmentActivity) {
- val m = vm.chatModel
- val appPrefs = m.controller.appPrefs
- m.controller.appPrefs.laMode.set(LAMode.SYSTEM)
- authenticate(
- generalGetString(R.string.auth_enable_simplex_lock),
- generalGetString(R.string.auth_confirm_credential),
- activity = activity,
- completed = { laResult ->
- when (laResult) {
- LAResult.Success -> {
- m.performLA.value = true
- appPrefs.performLA.set(true)
- laTurnedOnAlert()
- }
- is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
- is LAResult.Error -> {
- m.performLA.value = false
- appPrefs.performLA.set(false)
- laFailedAlert()
- }
- is LAResult.Unavailable -> {
- m.performLA.value = false
- appPrefs.performLA.set(false)
- m.showAdvertiseLAUnavailableAlert.value = true
- }
- }
- }
- )
- }
-
- private fun setPasscode() {
- val chatModel = vm.chatModel
- val appPrefs = chatModel.controller.appPrefs
- ModalManager.shared.showCustomModal { close ->
- Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
- SetAppPasscodeView(
- submit = {
- chatModel.performLA.value = true
- appPrefs.performLA.set(true)
- appPrefs.laMode.set(LAMode.PASSCODE)
- laTurnedOnAlert()
- },
- cancel = {
- chatModel.performLA.value = false
- appPrefs.performLA.set(false)
- laPasscodeNotSetAlert()
- },
- close = close
- )
- }
- }
- }
-
- private fun setPerformLA(on: Boolean, activity: FragmentActivity) {
- vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
- if (on) {
- enableLA(activity)
- } else {
- disableLA(activity)
- }
- }
-
- private fun enableLA(activity: FragmentActivity) {
- val m = vm.chatModel
- authenticate(
- if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
- generalGetString(R.string.auth_enable_simplex_lock)
- else
- generalGetString(R.string.new_passcode),
- if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
- generalGetString(R.string.auth_confirm_credential)
- else
- "",
- activity = activity,
- completed = { laResult ->
- val prefPerformLA = m.controller.appPrefs.performLA
- when (laResult) {
- LAResult.Success -> {
- m.performLA.value = true
- prefPerformLA.set(true)
- laTurnedOnAlert()
- }
- is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
- is LAResult.Error -> {
- m.performLA.value = false
- prefPerformLA.set(false)
- laFailedAlert()
- }
- is LAResult.Unavailable -> {
- m.performLA.value = false
- prefPerformLA.set(false)
- laUnavailableInstructionAlert()
- }
- }
- }
- )
- }
-
- private fun disableLA(activity: FragmentActivity) {
- val m = vm.chatModel
- authenticate(
- if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
- generalGetString(R.string.auth_disable_simplex_lock)
- else
- generalGetString(R.string.la_enter_app_passcode),
- if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
- generalGetString(R.string.auth_confirm_credential)
- else
- generalGetString(R.string.auth_disable_simplex_lock),
- activity = activity,
- completed = { laResult ->
- val prefPerformLA = m.controller.appPrefs.performLA
- val selfDestructPref = m.controller.appPrefs.selfDestruct
- when (laResult) {
- LAResult.Success -> {
- m.performLA.value = false
- prefPerformLA.set(false)
- ksAppPassword.remove()
- selfDestructPref.set(false)
- ksSelfDestructPassword.remove()
- }
- is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
- is LAResult.Error -> {
- m.performLA.value = true
- prefPerformLA.set(true)
- laFailedAlert()
- }
- is LAResult.Unavailable -> {
- m.performLA.value = false
- prefPerformLA.set(false)
- laUnavailableTurningOffAlert()
- }
- }
- }
- )
- }
-}
-
-class SimplexViewModel(application: Application): AndroidViewModel(application) {
- val app = getApplication()
- val chatModel = app.chatModel
-}
-
-@Composable
-fun MainPage(
- chatModel: ChatModel,
- userAuthorized: MutableState,
- laFailed: MutableState,
- destroyedAfterBackPress: MutableState,
- runAuthenticate: () -> Unit,
- setPerformLA: (Boolean, FragmentActivity) -> Unit,
- showLANotice: () -> Unit
-) {
- var showChatDatabaseError by rememberSaveable {
- mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
- }
- LaunchedEffect(chatModel.chatDbStatus.value) {
- showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null
- }
-
- var showAdvertiseLAAlert by remember { mutableStateOf(false) }
- LaunchedEffect(showAdvertiseLAAlert) {
- if (
- !chatModel.controller.appPrefs.laNoticeShown.get()
- && showAdvertiseLAAlert
- && chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
- && chatModel.chats.isNotEmpty()
- && chatModel.activeCallInvitation.value == null
- ) {
- showLANotice()
- }
- }
- LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) {
- if (chatModel.showAdvertiseLAUnavailableAlert.value) {
- laUnavailableInstructionAlert()
- }
- }
- LaunchedEffect(chatModel.clearOverlays.value) {
- if (chatModel.clearOverlays.value) {
- ModalManager.shared.closeModals()
- chatModel.clearOverlays.value = false
- }
- }
-
- @Composable
- fun AuthView() {
- Surface(color = MaterialTheme.colors.background) {
- Box(
- Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
- SimpleButton(
- stringResource(R.string.auth_unlock),
- icon = painterResource(R.drawable.ic_lock),
- click = {
- laFailed.value = false
- runAuthenticate()
- }
- )
- }
- }
- }
-
- Box {
- val onboarding = chatModel.onboardingStage.value
- val userCreated = chatModel.userCreated.value
- var showInitializationView by remember { mutableStateOf(false) }
- when {
- chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView()
- showChatDatabaseError -> {
- chatModel.chatDbStatus.value?.let {
- DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
- }
- }
- onboarding == null || userCreated == null -> SplashView()
- onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
- Box {
- showAdvertiseLAAlert = true
- BoxWithConstraints {
- var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
- val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
- Box(
- Modifier
- .graphicsLayer {
- translationX = -offset.value.dp.toPx()
- }
- ) {
- val stopped = chatModel.chatRunning.value == false
- if (chatModel.sharedContent.value == null)
- ChatListView(chatModel, setPerformLA, stopped)
- else
- ShareListView(chatModel, stopped)
- }
- val scope = rememberCoroutineScope()
- val onComposed: () -> Unit = {
- scope.launch {
- offset.animateTo(
- if (chatModel.chatId.value == null) 0f else maxWidth.value,
- chatListAnimationSpec()
- )
- if (offset.value == 0f) {
- currentChatId = null
- }
- }
- }
- LaunchedEffect(Unit) {
- launch {
- snapshotFlow { chatModel.chatId.value }
- .distinctUntilChanged()
- .collect {
- if (it != null) currentChatId = it
- else onComposed()
- }
- }
- }
- Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
- currentChatId?.let {
- ChatView(it, chatModel, onComposed)
- }
- }
- }
- }
- }
- onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
- onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
- onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel)
- onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
- }
- ModalManager.shared.showInView()
- val unauthorized = remember { derivedStateOf { userAuthorized.value != true } }
- if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) {
- LaunchedEffect(Unit) {
- // With these constrains when user presses back button while on ChatList, activity destroys and shows auth request
- // while the screen moves to a launcher. Detect it and prevent showing the auth
- if (!(destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) {
- runAuthenticate()
- }
- }
- if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
- AuthView()
- } else {
- SplashView()
- }
- } else if (chatModel.showCallView.value) {
- ActiveCallView(chatModel)
- }
- ModalManager.shared.showPasscodeInView()
- val invitation = chatModel.activeCallInvitation.value
- if (invitation != null) IncomingCallAlertView(invitation, chatModel)
- AlertManager.shared.showInView()
-
- LaunchedEffect(Unit) {
- delay(1000)
- if (chatModel.chatDbStatus.value == null) {
- showInitializationView = true
- }
- }
- }
-
- DisposableEffectOnRotate {
- // When using lock delay = 0 and screen rotates, the app will be locked which is not useful.
- // Let's prolong the unlocked period to 3 sec for screen rotation to take place
- if (chatModel.controller.appPrefs.laLockDelay.get() == 0) {
- enteredBackground.value = elapsedRealtime() + 3000
- }
- }
-}
-
-@Composable
-private fun InitializationView() {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- CircularProgressIndicator(
- Modifier
- .padding(bottom = DEFAULT_PADDING)
- .size(30.dp),
- color = MaterialTheme.colors.secondary,
- strokeWidth = 2.5.dp
- )
- Text(stringResource(R.string.opening_database))
- }
- }
-}
-
-fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
- val userId = getUserIdFromIntent(intent)
- when (intent?.action) {
- NtfManager.OpenChatAction -> {
- val chatId = intent.getStringExtra("chatId")
- Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
- if (chatId != null) {
- withBGApi {
- awaitChatStartedIfNeeded(chatModel)
- if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
- chatModel.controller.changeActiveUser(userId, null)
- }
- val cInfo = chatModel.getChat(chatId)?.chatInfo
- chatModel.clearOverlays.value = true
- if (cInfo != null) openChat(cInfo, chatModel)
- }
- }
- }
- NtfManager.ShowChatsAction -> {
- Log.d(TAG, "processNotificationIntent: ShowChatsAction")
- withBGApi {
- awaitChatStartedIfNeeded(chatModel)
- if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
- chatModel.controller.changeActiveUser(userId, null)
- }
- chatModel.chatId.value = null
- chatModel.clearOverlays.value = true
- }
- }
- NtfManager.AcceptCallAction -> {
- val chatId = intent.getStringExtra("chatId")
- if (chatId == null || chatId == "") return
- Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
- chatModel.clearOverlays.value = true
- val invitation = chatModel.callInvitations[chatId]
- if (invitation == null) {
- AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended))
- } else {
- chatModel.callManager.acceptIncomingCall(invitation = invitation)
- }
- }
- }
-}
-
-fun processIntent(intent: Intent?, chatModel: ChatModel) {
- when (intent?.action) {
- "android.intent.action.VIEW" -> {
- val uri = intent.data
- if (uri != null) connectIfOpenedViaUri(uri, chatModel)
- }
- }
-}
-
-fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
- when (intent?.action) {
- Intent.ACTION_SEND -> {
- // Close active chat and show a list of chats
- chatModel.chatId.value = null
- chatModel.clearOverlays.value = true
- when {
- intent.type == "text/plain" -> {
- val text = intent.getStringExtra(Intent.EXTRA_TEXT)
- if (text != null) {
- chatModel.sharedContent.value = SharedContent.Text(text)
- }
- }
- isMediaIntent(intent) -> {
- val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri
- if (uri != null) {
- chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri))
- } // All other mime types
- }
- else -> {
- val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri
- if (uri != null) {
- chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri)
- }
- }
- }
- }
- Intent.ACTION_SEND_MULTIPLE -> {
- // Close active chat and show a list of chats
- chatModel.chatId.value = null
- chatModel.clearOverlays.value = true
- Log.e(TAG, "ACTION_SEND_MULTIPLE ${intent.type}")
- when {
- isMediaIntent(intent) -> {
- val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) as? List
- if (uris != null) {
- chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris)
- } // All other mime types
- }
- else -> {}
- }
- }
- }
-}
-
-fun isMediaIntent(intent: Intent): Boolean =
- intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
-
-fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
- Log.d(TAG, "connectIfOpenedViaUri: opened via link")
- if (chatModel.currentUser.value == null) {
- chatModel.appOpenUrl.value = uri
- } else {
- withUriAction(uri) { linkType ->
- val title = when (linkType) {
- ConnectionLinkType.CONTACT -> generalGetString(R.string.connect_via_contact_link)
- ConnectionLinkType.INVITATION -> generalGetString(R.string.connect_via_invitation_link)
- ConnectionLinkType.GROUP -> generalGetString(R.string.connect_via_group_link)
- }
- AlertManager.shared.showAlertDialog(
- title = title,
- text = if (linkType == ConnectionLinkType.GROUP)
- generalGetString(R.string.you_will_join_group)
- else
- generalGetString(R.string.profile_will_be_sent_to_contact_sending_link),
- confirmText = generalGetString(R.string.connect_via_link_verb),
- onConfirm = {
- withApi {
- Log.d(TAG, "connectIfOpenedViaUri: connecting")
- connectViaUri(chatModel, linkType, uri)
- }
- }
- )
- }
- }
-}
-
-suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
- // Still decrypting database
- if (chatModel.chatRunning.value == null) {
- val step = 50L
- for (i in 0..(timeout / step)) {
- if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
- break
- }
- delay(step)
- }
- }
-}
-
-//fun testJson() {
-// val str: String = """
-// """.trimIndent()
-//
-// println(json.decodeFromString(str))
-//}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
deleted file mode 100644
index 0e5af1b6be..0000000000
--- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
+++ /dev/null
@@ -1,271 +0,0 @@
-package chat.simplex.app
-
-import android.app.Application
-import android.net.LocalServerSocket
-import android.util.Log
-import androidx.lifecycle.*
-import androidx.work.*
-import chat.simplex.app.model.*
-import chat.simplex.app.ui.theme.DefaultTheme
-import chat.simplex.app.views.helpers.*
-import chat.simplex.app.views.onboarding.OnboardingStage
-import chat.simplex.app.views.usersettings.NotificationsMode
-import kotlinx.coroutines.*
-import kotlinx.serialization.decodeFromString
-import java.io.*
-import java.util.*
-import java.util.concurrent.Semaphore
-import java.util.concurrent.TimeUnit
-import kotlin.concurrent.thread
-
-const val TAG = "SIMPLEX"
-
-// ghc's rts
-external fun initHS()
-// android-support
-external fun pipeStdOutToSocket(socketName: String) : Int
-
-// SimpleX API
-typealias ChatCtrl = Long
-external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array
-external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
-external fun chatRecvMsg(ctrl: ChatCtrl): String
-external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
-external fun chatParseMarkdown(str: String): String
-external fun chatParseServer(str: String): String
-external fun chatPasswordHash(pwd: String, salt: String): String
-
-class SimplexApp: Application(), LifecycleEventObserver {
- var isAppOnForeground: Boolean = false
-
- val defaultLocale: Locale = Locale.getDefault()
-
- suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
- val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
- val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
- val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
- val migrated: Array = chatMigrateInit(dbAbsolutePathPrefix, dbKey, confirm.value)
- val res: DBMigrationResult = kotlin.runCatching {
- json.decodeFromString(migrated[0] as String)
- }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
- val ctrl = if (res is DBMigrationResult.OK) {
- migrated[1] as Long
- } else null
- chatController.ctrl = ctrl
- chatModel.chatDbEncrypted.value = dbKey != ""
- chatModel.chatDbStatus.value = res
- if (res != DBMigrationResult.OK) {
- Log.d(TAG, "Unable to migrate successfully: $res")
- } else if (startChat) {
- // If we migrated successfully means previous re-encryption process on database level finished successfully too
- if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
- val user = chatController.apiGetActiveUser()
- if (user == null) {
- chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
- chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
- chatModel.currentUser.value = null
- chatModel.users.clear()
- } else {
- val savedOnboardingStage = appPreferences.onboardingStage.get()
- chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
- OnboardingStage.Step3_CreateSimpleXAddress
- } else {
- savedOnboardingStage
- }
- chatController.startChat(user)
- // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet
- if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
- chatController.showBackgroundServiceNoticeIfNeeded()
- if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
- SimplexService.start(applicationContext)
- }
- }
- }
- }
-
- val chatModel: ChatModel
- get() = chatController.chatModel
-
- private val ntfManager: NtfManager by lazy {
- NtfManager(applicationContext, appPreferences)
- }
-
- private val appPreferences: AppPreferences by lazy {
- AppPreferences(applicationContext)
- }
-
- val chatController: ChatController by lazy {
- ChatController(0L, ntfManager, applicationContext, appPreferences)
- }
-
-
- override fun onCreate() {
- super.onCreate()
- context = this
- context.getDir("temp", MODE_PRIVATE).deleteRecursively()
- withBGApi {
- initChatController()
- runMigrations()
- }
- ProcessLifecycleOwner.get().lifecycle.addObserver(this@SimplexApp)
- }
-
- override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
- Log.d(TAG, "onStateChanged: $event")
- withApi {
- when (event) {
- Lifecycle.Event.ON_START -> {
- isAppOnForeground = true
- if (chatModel.chatRunning.value == true) {
- kotlin.runCatching {
- val currentUserId = chatModel.currentUser.value?.userId
- val chats = ArrayList(chatController.apiGetChats())
- /** Active user can be changed in background while [ChatController.apiGetChats] is executing */
- if (chatModel.currentUser.value?.userId == currentUserId) {
- val currentChatId = chatModel.chatId.value
- val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null
- if (oldStats != null) {
- val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId }
- /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
- if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
- }
- chatModel.updateChats(chats)
- }
- }.onFailure { Log.e(TAG, it.stackTraceToString()) }
- }
- }
- Lifecycle.Event.ON_RESUME -> {
- isAppOnForeground = true
- if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
- chatController.showBackgroundServiceNoticeIfNeeded()
- }
- /**
- * We're starting service here instead of in [Lifecycle.Event.ON_START] because
- * after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
- * It can happen when app was started and a user enables battery optimization while app in background
- * */
- if (chatModel.chatRunning.value != false &&
- chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
- appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
- ) {
- SimplexService.start(applicationContext)
- }
- }
- else -> isAppOnForeground = false
- }
- }
- }
-
- fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
- appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name &&
- (!NotificationsMode.SERVICE.requiresIgnoringBattery || isIgnoringBatteryOptimizations(chatModel.controller.appContext))
- }
-
- private fun allowToStartPeriodically() = with(chatModel.controller) {
- appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC.name &&
- (!NotificationsMode.PERIODIC.requiresIgnoringBattery || isIgnoringBatteryOptimizations(chatModel.controller.appContext))
- }
-
- /*
- * It takes 1-10 milliseconds to process this function. Better to do it in a background thread
- * */
- fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch {
- if (!allowToStartServiceAfterAppExit()) {
- return@launch
- }
- val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
- val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
- Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
- ExistingPeriodicWorkPolicy.KEEP
- } else {
- Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
- chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
- ExistingPeriodicWorkPolicy.REPLACE
- }
- val work = PeriodicWorkRequestBuilder(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
- .addTag(SimplexService.TAG)
- .addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
- .build()
- Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
- WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
- }
-
- fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch {
- if (!allowToStartPeriodically()) {
- return@launch
- }
- MessagesFetcherWorker.scheduleWork()
- }
-
- private fun runMigrations() {
- val lastMigration = chatModel.controller.appPrefs.lastMigratedVersionCode
- if (lastMigration.get() < BuildConfig.VERSION_CODE) {
- while (true) {
- if (lastMigration.get() < 117) {
- if (chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.DARK.name) {
- chatModel.controller.appPrefs.currentTheme.set(DefaultTheme.SIMPLEX.name)
- }
- lastMigration.set(117)
- } else {
- lastMigration.set(BuildConfig.VERSION_CODE)
- break
- }
- }
- }
- }
-
- companion object {
- lateinit var context: SimplexApp private set
-
- init {
- val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2"
- val s = Semaphore(0)
- thread(name="stdout/stderr pipe") {
- Log.d(TAG, "starting server")
- var server: LocalServerSocket? = null
- for (i in 0..100) {
- try {
- server = LocalServerSocket(socketName + i)
- break
- } catch (e: IOException) {
- Log.e(TAG, e.stackTraceToString())
- }
- }
- if (server == null) {
- throw Error("Unable to setup local server socket. Contact developers")
- }
- Log.d(TAG, "started server")
- s.release()
- val receiver = server.accept()
- Log.d(TAG, "started receiver")
- val logbuffer = FifoQueue(500)
- if (receiver != null) {
- val inStream = receiver.inputStream
- val inStreamReader = InputStreamReader(inStream)
- val input = BufferedReader(inStreamReader)
- Log.d(TAG, "starting receiver loop")
- while (true) {
- val line = input.readLine() ?: break
- Log.w("$TAG (stdout/stderr)", line)
- logbuffer.add(line)
- }
- Log.w(TAG, "exited receiver loop")
- }
- }
-
- System.loadLibrary("app-lib")
-
- s.acquire()
- pipeStdOutToSocket(socketName)
-
- initHS()
- }
- }
-}
-
-class FifoQueue(private var capacity: Int) : LinkedList() {
- override fun add(element: E): Boolean {
- if(size > capacity) removeFirst()
- return super.add(element)
- }
-}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt
deleted file mode 100644
index 63e31cb226..0000000000
--- a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt
+++ /dev/null
@@ -1,329 +0,0 @@
-package chat.simplex.app
-
-import android.app.*
-import android.content.*
-import android.content.pm.PackageManager
-import android.os.*
-import android.provider.Settings
-import android.util.Log
-import androidx.core.app.NotificationCompat
-import androidx.core.content.ContextCompat
-import androidx.work.*
-import chat.simplex.app.views.helpers.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-
-// based on:
-// https://robertohuertas.com/2019/06/29/android_foreground_services/
-// https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt
-
-class SimplexService: Service() {
- private var wakeLock: PowerManager.WakeLock? = null
- private var isStartingService = false
- private var notificationManager: NotificationManager? = null
- private var serviceNotification: Notification? = null
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- Log.d(TAG, "onStartCommand startId: $startId")
- if (intent != null) {
- val action = intent.action
- Log.d(TAG, "intent action $action")
- when (action) {
- Action.START.name -> startService()
- else -> Log.e(TAG, "No action in the intent")
- }
- } else {
- Log.d(TAG, "null intent. Probably restarted by the system.")
- }
- return START_STICKY // to restart if killed
- }
-
- override fun onCreate() {
- super.onCreate()
- Log.d(TAG, "Simplex service created")
- val title = getString(R.string.simplex_service_notification_title)
- val text = getString(R.string.simplex_service_notification_text)
- notificationManager = createNotificationChannel()
- serviceNotification = createNotification(title, text)
- startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
- /**
- * The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
- * we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
- * To prevent that, we can call [stopSelf] only when the service made [startForeground] call
- * */
- if (stopAfterStart) {
- stopForeground(true)
- stopSelf()
- } else {
- isServiceStarted = true
- }
- }
-
- override fun onDestroy() {
- Log.d(TAG, "Simplex service destroyed")
- try {
- wakeLock?.let {
- while (it.isHeld) it.release() // release all, in case acquired more than once
- }
- wakeLock = null
- } catch (e: Exception) {
- Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
- }
- isServiceStarted = false
- stopAfterStart = false
- saveServiceState(this, ServiceState.STOPPED)
-
- // If notification service is enabled and battery optimization is disabled, restart the service
- if (SimplexApp.context.allowToStartServiceAfterAppExit())
- sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
- super.onDestroy()
- }
-
- private fun startService() {
- Log.d(TAG, "SimplexService startService")
- if (wakeLock != null || isStartingService) return
- val self = this
- isStartingService = true
- withApi {
- val chatController = (application as SimplexApp).chatController
- try {
- Log.w(TAG, "Starting foreground service")
- val chatDbStatus = chatController.chatModel.chatDbStatus.value
- if (chatDbStatus != DBMigrationResult.OK) {
- Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
- showPassphraseNotification(chatDbStatus)
- safeStopService(self)
- return@withApi
- }
- saveServiceState(self, ServiceState.STARTED)
- wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
- newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
- acquire()
- }
- }
- } finally {
- isStartingService = false
- }
- }
- }
-
- private fun createNotificationChannel(): NotificationManager? {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).let {
- it.setShowBadge(false) // no long-press badge
- it
- }
- notificationManager.createNotificationChannel(channel)
- return notificationManager
- }
- return null
- }
-
- private fun createNotification(title: String, text: String): Notification {
- val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
- PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
- }
-
- val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
- .setSmallIcon(R.drawable.ntf_service_icon)
- .setColor(0x88FFFF)
- .setContentTitle(title)
- .setContentText(text)
- .setContentIntent(pendingIntent)
- .setSilent(true)
- .setShowWhen(false) // no date/time
-
- // Shows a button which opens notification channel settings
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
- val setupIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
- setupIntent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
- setupIntent.putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL_ID)
- val setup = PendingIntent.getActivity(this, 0, setupIntent, flags)
- builder.addAction(0, getString(R.string.hide_notification), setup)
- }
-
- return builder.build()
- }
-
- override fun onBind(intent: Intent): IBinder? {
- return null // no binding
- }
-
- // re-schedules the task when "Clear recent apps" is pressed
- override fun onTaskRemoved(rootIntent: Intent) {
- // Just to make sure that after restart of the app the user will need to re-authenticate
- MainActivity.clearAuthState()
-
- // If notification service isn't enabled or battery optimization isn't disabled, we shouldn't restart the service
- if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
- return
- }
-
- val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
- it.setPackage(packageName)
- };
- val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE);
- applicationContext.getSystemService(Context.ALARM_SERVICE);
- val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
- alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
- }
-
- // restart on reboot
- class StartReceiver: BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- Log.d(TAG, "StartReceiver: onReceive called")
- scheduleStart(context)
- }
- companion object {
- fun toggleReceiver(enable: Boolean) {
- Log.d(TAG, "StartReceiver: toggleReceiver enabled: $enable")
- val component = ComponentName(BuildConfig.APPLICATION_ID, StartReceiver::class.java.name)
- SimplexApp.context.packageManager.setComponentEnabledSetting(
- component,
- if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
- PackageManager.DONT_KILL_APP
- )
- }
- }
- }
-
- // restart on destruction
- class AutoRestartReceiver: BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- Log.d(TAG, "AutoRestartReceiver: onReceive called")
- scheduleStart(context)
- }
- }
-
- class ServiceStartWorker(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params) {
- override suspend fun doWork(): Result {
- val id = this.id
- if (context.applicationContext !is Application) {
- Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: $id)")
- return Result.failure()
- }
- if (getServiceState(context) == ServiceState.STARTED) {
- Log.d(TAG, "ServiceStartWorker: Starting foreground service (work ID: $id)")
- start(context)
- }
- return Result.success()
- }
- }
-
- enum class Action {
- START,
- }
-
- enum class ServiceState {
- STARTED,
- STOPPED,
- }
-
- companion object {
- const val TAG = "SIMPLEX_SERVICE"
- const val NOTIFICATION_CHANNEL_ID = "chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION"
- const val NOTIFICATION_CHANNEL_NAME = "SimpleX Chat service"
- const val SIMPLEX_SERVICE_ID = 6789
- const val SERVICE_START_WORKER_VERSION = BuildConfig.VERSION_CODE
- const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
- const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change!
-
- private const val PASSPHRASE_NOTIFICATION_ID = 1535
-
- private const val WAKE_LOCK_TAG = "SimplexService::lock"
- private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS"
- private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
- private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
-
- private var isServiceStarted = false
- private var stopAfterStart = false
-
- fun scheduleStart(context: Context) {
- Log.d(TAG, "Enqueuing work to start subscriber service")
- val workManager = WorkManager.getInstance(context)
- val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
- workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
- }
-
- suspend fun start(context: Context) = serviceAction(context, Action.START)
-
- /**
- * If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an
- * exception related to foreground services lifecycle
- * */
- fun safeStopService(context: Context) {
- if (isServiceStarted) {
- context.stopService(Intent(context, SimplexService::class.java))
- } else {
- stopAfterStart = true
- }
- }
-
- private suspend fun serviceAction(context: Context, action: Action) {
- Log.d(TAG, "SimplexService serviceAction: ${action.name}")
- withContext(Dispatchers.IO) {
- Intent(context, SimplexService::class.java).also {
- it.action = action.name
- ContextCompat.startForegroundService(context, it)
- }
- }
- }
-
- fun restart(context: Context) {
- Intent(context, SimplexService::class.java).also { intent ->
- context.stopService(intent) // Service will auto-restart
- }
- }
-
- fun saveServiceState(context: Context, state: ServiceState) {
- getPreferences(context).edit()
- .putString(SHARED_PREFS_SERVICE_STATE, state.name)
- .apply()
- }
-
- fun getServiceState(context: Context): ServiceState {
- val value = getPreferences(context)
- .getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name)
- return ServiceState.valueOf(value!!)
- }
-
- fun showPassphraseNotification(chatDbStatus: DBMigrationResult?) {
- val pendingIntent: PendingIntent = Intent(SimplexApp.context, MainActivity::class.java).let { notificationIntent ->
- PendingIntent.getActivity(SimplexApp.context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
- }
-
- val title = when(chatDbStatus) {
- is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_title)
- is DBMigrationResult.OK -> return
- else -> generalGetString(R.string.database_initialization_error_title)
- }
-
- val description = when(chatDbStatus) {
- is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_desc)
- is DBMigrationResult.OK -> return
- else -> generalGetString(R.string.database_initialization_error_desc)
- }
-
- val builder = NotificationCompat.Builder(SimplexApp.context, NOTIFICATION_CHANNEL_ID)
- .setSmallIcon(R.drawable.ntf_service_icon)
- .setColor(0x88FFFF)
- .setContentTitle(title)
- .setContentText(description)
- .setContentIntent(pendingIntent)
- .setSilent(true)
- .setShowWhen(false)
-
- val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- notificationManager.notify(PASSPHRASE_NOTIFICATION_ID, builder.build())
- }
-
- fun cancelPassphraseNotification() {
- val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- notificationManager.cancel(PASSPHRASE_NOTIFICATION_ID)
- }
-
- private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
- }
-}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
deleted file mode 100644
index 682e3f3357..0000000000
--- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
+++ /dev/null
@@ -1,2400 +0,0 @@
-package chat.simplex.app.model
-
-import android.net.Uri
-import androidx.compose.material.MaterialTheme
-import androidx.compose.runtime.*
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.font.*
-import androidx.compose.ui.text.style.TextDecoration
-import chat.simplex.app.R
-import chat.simplex.app.ui.theme.*
-import chat.simplex.app.views.call.*
-import chat.simplex.app.views.chat.ComposeState
-import chat.simplex.app.views.helpers.*
-import chat.simplex.app.views.onboarding.OnboardingStage
-import chat.simplex.app.views.usersettings.NotificationPreviewMode
-import chat.simplex.app.views.usersettings.NotificationsMode
-import kotlinx.coroutines.*
-import kotlinx.datetime.*
-import kotlinx.datetime.TimeZone
-import kotlinx.serialization.*
-import kotlinx.serialization.descriptors.*
-import kotlinx.serialization.encoding.Decoder
-import kotlinx.serialization.encoding.Encoder
-import kotlinx.serialization.json.*
-import java.io.File
-import java.time.format.DateTimeFormatter
-import java.time.format.FormatStyle
-import java.util.*
-import kotlin.random.Random
-import kotlin.time.*
-
-/*
- * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
- * */
-@Stable
-class ChatModel(val controller: ChatController) {
- val onboardingStage = mutableStateOf(null)
- val currentUser = mutableStateOf(null)
- val users = mutableStateListOf()
- val userCreated = mutableStateOf(null)
- val chatRunning = mutableStateOf(null)
- val chatDbChanged = mutableStateOf(false)
- val chatDbEncrypted = mutableStateOf(false)
- val chatDbStatus = mutableStateOf(null)
- val chats = mutableStateListOf()
- // map of connections network statuses, key is agent connection id
- val networkStatuses = mutableStateMapOf()
-
- // current chat
- val chatId = mutableStateOf(null)
- val chatItems = mutableStateListOf()
- val groupMembers = mutableStateListOf()
-
- val terminalItems = mutableStateListOf()
- val userAddress = mutableStateOf(null)
- // Allows to temporary save servers that are being edited on multiple screens
- val userSMPServersUnsaved = mutableStateOf<(List)?>(null)
- val chatItemTTL = mutableStateOf(ChatItemTTL.None)
-
- // set when app opened from external intent
- val clearOverlays = mutableStateOf(false)
-
- // set when app is opened via contact or invitation URI
- val appOpenUrl = mutableStateOf(null)
-
- // preferences
- val notificationsMode = mutableStateOf(NotificationsMode.default)
- var notificationPreviewMode = mutableStateOf(NotificationPreviewMode.default)
- val performLA = mutableStateOf(false)
- val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
- var incognito = mutableStateOf(false)
-
- // current WebRTC call
- val callManager = CallManager(this)
- val callInvitations = mutableStateMapOf()
- val activeCallInvitation = mutableStateOf(null)
- val activeCall = mutableStateOf(null)
- val activeCallViewIsVisible = mutableStateOf(false)
- val callCommand = mutableStateOf(null)
- val showCallView = mutableStateOf(false)
- val switchingCall = mutableStateOf(false)
-
- // currently showing QR code
- val connReqInv = mutableStateOf(null as String?)
-
- var draft = mutableStateOf(null as ComposeState?)
- var draftChatId = mutableStateOf(null as String?)
-
- // working with external intents
- val sharedContent = mutableStateOf(null as SharedContent?)
-
- val filesToDelete = mutableSetOf()
- val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
-
- fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
- currentUser.value
- } else {
- users.firstOrNull { it.user.userId == userId }?.user
- }
-
- private fun getUserIndex(user: User): Int =
- users.indexOfFirst { it.user.userId == user.userId }
-
- fun updateUser(user: User) {
- val i = getUserIndex(user)
- if (i != -1) {
- users[i] = users[i].copy(user = user)
- }
- if (currentUser.value?.userId == user.userId) {
- currentUser.value = user
- }
- }
-
- fun removeUser(user: User) {
- val i = getUserIndex(user)
- if (i != -1 && users[i].user.userId != currentUser.value?.userId) {
- users.removeAt(i)
- }
- }
-
- fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
- fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
- fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
- private fun getChatIndex(id: String): Int = chats.indexOfFirst { it.id == id }
- fun addChat(chat: Chat) = chats.add(index = 0, chat)
-
- fun updateChatInfo(cInfo: ChatInfo) {
- val i = getChatIndex(cInfo.id)
- if (i >= 0) chats[i] = chats[i].copy(chatInfo = cInfo)
- }
-
- fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
-
- fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed)
-
- fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
-
- private fun updateChat(cInfo: ChatInfo, addMissing: Boolean = true) {
- if (hasChat(cInfo.id)) {
- updateChatInfo(cInfo)
- } else if (addMissing) {
- addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf()))
- }
- }
-
- fun updateChats(newChats: List) {
- chats.clear()
- chats.addAll(newChats)
-
- val cId = chatId.value
- // If chat is null, it was deleted in background after apiGetChats call
- if (cId != null && getChat(cId) == null) {
- chatId.value = null
- }
- }
-
- fun replaceChat(id: String, chat: Chat) {
- val i = getChatIndex(id)
- if (i >= 0) {
- chats[i] = chat
- } else {
- // invalid state, correcting
- chats.add(index = 0, chat)
- }
- }
-
- suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
- // update previews
- val i = getChatIndex(cInfo.id)
- val chat: Chat
- if (i >= 0) {
- chat = chats[i]
- chats[i] = chat.copy(
- chatItems = arrayListOf(cItem),
- chatStats =
- if (cItem.meta.itemStatus is CIStatus.RcvNew) {
- val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId
- increaseUnreadCounter(currentUser.value!!)
- chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, minUnreadItemId = minUnreadId)
- }
- else
- chat.chatStats
- )
- if (i > 0) {
- popChat_(i)
- }
- } else {
- addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
- }
- // add to current chat
- if (chatId.value == cInfo.id) {
- withContext(Dispatchers.Main) {
- // Prevent situation when chat item already in the list received from backend
- if (chatItems.none { it.id == cItem.id }) {
- if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
- chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
- } else {
- chatItems.add(cItem)
- }
- }
- }
- }
- }
-
- suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean {
- // update previews
- val i = getChatIndex(cInfo.id)
- val chat: Chat
- val res: Boolean
- if (i >= 0) {
- chat = chats[i]
- val pItem = chat.chatItems.lastOrNull()
- if (pItem?.id == cItem.id) {
- chats[i] = chat.copy(chatItems = arrayListOf(cItem))
- if (pItem.isRcvNew && !cItem.isRcvNew) {
- // status changed from New to Read, update counter
- decreaseCounterInChat(cInfo.id)
- }
- }
- res = false
- } else {
- addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
- res = true
- }
- // update current chat
- return if (chatId.value == cInfo.id) {
- withContext(Dispatchers.Main) {
- val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
- if (itemIndex >= 0) {
- chatItems[itemIndex] = cItem
- false
- } else {
- chatItems.add(cItem)
- true
- }
- }
- } else {
- res
- }
- }
-
- suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) {
- if (chatId.value == cInfo.id) {
- withContext(Dispatchers.Main) {
- val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
- if (itemIndex >= 0) {
- chatItems[itemIndex] = cItem
- }
- }
- }
- }
-
- fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
- if (cItem.isRcvNew) {
- decreaseCounterInChat(cInfo.id)
- }
- // update previews
- val i = getChatIndex(cInfo.id)
- val chat: Chat
- if (i >= 0) {
- chat = chats[i]
- val pItem = chat.chatItems.lastOrNull()
- if (pItem?.id == cItem.id) {
- chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
- }
- }
- // remove from current chat
- if (chatId.value == cInfo.id) {
- val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
- if (itemIndex >= 0) {
- AudioPlayer.stop(chatItems[itemIndex])
- chatItems.removeAt(itemIndex)
- }
- }
- }
-
- fun clearChat(cInfo: ChatInfo) {
- // clear preview
- val i = getChatIndex(cInfo.id)
- if (i >= 0) {
- decreaseUnreadCounter(currentUser.value!!, chats[i].chatStats.unreadCount)
- chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo)
- }
- // clear current chat
- if (chatId.value == cInfo.id) {
- chatItems.clear()
- }
- }
-
- fun updateCurrentUser(newProfile: Profile, preferences: FullChatPreferences? = null) {
- val current = currentUser.value ?: return
- val updated = current.copy(
- profile = newProfile.toLocalProfile(current.profile.profileId),
- fullPreferences = preferences ?: current.fullPreferences
- )
- val indexInUsers = users.indexOfFirst { it.user.userId == current.userId }
- if (indexInUsers != -1) {
- users[indexInUsers] = UserInfo(updated, users[indexInUsers].unreadCount)
- }
- currentUser.value = updated
- }
-
- suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
- val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
- withContext(Dispatchers.Main) {
- chatItems.add(cItem)
- }
- return cItem
- }
-
- fun removeLiveDummy() {
- if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
- chatItems.removeLast()
- }
- }
-
- fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) {
- val markedRead = markItemsReadInCurrentChat(cInfo, range)
- // update preview
- val chatIdx = getChatIndex(cInfo.id)
- if (chatIdx >= 0) {
- val chat = chats[chatIdx]
- val lastId = chat.chatItems.lastOrNull()?.id
- if (lastId != null) {
- val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0
- decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
- chats[chatIdx] = chat.copy(
- chatStats = chat.chatStats.copy(
- unreadCount = unreadCount,
- // Can't use minUnreadItemId currently since chat items can have unread items between read items
- //minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1
- )
- )
- }
- }
- }
-
- private fun markItemsReadInCurrentChat(cInfo: ChatInfo, range: CC.ItemRange? = null): Int {
- var markedRead = 0
- if (chatId.value == cInfo.id) {
- var i = 0
- while (i < chatItems.count()) {
- val item = chatItems[i]
- if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
- val newItem = item.withStatus(CIStatus.RcvRead())
- chatItems[i] = newItem
- if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
- chatItems[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
- deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
- )
- }
- markedRead++
- }
- i += 1
- }
- }
- return markedRead
- }
-
- private fun decreaseCounterInChat(chatId: ChatId) {
- val chatIndex = getChatIndex(chatId)
- if (chatIndex == -1) return
-
- val chat = chats[chatIndex]
- val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0)
- decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
- chats[chatIndex] = chat.copy(
- chatStats = chat.chatStats.copy(
- unreadCount = unreadCount,
- )
- )
- }
-
- fun increaseUnreadCounter(user: User) {
- changeUnreadCounter(user, 1)
- }
-
- fun decreaseUnreadCounter(user: User, by: Int = 1) {
- changeUnreadCounter(user, -by)
- }
-
- private fun changeUnreadCounter(user: User, by: Int) {
- val i = users.indexOfFirst { it.user.userId == user.userId }
- if (i != -1) {
- users[i] = UserInfo(user, users[i].unreadCount + by)
- }
- }
-
-// func popChat(_ id: String) {
-// if let i = getChatIndex(id) {
-// popChat_(i)
-// }
-// }
-
- private fun popChat_(i: Int) {
- val chat = chats.removeAt(i)
- chats.add(index = 0, chat)
- }
-
- fun dismissConnReqView(id: String) {
- if (connReqInv.value == null) return
- val info = getChat(id)?.chatInfo as? ChatInfo.ContactConnection ?: return
- if (info.contactConnection.connReqInv == connReqInv.value) {
- connReqInv.value = null
- ModalManager.shared.closeModals()
- }
- }
-
- fun removeChat(id: String) {
- chats.removeAll { it.id == id }
- }
-
- fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean {
- // user member was updated
- if (groupInfo.membership.groupMemberId == member.groupMemberId) {
- updateGroup(groupInfo)
- return false
- }
- // update current chat
- return if (chatId.value == groupInfo.id) {
- val memberIndex = groupMembers.indexOfFirst { it.id == member.id }
- if (memberIndex >= 0) {
- groupMembers[memberIndex] = member
- false
- } else {
- groupMembers.add(member)
- true
- }
- } else {
- false
- }
- }
-
- fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) {
- networkStatuses[contact.activeConn.agentConnId] = status
- }
-
- fun contactNetworkStatus(contact: Contact): NetworkStatus =
- networkStatuses[contact.activeConn.agentConnId] ?: NetworkStatus.Unknown()
-
- fun addTerminalItem(item: TerminalItem) {
- if (terminalItems.size >= 500) {
- terminalItems.removeAt(0)
- }
- terminalItems.add(item)
- }
-}
-
-enum class ChatType(val type: String) {
- Direct("@"),
- Group("#"),
- ContactRequest("<@"),
- ContactConnection(":");
-}
-
-@Serializable
-data class User(
- val userId: Long,
- val userContactId: Long,
- val localDisplayName: String,
- val profile: LocalProfile,
- val fullPreferences: FullChatPreferences,
- val activeUser: Boolean,
- val showNtfs: Boolean,
- val viewPwdHash: UserPwdHash?
-): NamedChat {
- override val displayName: String get() = profile.displayName
- override val fullName: String get() = profile.fullName
- override val image: String? get() = profile.image
- override val localAlias: String = ""
-
- val hidden: Boolean = viewPwdHash != null
-
- val showNotifications: Boolean = activeUser || showNtfs
-
- val addressShared: Boolean = profile.contactLink != null
-
- companion object {
- val sampleData = User(
- userId = 1,
- userContactId = 1,
- localDisplayName = "alice",
- profile = LocalProfile.sampleData,
- fullPreferences = FullChatPreferences.sampleData,
- activeUser = true,
- showNtfs = true,
- viewPwdHash = null,
- )
- }
-}
-
-@Serializable
-data class UserPwdHash(
- val hash: String,
- val salt: String
-)
-
-@Serializable
-data class UserInfo(
- val user: User,
- val unreadCount: Int
-) {
- companion object {
- val sampleData = UserInfo(
- user = User.sampleData,
- unreadCount = 1
- )
- }
-}
-
-typealias ChatId = String
-
-interface NamedChat {
- val displayName: String
- val fullName: String
- val image: String?
- val localAlias: String
- val chatViewName: String
- get() = localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
-}
-
-interface SomeChat {
- val chatType: ChatType
- val localDisplayName: String
- val id: ChatId
- val apiId: Long
- val ready: Boolean
- val sendMsgEnabled: Boolean
- val ntfsEnabled: Boolean
- val incognito: Boolean
- fun featureEnabled(feature: ChatFeature): Boolean
- val timedMessagesTTL: Int?
- val createdAt: Instant
- val updatedAt: Instant
-}
-
-@Serializable @Stable
-data class Chat (
- val chatInfo: ChatInfo,
- val chatItems: List,
- val chatStats: ChatStats = ChatStats(),
-) {
- val userCanSend: Boolean
- get() = when (chatInfo) {
- is ChatInfo.Direct -> true
- is ChatInfo.Group -> {
- val m = chatInfo.groupInfo.membership
- m.memberActive && m.memberRole >= GroupMemberRole.Member
- }
- else -> false
- }
-
- val userIsObserver: Boolean get() = when(chatInfo) {
- is ChatInfo.Group -> {
- val m = chatInfo.groupInfo.membership
- m.memberActive && m.memberRole == GroupMemberRole.Observer
- }
- else -> false
- }
-
- val id: String get() = chatInfo.id
-
- @Serializable
- data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false)
-
- companion object {
- val sampleData = Chat(
- chatInfo = ChatInfo.Direct.sampleData,
- chatItems = arrayListOf(ChatItem.getSampleData())
- )
- }
-}
-
-@Serializable
-sealed class ChatInfo: SomeChat, NamedChat {
-
- @Serializable @SerialName("direct")
- data class Direct(val contact: Contact): ChatInfo() {
- override val chatType get() = ChatType.Direct
- override val localDisplayName get() = contact.localDisplayName
- override val id get() = contact.id
- override val apiId get() = contact.apiId
- override val ready get() = contact.ready
- override val sendMsgEnabled get() = contact.sendMsgEnabled
- override val ntfsEnabled get() = contact.ntfsEnabled
- override val incognito get() = contact.incognito
- override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature)
- override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL
- override val createdAt get() = contact.createdAt
- override val updatedAt get() = contact.updatedAt
- override val displayName get() = contact.displayName
- override val fullName get() = contact.fullName
- override val image get() = contact.image
- override val localAlias: String get() = contact.localAlias
-
- companion object {
- val sampleData = Direct(Contact.sampleData)
- }
- }
-
- @Serializable @SerialName("group")
- data class Group(val groupInfo: GroupInfo): ChatInfo() {
- override val chatType get() = ChatType.Group
- override val localDisplayName get() = groupInfo.localDisplayName
- override val id get() = groupInfo.id
- override val apiId get() = groupInfo.apiId
- override val ready get() = groupInfo.ready
- override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
- override val ntfsEnabled get() = groupInfo.ntfsEnabled
- override val incognito get() = groupInfo.incognito
- override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature)
- override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL
- override val createdAt get() = groupInfo.createdAt
- override val updatedAt get() = groupInfo.updatedAt
- override val displayName get() = groupInfo.displayName
- override val fullName get() = groupInfo.fullName
- override val image get() = groupInfo.image
- override val localAlias get() = groupInfo.localAlias
-
- companion object {
- val sampleData = Group(GroupInfo.sampleData)
- }
- }
-
- @Serializable @SerialName("contactRequest")
- class ContactRequest(val contactRequest: UserContactRequest): ChatInfo() {
- override val chatType get() = ChatType.ContactRequest
- override val localDisplayName get() = contactRequest.localDisplayName
- override val id get() = contactRequest.id
- override val apiId get() = contactRequest.apiId
- override val ready get() = contactRequest.ready
- override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
- override val ntfsEnabled get() = contactRequest.ntfsEnabled
- override val incognito get() = contactRequest.incognito
- override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature)
- override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL
- override val createdAt get() = contactRequest.createdAt
- override val updatedAt get() = contactRequest.updatedAt
- override val displayName get() = contactRequest.displayName
- override val fullName get() = contactRequest.fullName
- override val image get() = contactRequest.image
- override val localAlias get() = contactRequest.localAlias
-
- companion object {
- val sampleData = ContactRequest(UserContactRequest.sampleData)
- }
- }
-
- @Serializable @SerialName("contactConnection")
- class ContactConnection(val contactConnection: PendingContactConnection): ChatInfo() {
- override val chatType get() = ChatType.ContactConnection
- override val localDisplayName get() = contactConnection.localDisplayName
- override val id get() = contactConnection.id
- override val apiId get() = contactConnection.apiId
- override val ready get() = contactConnection.ready
- override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
- override val ntfsEnabled get() = contactConnection.incognito
- override val incognito get() = contactConnection.incognito
- override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature)
- override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL
- override val createdAt get() = contactConnection.createdAt
- override val updatedAt get() = contactConnection.updatedAt
- override val displayName get() = contactConnection.displayName
- override val fullName get() = contactConnection.fullName
- override val image get() = contactConnection.image
- override val localAlias get() = contactConnection.localAlias
-
- companion object {
- fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): ContactConnection =
- ContactConnection(PendingContactConnection.getSampleData(status, viaContactUri))
- }
- }
-
- @Serializable @SerialName("invalidJSON")
- class InvalidJSON(val json: String): ChatInfo() {
- override val chatType get() = ChatType.Direct
- override val localDisplayName get() = invalidChatName
- override val id get() = ""
- override val apiId get() = 0L
- override val ready get() = false
- override val sendMsgEnabled get() = false
- override val ntfsEnabled get() = false
- override val incognito get() = false
- override fun featureEnabled(feature: ChatFeature) = false
- override val timedMessagesTTL: Int? get() = null
- override val createdAt get() = Clock.System.now()
- override val updatedAt get() = Clock.System.now()
- override val displayName get() = invalidChatName
- override val fullName get() = invalidChatName
- override val image get() = null
- override val localAlias get() = ""
-
- companion object {
- private val invalidChatName = generalGetString(R.string.invalid_chat)
- }
- }
-}
-
-@Serializable
-sealed class NetworkStatus {
- val statusString: String get() =
- when (this) {
- is Connected -> generalGetString(R.string.server_connected)
- is Error -> generalGetString(R.string.server_error)
- else -> generalGetString(R.string.server_connecting)
- }
- val statusExplanation: String get() =
- when (this) {
- is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact)
- is Error -> String.format(generalGetString(R.string.trying_to_connect_to_server_to_receive_messages_with_error), error)
- else -> generalGetString(R.string.trying_to_connect_to_server_to_receive_messages)
- }
-
- @Serializable @SerialName("unknown") class Unknown: NetworkStatus()
- @Serializable @SerialName("connected") class Connected: NetworkStatus()
- @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
- @Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
-}
-
-@Serializable
-data class Contact(
- val contactId: Long,
- override val localDisplayName: String,
- val profile: LocalProfile,
- val activeConn: Connection,
- val viaGroup: Long? = null,
- val contactUsed: Boolean,
- val chatSettings: ChatSettings,
- val userPreferences: ChatPreferences,
- val mergedPreferences: ContactUserPreferences,
- override val createdAt: Instant,
- override val updatedAt: Instant
-): SomeChat, NamedChat {
- override val chatType get() = ChatType.Direct
- override val id get() = "@$contactId"
- override val apiId get() = contactId
- override val ready get() = activeConn.connStatus == ConnStatus.Ready
- override val sendMsgEnabled get() = true
- override val ntfsEnabled get() = chatSettings.enableNtfs
- override val incognito get() = contactConnIncognito
- override fun featureEnabled(feature: ChatFeature) = when (feature) {
- ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
- ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
- ChatFeature.Reactions -> mergedPreferences.reactions.enabled.forUser
- ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
- ChatFeature.Calls -> mergedPreferences.calls.enabled.forUser
- }
- override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null }
- override val displayName get() = localAlias.ifEmpty { profile.displayName }
- override val fullName get() = profile.fullName
- override val image get() = profile.image
- val contactLink: String? = profile.contactLink
- override val localAlias get() = profile.localAlias
- val verified get() = activeConn.connectionCode != null
-
- val directOrUsed: Boolean get() =
- (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
-
- val contactConnIncognito =
- activeConn.customUserProfileId != null
-
- fun allowsFeature(feature: ChatFeature): Boolean = when (feature) {
- ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
- ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
- ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
- ChatFeature.Reactions -> mergedPreferences.reactions.contactPreference.allow != FeatureAllowed.NO
- ChatFeature.Calls -> mergedPreferences.calls.contactPreference.allow != FeatureAllowed.NO
- }
-
- fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
- ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
- ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
- ChatFeature.Reactions -> mergedPreferences.reactions.userPreference.pref.allow != FeatureAllowed.NO
- ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
- ChatFeature.Calls -> mergedPreferences.calls.userPreference.pref.allow != FeatureAllowed.NO
- }
-
- companion object {
- val sampleData = Contact(
- contactId = 1,
- localDisplayName = "alice",
- profile = LocalProfile.sampleData,
- activeConn = Connection.sampleData,
- contactUsed = true,
- chatSettings = ChatSettings(true),
- userPreferences = ChatPreferences.sampleData,
- mergedPreferences = ContactUserPreferences.sampleData,
- createdAt = Clock.System.now(),
- updatedAt = Clock.System.now()
- )
- }
-}
-
-@Serializable
-class ContactRef(
- val contactId: Long,
- val agentConnId: String,
- val connId: Long,
- var localDisplayName: String
-) {
- val id: ChatId get() = "@$contactId"
-}
-
-@Serializable
-class ContactSubStatus(
- val contact: Contact,
- val contactError: ChatError? = null
-)
-
-@Serializable
-data class Connection(
- val connId: Long,
- val agentConnId: String,
- val connStatus: ConnStatus,
- val connLevel: Int,
- val viaGroupLink: Boolean,
- val customUserProfileId: Long? = null,
- val connectionCode: SecurityCode? = null
-) {
- val id: ChatId get() = ":$connId"
- companion object {
- val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null)
- }
-}
-
-@Serializable
-data class SecurityCode(val securityCode: String, val verifiedAt: Instant)
-
-@Serializable
-data class Profile(
- override val displayName: String,
- override val fullName: String,
- override val image: String? = null,
- override val localAlias : String = "",
- val contactLink: String? = null,
- val preferences: ChatPreferences? = null
-): NamedChat {
- val profileViewName: String
- get() {
- return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
- }
-
- fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias, contactLink, preferences)
-
- companion object {
- val sampleData = Profile(
- displayName = "alice",
- fullName = "Alice"
- )
- }
-}
-
-@Serializable
-data class LocalProfile(
- val profileId: Long,
- override val displayName: String,
- override val fullName: String,
- override val image: String? = null,
- override val localAlias: String,
- val contactLink: String? = null,
- val preferences: ChatPreferences? = null
-): NamedChat {
- val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
-
- fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias, contactLink, preferences)
-
- companion object {
- val sampleData = LocalProfile(
- profileId = 1L,
- displayName = "alice",
- fullName = "Alice",
- preferences = ChatPreferences.sampleData,
- localAlias = ""
- )
- }
-}
-
-@Serializable
-class Group (
- val groupInfo: GroupInfo,
- var members: List
-)
-
-@Serializable
-data class GroupInfo (
- val groupId: Long,
- override val localDisplayName: String,
- val groupProfile: GroupProfile,
- val fullGroupPreferences: FullGroupPreferences,
- val membership: GroupMember,
- val hostConnCustomUserProfileId: Long? = null,
- val chatSettings: ChatSettings,
- override val createdAt: Instant,
- override val updatedAt: Instant
-): SomeChat, NamedChat {
- override val chatType get() = ChatType.Group
- override val id get() = "#$groupId"
- override val apiId get() = groupId
- override val ready get() = membership.memberActive
- override val sendMsgEnabled get() = membership.memberActive
- override val ntfsEnabled get() = chatSettings.enableNtfs
- override val incognito get() = membership.memberIncognito
- override fun featureEnabled(feature: ChatFeature) = when (feature) {
- ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
- ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
- ChatFeature.Reactions -> fullGroupPreferences.reactions.on
- ChatFeature.Voice -> fullGroupPreferences.voice.on
- ChatFeature.Calls -> false
- }
- override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
- override val displayName get() = groupProfile.displayName
- override val fullName get() = groupProfile.fullName
- override val image get() = groupProfile.image
- override val localAlias get() = ""
-
- val canEdit: Boolean
- get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent
-
- val canDelete: Boolean
- get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrent
-
- val canAddMembers: Boolean
- get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive
-
- companion object {
- val sampleData = GroupInfo(
- groupId = 1,
- localDisplayName = "team",
- groupProfile = GroupProfile.sampleData,
- fullGroupPreferences = FullGroupPreferences.sampleData,
- membership = GroupMember.sampleData,
- hostConnCustomUserProfileId = null,
- chatSettings = ChatSettings(true),
- createdAt = Clock.System.now(),
- updatedAt = Clock.System.now()
- )
- }
-}
-
-@Serializable
-data class GroupProfile (
- override val displayName: String,
- override val fullName: String,
- val description: String? = null,
- override val image: String? = null,
- override val localAlias: String = "",
- val groupPreferences: GroupPreferences? = null
-): NamedChat {
- companion object {
- val sampleData = GroupProfile(
- displayName = "team",
- fullName = "My Team"
- )
- }
-}
-
-@Serializable
-data class GroupMember (
- val groupMemberId: Long,
- val groupId: Long,
- val memberId: String,
- var memberRole: GroupMemberRole,
- var memberCategory: GroupMemberCategory,
- var memberStatus: GroupMemberStatus,
- var invitedBy: InvitedBy,
- val localDisplayName: String,
- val memberProfile: LocalProfile,
- val memberContactId: Long? = null,
- val memberContactProfileId: Long,
- var activeConn: Connection? = null
-) {
- val id: String get() = "#$groupId @$groupMemberId"
- val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName }
- val fullName: String get() = memberProfile.fullName
- val image: String? get() = memberProfile.image
- val contactLink: String? = memberProfile.contactLink
- val verified get() = activeConn?.connectionCode != null
-
- val chatViewName: String
- get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
-
- val memberActive: Boolean get() = when (this.memberStatus) {
- GroupMemberStatus.MemRemoved -> false
- GroupMemberStatus.MemLeft -> false
- GroupMemberStatus.MemGroupDeleted -> false
- GroupMemberStatus.MemInvited -> false
- GroupMemberStatus.MemIntroduced -> false
- GroupMemberStatus.MemIntroInvited -> false
- GroupMemberStatus.MemAccepted -> false
- GroupMemberStatus.MemAnnounced -> false
- GroupMemberStatus.MemConnected -> true
- GroupMemberStatus.MemComplete -> true
- GroupMemberStatus.MemCreator -> true
- }
-
- val memberCurrent: Boolean get() = when (this.memberStatus) {
- GroupMemberStatus.MemRemoved -> false
- GroupMemberStatus.MemLeft -> false
- GroupMemberStatus.MemGroupDeleted -> false
- GroupMemberStatus.MemInvited -> false
- GroupMemberStatus.MemIntroduced -> true
- GroupMemberStatus.MemIntroInvited -> true
- GroupMemberStatus.MemAccepted -> true
- GroupMemberStatus.MemAnnounced -> true
- GroupMemberStatus.MemConnected -> true
- GroupMemberStatus.MemComplete -> true
- GroupMemberStatus.MemCreator -> true
- }
-
- fun canBeRemoved(groupInfo: GroupInfo): Boolean {
- val userRole = groupInfo.membership.memberRole
- return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft
- && userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberCurrent
- }
-
- fun canChangeRoleTo(groupInfo: GroupInfo): List? =
- if (!canBeRemoved(groupInfo)) null
- else groupInfo.membership.memberRole.let { userRole ->
- GroupMemberRole.values().filter { it <= userRole }
- }
-
- val memberIncognito = memberProfile.profileId != memberContactProfileId
-
- companion object {
- val sampleData = GroupMember(
- groupMemberId = 1,
- groupId = 1,
- memberId = "abcd",
- memberRole = GroupMemberRole.Member,
- memberCategory = GroupMemberCategory.InviteeMember,
- memberStatus = GroupMemberStatus.MemComplete,
- invitedBy = InvitedBy.IBUser(),
- localDisplayName = "alice",
- memberProfile = LocalProfile.sampleData,
- memberContactId = 1,
- memberContactProfileId = 1L,
- activeConn = Connection.sampleData
- )
- }
-}
-
-@Serializable
-class GroupMemberRef(
- val groupMemberId: Long,
- val profile: Profile
-)
-
-@Serializable
-enum class GroupMemberRole(val memberRole: String) {
- @SerialName("observer") Observer("observer"), // order matters in comparisons
- @SerialName("member") Member("member"),
- @SerialName("admin") Admin("admin"),
- @SerialName("owner") Owner("owner");
-
- val text: String get() = when (this) {
- Observer -> generalGetString(R.string.group_member_role_observer)
- Member -> generalGetString(R.string.group_member_role_member)
- Admin -> generalGetString(R.string.group_member_role_admin)
- Owner -> generalGetString(R.string.group_member_role_owner)
- }
-}
-
-@Serializable
-enum class GroupMemberCategory {
- @SerialName("user") UserMember,
- @SerialName("invitee") InviteeMember,
- @SerialName("host") HostMember,
- @SerialName("pre") PreMember,
- @SerialName("post") PostMember;
-}
-
-@Serializable
-enum class GroupMemberStatus {
- @SerialName("removed") MemRemoved,
- @SerialName("left") MemLeft,
- @SerialName("deleted") MemGroupDeleted,
- @SerialName("invited") MemInvited,
- @SerialName("introduced") MemIntroduced,
- @SerialName("intro-inv") MemIntroInvited,
- @SerialName("accepted") MemAccepted,
- @SerialName("announced") MemAnnounced,
- @SerialName("connected") MemConnected,
- @SerialName("complete") MemComplete,
- @SerialName("creator") MemCreator;
-
- val text: String get() = when (this) {
- MemRemoved -> generalGetString(R.string.group_member_status_removed)
- MemLeft -> generalGetString(R.string.group_member_status_left)
- MemGroupDeleted -> generalGetString(R.string.group_member_status_group_deleted)
- MemInvited -> generalGetString(R.string.group_member_status_invited)
- MemIntroduced -> generalGetString(R.string.group_member_status_introduced)
- MemIntroInvited -> generalGetString(R.string.group_member_status_intro_invitation)
- MemAccepted -> generalGetString(R.string.group_member_status_accepted)
- MemAnnounced -> generalGetString(R.string.group_member_status_announced)
- MemConnected -> generalGetString(R.string.group_member_status_connected)
- MemComplete -> generalGetString(R.string.group_member_status_complete)
- MemCreator -> generalGetString(R.string.group_member_status_creator)
- }
-
- val shortText: String get() = when (this) {
- MemRemoved -> generalGetString(R.string.group_member_status_removed)
- MemLeft -> generalGetString(R.string.group_member_status_left)
- MemGroupDeleted -> generalGetString(R.string.group_member_status_group_deleted)
- MemInvited -> generalGetString(R.string.group_member_status_invited)
- MemIntroduced -> generalGetString(R.string.group_member_status_connecting)
- MemIntroInvited -> generalGetString(R.string.group_member_status_connecting)
- MemAccepted -> generalGetString(R.string.group_member_status_connecting)
- MemAnnounced -> generalGetString(R.string.group_member_status_connecting)
- MemConnected -> generalGetString(R.string.group_member_status_connected)
- MemComplete -> generalGetString(R.string.group_member_status_complete)
- MemCreator -> generalGetString(R.string.group_member_status_creator)
- }
-}
-
-@Serializable
-sealed class InvitedBy {
- @Serializable @SerialName("contact") class IBContact(val byContactId: Long): InvitedBy()
- @Serializable @SerialName("user") class IBUser: InvitedBy()
- @Serializable @SerialName("unknown") class IBUnknown: InvitedBy()
-}
-
-@Serializable
-class LinkPreview (
- val uri: String,
- val title: String,
- val description: String,
- val image: String
-) {
- companion object {
- val sampleData = LinkPreview(
- uri = "https://www.duckduckgo.com",
- title = "Privacy, simplified.",
- description = "The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.",
- image = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"
- )
- }
-}
-
-@Serializable
-class MemberSubError (
- val member: GroupMember,
- val memberError: ChatError
-)
-
-@Serializable
-class UserContactRequest (
- val contactRequestId: Long,
- override val localDisplayName: String,
- val profile: Profile,
- override val createdAt: Instant,
- override val updatedAt: Instant
-): SomeChat, NamedChat {
- override val chatType get() = ChatType.ContactRequest
- override val id get() = "<@$contactRequestId"
- override val apiId get() = contactRequestId
- override val ready get() = true
- override val sendMsgEnabled get() = false
- override val ntfsEnabled get() = false
- override val incognito get() = false
- override fun featureEnabled(feature: ChatFeature) = false
- override val timedMessagesTTL: Int? get() = null
- override val displayName get() = profile.displayName
- override val fullName get() = profile.fullName
- override val image get() = profile.image
- override val localAlias get() = ""
-
- companion object {
- val sampleData = UserContactRequest(
- contactRequestId = 1,
- localDisplayName = "alice",
- profile = Profile.sampleData,
- createdAt = Clock.System.now(),
- updatedAt = Clock.System.now()
- )
- }
-}
-
-@Serializable
-class PendingContactConnection(
- val pccConnId: Long,
- val pccAgentConnId: String,
- val pccConnStatus: ConnStatus,
- val viaContactUri: Boolean,
- val groupLinkId: String? = null,
- val customUserProfileId: Long? = null,
- val connReqInv: String? = null,
- override val localAlias: String,
- override val createdAt: Instant,
- override val updatedAt: Instant
-): SomeChat, NamedChat {
- override val chatType get() = ChatType.ContactConnection
- override val id get () = ":$pccConnId"
- override val apiId get() = pccConnId
- override val ready get() = false
- override val sendMsgEnabled get() = false
- override val ntfsEnabled get() = false
- override val incognito get() = customUserProfileId != null
- override fun featureEnabled(feature: ChatFeature) = false
- override val timedMessagesTTL: Int? get() = null
- override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
- override val displayName: String get() {
- if (localAlias.isNotEmpty()) return localAlias
- val initiated = pccConnStatus.initiated
- return if (initiated == null) {
- // this should not be in the chat list
- generalGetString(R.string.display_name_connection_established)
- } else {
- generalGetString(
- if (initiated && !viaContactUri) R.string.display_name_invited_to_connect
- else R.string.display_name_connecting
- )
- }
- }
- override val fullName get() = ""
- override val image get() = null
-
- val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
-
- val description: String get() {
- val initiated = pccConnStatus.initiated
- return if (initiated == null) "" else generalGetString(
- if (initiated && !viaContactUri)
- if (incognito) R.string.description_you_shared_one_time_link_incognito else R.string.description_you_shared_one_time_link
- else if (viaContactUri)
- if (groupLinkId != null)
- if (incognito) R.string.description_via_group_link_incognito else R.string.description_via_group_link
- else
- if (incognito) R.string.description_via_contact_address_link_incognito else R.string.description_via_contact_address_link
- else
- if (incognito) R.string.description_via_one_time_link_incognito else R.string.description_via_one_time_link
- )
- }
-
- companion object {
- fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): PendingContactConnection =
- PendingContactConnection(
- pccConnId = 1,
- pccAgentConnId = "abcd",
- pccConnStatus = status,
- viaContactUri = viaContactUri,
- localAlias = "",
- customUserProfileId = null,
- createdAt = Clock.System.now(),
- updatedAt = Clock.System.now()
- )
- }
-}
-
-@Serializable
-enum class ConnStatus {
- @SerialName("new") New,
- @SerialName("joined") Joined,
- @SerialName("requested") Requested,
- @SerialName("accepted") Accepted,
- @SerialName("snd-ready") SndReady,
- @SerialName("ready") Ready,
- @SerialName("deleted") Deleted;
-
- val initiated: Boolean? get() = when (this) {
- New -> true
- Joined -> false
- Requested -> true
- Accepted -> true
- SndReady -> false
- Ready -> null
- Deleted -> null
- }
-}
-
-@Serializable
-class AChatItem (
- val chatInfo: ChatInfo,
- val chatItem: ChatItem
-)
-
-@Serializable
-class ACIReaction(
- val chatInfo: ChatInfo,
- val chatReaction: CIReaction
-)
-
-@Serializable
-class CIReaction(
- val chatDir: CIDirection,
- val chatItem: ChatItem,
- val sentAt: Instant,
- val reaction: MsgReaction
-)
-
-@Serializable @Stable
-data class ChatItem (
- val chatDir: CIDirection,
- val meta: CIMeta,
- val content: CIContent,
- val formattedText: List? = null,
- val quotedItem: CIQuote? = null,
- val reactions: List,
- val file: CIFile? = null
-) {
- val id: Long get() = meta.itemId
- val timestampText: String get() = meta.timestampText
-
- val text: String get() {
- val mc = content.msgContent
- return when {
- content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(R.string.voice_message_with_duration), durationText(mc.duration))
- content.text == "" && file != null -> file.fileName
- else -> content.text
- }
- }
-
- val isRcvNew: Boolean get() = meta.isRcvNew
-
- val allowAddReaction: Boolean get() =
- meta.itemDeleted == null && !isLiveDummy && (reactions.count { it.userReacted } < 3)
-
- private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID
-
- val memberDisplayName: String? get() =
- if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
- else null
-
- val isDeletedContent: Boolean get() =
- when (content) {
- is CIContent.SndDeleted -> true
- is CIContent.RcvDeleted -> true
- is CIContent.SndModerated -> true
- is CIContent.RcvModerated -> true
- else -> false
- }
-
- fun memberToModerate(chatInfo: ChatInfo): Pair? {
- return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
- val m = chatInfo.groupInfo.membership
- if (m.memberRole >= GroupMemberRole.Admin && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) {
- chatInfo.groupInfo to chatDir.groupMember
- } else {
- null
- }
- } else {
- null
- }
- }
-
- private val showNtfDir: Boolean get() = !chatDir.sent
-
- val showNotification: Boolean get() =
- when (content) {
- is CIContent.SndMsgContent -> showNtfDir
- is CIContent.RcvMsgContent -> showNtfDir
- is CIContent.SndDeleted -> showNtfDir
- is CIContent.RcvDeleted -> showNtfDir
- is CIContent.SndCall -> showNtfDir
- is CIContent.RcvCall -> false // notification is shown on CallInvitation instead
- is CIContent.RcvIntegrityError -> showNtfDir
- is CIContent.RcvDecryptionError -> showNtfDir
- is CIContent.RcvGroupInvitation -> showNtfDir
- is CIContent.SndGroupInvitation -> showNtfDir
- is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
- is RcvGroupEvent.MemberAdded -> false
- is RcvGroupEvent.MemberConnected -> false
- is RcvGroupEvent.MemberLeft -> false
- is RcvGroupEvent.MemberRole -> false
- is RcvGroupEvent.UserRole -> showNtfDir
- is RcvGroupEvent.MemberDeleted -> false
- is RcvGroupEvent.UserDeleted -> showNtfDir
- is RcvGroupEvent.GroupDeleted -> showNtfDir
- is RcvGroupEvent.GroupUpdated -> false
- is RcvGroupEvent.InvitedViaGroupLink -> false
- }
- is CIContent.SndGroupEventContent -> showNtfDir
- is CIContent.RcvConnEventContent -> false
- is CIContent.SndConnEventContent -> showNtfDir
- is CIContent.RcvChatFeature -> false
- is CIContent.SndChatFeature -> showNtfDir
- is CIContent.RcvChatPreference -> false
- is CIContent.SndChatPreference -> showNtfDir
- is CIContent.RcvGroupFeature -> false
- is CIContent.SndGroupFeature -> showNtfDir
- is CIContent.RcvChatFeatureRejected -> showNtfDir
- is CIContent.RcvGroupFeatureRejected -> showNtfDir
- is CIContent.SndModerated -> true
- is CIContent.RcvModerated -> true
- is CIContent.InvalidJSON -> false
- }
-
- fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))
-
- companion object {
- fun getSampleData(
- id: Long = 1,
- dir: CIDirection = CIDirection.DirectSnd(),
- ts: Instant = Clock.System.now(),
- text: String = "hello\nthere",
- status: CIStatus = CIStatus.SndNew(),
- quotedItem: CIQuote? = null,
- file: CIFile? = null,
- itemDeleted: CIDeleted? = null,
- itemEdited: Boolean = false,
- itemTimed: CITimed? = null,
- editable: Boolean = true
- ) =
- ChatItem(
- chatDir = dir,
- meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, itemTimed, editable),
- content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
- quotedItem = quotedItem,
- reactions = listOf(),
- file = file
- )
-
- fun getFileMsgContentSample(
- id: Long = 1,
- text: String = "",
- fileName: String = "test.txt",
- fileSize: Long = 100,
- fileStatus: CIFileStatus = CIFileStatus.RcvComplete
- ) =
- ChatItem(
- chatDir = CIDirection.DirectRcv(),
- meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead()),
- content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile(text)),
- quotedItem = null,
- reactions = listOf(),
- file = CIFile.getSample(fileName = fileName, fileSize = fileSize, fileStatus = fileStatus)
- )
-
- fun getDeletedContentSampleData(
- id: Long = 1,
- dir: CIDirection = CIDirection.DirectRcv(),
- ts: Instant = Clock.System.now(),
- text: String = "this item is deleted", // sample not localized
- status: CIStatus = CIStatus.RcvRead()
- ) =
- ChatItem(
- chatDir = dir,
- meta = CIMeta.getSample(id, ts, text, status),
- content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
- quotedItem = null,
- reactions = listOf(),
- file = null
- )
-
- fun getGroupInvitationSample(status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending) =
- ChatItem(
- chatDir = CIDirection.DirectRcv(),
- meta = CIMeta.getSample(1, Clock.System.now(), "received invitation to join group team as admin", CIStatus.RcvRead()),
- content = CIContent.RcvGroupInvitation(groupInvitation = CIGroupInvitation.getSample(status = status), memberRole = GroupMemberRole.Admin),
- quotedItem = null,
- reactions = listOf(),
- file = null
- )
-
- fun getGroupEventSample() =
- ChatItem(
- chatDir = CIDirection.DirectRcv(),
- meta = CIMeta.getSample(1, Clock.System.now(), "group event text", CIStatus.RcvRead()),
- content = CIContent.RcvGroupEventContent(rcvGroupEvent = RcvGroupEvent.MemberAdded(groupMemberId = 1, profile = Profile.sampleData)),
- quotedItem = null,
- reactions = listOf(),
- file = null
- )
-
- fun getChatFeatureSample(feature: ChatFeature, enabled: FeatureEnabled): ChatItem {
- val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled, param = null)
- return ChatItem(
- chatDir = CIDirection.DirectRcv(),
- meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead()),
- content = content,
- quotedItem = null,
- reactions = listOf(),
- file = null
- )
- }
-
- private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
- const val TEMP_LIVE_CHAT_ITEM_ID = -2L
-
- val deletedItemDummy: ChatItem
- get() = ChatItem(
- chatDir = CIDirection.DirectRcv(),
- meta = CIMeta(
- itemId = TEMP_DELETED_CHAT_ITEM_ID,
- itemTs = Clock.System.now(),
- itemText = generalGetString(R.string.deleted_description),
- itemStatus = CIStatus.RcvRead(),
- createdAt = Clock.System.now(),
- updatedAt = Clock.System.now(),
- itemDeleted = null,
- itemEdited = false,
- itemTimed = null,
- itemLive = false,
- editable = false
- ),
- content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
- quotedItem = null,
- reactions = listOf(),
- file = null
- )
-
- fun liveDummy(direct: Boolean): ChatItem = ChatItem(
- chatDir = if (direct) CIDirection.DirectSnd() else CIDirection.GroupSnd(),
- meta = CIMeta(
- itemId = TEMP_LIVE_CHAT_ITEM_ID,
- itemTs = Clock.System.now(),
- itemText = "",
- itemStatus = CIStatus.RcvRead(),
- createdAt = Clock.System.now(),
- updatedAt = Clock.System.now(),
- itemDeleted = null,
- itemEdited = false,
- itemTimed = null,
- itemLive = true,
- editable = false
- ),
- content = CIContent.SndMsgContent(MsgContent.MCText("")),
- quotedItem = null,
- reactions = listOf(),
- file = null
- )
-
- fun invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String): ChatItem =
- ChatItem(
- chatDir = chatDir ?: CIDirection.DirectSnd(),
- meta = meta ?: CIMeta.invalidJSON(),
- content = CIContent.InvalidJSON(json),
- quotedItem = null,
- reactions = listOf(),
- file = null
- )
- }
-}
-
-@Serializable
-sealed class CIDirection {
- @Serializable @SerialName("directSnd") class DirectSnd: CIDirection()
- @Serializable @SerialName("directRcv") class DirectRcv: CIDirection()
- @Serializable @SerialName("groupSnd") class GroupSnd: CIDirection()
- @Serializable @SerialName("groupRcv") class GroupRcv(val groupMember: GroupMember): CIDirection()
-
- val sent: Boolean get() = when(this) {
- is DirectSnd -> true
- is DirectRcv -> false
- is GroupSnd -> true
- is GroupRcv -> false
- }
-}
-
-@Serializable
-data class CIMeta (
- val itemId: Long,
- val itemTs: Instant,
- val itemText: String,
- val itemStatus: CIStatus,
- val createdAt: Instant,
- val updatedAt: Instant,
- val itemDeleted: CIDeleted?,
- val itemEdited: Boolean,
- val itemTimed: CITimed?,
- val itemLive: Boolean?,
- val editable: Boolean
-) {
- val timestampText: String get() = getTimestampText(itemTs)
- val recent: Boolean get() = updatedAt + 10.toDuration(DurationUnit.SECONDS) > Clock.System.now()
- val isLive: Boolean get() = itemLive == true
- val disappearing: Boolean get() = !isRcvNew && itemTimed?.deleteAt != null
-
- val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew
-
- fun statusIcon(primaryColor: Color, metaColor: Color = CurrentColors.value.colors.secondary): Pair? =
- when (itemStatus) {
- is CIStatus.SndSent -> R.drawable.ic_check_filled to metaColor
- is CIStatus.SndErrorAuth -> R.drawable.ic_close to Color.Red
- is CIStatus.SndError -> R.drawable.ic_warning_filled to WarningYellow
- is CIStatus.RcvNew -> R.drawable.ic_circle_filled to primaryColor
- else -> null
- }
-
- companion object {
- fun getSample(
- id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
- itemDeleted: CIDeleted? = null, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, editable: Boolean = true
- ): CIMeta =
- CIMeta(
- itemId = id,
- itemTs = ts,
- itemText = text,
- itemStatus = status,
- createdAt = ts,
- updatedAt = ts,
- itemDeleted = itemDeleted,
- itemEdited = itemEdited,
- itemTimed = itemTimed,
- itemLive = itemLive,
- editable = editable
- )
-
- fun invalidJSON(): CIMeta =
- CIMeta(
- // itemId can not be the same for different items, otherwise ChatView will crash
- itemId = Random.nextLong(-1000000L, -1000L),
- itemTs = Clock.System.now(),
- itemText = "invalid JSON",
- itemStatus = CIStatus.SndNew(),
- createdAt = Clock.System.now(),
- updatedAt = Clock.System.now(),
- itemDeleted = null,
- itemEdited = false,
- itemTimed = null,
- itemLive = false,
- editable = false
- )
- }
-}
-
-@Serializable
-data class CITimed(
- val ttl: Int,
- val deleteAt: Instant?
-)
-
-fun getTimestampText(t: Instant): String {
- val tz = TimeZone.currentSystemDefault()
- val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
- val time: LocalDateTime = t.toLocalDateTime(tz)
- val recent = now.date == time.date ||
- (now.date.minus(time.date).days == 1 && now.hour < 12 && time.hour >= 18 )
- val dateFormatter =
- if (recent) {
- DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
- } else {
- DateTimeFormatter.ofPattern(
- when (Locale.getDefault().country) {
- "US" -> "M/dd"
- "DE" -> "dd.MM"
- "RU" -> "dd.MM"
- else -> "dd/MM"
- }
- )
-// DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
- }
- return time.toJavaLocalDateTime().format(dateFormatter)
-}
-
-fun localTimestamp(t: Instant): String {
- val tz = TimeZone.currentSystemDefault()
- val ts: LocalDateTime = t.toLocalDateTime(tz)
- val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
- return ts.toJavaLocalDateTime().format(dateFormatter)
-}
-
-@Serializable
-sealed class CIStatus {
- @Serializable @SerialName("sndNew") class SndNew: CIStatus()
- @Serializable @SerialName("sndSent") class SndSent: CIStatus()
- @Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
- @Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus()
- @Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
- @Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
-}
-
-@Serializable
-sealed class CIDeleted {
- @Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted()
- @Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted()
-}
-
-@Serializable
-enum class CIDeleteMode(val deleteMode: String) {
- @SerialName("internal") cidmInternal("internal"),
- @SerialName("broadcast") cidmBroadcast("broadcast");
-}
-
-interface ItemContent {
- val text: String
-}
-
-@Serializable
-sealed class CIContent: ItemContent {
- abstract val msgContent: MsgContent?
-
- @Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent()
- @Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent()
- @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvChatPreference") class RcvChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("sndChatPreference") class SndChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("sndModerated") object SndModerated: CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("rcvModerated") object RcvModerated: CIContent() { override val msgContent: MsgContent? get() = null }
- @Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null }
-
- override val text: String get() = when (this) {
- is SndMsgContent -> msgContent.text
- is RcvMsgContent -> msgContent.text
- is SndDeleted -> generalGetString(R.string.deleted_description)
- is RcvDeleted -> generalGetString(R.string.deleted_description)
- is SndCall -> status.text(duration)
- is RcvCall -> status.text(duration)
- is RcvIntegrityError -> msgError.text
- is RcvDecryptionError -> msgDecryptError.text
- is RcvGroupInvitation -> groupInvitation.text
- is SndGroupInvitation -> groupInvitation.text
- is RcvGroupEventContent -> rcvGroupEvent.text
- is SndGroupEventContent -> sndGroupEvent.text
- is RcvConnEventContent -> rcvConnEvent.text
- is SndConnEventContent -> sndConnEvent.text
- is RcvChatFeature -> featureText(feature, enabled.text, param)
- is SndChatFeature -> featureText(feature, enabled.text, param)
- is RcvChatPreference -> preferenceText(feature, allowed, param)
- is SndChatPreference -> preferenceText(feature, allowed, param)
- is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param)
- is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param)
- is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
- is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
- is SndModerated -> generalGetString(R.string.moderated_description)
- is RcvModerated -> generalGetString(R.string.moderated_description)
- is InvalidJSON -> "invalid data"
- }
-
- companion object {
- fun featureText(feature: Feature, enabled: String, param: Int?): String =
- if (feature.hasParam) {
- "${feature.text}: ${timeText(param)}"
- } else {
- "${feature.text}: $enabled"
- }
-
- fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when {
- allowed != FeatureAllowed.NO && feature.hasParam && param != null ->
- String.format(generalGetString(R.string.feature_offered_item_with_param), feature.text, timeText(param))
- allowed != FeatureAllowed.NO ->
- String.format(generalGetString(R.string.feature_offered_item), feature.text, timeText(param))
- else ->
- String.format(generalGetString(R.string.feature_cancelled_item), feature.text, timeText(param))
- }
- }
-}
-
-@Serializable
-enum class MsgDecryptError {
- @SerialName("ratchetHeader") RatchetHeader,
- @SerialName("tooManySkipped") TooManySkipped;
-
- val text: String get() = when (this) {
- RatchetHeader -> generalGetString(R.string.decryption_error)
- TooManySkipped -> generalGetString(R.string.decryption_error)
- }
-}
-
-@Serializable
-class CIQuote (
- val chatDir: CIDirection? = null,
- val itemId: Long? = null,
- val sharedMsgId: String? = null,
- val sentAt: Instant,
- val content: MsgContent,
- val formattedText: List? = null
-): ItemContent {
- override val text: String by lazy {
- if (content.text == "" && content is MsgContent.MCVoice)
- durationText(content.duration)
- else
- content.text
- }
-
-
- fun sender(membership: GroupMember?): String? = when (chatDir) {
- is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun)
- is CIDirection.DirectRcv -> null
- is CIDirection.GroupSnd -> membership?.displayName
- is CIDirection.GroupRcv -> chatDir.groupMember.displayName
- null -> null
- }
-
- companion object {
- fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote =
- CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text))
- }
-}
-
-@Serializable
-class CIReactionCount(val reaction: MsgReaction, val userReacted: Boolean, val totalReacted: Int)
-
-@Serializable(with = MsgReactionSerializer::class)
-sealed class MsgReaction {
- @Serializable(with = MsgReactionSerializer::class) class Emoji(val emoji: MREmojiChar): MsgReaction()
- @Serializable(with = MsgReactionSerializer::class) class Unknown(val type: String? = null, val json: JsonElement): MsgReaction()
-
- val text: String get() = when (this) {
- is Emoji -> when (emoji) {
- MREmojiChar.Heart -> "❤️"
- else -> emoji.value
- }
- is Unknown -> ""
- }
-
- companion object {
- val values: List get() = MREmojiChar.values().map(::Emoji)
- }
-}
-
-object MsgReactionSerializer : KSerializer {
- override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgReaction", PolymorphicKind.SEALED) {
- element("Emoji", buildClassSerialDescriptor("Emoji") {
- element("emoji")
- })
- element("Unknown", buildClassSerialDescriptor("Unknown"))
- }
-
- override fun deserialize(decoder: Decoder): MsgReaction {
- require(decoder is JsonDecoder)
- val json = decoder.decodeJsonElement()
- return if (json is JsonObject && "type" in json) {
- when(val t = json["type"]?.jsonPrimitive?.content ?: "") {
- "emoji" -> {
- val emoji = Json.decodeFromString(json["emoji"].toString())
- if (emoji == null) MsgReaction.Unknown(t, json) else MsgReaction.Emoji(emoji)
- }
- else -> MsgReaction.Unknown(t, json)
- }
- } else {
- MsgReaction.Unknown("", json)
- }
- }
-
- override fun serialize(encoder: Encoder, value: MsgReaction) {
- require(encoder is JsonEncoder)
- val json = when (value) {
- is MsgReaction.Emoji ->
- buildJsonObject {
- put("type", "emoji")
- put("emoji", json.encodeToJsonElement(value.emoji))
- }
- is MsgReaction.Unknown -> value.json
- }
- encoder.encodeJsonElement(json)
- }
-}
-
-@Serializable
-enum class MREmojiChar(val value: String) {
- @SerialName("👍") ThumbsUp("👍"),
- @SerialName("👎") ThumbsDown("👎"),
- @SerialName("😀") Smile("😀"),
- @SerialName("😢") Sad("😢"),
- @SerialName("❤") Heart("❤"),
- @SerialName("🚀") Launch("🚀");
-}
-
-@Serializable
-class CIFile(
- val fileId: Long,
- val fileName: String,
- val fileSize: Long,
- val filePath: String? = null,
- val fileStatus: CIFileStatus,
- val fileProtocol: FileProtocol
-) {
- val loaded: Boolean = when (fileStatus) {
- is CIFileStatus.SndStored -> true
- is CIFileStatus.SndTransfer -> true
- is CIFileStatus.SndComplete -> true
- is CIFileStatus.SndCancelled -> true
- is CIFileStatus.SndError -> true
- is CIFileStatus.RcvInvitation -> false
- is CIFileStatus.RcvAccepted -> false
- is CIFileStatus.RcvTransfer -> false
- is CIFileStatus.RcvCancelled -> false
- is CIFileStatus.RcvComplete -> true
- is CIFileStatus.RcvError -> false
- }
-
- val cancelAction: CancelAction? = when (fileStatus) {
- is CIFileStatus.SndStored -> sndCancelAction
- is CIFileStatus.SndTransfer -> sndCancelAction
- is CIFileStatus.SndComplete ->
- if (fileProtocol == FileProtocol.XFTP) {
- revokeCancelAction
- } else {
- null
- }
- is CIFileStatus.SndCancelled -> null
- is CIFileStatus.SndError -> null
- is CIFileStatus.RcvInvitation -> null
- is CIFileStatus.RcvAccepted -> rcvCancelAction
- is CIFileStatus.RcvTransfer -> rcvCancelAction
- is CIFileStatus.RcvCancelled -> null
- is CIFileStatus.RcvComplete -> null
- is CIFileStatus.RcvError -> null
- }
-
- companion object {
- fun getSample(
- fileId: Long = 1,
- fileName: String = "test.txt",
- fileSize: Long = 100,
- filePath: String? = "test.txt",
- fileStatus: CIFileStatus = CIFileStatus.RcvComplete
- ): CIFile =
- CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
- }
-}
-
-@Serializable
-class CancelAction(
- val uiActionId: Int,
- val alert: AlertInfo
-)
-
-@Serializable
-class AlertInfo(
- val titleId: Int,
- val messageId: Int,
- val confirmId: Int
-)
-
-private val sndCancelAction: CancelAction = CancelAction(
- uiActionId = R.string.stop_file__action,
- alert = AlertInfo(
- titleId = R.string.stop_snd_file__title,
- messageId = R.string.stop_snd_file__message,
- confirmId = R.string.stop_file__confirm
- )
-)
-private val revokeCancelAction: CancelAction = CancelAction(
- uiActionId = R.string.revoke_file__action,
- alert = AlertInfo(
- titleId = R.string.revoke_file__title,
- messageId = R.string.revoke_file__message,
- confirmId = R.string.revoke_file__confirm
- )
-)
-private val rcvCancelAction: CancelAction = CancelAction(
- uiActionId = R.string.stop_file__action,
- alert = AlertInfo(
- titleId = R.string.stop_rcv_file__title,
- messageId = R.string.stop_rcv_file__message,
- confirmId = R.string.stop_file__confirm
- )
-)
-
-@Serializable
-enum class FileProtocol {
- @SerialName("smp") SMP,
- @SerialName("xftp") XFTP;
-}
-
-@Serializable
-sealed class CIFileStatus {
- @Serializable @SerialName("sndStored") object SndStored: CIFileStatus()
- @Serializable @SerialName("sndTransfer") class SndTransfer(val sndProgress: Long, val sndTotal: Long): CIFileStatus()
- @Serializable @SerialName("sndComplete") object SndComplete: CIFileStatus()
- @Serializable @SerialName("sndCancelled") object SndCancelled: CIFileStatus()
- @Serializable @SerialName("sndError") object SndError: CIFileStatus()
- @Serializable @SerialName("rcvInvitation") object RcvInvitation: CIFileStatus()
- @Serializable @SerialName("rcvAccepted") object RcvAccepted: CIFileStatus()
- @Serializable @SerialName("rcvTransfer") class RcvTransfer(val rcvProgress: Long, val rcvTotal: Long): CIFileStatus()
- @Serializable @SerialName("rcvComplete") object RcvComplete: CIFileStatus()
- @Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus()
- @Serializable @SerialName("rcvError") object RcvError: CIFileStatus()
-}
-
-@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
-@Serializable(with = MsgContentSerializer::class)
-sealed class MsgContent {
- abstract val text: String
-
- @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
- @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
- @Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
- @Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent()
- @Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
- @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
- @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
-
- val cmdString: String get() =
- if (this is MCUnknown) "json $json" else "json ${json.encodeToString(this)}"
-}
-
-@Serializable
-class CIGroupInvitation (
- val groupId: Long,
- val groupMemberId: Long,
- val localDisplayName: String,
- val groupProfile: GroupProfile,
- val status: CIGroupInvitationStatus,
- ) {
- val text: String get() = String.format(
- generalGetString(R.string.group_invitation_item_description),
- groupProfile.displayName)
-
- companion object {
- fun getSample(
- groupId: Long = 1,
- groupMemberId: Long = 1,
- localDisplayName: String = "team",
- groupProfile: GroupProfile = GroupProfile.sampleData,
- status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending
- ): CIGroupInvitation =
- CIGroupInvitation(groupId = groupId, groupMemberId = groupMemberId, localDisplayName = localDisplayName, groupProfile = groupProfile, status = status)
- }
-}
-
-@Serializable
-enum class CIGroupInvitationStatus {
- @SerialName("pending") Pending,
- @SerialName("accepted") Accepted,
- @SerialName("rejected") Rejected,
- @SerialName("expired") Expired;
-}
-
-object MsgContentSerializer : KSerializer {
- override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) {
- element("MCText", buildClassSerialDescriptor("MCText") {
- element("text")
- })
- element("MCLink", buildClassSerialDescriptor("MCLink") {
- element("text")
- element("preview")
- })
- element("MCImage", buildClassSerialDescriptor("MCImage") {
- element("text")
- element("image")
- })
- element("MCVideo", buildClassSerialDescriptor("MCVideo") {
- element("text")
- element("image")
- element("duration")
- })
- element("MCFile", buildClassSerialDescriptor("MCFile") {
- element("text")
- })
- element("MCUnknown", buildClassSerialDescriptor("MCUnknown"))
- }
-
- override fun deserialize(decoder: Decoder): MsgContent {
- require(decoder is JsonDecoder)
- val json = decoder.decodeJsonElement()
- return if (json is JsonObject) {
- if ("type" in json) {
- val t = json["type"]?.jsonPrimitive?.content ?: ""
- val text = json["text"]?.jsonPrimitive?.content ?: generalGetString(R.string.unknown_message_format)
- when (t) {
- "text" -> MsgContent.MCText(text)
- "link" -> {
- val preview = Json.decodeFromString(json["preview"].toString())
- MsgContent.MCLink(text, preview)
- }
- "image" -> {
- val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
- MsgContent.MCImage(text, image)
- }
- "video" -> {
- val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
- val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
- MsgContent.MCVideo(text, image, duration)
- }
- "voice" -> {
- val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
- MsgContent.MCVoice(text, duration)
- }
- "file" -> MsgContent.MCFile(text)
- else -> MsgContent.MCUnknown(t, text, json)
- }
- } else {
- MsgContent.MCUnknown(text = generalGetString(R.string.invalid_message_format), json = json)
- }
- } else {
- MsgContent.MCUnknown(text = generalGetString(R.string.invalid_message_format), json = json)
- }
- }
-
- override fun serialize(encoder: Encoder, value: MsgContent) {
- require(encoder is JsonEncoder)
- val json = when (value) {
- is MsgContent.MCText ->
- buildJsonObject {
- put("type", "text")
- put("text", value.text)
- }
- is MsgContent.MCLink ->
- buildJsonObject {
- put("type", "link")
- put("text", value.text)
- put("preview", json.encodeToJsonElement(value.preview))
- }
- is MsgContent.MCImage ->
- buildJsonObject {
- put("type", "image")
- put("text", value.text)
- put("image", value.image)
- }
- is MsgContent.MCVideo ->
- buildJsonObject {
- put("type", "video")
- put("text", value.text)
- put("image", value.image)
- put("duration", value.duration)
- }
- is MsgContent.MCVoice ->
- buildJsonObject {
- put("type", "voice")
- put("text", value.text)
- put("duration", value.duration)
- }
- is MsgContent.MCFile ->
- buildJsonObject {
- put("type", "file")
- put("text", value.text)
- }
- is MsgContent.MCUnknown -> value.json
- }
- encoder.encodeJsonElement(json)
- }
-}
-
-@Serializable
-class FormattedText(val text: String, val format: Format? = null) {
- // TODO make it dependent on simplexLinkMode preference
- fun link(mode: SimplexLinkMode): String? = when (format) {
- is Format.Uri -> text
- is Format.SimplexLink -> if (mode == SimplexLinkMode.BROWSER) text else format.simplexUri
- is Format.Email -> "mailto:$text"
- is Format.Phone -> "tel:$text"
- else -> null
- }
-
- // TODO make it dependent on simplexLinkMode preference
- fun viewText(mode: SimplexLinkMode): String =
- if (format is Format.SimplexLink && mode == SimplexLinkMode.DESCRIPTION) simplexLinkText(format.linkType, format.smpHosts) else text
-
- fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List): String =
- "${linkType.description} (${String.format(generalGetString(R.string.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
-}
-
-@Serializable
-sealed class Format {
- @Serializable @SerialName("bold") class Bold: Format()
- @Serializable @SerialName("italic") class Italic: Format()
- @Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
- @Serializable @SerialName("snippet") class Snippet: Format()
- @Serializable @SerialName("secret") class Secret: Format()
- @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
- @Serializable @SerialName("uri") class Uri: Format()
- @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val trustedUri: Boolean, val smpHosts: List): Format()
- @Serializable @SerialName("email") class Email: Format()
- @Serializable @SerialName("phone") class Phone: Format()
-
- val style: SpanStyle @Composable get() = when (this) {
- is Bold -> SpanStyle(fontWeight = FontWeight.Bold)
- is Italic -> SpanStyle(fontStyle = FontStyle.Italic)
- is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
- is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
- is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
- is Colored -> SpanStyle(color = this.color.uiColor)
- is Uri -> linkStyle
- is SimplexLink -> linkStyle
- is Email -> linkStyle
- is Phone -> linkStyle
- }
-
- companion object {
- val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline)
- }
-}
-
-@Serializable
-enum class SimplexLinkType(val linkType: String) {
- contact("contact"),
- invitation("invitation"),
- group("group");
-
- val description: String get() = generalGetString(when (this) {
- contact -> R.string.simplex_link_contact
- invitation -> R.string.simplex_link_invitation
- group -> R.string.simplex_link_group
- })
-}
-
-@Serializable
-enum class FormatColor(val color: String) {
- red("red"),
- green("green"),
- blue("blue"),
- yellow("yellow"),
- cyan("cyan"),
- magenta("magenta"),
- black("black"),
- white("white");
-
- val uiColor: Color @Composable get() = when (this) {
- red -> Color.Red
- green -> SimplexGreen
- blue -> SimplexBlue
- yellow -> WarningYellow
- cyan -> Color.Cyan
- magenta -> Color.Magenta
- black -> MaterialTheme.colors.onBackground
- white -> MaterialTheme.colors.onBackground
- }
-}
-
-@Serializable
-class SndFileTransfer() {}
-
-@Serializable
-class RcvFileTransfer() {}
-
-@Serializable
-class FileTransferMeta() {}
-
-@Serializable
-enum class CICallStatus {
- @SerialName("pending") Pending,
- @SerialName("missed") Missed,
- @SerialName("rejected") Rejected,
- @SerialName("accepted") Accepted,
- @SerialName("negotiated") Negotiated,
- @SerialName("progress") Progress,
- @SerialName("ended") Ended,
- @SerialName("error") Error;
-
- fun text(sec: Int): String = when (this) {
- Pending -> generalGetString(R.string.callstatus_calling)
- Missed -> generalGetString(R.string.callstatus_missed)
- Rejected -> generalGetString(R.string.callstatus_rejected)
- Accepted -> generalGetString(R.string.callstatus_accepted)
- Negotiated -> generalGetString(R.string.callstatus_connecting)
- Progress -> generalGetString(R.string.callstatus_in_progress)
- Ended -> String.format(generalGetString(R.string.callstatus_ended), durationText(sec))
- Error -> generalGetString(R.string.callstatus_error)
- }
-}
-
-fun durationText(sec: Int): String {
- val s = sec % 60
- val m = sec / 60
- return if (m < 60) "%02d:%02d".format(m, s) else "%02d:%02d:%02d".format(m / 60, m % 60, s)
-}
-
-@Serializable
-sealed class MsgErrorType() {
- @Serializable @SerialName("msgSkipped") class MsgSkipped(val fromMsgId: Long, val toMsgId: Long): MsgErrorType()
- @Serializable @SerialName("msgBadId") class MsgBadId(val msgId: Long): MsgErrorType()
- @Serializable @SerialName("msgBadHash") class MsgBadHash(): MsgErrorType()
- @Serializable @SerialName("msgDuplicate") class MsgDuplicate(): MsgErrorType()
-
- val text: String get() = when (this) {
- is MsgSkipped -> String.format(generalGetString(R.string.integrity_msg_skipped), toMsgId - fromMsgId + 1)
- is MsgBadHash -> generalGetString(R.string.integrity_msg_bad_hash) // not used now
- is MsgBadId -> generalGetString(R.string.integrity_msg_bad_id) // not used now
- is MsgDuplicate -> generalGetString(R.string.integrity_msg_duplicate) // not used now
- }
-}
-
-@Serializable
-sealed class RcvGroupEvent() {
- @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
- @Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent()
- @Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent()
- @Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): RcvGroupEvent()
- @Serializable @SerialName("userRole") class UserRole(val role: GroupMemberRole): RcvGroupEvent()
- @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
- @Serializable @SerialName("userDeleted") class UserDeleted(): RcvGroupEvent()
- @Serializable @SerialName("groupDeleted") class GroupDeleted(): RcvGroupEvent()
- @Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): RcvGroupEvent()
- @Serializable @SerialName("invitedViaGroupLink") class InvitedViaGroupLink(): RcvGroupEvent()
-
- val text: String get() = when (this) {
- is MemberAdded -> String.format(generalGetString(R.string.rcv_group_event_member_added), profile.profileViewName)
- is MemberConnected -> generalGetString(R.string.rcv_group_event_member_connected)
- is MemberLeft -> generalGetString(R.string.rcv_group_event_member_left)
- is MemberRole -> String.format(generalGetString(R.string.rcv_group_event_changed_member_role), profile.profileViewName, role.text)
- is UserRole -> String.format(generalGetString(R.string.rcv_group_event_changed_your_role), role.text)
- is MemberDeleted -> String.format(generalGetString(R.string.rcv_group_event_member_deleted), profile.profileViewName)
- is UserDeleted -> generalGetString(R.string.rcv_group_event_user_deleted)
- is GroupDeleted -> generalGetString(R.string.rcv_group_event_group_deleted)
- is GroupUpdated -> generalGetString(R.string.rcv_group_event_updated_group_profile)
- is InvitedViaGroupLink -> generalGetString(R.string.rcv_group_event_invited_via_your_group_link)
- }
-}
-
-@Serializable
-sealed class SndGroupEvent() {
- @Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): SndGroupEvent()
- @Serializable @SerialName("userRole") class UserRole(val role: GroupMemberRole): SndGroupEvent()
- @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
- @Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent()
- @Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent()
-
- val text: String get() = when (this) {
- is MemberRole -> String.format(generalGetString(R.string.snd_group_event_changed_member_role), profile.profileViewName, role.text)
- is UserRole -> String.format(generalGetString(R.string.snd_group_event_changed_role_for_yourself), role.text)
- is MemberDeleted -> String.format(generalGetString(R.string.snd_group_event_member_deleted), profile.profileViewName)
- is UserLeft -> generalGetString(R.string.snd_group_event_user_left)
- is GroupUpdated -> generalGetString(R.string.snd_group_event_group_profile_updated)
- }
-}
-
-@Serializable
-sealed class RcvConnEvent {
- @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase): RcvConnEvent()
-
- val text: String get() = when (this) {
- is SwitchQueue -> when (phase) {
- SwitchPhase.Completed -> generalGetString(R.string.rcv_conn_event_switch_queue_phase_completed)
- else -> generalGetString(R.string.rcv_conn_event_switch_queue_phase_changing)
- }
- }
-}
-
-@Serializable
-sealed class SndConnEvent {
- @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase, val member: GroupMemberRef? = null): SndConnEvent()
-
- val text: String
- get() = when (this) {
- is SwitchQueue -> {
- member?.profile?.profileViewName?.let {
- return when (phase) {
- SwitchPhase.Completed -> String.format(generalGetString(R.string.snd_conn_event_switch_queue_phase_completed_for_member), it)
- else -> String.format(generalGetString(R.string.snd_conn_event_switch_queue_phase_changing_for_member), it)
- }
- }
- when (phase) {
- SwitchPhase.Completed -> generalGetString(R.string.snd_conn_event_switch_queue_phase_completed)
- else -> generalGetString(R.string.snd_conn_event_switch_queue_phase_changing)
- }
- }
- }
-}
-
-@Serializable
-enum class SwitchPhase {
- @SerialName("started") Started,
- @SerialName("confirmed") Confirmed,
- @SerialName("completed") Completed
-}
-
-sealed class ChatItemTTL: Comparable {
- object Day: ChatItemTTL()
- object Week: ChatItemTTL()
- object Month: ChatItemTTL()
- data class Seconds(val secs: Long): ChatItemTTL()
- object None: ChatItemTTL()
-
- override fun compareTo(other: ChatItemTTL?): Int = (seconds ?: Long.MAX_VALUE).compareTo(other?.seconds ?: Long.MAX_VALUE)
-
- val seconds: Long?
- get() =
- when (this) {
- is None -> null
- is Day -> 86400L
- is Week -> 7 * 86400L
- is Month -> 30 * 86400L
- is Seconds -> secs
- }
-
- companion object {
- fun fromSeconds(seconds: Long?): ChatItemTTL =
- when (seconds) {
- null -> None
- 86400L -> Day
- 7 * 86400L -> Week
- 30 * 86400L -> Month
- else -> Seconds(seconds)
- }
- }
-}
-
-@Serializable
-class ChatItemInfo(
- val itemVersions: List,
-)
-
-@Serializable
-data class ChatItemVersion(
- val chatItemVersionId: Long,
- val msgContent: MsgContent,
- val formattedText: List?,
- val itemVersionTs: Instant,
- val createdAt: Instant,
-)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
deleted file mode 100644
index ff80579dff..0000000000
--- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
+++ /dev/null
@@ -1,3898 +0,0 @@
-package chat.simplex.app.model
-
-import android.annotation.SuppressLint
-import android.app.Application
-import android.content.*
-import android.net.Uri
-import android.os.PowerManager
-import android.provider.Settings
-import android.util.Log
-import androidx.compose.foundation.layout.*
-import androidx.compose.material.*
-import chat.simplex.app.views.helpers.*
-import androidx.compose.runtime.*
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import chat.simplex.app.*
-import chat.simplex.app.R
-import chat.simplex.app.ui.theme.*
-import chat.simplex.app.views.call.*
-import chat.simplex.app.views.newchat.ConnectViaLinkTab
-import chat.simplex.app.views.onboarding.OnboardingStage
-import chat.simplex.app.views.usersettings.*
-import com.charleskorn.kaml.Yaml
-import com.charleskorn.kaml.YamlConfiguration
-import kotlinx.coroutines.*
-import kotlinx.datetime.Clock
-import kotlinx.datetime.Instant
-import kotlinx.serialization.*
-import kotlinx.serialization.builtins.MapSerializer
-import kotlinx.serialization.builtins.serializer
-import kotlinx.serialization.json.*
-import java.util.Date
-
-typealias ChatCtrl = Long
-
-enum class CallOnLockScreen {
- DISABLE,
- SHOW,
- ACCEPT;
-
- companion object {
- val default = SHOW
- }
-}
-
-enum class SimplexLinkMode {
- DESCRIPTION,
- FULL,
- BROWSER;
-
- companion object {
- val default = SimplexLinkMode.DESCRIPTION
- }
-}
-
-class AppPreferences(val context: Context) {
- private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
- private val sharedPreferencesThemes: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_THEMES_ID, Context.MODE_PRIVATE)
-
- // deprecated, remove in 2024
- private val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
- val notificationsMode = mkStrPreference(SHARED_PREFS_NOTIFICATIONS_MODE,
- if (!runServiceInBackground.get()) NotificationsMode.OFF.name else NotificationsMode.default.name
- )
- val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name)
- val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
- val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false)
- val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
- val webrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true)
- private val _callOnLockScreen = mkStrPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default.name)
- val callOnLockScreen: SharedPreference = SharedPreference(
- get = fun(): CallOnLockScreen {
- val value = _callOnLockScreen.get() ?: return CallOnLockScreen.default
- return try {
- CallOnLockScreen.valueOf(value)
- } catch (e: Error) {
- CallOnLockScreen.default
- }
- },
- set = fun(action: CallOnLockScreen) { _callOnLockScreen.set(action.name) }
- )
- val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false)
- val laMode = mkEnumPreference(SHARED_PREFS_LA_MODE, LAMode.SYSTEM) { LAMode.values().firstOrNull { it.name == this } }
- val laLockDelay = mkIntPreference(SHARED_PREFS_LA_LOCK_DELAY, 30)
- val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false)
- val webrtcIceServers = mkStrPreference(SHARED_PREFS_WEBRTC_ICE_SERVERS, null)
- val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
- val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
- val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
- private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name)
- val simplexLinkMode: SharedPreference = SharedPreference(
- get = fun(): SimplexLinkMode {
- val value = _simplexLinkMode.get() ?: return SimplexLinkMode.default
- return try {
- SimplexLinkMode.valueOf(value)
- } catch (e: Error) {
- SimplexLinkMode.default
- }
- },
- set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) }
- )
- val privacyFullBackup = mkBoolPreference(SHARED_PREFS_PRIVACY_FULL_BACKUP, false)
- val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
- val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
- val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
- val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
- val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
- val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
- val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050")
- private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name)
- val networkSessionMode: SharedPreference = SharedPreference(
- get = fun(): TransportSessionMode {
- val value = _networkSessionMode.get() ?: return TransportSessionMode.default
- return try {
- TransportSessionMode.valueOf(value)
- } catch (e: Error) {
- TransportSessionMode.default
- }
- },
- set = fun(mode: TransportSessionMode) { _networkSessionMode.set(mode.name) }
- )
- val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name)
- val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false)
- val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout)
- val networkTCPTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT, NetCfg.defaults.tcpTimeout, NetCfg.proxyDefaults.tcpTimeout)
- val networkSMPPingInterval = mkLongPreference(SHARED_PREFS_NETWORK_SMP_PING_INTERVAL, NetCfg.defaults.smpPingInterval)
- val networkSMPPingCount = mkIntPreference(SHARED_PREFS_NETWORK_SMP_PING_COUNT, NetCfg.defaults.smpPingCount)
- val networkEnableKeepAlive = mkBoolPreference(SHARED_PREFS_NETWORK_ENABLE_KEEP_ALIVE, NetCfg.defaults.enableKeepAlive)
- val networkTCPKeepIdle = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_IDLE, KeepAliveOpts.defaults.keepIdle)
- val networkTCPKeepIntvl = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_INTVL, KeepAliveOpts.defaults.keepIntvl)
- val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt)
- val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false)
- val connectViaLinkTab = mkStrPreference(SHARED_PREFS_CONNECT_VIA_LINK_TAB, ConnectViaLinkTab.SCAN.name)
- val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false)
- val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true)
- val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true)
- val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
-
- val onboardingStage = mkEnumPreference(SHARED_PREFS_ONBOARDING_STAGE, OnboardingStage.OnboardingComplete) { OnboardingStage.values().firstOrNull { it.name == this } }
- val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true)
- val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false)
- val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null)
- val initializationVectorDBPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE, null)
- val encryptedAppPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_APP_PASSPHRASE, null)
- val initializationVectorAppPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_APP_PASSPHRASE, null)
- val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null)
- val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null)
- val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true)
- val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false)
- val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false)
- val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null)
-
- val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
- val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.SIMPLEX.name)
- val themeOverrides = mkMapPreference(SHARED_PREFS_THEMES, mapOf(), encode = {
- json.encodeToString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it)
- }, decode = {
- json.decodeFromString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it)
- }, sharedPreferencesThemes)
-
- val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
- val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0)
- val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300)
-
- private fun mkIntPreference(prefName: String, default: Int) =
- SharedPreference(
- get = fun() = sharedPreferences.getInt(prefName, default),
- set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
- )
-
- private fun mkLongPreference(prefName: String, default: Long) =
- SharedPreference(
- get = fun() = sharedPreferences.getLong(prefName, default),
- set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
- )
-
- private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): SharedPreference {
- val d = if (networkUseSocksProxy.get()) proxyDefault else default
- return SharedPreference(
- get = fun() = sharedPreferences.getLong(prefName, d),
- set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
- )
- }
-
- private fun mkBoolPreference(prefName: String, default: Boolean) =
- SharedPreference(
- get = fun() = sharedPreferences.getBoolean(prefName, default),
- set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply()
- )
-
- private fun mkStrPreference(prefName: String, default: String?): SharedPreference =
- SharedPreference(
- get = fun() = sharedPreferences.getString(prefName, default),
- set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply()
- )
-
- private fun mkEnumPreference(prefName: String, default: T, construct: String.() -> T?): SharedPreference =
- SharedPreference(
- get = fun() = sharedPreferences.getString(prefName, default.toString())?.construct() ?: default,
- set = fun(value) = sharedPreferences.edit().putString(prefName, value.toString()).apply()
- )
-
- /**
- * Provide `[commit] = true` to save preferences right now, not after some unknown period of time.
- * So in case of a crash this value will be saved 100%
- * */
- private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): SharedPreference =
- SharedPreference(
- get = {
- val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString())
- pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) }
- },
- set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).let {
- if (commit) it.commit() else it.apply()
- }
- )
-
- private fun mkMapPreference(prefName: String, default: Map, encode: (Map) -> String, decode: (String) -> Map, prefs: SharedPreferences = sharedPreferences): SharedPreference