diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
deleted file mode 100644
index a6ffb6d328..0000000000
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ /dev/null
@@ -1,68 +0,0 @@
-name: Bug
-description: File a bug report/issue
-title: "[Bug]: "
-labels: ["bug", "triage"]
-body:
-- type: checkboxes
- attributes:
- label: Is there an existing issue for this?
- description: Please search to see if an issue already exists for the bug you encountered.
- options:
- - label: I have searched the existing issues
- required: true
-- type: dropdown
- attributes:
- label: Platform
- description: Multiple selections are possible.
- multiple: true
- options:
- - Linux
- - Mac
- - Windows
- - Android
- - iOS
- validations:
- required: true
-- type: input
- attributes:
- label: OS version
- description: Specify the OS version
- placeholder: ex. Android 12, Ubuntu 20.04
- validations:
- required: true
-- type: input
- attributes:
- label: App version
- description: Specify the SimpleX version
- placeholder: ex. 4.3.2
- validations:
- required: true
-- type: textarea
- attributes:
- label: Current Behavior
- description: A concise description of what you're experiencing.
- placeholder: Bug happened!
- validations:
- required: true
-- type: textarea
- attributes:
- label: Expected Behavior
- description: A concise description of what you expected to happen.
- placeholder: No bug should happen!
- validations:
- required: true
-- type: textarea
- attributes:
- label: Steps To Reproduce
- description: Steps to reproduce the behavior.
- placeholder: |
- 1. Go to ...
- 3. Click on ...
- 4. See error...
- validations:
- required: true
-- type: textarea
- attributes:
- label: Relevant log output
- description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
- render: shell
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
deleted file mode 100644
index 0086358db1..0000000000
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ /dev/null
@@ -1 +0,0 @@
-blank_issues_enabled: true
diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml
deleted file mode 100644
index 936d1dcaf5..0000000000
--- a/.github/ISSUE_TEMPLATE/feature.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: Feature
-description: Suggest your feature
-title: "[Feature]: "
-labels: ["enhancement", "triage"]
-body:
-- type: checkboxes
- attributes:
- label: Is there an existing issue for this?
- description: Please search to see if an issue already exists for the bug you encountered.
- options:
- - label: I have searched the existing issues
- required: true
-- type: dropdown
- attributes:
- label: Platform
- description: Multiple selections are possible. If selected input is "all", this considered to be a general feature.
- multiple: true
- options:
- - Linux
- - Mac
- - Windows
- - Android
- - iOS
- - all
- validations:
- required: true
-- type: input
- attributes:
- label: App version
- description: Specify the SimpleX version
- placeholder: ex. 4.3.2
- validations:
- required: false
-- type: textarea
- attributes:
- label: Feature
- description: Describe the feature you would like to see added
- placeholder: SimpleX Chat should make me coffee!
- validations:
- required: true
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
deleted file mode 100644
index 8506c69e0a..0000000000
--- a/.github/ISSUE_TEMPLATE/question.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-name: Question
-description: Ask your question
-title: "[Q]: "
-labels: ["question", "triage"]
-body:
-- type: markdown
- attributes:
- value: |
- Generally, we encourage you to ask questions in our [official group](https://simplex.chat/invitation/#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3Dsimplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D), but you can do it anyway :)
-- type: textarea
- attributes:
- label: Question
- description: Please ask your question in plain english.
- placeholder: Is SimpleX - chat?
- validations:
- required: true
diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml
deleted file mode 100644
index ce75b7a57c..0000000000
--- a/.github/actions/prepare-build/action.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: "Prebuilt steps for build"
-description: "Reusable steps for multiple jobs"
-inputs:
- java_ver:
- required: true
- description: "Java version to install"
- ghc_ver:
- required: true
- description: "GHC version to install"
- github_ref:
- required: true
- description: "Git reference"
- os:
- required: true
- description: "Target OS"
- cache_path:
- required: false
- default: "~/.cabal/store"
- description: "Cache path"
- cabal_ver:
- required: false
- default: 3.10.1.0
- description: "GHC version to install"
-runs:
- using: "composite"
- steps:
- - name: Setup Haskell
- uses: simplex-chat/setup-haskell-action@v2
- with:
- ghc-version: ${{ inputs.ghc_ver }}
- cabal-version: ${{ inputs.cabal_ver }}
-
- - name: Setup Java
- if: startsWith(inputs.github_ref, 'refs/tags/v')
- uses: actions/setup-java@v3
- with:
- distribution: 'corretto'
- java-version: ${{ inputs.java_ver }}
- cache: 'gradle'
-
- - name: Restore cached build
- uses: actions/cache@v4
- with:
- path: |
- ${{ inputs.cache_path }}
- dist-newstyle
- key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
diff --git a/.github/actions/prepare-release/action.yml b/.github/actions/prepare-release/action.yml
deleted file mode 100644
index e0d32bd596..0000000000
--- a/.github/actions/prepare-release/action.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-name: "Upload binary and update hash"
-description: "Reusable steps for multiple jobs"
-inputs:
- bin_path:
- required: true
- description: "Path to binary to upload"
- bin_name:
- required: true
- description: "Name of uploaded binary"
- bin_hash:
- required: true
- description: "Message with SHA to include in release"
- github_ref:
- required: true
- description: "Github reference"
- github_token:
- required: true
- description: "Github token"
-runs:
- using: "composite"
- steps:
- - name: Upload file with specific name
- if: startsWith(inputs.github_ref, 'refs/tags/v')
- uses: simplex-chat/upload-release-action@v2
- with:
- repo_token: ${{ inputs.github_token }}
- file: ${{ inputs.bin_path }}
- asset_name: ${{ inputs.bin_name }}
- tag: ${{ inputs.github_ref }}
-
- - name: Add hash to release notes
- if: startsWith(inputs.github_ref, 'refs/tags/v')
- uses: simplex-chat/action-gh-release@v2
- env:
- GITHUB_TOKEN: ${{ inputs.github_token }}
- with:
- append_body: true
- body: |
- ${{ inputs.bin_hash }}
diff --git a/.github/actions/swap/action.yml b/.github/actions/swap/action.yml
deleted file mode 100644
index 87d670b147..0000000000
--- a/.github/actions/swap/action.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-name: 'Set Swap Space'
-description: 'Add moar swap'
-branding:
- icon: 'crop'
- color: 'orange'
-inputs:
- swap-size-gb:
- description: 'Swap space to create, in Gigabytes.'
- required: false
- default: '10'
-runs:
- using: "composite"
- steps:
- - name: Swap space report before modification
- shell: bash
- run: |
- echo "Memory and swap:"
- free -h
- echo
- swapon --show
- echo
- - name: Set Swap
- shell: bash
- run: |
- export SWAP_FILE=$(swapon --show=NAME | tail -n 1)
- echo "Swap file: $SWAP_FILE"
- if [ -z "$SWAP_FILE" ]; then
- SWAP_FILE=/opt/swapfile
- else
- sudo swapoff $SWAP_FILE
- sudo rm $SWAP_FILE
- fi
- sudo fallocate -l ${{ inputs.swap-size-gb }}G $SWAP_FILE
- sudo chmod 600 $SWAP_FILE
- sudo mkswap $SWAP_FILE
- sudo swapon $SWAP_FILE
- - name: Swap space report after modification
- shell: bash
- run: |
- echo "Memory and swap:"
- free -h
- echo
- swapon --show
- echo
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ca1bc79510..acf5b6f51e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,75 +5,22 @@ on:
branches:
- master
- stable
+ - users
tags:
- "v*"
- - "!*-fdroid"
- - "!*-armv7a"
pull_request:
- paths-ignore:
- - "apps/ios"
- - "apps/multiplatform"
- - "blog"
- - "docs"
- - "fastlane"
- - "images"
- - "packages"
- - "website"
- - "README.md"
- - "PRIVACY.md"
-
-# This workflow uses custom actions (prepare-build and prepare-release) defined in:
-#
-# .github/actions/
-# ├── prepare-build
-# │ └── action.yml
-# └── prepare-release
-# └── action.yml
-
-# Important!
-# Do not use always(), it makes build unskippable.
-# See: https://github.com/actions/runner/issues/1846#issuecomment-1246102753
jobs:
-
-# =============================
-# Global variables
-# =============================
-
-# That is the only and less hacky way to setup global variables
-# to use in strategy matrix (env:/YAML anchors doesn't work).
-# See: https://github.com/orgs/community/discussions/56787#discussioncomment-6041789
-# https://github.com/actions/runner/issues/1182
-# https://stackoverflow.com/a/77549656
-
- variables:
- runs-on: ubuntu-latest
- outputs:
- GHC_VER: 9.6.3
- JAVA_VER: 17
- steps:
- - name: Dummy job when we have just simple variables
- if: false
- run: echo
-
-# =============================
-# Create release
-# =============================
-
-# Create release, but only if it's triggered by tag push.
-# On pull requests/commits push, this job will always complete.
-
- maybe-release:
+ prepare-release:
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Clone project
- if: startsWith(github.ref, 'refs/tags/v')
- uses: actions/checkout@v3
+ uses: actions/checkout@v2
- name: Build changelog
id: build_changelog
- if: startsWith(github.ref, 'refs/tags/v')
- uses: simplex-chat/release-changelog-builder-action@v5
+ uses: mikepenz/release-changelog-builder-action@v1
with:
configuration: .github/changelog_conf.json
failOnError: true
@@ -83,8 +30,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
- if: startsWith(github.ref, 'refs/tags/v')
- uses: simplex-chat/action-gh-release@v2
+ uses: softprops/action-gh-release@v1
with:
body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: true
@@ -94,439 +40,116 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-# =========================
-# Linux Build
-# =========================
-
- build-linux:
- name: "ubuntu-${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
- needs: [maybe-release, variables]
- runs-on: ubuntu-${{ matrix.os }}
+ build:
+ name: build-${{ matrix.os }}
+ if: always()
+ needs: prepare-release
+ runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- - os: 22.04
- ghc: "8.10.7"
- should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }}
- - os: 22.04
- ghc: ${{ needs.variables.outputs.GHC_VER }}
- cli_asset_name: simplex-chat-ubuntu-22_04-x86-64
- desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
- should_run: true
- - os: 24.04
- ghc: ${{ needs.variables.outputs.GHC_VER }}
- cli_asset_name: simplex-chat-ubuntu-24_04-x86-64
- desktop_asset_name: simplex-desktop-ubuntu-24_04-x86_64.deb
- should_run: true
+ - os: ubuntu-20.04
+ cache_path: ~/.cabal/store
+ asset_name: simplex-chat-ubuntu-20_04-x86-64
+ - os: ubuntu-18.04
+ cache_path: ~/.cabal/store
+ asset_name: simplex-chat-ubuntu-18_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
steps:
- - name: Checkout Code
- if: matrix.should_run == true
- uses: actions/checkout@v3
+ - name: Clone project
+ uses: actions/checkout@v2
- - name: Setup swap
- if: matrix.ghc == '8.10.7' && matrix.should_run == true
- uses: ./.github/actions/swap
+ - name: Setup Haskell
+ uses: haskell/actions/setup@v1
with:
- swap-size-gb: 30
+ ghc-version: "8.10.7"
+ cabal-version: "latest"
- # Otherwise we run out of disk space with Docker build
- - name: Free disk space
- if: matrix.should_run == true
- shell: bash
- run: ./scripts/ci/linux_util_free_space.sh
-
- - name: Restore cached build
- if: matrix.should_run == true
- uses: actions/cache@v4
+ - name: Cache dependencies
+ uses: actions/cache@v2
with:
path: |
- ~/.cabal/store
+ ${{ matrix.cache_path }}
dist-newstyle
- key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
+ key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
- - name: Set up Docker Buildx
- if: matrix.should_run == true
- uses: simplex-chat/docker-setup-buildx-action@v3
+ # / Unix
- - name: Build and cache Docker image
- if: matrix.should_run == true
- uses: simplex-chat/docker-build-push-action@v6
- with:
- context: .
- load: true
- file: Dockerfile.build
- tags: build/${{ matrix.os }}:latest
- build-args: |
- TAG=${{ matrix.os }}
- GHC=${{ matrix.ghc }}
-
- # Docker needs these flags for AppImage build:
- # --device /dev/fuse
- # --cap-add SYS_ADMIN
- # --security-opt apparmor:unconfined
- - name: Start container
- if: matrix.should_run == true
+ - name: Unix prepare cabal.project.local for Mac
+ if: matrix.os == 'macos-latest'
shell: bash
run: |
- docker run -t -d \
- --device /dev/fuse \
- --cap-add SYS_ADMIN \
- --security-opt apparmor:unconfined \
- --name builder \
- -v ~/.cabal:/root/.cabal \
- -v /home/runner/work/_temp:/home/runner/work/_temp \
- -v ${{ github.workspace }}:/project \
- build/${{ matrix.os }}:latest
+ echo "ignore-project: False" >> cabal.project.local
+ echo "package direct-sqlcipher" >> cabal.project.local
+ echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
+ echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
+ echo " flags: +openssl" >> cabal.project.local
- - name: Prepare cabal.project.local
- if: matrix.should_run == true
+ - name: Install 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-18.04'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- # chmod/git commands are used to workaround permission issues when cache is restored
- - name: Build CLI
- if: matrix.should_run == true
- shell: docker exec -t builder sh -eu {0}
- run: |
- chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*'
- cabal clean
- cabal update
- cabal build -j --enable-tests
- mkdir -p /out
- for i in simplex-chat simplex-chat-test; do
- bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
- chmod +x "$bin"
- mv "$bin" /out/
- done
- strip /out/simplex-chat
-
- - name: Copy tests from container
- if: matrix.should_run == true
+ - name: Unix build
+ id: unix_build
+ if: matrix.os != 'windows-latest'
shell: bash
run: |
- docker cp builder:/out/simplex-chat-test .
+ cabal build --enable-tests
+ echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
- - name: Copy CLI from container and prepare it
- id: linux_cli_prepare
- if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
+ - name: Unix test
+ if: matrix.os != 'windows-latest' && matrix.os != 'ubuntu-20.04'
+ timeout-minutes: 20
shell: bash
- run: |
- 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
+ run: cabal test --test-show-details=direct
- - name: Upload CLI
- if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
- uses: ./.github/actions/prepare-release
+ - name: Unix upload binary to release
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
+ uses: svenstaro/upload-release-action@v2
with:
- bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }}
- bin_name: ${{ matrix.cli_asset_name }}
- bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }}
- github_ref: ${{ github.ref }}
- github_token: ${{ secrets.GITHUB_TOKEN }}
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ file: ${{ steps.unix_build.outputs.bin_path }}
+ asset_name: ${{ matrix.asset_name }}
+ tag: ${{ github.ref }}
- - name: Build Desktop
- if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
- shell: docker exec -t builder sh -eu {0}
+ # 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: |
- scripts/desktop/build-lib-linux.sh
- cd apps/multiplatform
- ./gradlew packageDeb
+ 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%
- - 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
+ - name: Windows upload binary to release
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
+ uses: svenstaro/upload-release-action@v2
with:
- bin_path: ${{ steps.linux_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 }}
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ file: ${{ steps.windows_build.outputs.bin_path }}
+ asset_name: ${{ matrix.asset_name }}
+ tag: ${{ github.ref }}
- - 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 }}
+ # Windows /
diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
index b396c9a289..72a7cf2b94 100644
--- a/.github/workflows/cla.yml
+++ b/.github/workflows/cla.yml
@@ -5,20 +5,14 @@ on:
pull_request_target:
types: [opened, closed, synchronize]
-permissions:
- actions: write
- contents: write
- pull-requests: write
- statuses: write
-
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
- if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
+ if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request'
# Beta Release
- uses: cla-assistant/github-action@v2.3.0
+ uses: cla-assistant/github-action@v2.1.3-beta
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
@@ -39,4 +33,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
+ #use-dco-flag: true - If you are using DCO instead of CLA
\ No newline at end of file
diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml
deleted file mode 100644
index 7de44addc7..0000000000
--- a/.github/workflows/reproduce-schedule.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-name: Reproduce latest release
-
-on:
- workflow_dispatch:
- schedule:
- - cron: '0 2 * * *' # every day at 02:00 night
-
-jobs:
- reproduce:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v3
-
- - name: Get latest release
- shell: bash
- run: |
- curl --proto '=https' \
- --tlsv1.2 \
- -sSf -L \
- 'https://api.github.com/repos/simplex-chat/simplex-chat/releases/latest' \
- 2>/dev/null | \
- grep -i "tag_name" | \
- awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV
-
- - name: Execute reproduce script
- run: |
- ${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG"
-
- - name: Check if build has been reproduced
- env:
- url: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_URL }}
- user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }}
- pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }}
- run: |
- if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then
- exit 0
- else
- curl --proto '=https' --tlsv1.2 -sSf \
- -u "${user}:${pass}" \
- -H 'Content-Type: application/json' \
- -d '{"title": "👾 GitHub: Runner", "description": "⛔️ '"$TAG"' did not reproduce."}' \
- "$url"
- exit 1
- fi
diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml
index 5fbe8293bc..f29f118ecc 100644
--- a/.github/workflows/web.yml
+++ b/.github/workflows/web.yml
@@ -4,13 +4,12 @@ on:
push:
branches:
- master
+ - stable
paths:
- website/**
- images/**
- blog/**
- - docs/**
- .github/workflows/web.yml
- - PRIVACY.md
jobs:
build:
@@ -18,7 +17,7 @@ jobs:
strategy:
matrix:
- node-version: [16.x]
+ node-version: [12.x]
steps:
- uses: actions/checkout@v2
@@ -33,7 +32,7 @@ jobs:
./website/web.sh
- name: Deploy
- uses: simplex-chat/actions-gh-pages@v3
+ uses: peaceiris/actions-gh-pages@v3
with:
publish_dir: ./website/_site
github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 645b55ec9d..69111c9abb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,19 +49,16 @@ logs/
# for website
website/node_modules/
website/src/blog/
-website/src/docs/
website/translations.json
+website/src/_data/supported_languages.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/
@@ -78,4 +75,3 @@ website/package-lock.json
# Ignore test files
website/.cache
website/test/stubs-layout-cache/_includes/*.js
-apps/android/app/release
diff --git a/Dockerfile b/Dockerfile
index cdcbc40d7d..b78e2f2448 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,41 +1,32 @@
-ARG TAG=22.04
+FROM ubuntu:focal AS build
-FROM ubuntu:${TAG} AS build
-
-### Build stage
-
-# Install curl and git and simplex-chat dependencies
-RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
-
-# Specify bootstrap Haskell versions
-ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
-ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
+# Install curl and simplex-chat-related dependencies
+RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
# Install ghcup
-RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
-
-# Adjust PATH
-ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
+RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
+ chmod +x /usr/bin/ghcup
+# Install ghc
+RUN ghcup install ghc 8.10.7
+# Install cabal
+RUN ghcup install cabal
# Set both as default
-RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
- ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
+RUN ghcup set ghc 8.10.7 && \
+ ghcup set cabal
COPY . /project
WORKDIR /project
+# Adjust PATH
+ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
+
# Adjust build
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
-RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library'
+RUN cabal install
-# Strip the binary from debug symbols to reduce size
-RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
- mv "$bin" ./ && \
- strip ./simplex-chat
-
-# Copy compiled app from build stage
FROM scratch AS export-stage
-COPY --from=build /project/simplex-chat /
+COPY --from=build /root/.cabal/bin/simplex-chat /
diff --git a/Dockerfile.build b/Dockerfile.build
deleted file mode 100644
index 76bb1127f2..0000000000
--- a/Dockerfile.build
+++ /dev/null
@@ -1,92 +0,0 @@
-# syntax=docker/dockerfile:1.7.0-labs
-ARG TAG=24.04
-FROM ubuntu:${TAG} AS build
-
-### Build stage
-
-ARG GHC=9.6.3
-ARG CABAL=3.10.1.0
-ARG JAVA=17
-
-ENV TZ=Etc/UTC \
- DEBIAN_FRONTEND=noninteractive
-
-# Install curl, git and and simplex-chat dependencies
-RUN apt-get update && \
- apt-get install -y curl \
- libpq-dev \
- git \
- sqlite3 \
- libsqlite3-dev \
- build-essential \
- libgmp3-dev \
- zlib1g-dev \
- llvm \
- cmake \
- llvm-dev \
- libnuma-dev \
- libssl-dev \
- desktop-file-utils \
- patchelf \
- ca-certificates \
- zip \
- wget \
- fuse3 \
- file \
- appstream \
- gpg \
- unzip &&\
- ln -s /bin/fusermount /bin/fusermount3 || :
-
-# Install Java Coretto
-# Required, because official Java in Ubuntu
-# depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final
-# /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin
-# to fix this :(
-RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\
- echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\
- apt update &&\
- apt install -y java-${JAVA}-amazon-corretto-jdk
-
-# Specify bootstrap Haskell versions
-ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC}
-ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL}
-
-# Do not install Stack
-ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true
-ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
-
-# Install ghcup
-RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
-
-# Adjust PATH
-ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
-
-# Set both as default
-RUN ghcup set ghc "${GHC}" && \
- ghcup set cabal "${CABAL}"
-
-#=====================
-# Install Android SDK
-#=====================
-ARG SDK_VERSION=13114758
-
-ENV SDK_VERSION=$SDK_VERSION \
- ANDROID_HOME=/root
-
-RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \
- unzip tools.zip && rm tools.zip && \
- mv cmdline-tools tools && mkdir "$ANDROID_HOME/cmdline-tools" && mv tools "$ANDROID_HOME/cmdline-tools/" && \
- ln -s "$ANDROID_HOME/cmdline-tools/tools" "$ANDROID_HOME/cmdline-tools/latest"
-
-ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin"
-
-# https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded
-RUN mkdir -p ~/.android ~/.gradle && \
- touch ~/.android/repositories.cfg && \
- echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\
- yes | sdkmanager --licenses >/dev/null
-
-ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools
-
-WORKDIR /project
diff --git a/PRIVACY.md b/PRIVACY.md
index 18e5539726..b19363dbf3 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,246 +1,96 @@
----
-layout: layouts/privacy.html
-permalink: /privacy/index.html
----
+# SimpleX Chat Terms & Privacy Policy
-# SimpleX Chat Operators Privacy Policy and Conditions of Use
+SimpleX Chat is the first communication platform that has no user profile IDs of any kind, not even random numbers. Not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we cannot observe your connections graph.
-## Summary
-
-[Introduction](#introduction) and [General principles](#general-principles) cover SimpleX Chat network design, the network operators, and the principles of privacy and security provided by SimpleX network.
-
-[Privacy policy](#privacy-policy) covers:
-- data stored only on your device - [your profiles](#user-profiles), delivered [messages and files](#messages-and-files). You can transfer this information to another device, and you are responsible for its preservation - if you delete the app it will be lost.
-- [private message delivery](#private-message-delivery) that protects your IP address and connection graph from the destination servers.
-- [undelivered messages and files](#storage-of-messages-and-files-on-the-servers) stored on the servers.
-- [how users connect](#connections-with-other-users) without any user profile identifiers.
-- [iOS push notifications](#ios-push-notifications) privacy limitations.
-- [user support](#user-support), [SimpleX directory](#simplex-directory) and [any other data](#another-information-stored-on-the-servers) that may be stored on the servers.
-- [preset server operators](#preset-server-operators) and the [information they may share](#information-preset-server-operators-may-share).
-- [source code license](#source-code-license) and [updates to this document](#updates).
-
-[Conditions of Use](#conditions-of-use-of-software-and-infrastructure) are the conditions you need to accept to use SimpleX Chat applications and the relay servers of preset operators. Their purpose is to protect the users and preset server operators.
-
-*Please note*: this summary and any links in this document are provided for information only - they are not a part of the Privacy Policy and Conditions of Use.
-
-## Introduction
-
-SimpleX Chat (also referred to as SimpleX) is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
-
-SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts.
-
-Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
-
-If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
+If you believe that some of the clauses in this document are not aligned with our mission or principles, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Privacy Policy
-### General principles
+SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and 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.
-SimpleX network software uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from being compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack).
+SimpleX Chat 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 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.
+### Information you provide
-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.
+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 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.
+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 design is based on the principles of users and data sovereignty, and device and operator portability.
+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).
-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).
+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 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).
+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.
-### Your information
+### Information we may share
-#### User profiles
+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.
-Servers used by SimpleX Chat apps do not create, store or identify user chat profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
+We 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.
-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.
+The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
-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 meet any applicable law, regulation, legal process or enforceable governmental request.
+- To enforce applicable Terms, including investigation of potential violations.
- To detect, prevent, or otherwise address fraud, security, or technical issues.
-- To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law.
+- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law.
-By the time of updating this document, the preset server operators were not served with any enforceable requests and did not provide any information from the servers to any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
-
-Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
-
-### Source code license
-
-As this software is fully open-source and provided under AGPLv3 license, all infrastructure owners and operators, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the servers.
-
-In addition to the AGPLv3 license terms, the preset relay server operators are committed to the software users that these servers will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
+At the time of updating this document, we have never provided or have been requested the access to our servers or any information from our servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process.
### Updates
-This Privacy Policy applies to SimpleX Chat Ltd and all other preset server operators you use in the app.
+We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
-This Privacy Policy may be updated as needed so that it is current, accurate, and as clear as possible. When it is updated, you will have to review and accept the changed policy within 30 days of such changes to continue using preset relay servers. Even if you fail to accept the changed policy, your continued use of SimpleX Chat software applications and preset relay servers confirms your acceptance of the updated Privacy Policy.
+Please also read our Terms of Service.
-Please also read The Conditions of Use of Software and Infrastructure below.
+If you have questions about our Privacy Policy please contact us at chat@simplex.chat.
-If you have questions about this Privacy Policy please contact SimpleX Chat Ltd via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
+## Terms of Service
-## Conditions of Use of Software and Infrastructure
+You accept to our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
-You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of SimpleX Chat software or using any of server infrastructure (collectively referred to as "Applications") operated by the Preset Server Operators, including SimpleX Chat Ltd, whether these servers are preset in the software or not.
+**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
-**Minimal age**. You must be at least 13 years old to use SimpleX Chat Applications. The minimum age to use SimpleX Applications without parental approval may be higher in your country.
+**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we 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.
-**Infrastructure**. Infrastructure of the preset server operators includes messaging and file relay servers. SimpleX Chat Ltd also provides iOS push notification servers for public use. This infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
+**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
-**Client applications**. SimpleX Chat client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on SimpleX Chat code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any tracking information with SimpleX Chat Ltd, preset server operators or any other third parties. If you ever discover any tracking or analytics code, please report it to SimpleX Chat Ltd, so it can be removed.
+**Operating our services**. For the purpose of operating our Services, you agree that your end-to-end encrypted messages are transferred via our servers in the United Kingdom, the United States and other countries where we have or use facilities and service providers or partners.
-**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to the [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about the privacy model and known security and privacy risks.
+**Software**. You agree to downloading and installing updates to our Services when they are available; they would only be automatic if you configure your devices in this way.
-**Privacy of user data**. Servers do not retain any data you transmit for any longer than necessary to deliver the messages between apps. Preset server operators collect aggregate statistics across all their servers, as supported by published code and can be enabled by any infrastructure operator, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. SimpleX Chat Ltd does not have information about how many people use SimpleX Chat applications, it only knows an approximate number of app installations and the aggregate traffic through the preset servers. In any case, preset server operators do not and will not sell or in any way monetize user data. The future business model assumes charging for some optional Software features instead, in a transparent and fair way.
+**Traffic and device costs**. You are solely responsible for the traffic and device costs on which you use our Services, and any associated taxes.
-**Operating Infrastructure**. For the purpose of using SimpleX Chat Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where preset server operators have or use facilities and service providers or partners. The information about geographic location and hosting providers of the preset messaging servers is available on server pages.
+**Legal and acceptable usage**. You agree to use our Services only for legal and acceptable purposes. You will not use (or assist others in using) our Services in ways that: 1) violate or infringe the rights of SimpleX Chat, our users, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam.
-**Software**. You agree to downloading and installing updates to SimpleX Chat Applications when they are available; they would only be automatic if you configure your devices in this way.
+**Damage to SimpleX Chat**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Services in unauthorized manners, or in ways that harm SimpleX Chat, our Services, or systems. For example, you must not 1) access our Services or systems without authorization, other than by using the apps; 2) disrupt the integrity or performance of our Services; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Services.
-**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using SimpleX Chat Applications, and any associated taxes.
+**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
-**Legal usage**. You agree to use SimpleX Chat Applications only for legal purposes. You will not use (or assist others in using) the Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, other preset server operators, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While server operators cannot access content or identify messages or groups, in some cases the links to the illegal communications can be shared publicly on social media or websites. Preset server operators reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via their servers, whether they were reported by the users or discovered by the operators themselves.
+**Storing the messages on the device**. 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.
-**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.
+**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.
-**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.
+**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.
-**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.
+**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.
-**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.
+**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 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.
+**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.
-**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.
+**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.
-**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.
+**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.
-**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.
+**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.
-**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).
+**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.
-**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.
+**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.
-**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.
+**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.
-**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.
+**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.
-**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
+Updated November 8, 2022
diff --git a/README.md b/README.md
index 554c6068d9..3d661a13ce 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), [PL](/docs/lang/pl/README.md) |
+| 19/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/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.whonix.org/wiki/Chat#Recommendation) [](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.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), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
+2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups).
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
-5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
+5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
[Learn more about SimpleX Chat](#contents).
@@ -40,62 +40,22 @@
- 🚀 [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
-
-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:
+## Connect to the team via the app
- 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, 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
-- avoid spam (too frequent messages, even if they are relevant)
-- avoid any personal attacks or hostility.
-- avoid sharing any content that is not relevant to the above (that includes, but is not limited to, discussing politics or any aspects of society other than privacy, security, technology and communications, sharing any content that may be found offensive by other users, etc.).
-
-Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
-
-You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FiBkJE72asZX1NUZaYFIeKRVk6oVjb-iv%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAinqu3j74AMjODLoIRR487ZW6ysip_dlpD6Zxk18SPFY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22groupLinkId%22%3A%223wAFGCLygQHR5AwynZOHlQ%3D%3D%22%7D)
-
-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.
+You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-2](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FQP8zaGjjmlXV-ix_Er4JgJ0lNPYGS1KX%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEApAgBkRZ3x12ayZ7sHrjHQWNMvqzZpWUgM_fFCUdLXwo%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xWpPXEZZsQp_F7vwAcAYDw%3D%3D%22%7D)
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
-[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmfiivxDKWFuowXrQOp11jsY8TuP__rBL%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAiz3pKNwvKudckFYMUfgoT0s96B0jfZ7ALHAu7rtE9HQ%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22jZeJpXGrRXQJU_-MSJ_v2A%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FJ5ES83pJimY2BRklS8fvy_iQwIU37xra%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0F0STP6UqN_12_k2cjjTrIjFgBGeWhOAmbY1qlk3pnM%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22VmUU0fqmYdCRmVCyvStvHA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FxCHBE_6PBRMqNEpm4UQDHXb9cz-mN7dd%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAetqlcM7zTCRw-iatnwCrvpJSto7lq5Yv6AsBMWv7GSM%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22foO5Xw4hhjOa_x7zET7otw%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FVXQTB0J2lLjYkgjWByhl6-1qmb5fgZHh%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAI6JaEWezfSwvcoTEkk6au-gkjrXR2ew2OqZYMYBvayk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22ORH9OEe8Duissh-hslfeVg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FqpHu0psOUdYfc11yQCzSyq5JhijrBzZT%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEACZ_7fbwlM45wl6cGif8cY47oPQ_AMdP0ATqOYLA6zHY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%229uRQRTir3ealdcSfB0zsrw%3D%3D%22%7D) (Italian-speaking).
+[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
-## 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.
@@ -106,19 +66,6 @@ The channel through which you share the link does not have to be secure - it is
After you connect, you can [verify connection security code](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification).
-## User guide (NEW)
-
-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.
@@ -128,29 +75,26 @@ 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/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/) -|||
+|🇪🇸 es|Español ||[](https://hosted.weblate.org/projects/simplex-chat/android/es/) [](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|||
+|🇫🇷 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)|
|🇮🇹 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/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/)||
+|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/) [](https://hosted.weblate.org/projects/simplex-chat/ios/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!
+Languages in progress: Arabic, Hindi, Japanese, Spanish and [many others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
-## Please support us with your donations
+## Contribute
+
+We would love to have you join the development! You can help us with:
+
+- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
+- contributing to SimpleX Chat knowledge-base.
+- developing features - please connect to us via chat so we can help you get started.
+
+## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat!
@@ -158,21 +102,17 @@ We are prioritizing users privacy and security - it would be impossible without
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
-Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us.
+Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
It is possible to donate via:
-- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
-- BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u
-- XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
-- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
-- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
-- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
-- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg
-- ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq
-- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf
-- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
-- please ask if you want to donate any other coins.
+- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
+- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
+- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
+- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
+- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
+- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
+- Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
Thank you,
@@ -192,7 +132,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 and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations)
+- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
- [For developers](#for-developers)
- [Roadmap](#roadmap)
- [Disclaimers, Security contact, License](#disclaimers)
@@ -233,29 +173,17 @@ You can use SimpleX with your own servers and still communicate with people usin
## News and updates
-Recent and important updates:
+Recent 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)
+[Feb 04, 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 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
+[Jan 03, 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 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)
+[Dec 06, 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).
-[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)
+[Nov 08, 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).
-[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 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).
-
-[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)
@@ -285,39 +213,29 @@ 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 and security: technical details and limitations
+## Privacy: 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](./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).
+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.
-We plan to add:
+We plan to add soon:
-1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
-2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
-3. Reproducible clients builds – this is a complex problem, but we are aiming to have it in 2025 at least partially.
-4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
+1. 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.
## For developers
@@ -330,8 +248,6 @@ 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.
@@ -365,52 +281,34 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Multiple user profiles in the same chat database.
- ✅ Optionally avoid re-using the same TCP session for multiple connections.
- ✅ Preserve message drafts.
-- ✅ File server to optimize for efficient and private sending of large files.
-- ✅ Improved audio & video calls.
-- ✅ Support older Android OS and 32-bit CPUs.
-- ✅ Hidden chat profiles.
-- ✅ 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.
-- ✅ 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.
-- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
-- Feeds/broadcasts.
+- 🏗 File server to optimize for efficient and private sending of large files.
+- 🏗 Improved audio & video calls.
+- 🏗 SMP queue redundancy and rotation (manual is supported).
+- 🏗 Reduced battery and traffic usage in large groups.
+- 🏗 Support older Android OS and 32-bit CPUs.
- Ephemeral/disappearing/OTR conversations with the existing contacts.
-- Privately share your location.
+- Access password/pin (with optional alternative access password).
+- Local app files encryption.
+- Video messages.
+- 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).
+- Feeds/broadcasts.
- 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 implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about) – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
-
-The cryptographic review of SimpleX protocols was done in July 2024 by Trail of Bits – see [the announcement](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
+The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
@@ -420,13 +318,13 @@ We have never provided or have been requested access to our servers or any infor
We do not log IP addresses of the users and we do not perform any traffic correlation on our servers. If transport level security is critical you must use Tor or some other similar network to access messaging servers. We will be improving the client applications to reduce the opportunities for traffic correlation.
-Please read more in [Privacy Policy](./PRIVACY.md).
+Please read more in [Terms & privacy policy](./PRIVACY.md).
## Security contact
-Please see our [Security Policy](./docs/SECURITY.md) on how to report security vulnerabilities to us. We will coordinate the fix and disclosure.
+To report a security vulnerability, please send us email to chat@simplex.chat. We will coordinate the fix and disclosure. Please do NOT report security vulnerabilities via GitHub issues.
-Please do NOT report security vulnerabilities via GitHub issues.
+Please treat any findings of possible traffic correlation attacks allowing to correlate two different conversations to the same user, other than covered in [the threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#threat-model), as security vulnerabilities, and follow this disclosure process.
## License
diff --git a/apps/android/.gitignore b/apps/android/.gitignore
new file mode 100644
index 0000000000..9ddf412fc2
--- /dev/null
+++ b/apps/android/.gitignore
@@ -0,0 +1,20 @@
+*.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
new file mode 100644
index 0000000000..ccb58e52e1
--- /dev/null
+++ b/apps/android/.idea/.name
@@ -0,0 +1 @@
+SimpleX
\ No newline at end of file
diff --git a/apps/multiplatform/.idea/codeStyles/Project.xml b/apps/android/.idea/codeStyles/Project.xml
similarity index 100%
rename from apps/multiplatform/.idea/codeStyles/Project.xml
rename to apps/android/.idea/codeStyles/Project.xml
diff --git a/apps/multiplatform/.idea/codeStyles/codeStyleConfig.xml b/apps/android/.idea/codeStyles/codeStyleConfig.xml
similarity index 100%
rename from apps/multiplatform/.idea/codeStyles/codeStyleConfig.xml
rename to apps/android/.idea/codeStyles/codeStyleConfig.xml
diff --git a/apps/android/.idea/compiler.xml b/apps/android/.idea/compiler.xml
new file mode 100644
index 0000000000..fb7f4a8a46
--- /dev/null
+++ b/apps/android/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/android/.idea/gradle.xml b/apps/android/.idea/gradle.xml
new file mode 100644
index 0000000000..a2d7c21338
--- /dev/null
+++ b/apps/android/.idea/gradle.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/android/.idea/inspectionProfiles/Project_Default.xml b/apps/android/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000000..28422375b6
--- /dev/null
+++ b/apps/android/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/android/.idea/vcs.xml b/apps/android/.idea/vcs.xml
new file mode 100644
index 0000000000..b2bdec2d71
--- /dev/null
+++ b/apps/android/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/multiplatform/README.md b/apps/android/README.md
similarity index 79%
rename from apps/multiplatform/README.md
rename to apps/android/README.md
index e8b0e086c9..7822847e56 100644
--- a/apps/multiplatform/README.md
+++ b/apps/android/README.md
@@ -4,6 +4,7 @@ This readme is currently a stub and as such is in development.
Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app.
+
## Gotchas
#### SHA Signature for verification for app links/deep links
@@ -22,15 +23,3 @@ To find your SHA certificate fingerprint perform the following steps.
More information is available [here](https://developer.android.com/training/app-links/verify-site-associations#manual-verification). If there is no response when running the `pm get-app-links` command, the intents in `AndroidManifest.xml` are likely misspecified. A verification attempt can be triggered using `adb shell pm verify-app-links --re-verify chat.simplex.app`.
Note that this is not an issue for the app store build of the app as this is signed with our app store credentials and thus there is a stable signature over users. Developers do not have general access to these credentials for development and testing.
-
-## Adding icons
-
-1. Find a [Material symbol](https://fonts.google.com/icons?icon.style=Rounded) in Rounded category.
-
-2. Set weight to 400, grade to -25 and size to 48px.
-
-3. Click on the icon, choose Android and download XML file.
-
-4. Update the color to black (#FF000000) and the size to "24.dp", as in other icons.
-
-For example, this is [add reaction icon](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:add_reaction:FILL@0;wght@300;GRAD@-25;opsz@24&icon.style=Rounded).
diff --git a/apps/multiplatform/android/.gitignore b/apps/android/app/.gitignore
similarity index 100%
rename from apps/multiplatform/android/.gitignore
rename to apps/android/app/.gitignore
diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle
new file mode 100644
index 0000000000..1b81bf7d5b
--- /dev/null
+++ b/apps/android/app/build.gradle
@@ -0,0 +1,204 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+ id 'org.jetbrains.kotlin.plugin.serialization'
+}
+
+android {
+ compileSdk 33
+
+ defaultConfig {
+ applicationId "chat.simplex.app"
+ minSdk 29
+ targetSdk 33
+ versionCode 104
+ versionName "4.6-beta.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ ndk {
+ abiFilters 'arm64-v8a'
+ }
+ 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
+// if (isRelease) {
+ // Comma separated list of languages that will be included in the apk
+ android.defaultConfig.resConfigs("en", "cs", "de", "es", "fr", "it", "nl", "ru", "zh-rCN")
+// }
+}
+
+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 "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"
+
+ 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"
+}
+
+def buildType = "unknown"
+// Don't do anything if no compression is needed
+if (compression_level != "0") {
+ tasks.whenTaskAdded { task ->
+ if (task.name == 'packageDebug') {
+ task.doLast {
+ buildType = "debug"
+ }
+ task.finalizedBy compressApk
+ } else if (task.name == 'packageRelease') {
+ task.doLast {
+ buildType = "release"
+ }
+ task.finalizedBy compressApk
+ }
+ }
+}
+
+tasks.register("compressApk") {
+ doLast {
+ 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"))
+ }
+
+ // View all gradle properties set
+ // project.properties.each { k, v -> println "$k -> $v" }
+ }
+}
diff --git a/apps/multiplatform/android/proguard-rules.pro b/apps/android/app/proguard-rules.pro
similarity index 100%
rename from apps/multiplatform/android/proguard-rules.pro
rename to apps/android/app/proguard-rules.pro
diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt b/apps/android/app/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt
similarity index 100%
rename from apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt
rename to apps/android/app/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt
diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
similarity index 62%
rename from apps/multiplatform/android/src/main/AndroidManifest.xml
rename to apps/android/app/src/main/AndroidManifest.xml
index 0470977bcd..3476fe6051 100644
--- a/apps/multiplatform/android/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -6,7 +6,6 @@
-
@@ -15,36 +14,24 @@
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -77,33 +63,8 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -142,13 +103,11 @@
-
+ android:launchMode="singleTask"/>
+ android:stopWithTask="false">
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/README.md b/apps/android/app/src/main/assets/www/README.md
similarity index 100%
rename from apps/multiplatform/common/src/commonMain/resources/assets/www/README.md
rename to apps/android/app/src/main/assets/www/README.md
diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.html b/apps/android/app/src/main/assets/www/call.html
similarity index 100%
rename from apps/multiplatform/common/src/commonMain/resources/assets/www/call.html
rename to apps/android/app/src/main/assets/www/call.html
diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js
new file mode 100644
index 0000000000..c7cf4a9324
--- /dev/null
+++ b/apps/android/app/src/main/assets/www/call.js
@@ -0,0 +1,672 @@
+"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
new file mode 100644
index 0000000000..2d1900a0d3
--- /dev/null
+++ b/apps/android/app/src/main/assets/www/lz-string.min.js
@@ -0,0 +1 @@
+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/multiplatform/common/src/commonMain/resources/assets/www/style.css b/apps/android/app/src/main/assets/www/style.css
similarity index 100%
rename from apps/multiplatform/common/src/commonMain/resources/assets/www/style.css
rename to apps/android/app/src/main/assets/www/style.css
diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt b/apps/android/app/src/main/cpp/CMakeLists.txt
similarity index 91%
rename from apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt
rename to apps/android/app/src/main/cpp/CMakeLists.txt
index 44cb31d424..e97f01708f 100644
--- a/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt
+++ b/apps/android/app/src/main/cpp/CMakeLists.txt
@@ -53,6 +53,10 @@ add_library( support SHARED IMPORTED )
set_target_properties( support PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
+add_library( crypto SHARED IMPORTED )
+set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
+ ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libcrypto.so)
+
# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.
@@ -60,8 +64,8 @@ set_target_properties( support PROPERTIES IMPORTED_LOCATION
target_link_libraries( # Specifies the target library.
app-lib
- simplex support
+ simplex support crypto
# Links the target library to the log library
# included in the NDK.
- ${log-lib})
+ ${log-lib})
\ No newline at end of file
diff --git a/apps/android/app/src/main/cpp/simplex-api.c b/apps/android/app/src/main/cpp/simplex-api.c
new file mode 100644
index 0000000000..a5264e3359
--- /dev/null
+++ b/apps/android/app/src/main/cpp/simplex-api.c
@@ -0,0 +1,87 @@
+#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);
+
+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, 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);
+
+JNIEXPORT jobjectArray JNICALL
+Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
+ const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
+ const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
+ jlong _ctrl = (jlong) 0;
+ jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, &_ctrl));
+ (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
+ (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
+
+ // 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;
+}
diff --git a/apps/multiplatform/android/src/main/icon-playstore.png b/apps/android/app/src/main/icon-playstore.png
similarity index 100%
rename from apps/multiplatform/android/src/main/icon-playstore.png
rename to apps/android/app/src/main/icon-playstore.png
diff --git a/apps/multiplatform/android/src/main/icon_dark_blue-playstore.png b/apps/android/app/src/main/icon_dark_blue-playstore.png
similarity index 100%
rename from apps/multiplatform/android/src/main/icon_dark_blue-playstore.png
rename to apps/android/app/src/main/icon_dark_blue-playstore.png
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
new file mode 100644
index 0000000000..bbe4e8318c
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/BackupAgent.kt
@@ -0,0 +1,18 @@
+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
new file mode 100644
index 0000000000..c868a9731c
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
@@ -0,0 +1,515 @@
+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.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Lock
+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.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.*
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.model.NtfManager
+import chat.simplex.app.model.NtfManager.Companion.getUserIdFromIntent
+import chat.simplex.app.ui.theme.SimpleButton
+import chat.simplex.app.ui.theme.SimpleXTheme
+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.chatlist.*
+import chat.simplex.app.views.database.DatabaseErrorView
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.newchat.*
+import chat.simplex.app.views.onboarding.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
+
+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()
+
+ 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(
+ Modifier
+ .background(MaterialTheme.colors.background)
+ .fillMaxSize()
+ ) {
+ MainPage(
+ m,
+ userAuthorized,
+ laFailed,
+ ::runAuthenticate,
+ ::setPerformLA,
+ showLANotice = { m.controller.showLANotice(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 onStart() {
+ super.onStart()
+ val enteredBackgroundVal = enteredBackground.value
+ if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30_000) {
+ 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()
+ 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
+ }
+ if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
+ // Drop shared content
+ SimplexApp.context.chatModel.sharedContent.value = null
+ }
+ }
+
+ private fun runAuthenticate() {
+ val m = vm.chatModel
+ if (!m.controller.appPrefs.performLA.get()) {
+ userAuthorized.value = true
+ } else {
+ userAuthorized.value = false
+ ModalManager.shared.closeModals()
+ authenticate(
+ generalGetString(R.string.auth_unlock),
+ generalGetString(R.string.auth_log_in_using_credential),
+ this@MainActivity,
+ completed = { laResult ->
+ when (laResult) {
+ LAResult.Success ->
+ userAuthorized.value = true
+ is LAResult.Error, LAResult.Failed ->
+ laFailed.value = true
+ LAResult.Unavailable -> {
+ userAuthorized.value = true
+ m.performLA.value = false
+ m.controller.appPrefs.performLA.set(false)
+ laUnavailableTurningOffAlert()
+ }
+ }
+ }
+ )
+ }
+ }
+
+ private fun setPerformLA(on: Boolean) {
+ vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
+ if (on) {
+ enableLA()
+ } else {
+ disableLA()
+ }
+ }
+
+ private fun enableLA() {
+ val m = vm.chatModel
+ authenticate(
+ generalGetString(R.string.auth_enable_simplex_lock),
+ generalGetString(R.string.auth_confirm_credential),
+ this@MainActivity,
+ completed = { laResult ->
+ val prefPerformLA = m.controller.appPrefs.performLA
+ when (laResult) {
+ LAResult.Success -> {
+ m.performLA.value = true
+ prefPerformLA.set(true)
+ laTurnedOnAlert()
+ }
+ is LAResult.Error, LAResult.Failed -> {
+ m.performLA.value = false
+ prefPerformLA.set(false)
+ }
+ LAResult.Unavailable -> {
+ m.performLA.value = false
+ prefPerformLA.set(false)
+ laUnavailableInstructionAlert()
+ }
+ }
+ }
+ )
+ }
+
+ private fun disableLA() {
+ val m = vm.chatModel
+ authenticate(
+ generalGetString(R.string.auth_disable_simplex_lock),
+ generalGetString(R.string.auth_confirm_credential),
+ this@MainActivity,
+ completed = { laResult ->
+ val prefPerformLA = m.controller.appPrefs.performLA
+ when (laResult) {
+ LAResult.Success -> {
+ m.performLA.value = false
+ prefPerformLA.set(false)
+ }
+ is LAResult.Error, LAResult.Failed -> {
+ m.performLA.value = true
+ prefPerformLA.set(true)
+ }
+ 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,
+ runAuthenticate: () -> Unit,
+ setPerformLA: (Boolean) -> Unit,
+ showLANotice: () -> Unit
+) {
+ // this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
+ var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(userAuthorized.value) {
+ if (chatModel.controller.appPrefs.performLA.get()) {
+ delay(500L)
+ }
+ chatsAccessAuthorized = userAuthorized.value == true
+ }
+ 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() {
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ SimpleButton(
+ stringResource(R.string.auth_unlock),
+ icon = Icons.Outlined.Lock,
+ click = {
+ laFailed.value = false
+ runAuthenticate()
+ }
+ )
+ }
+ }
+
+ Box {
+ val onboarding = chatModel.onboardingStage.value
+ val userCreated = chatModel.userCreated.value
+ when {
+ showChatDatabaseError -> {
+ chatModel.chatDbStatus.value?.let {
+ DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
+ }
+ }
+ onboarding == null || userCreated == null -> SplashView()
+ !chatsAccessAuthorized -> {
+ if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
+ authView()
+ } else {
+ SplashView()
+ }
+ }
+ onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
+ Box {
+ if (chatModel.showCallView.value) ActiveCallView(chatModel)
+ else {
+ 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_SetNotificationsMode -> SetNotificationsMode(chatModel)
+ }
+ ModalManager.shared.showInView()
+ val invitation = chatModel.activeCallInvitation.value
+ if (invitation != null) IncomingCallAlertView(invitation, chatModel)
+ AlertManager.shared.showInView()
+ }
+}
+
+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 {
+ if (userId != null && userId != chatModel.currentUser.value?.userId) {
+ chatModel.controller.changeActiveUser(userId)
+ }
+ val cInfo = chatModel.getChat(chatId)?.chatInfo
+ chatModel.clearOverlays.value = true
+ if (cInfo != null) openChat(cInfo, chatModel)
+ }
+ }
+ }
+ NtfManager.ShowChatsAction -> {
+ Log.d(TAG, "processNotificationIntent: ShowChatsAction")
+ withBGApi {
+ if (userId != null && userId != chatModel.currentUser.value?.userId) {
+ chatModel.controller.changeActiveUser(userId)
+ }
+ 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 {
+ "text/plain" == intent.type -> intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
+ chatModel.sharedContent.value = SharedContent.Text(it)
+ }
+ intent.type?.startsWith("image/") == true -> (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let {
+ chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(it))
+ } // All other mime types
+ else -> (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let {
+ chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
+ }
+ }
+ }
+ Intent.ACTION_SEND_MULTIPLE -> {
+ // Close active chat and show a list of chats
+ chatModel.chatId.value = null
+ chatModel.clearOverlays.value = true
+ when {
+ intent.type?.startsWith("image/") == true -> (intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) as? List)?.let {
+ chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
+ } // All other mime types
+ else -> {}
+ }
+ }
+ }
+}
+
+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.showAlertMsg(
+ 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)
+ }
+ }
+ )
+ }
+ }
+}
+//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
new file mode 100644
index 0000000000..923f71b970
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -0,0 +1,239 @@
+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.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): 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
+
+class SimplexApp: Application(), LifecycleEventObserver {
+ lateinit var chatController: ChatController
+
+ var isAppOnForeground: Boolean = false
+
+ val defaultLocale: Locale = Locale.getDefault()
+
+ fun initChatController(useKey: String? = null, startChat: Boolean = true) {
+ val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
+ val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
+ val migrated: Array = chatMigrateInit(dbAbsolutePathPrefix, dbKey)
+ 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
+ if (::chatController.isInitialized) {
+ chatController.ctrl = ctrl
+ } else {
+ chatController = ChatController(ctrl, ntfManager, applicationContext, appPreferences)
+ }
+ 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)
+ withApi {
+ val user = chatController.apiGetActiveUser()
+ if (user == null) {
+ chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
+ } else {
+ chatController.startChat(user)
+ 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)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ context = this
+ initChatController()
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
+ context.getDir("temp", MODE_PRIVATE).deleteRecursively()
+ }
+
+ 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()
+ }
+
+ 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
new file mode 100644
index 0000000000..4f5eb464b5
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt
@@ -0,0 +1,329 @@
+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)
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000..b573a13758
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
@@ -0,0 +1,2097 @@
+package chat.simplex.app.model
+
+import android.net.Uri
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+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.serialization.*
+import kotlinx.serialization.descriptors.*
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.*
+import java.io.File
+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 chatDbDeleted = mutableStateOf(false)
+ 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)
+ val userSMPServers = mutableStateOf<(List)?>(null)
+ // Allows to temporary save servers that are being edited on multiple screens
+ val userSMPServersUnsaved = mutableStateOf<(List)?>(null)
+ val presetSMPServers = 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 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 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) {
+ 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
+ if (chatId.value == cInfo.id) {
+ val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
+ if (itemIndex >= 0) {
+ chatItems[itemIndex] = cItem
+ return false
+ } else {
+ withContext(Dispatchers.Main) {
+ chatItems.add(cItem)
+ }
+ return true
+ }
+ } else {
+ return res
+ }
+ }
+
+ 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
+): 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 = ""
+
+ companion object {
+ val sampleData = User(
+ userId = 1,
+ userContactId = 1,
+ localDisplayName = "alice",
+ profile = LocalProfile.sampleData,
+ fullPreferences = FullChatPreferences.sampleData,
+ activeUser = true
+ )
+ }
+}
+
+@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.Voice -> mergedPreferences.voice.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
+ 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
+ }
+
+ 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.Voice -> mergedPreferences.voice.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 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, preferences)
+
+ companion object {
+ val sampleData = Profile(
+ displayName = "alice",
+ fullName = "Alice"
+ )
+ }
+}
+
+@Serializable
+class LocalProfile(
+ val profileId: Long,
+ override val displayName: String,
+ override val fullName: String,
+ override val image: String? = null,
+ override val localAlias: String,
+ 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, 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.Voice -> fullGroupPreferences.voice.on
+ }
+ 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 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 @Stable
+data class ChatItem (
+ val chatDir: CIDirection,
+ val meta: CIMeta,
+ val content: CIContent,
+ val formattedText: List? = null,
+ val quotedItem: CIQuote? = null,
+ 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 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.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,
+ 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,
+ 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,
+ 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,
+ 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,
+ 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,
+ 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,
+ 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,
+ file = null
+ )
+
+ fun invalidJSON(json: String): ChatItem =
+ ChatItem(
+ chatDir = CIDirection.DirectSnd(),
+ meta = CIMeta.invalidJSON(),
+ content = CIContent.InvalidJSON(json),
+ quotedItem = null,
+ 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 = HighOrLowlight): Pair? =
+ when (itemStatus) {
+ is CIStatus.SndSent -> Icons.Filled.Check to metaColor
+ is CIStatus.SndErrorAuth -> Icons.Filled.Close to Color.Red
+ is CIStatus.SndError -> Icons.Filled.WarningAmber to WarningYellow
+ is CIStatus.RcvNew -> Icons.Filled.Circle 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 )
+ return if (recent) String.format("%02d:%02d", time.hour, time.minute)
+ else String.format("%02d/%02d", time.dayOfMonth, time.monthNumber)
+}
+
+@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: CIDeleted()
+ @Serializable @SerialName("moderated") class Moderated(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("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 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}: ${TimedMessagesPreference.ttlText(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, TimedMessagesPreference.ttlText(param))
+ allowed != FeatureAllowed.NO ->
+ String.format(generalGetString(R.string.feature_offered_item), feature.text, TimedMessagesPreference.ttlText(param))
+ else ->
+ String.format(generalGetString(R.string.feature_cancelled_item), feature.text, TimedMessagesPreference.ttlText(param))
+ }
+ }
+}
+
+@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 CIFile(
+ val fileId: Long,
+ val fileName: String,
+ val fileSize: Long,
+ val filePath: String? = null,
+ val fileStatus: CIFileStatus
+) {
+ val loaded: Boolean = when (fileStatus) {
+ CIFileStatus.SndStored -> true
+ CIFileStatus.SndTransfer -> true
+ CIFileStatus.SndComplete -> true
+ CIFileStatus.SndCancelled -> true
+ CIFileStatus.RcvInvitation -> false
+ CIFileStatus.RcvAccepted -> false
+ CIFileStatus.RcvTransfer -> false
+ CIFileStatus.RcvCancelled -> false
+ CIFileStatus.RcvComplete -> true
+ }
+
+ 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)
+ }
+}
+
+@Serializable
+enum class CIFileStatus {
+ @SerialName("snd_stored") SndStored,
+ @SerialName("snd_transfer") SndTransfer,
+ @SerialName("snd_complete") SndComplete,
+ @SerialName("snd_cancelled") SndCancelled,
+ @SerialName("rcv_invitation") RcvInvitation,
+ @SerialName("rcv_accepted") RcvAccepted,
+ @SerialName("rcv_transfer") RcvTransfer,
+ @SerialName("rcv_complete") RcvComplete,
+ @SerialName("rcv_cancelled") RcvCancelled;
+}
+
+@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 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("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)
+ }
+ "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.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 = "%02d:%02d".format(sec / 60, sec % 60)
+
+@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)
+ }
+ }
+}
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
similarity index 58%
rename from apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
rename to apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
index cf19589d4a..b6c3c11653 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
@@ -1,59 +1,54 @@
package chat.simplex.app.model
import android.app.*
-import android.app.TaskStackBuilder
import android.content.*
-import android.content.pm.PackageManager
-import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.hardware.display.DisplayManager
import android.media.AudioAttributes
import android.net.Uri
+import android.util.Log
import android.view.Display
-import androidx.compose.ui.graphics.asAndroidBitmap
-import androidx.core.app.*
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
-import chat.simplex.app.TAG
-import chat.simplex.app.views.call.CallActivity
-import chat.simplex.app.views.call.getKeyguardManager
-import chat.simplex.common.views.helpers.*
-import chat.simplex.common.model.*
-import chat.simplex.common.platform.*
-import chat.simplex.common.views.call.CallMediaType
-import chat.simplex.common.views.call.RcvCallInvitation
+import chat.simplex.app.views.call.*
+import chat.simplex.app.views.chatlist.acceptContactRequest
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.usersettings.NotificationPreviewMode
import kotlinx.datetime.Clock
-import chat.simplex.res.MR
-object NtfManager {
- const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
- const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
- const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
- const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
+class NtfManager(val context: Context, private val appPreferences: AppPreferences) {
+ companion object {
+ const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
+ const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
+ const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
+ const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
- // DO NOT change notification channel settings / names
- const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2"
- const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
- const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
- const val EndCallAction: String = "chat.simplex.app.END_CALL"
- const val CallNotificationId: Int = -1
- private const val UserIdKey: String = "userId"
- private const val ChatIdKey: String = "chatId"
- private val appPreferences: AppPreferences = ChatController.appPrefs
- private val context: Context
- get() = SimplexApp.context
+ // DO NOT change notification channel settings / names
+ const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1"
+ const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
+ const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
+ const val CallNotificationId: Int = -1
- fun getUserIdFromIntent(intent: Intent?): Long? {
- val userId = intent?.getLongExtra(UserIdKey, -1L)
- return if (userId == -1L || userId == null) null else userId
+ private const val UserIdKey: String = "userId"
+ private const val ChatIdKey: String = "chatId"
+
+ fun getUserIdFromIntent(intent: Intent?): Long? {
+ val userId = intent?.getLongExtra(UserIdKey, -1L)
+ return if (userId == -1L || userId == null) null else userId
+ }
}
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- // (UserId, ChatId) -> Time
- private var prevNtfTime = mutableMapOf, Long>()
+ private var prevNtfTime = mutableMapOf()
private val msgNtfTimeoutMs = 30000L
init {
- if (areNotificationsEnabledInSystem()) createNtfChannelsMaybeShowAlert()
+ if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert()
+ }
+
+ enum class NotificationAction {
+ ACCEPT_CONTACT_REQUEST
}
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
@@ -62,8 +57,8 @@ object NtfManager {
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build()
- val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/ring_once")
- Log.d(TAG, "callNotificationChannel sound: $soundUri")
+ val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
+ Log.d(TAG,"callNotificationChannel sound: $soundUri")
callChannel.setSound(soundUri, attrs)
callChannel.enableVibration(true)
// the numbers below are explained here: https://developer.android.com/reference/android/os/Vibrator
@@ -73,45 +68,56 @@ object NtfManager {
}
fun cancelNotificationsForChat(chatId: String) {
- val key = prevNtfTime.keys.firstOrNull { it.second == chatId }
- prevNtfTime.remove(key)
+ prevNtfTime.remove(chatId)
manager.cancel(chatId.hashCode())
- val msgNtfs = manager.activeNotifications.filter { ntf ->
- ntf.notification.channelId == MessageChannel
+ val msgNtfs = manager.activeNotifications.filter {
+ ntf -> ntf.notification.channelId == MessageChannel
}
- if (msgNtfs.size <= 1) {
+ if (msgNtfs.count() == 1) {
// Have a group notification with no children so cancel it
manager.cancel(0)
}
}
- fun cancelNotificationsForUser(userId: Long) {
- prevNtfTime.keys.filter { it.first == userId }.forEach {
- prevNtfTime.remove(it)
- manager.cancel(it.second.hashCode())
- }
- val msgNtfs = manager.activeNotifications.filter { ntf ->
- ntf.notification.channelId == MessageChannel
- }
- if (msgNtfs.size <= 1) {
- // Have a group notification with no children so cancel it
- manager.cancel(0)
- }
+ fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
+ notifyMessageReceived(
+ user = user,
+ chatId = cInfo.id,
+ displayName = cInfo.displayName,
+ msgText = generalGetString(R.string.notification_new_contact_request),
+ image = cInfo.image,
+ listOf(NotificationAction.ACCEPT_CONTACT_REQUEST)
+ )
}
- fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List = emptyList()) {
- if (!user.showNotifications) return
+ fun notifyContactConnected(user: User, contact: Contact) {
+ notifyMessageReceived(
+ user = user,
+ chatId = contact.id,
+ displayName = contact.displayName,
+ msgText = generalGetString(R.string.notification_contact_connected)
+ )
+ }
+
+ fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
+ if (!cInfo.ntfsEnabled) return
+
+ notifyMessageReceived(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
+ }
+
+ fun notifyMessageReceived(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List = emptyList()) {
Log.d(TAG, "notifyMessageReceived $chatId")
val now = Clock.System.now().toEpochMilliseconds()
- val recentNotification = (now - prevNtfTime.getOrDefault(user.userId to chatId, 0) < msgNtfTimeoutMs)
- prevNtfTime[user.userId to chatId] = now
+ val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
+ prevNtfTime[chatId] = now
+
val previewMode = appPreferences.notificationPreviewMode.get()
- val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(MR.strings.notification_preview_somebody) else displayName
- val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(MR.strings.notification_preview_new_message) else msgText
+ val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(R.string.notification_preview_somebody) else displayName
+ val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(R.string.notification_preview_new_message) else msgText
val largeIcon = when {
actions.isEmpty() -> null
image == null || previewMode == NotificationPreviewMode.HIDDEN.name -> BitmapFactory.decodeResource(context.resources, R.drawable.icon)
- else -> base64ToBitmap(image).asAndroidBitmap()
+ else -> base64ToBitmap(image)
}
val builder = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(title)
@@ -135,11 +141,11 @@ object NtfManager {
actionIntent.putExtra(ChatIdKey, chatId)
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
val actionButton = when (action) {
- NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(MR.strings.accept)
- NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO -> generalGetString(MR.strings.accept_contact_incognito_button)
+ NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(R.string.accept)
}
builder.addAction(0, actionButton, actionPendingIntent)
}
+
val summary = NotificationCompat.Builder(context, MessageChannel)
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
@@ -151,23 +157,20 @@ object NtfManager {
with(NotificationManagerCompat.from(context)) {
// using cInfo.id only shows one notification per chat and updates it when the message arrives
- if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
- notify(chatId.hashCode(), builder.build())
- notify(0, summary)
- }
+ notify(chatId.hashCode(), builder.build())
+ notify(0, summary)
}
}
- fun notifyCallInvitation(invitation: RcvCallInvitation): Boolean {
+ fun notifyCallInvitation(invitation: RcvCallInvitation) {
val keyguardManager = getKeyguardManager(context)
- Log.d(
- TAG,
+ Log.d(TAG,
"notifyCallInvitation pre-requests: " +
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
- "onForeground ${isAppOnForeground}"
+ "onForeground ${SimplexApp.context.isAppOnForeground}"
)
- if (isAppOnForeground) return false
+ if (SimplexApp.context.isAppOnForeground) return
val contactId = invitation.contact.id
Log.d(TAG, "notifyCallInvitation $contactId")
val image = invitation.contact.image
@@ -175,37 +178,37 @@ object NtfManager {
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
var ntfBuilder =
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
- val fullScreenIntent = Intent(context, CallActivity::class.java)
+ val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
} else {
- val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/ring_once")
+ val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CallChannel)
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.user.userId, invitation.contact.id))
- .addAction(R.drawable.ntf_icon, generalGetString(MR.strings.accept), chatPendingIntent(AcceptCallAction, invitation.user.userId, contactId))
- .addAction(R.drawable.ntf_icon, generalGetString(MR.strings.reject), chatPendingIntent(RejectCallAction, invitation.user.userId, contactId, true))
+ .addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, invitation.user.userId, contactId))
+ .addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, invitation.user.userId, contactId, true))
.setFullScreenIntent(fullScreenPendingIntent, true)
.setSound(soundUri)
}
val text = generalGetString(
if (invitation.callType.media == CallMediaType.Video) {
- if (invitation.sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call
+ if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
} else {
- if (invitation.sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call
+ if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
}
)
val previewMode = appPreferences.notificationPreviewMode.get()
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
- generalGetString(MR.strings.notification_preview_somebody)
+ generalGetString(R.string.notification_preview_somebody)
else
invitation.contact.displayName
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
BitmapFactory.decodeResource(context.resources, R.drawable.icon)
else
- base64ToBitmap(image).asAndroidBitmap()
+ base64ToBitmap(image)
ntfBuilder = ntfBuilder
.setContentTitle(title)
@@ -220,42 +223,7 @@ object NtfManager {
// This makes notification sound and vibration repeat endlessly
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
with(NotificationManagerCompat.from(context)) {
- if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
- notify(CallNotificationId, notification)
- }
- }
- return true
- }
-
- fun showMessage(title: String, text: String) {
- val builder = NotificationCompat.Builder(context, MessageChannel)
- .setContentTitle(title)
- .setContentText(text)
- .setPriority(NotificationCompat.PRIORITY_HIGH)
- .setGroup(MessageGroup)
- .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
- .setSmallIcon(R.drawable.ntf_icon)
- .setLargeIcon(null as Bitmap?)
- .setColor(0x88FFFF)
- .setAutoCancel(true)
- .setVibrate(null)
- .setContentIntent(chatPendingIntent(ShowChatsAction, null, null))
- .setSilent(false)
-
- val summary = NotificationCompat.Builder(context, MessageChannel)
- .setSmallIcon(R.drawable.ntf_icon)
- .setColor(0x88FFFF)
- .setGroup(MessageGroup)
- .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
- .setGroupSummary(true)
- .setContentIntent(chatPendingIntent(ShowChatsAction, null))
- .build()
-
- with(NotificationManagerCompat.from(context)) {
- if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
- notify("MESSAGE".hashCode(), builder.build())
- notify(0, summary)
- }
+ notify(CallNotificationId, notification)
}
}
@@ -263,12 +231,21 @@ object NtfManager {
manager.cancel(CallNotificationId)
}
- fun cancelAllNotifications() {
- manager.cancelAll()
- }
-
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
+ private fun hideSecrets(cItem: ChatItem) : String {
+ val md = cItem.formattedText
+ return if (md != null) {
+ var res = ""
+ for (ft in md) {
+ res += if (ft.format is Format.Secret) "..." else ft.text
+ }
+ res
+ } else {
+ cItem.text
+ }
+ }
+
private fun chatPendingIntent(intentAction: String, userId: Long?, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
Log.d(TAG, "chatPendingIntent for $intentAction")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
@@ -287,8 +264,6 @@ object NtfManager {
}
}
- fun areNotificationsEnabledInSystem() = manager.areNotificationsEnabled()
-
/**
* This function creates notifications channels. On Android 13+ calling it for the first time will trigger system alert,
* The alert asks a user to allow or disallow to show notifications for the app. That's why it should be called only when the user
@@ -297,11 +272,10 @@ object NtfManager {
* old ones if needed
* */
fun createNtfChannelsMaybeShowAlert() {
- manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(MR.strings.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
- manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(MR.strings.ntf_channel_calls)))
+ manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
+ manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
// Remove old channels since they can't be edited
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
- manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION_1")
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
}
@@ -315,15 +289,23 @@ object NtfManager {
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
val m = SimplexApp.context.chatModel
when (intent.action) {
- NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, incognito = false, chatId)
- NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO.name -> ntfManager.acceptContactRequestAction(userId, incognito = true, chatId)
+ NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
+ val isCurrentUser = m.currentUser.value?.userId == userId
+ val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
+ (m.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
+ } else {
+ null
+ }
+ val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
+ acceptContactRequest(apiId, cInfo, isCurrentUser, m)
+ m.controller.ntfManager.cancelNotificationsForChat(chatId)
+ }
RejectCallAction -> {
val invitation = m.callInvitations[chatId]
if (invitation != null) {
m.callManager.endCall(invitation = invitation)
}
}
-
else -> {
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
}
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
new file mode 100644
index 0000000000..35b9187765
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
@@ -0,0 +1,3458 @@
+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 androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.FragmentActivity
+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.helpers.*
+import chat.simplex.app.views.newchat.ConnectViaLinkTab
+import chat.simplex.app.views.onboarding.OnboardingStage
+import chat.simplex.app.views.usersettings.*
+import kotlinx.coroutines.*
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.*
+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)
+
+ // 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 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)
+ 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 appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
+
+ 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 encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true)
+
+ val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
+ val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb())
+
+ val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
+
+ 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()
+ )
+
+ /**
+ * 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()
+ }
+ )
+
+ companion object {
+ internal const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
+ private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
+ private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
+ private const val SHARED_PREFS_NOTIFICATIONS_MODE = "NotificationsMode"
+ private const val SHARED_PREFS_NOTIFICATION_PREVIEW_MODE = "NotificationPreviewMode"
+ private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown"
+ private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown"
+ private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay"
+ private const val SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN = "CallsOnLockScreen"
+ private const val SHARED_PREFS_PERFORM_LA = "PerformLA"
+ private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown"
+ private const val SHARED_PREFS_WEBRTC_ICE_SERVERS = "WebrtcICEServers"
+ private const val SHARED_PREFS_PRIVACY_PROTECT_SCREEN = "PrivacyProtectScreen"
+ private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
+ private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
+ private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
+ private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
+ internal const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
+ private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
+ private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
+ private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
+ private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage"
+ private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
+ private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
+ private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
+ private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode"
+ private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode"
+ private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode"
+ private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout"
+ private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT = "NetworkTCPTimeout"
+ private const val SHARED_PREFS_NETWORK_SMP_PING_INTERVAL = "NetworkSMPPingInterval"
+ private const val SHARED_PREFS_NETWORK_SMP_PING_COUNT = "NetworkSMPPingCount"
+ private const val SHARED_PREFS_NETWORK_ENABLE_KEEP_ALIVE = "NetworkEnableKeepAlive"
+ private const val SHARED_PREFS_NETWORK_TCP_KEEP_IDLE = "NetworkTCPKeepIdle"
+ private const val SHARED_PREFS_NETWORK_TCP_KEEP_INTVL = "NetworkTCPKeepIntvl"
+ private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt"
+ private const val SHARED_PREFS_INCOGNITO = "Incognito"
+ private const val SHARED_PREFS_CONNECT_VIA_LINK_TAB = "ConnectViaLinkTab"
+ private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown"
+ private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
+ private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase"
+ private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase"
+ private const val SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE = "InitializationVectorDBPassphrase"
+ private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
+ private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme"
+ private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor"
+ private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
+ }
+}
+
+private const val MESSAGE_TIMEOUT: Int = 15_000_000
+
+open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) {
+ val chatModel = ChatModel(this)
+ private var receiverStarted = false
+ var lastMsgReceivedTimestamp: Long = System.currentTimeMillis()
+ private set
+
+ init {
+ chatModel.notificationsMode.value =
+ kotlin.runCatching { NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!) }.getOrDefault(NotificationsMode.default)
+ chatModel.notificationPreviewMode.value =
+ kotlin.runCatching { NotificationPreviewMode.valueOf(appPrefs.notificationPreviewMode.get()!!) }.getOrDefault(NotificationPreviewMode.default)
+ chatModel.performLA.value = appPrefs.performLA.get()
+ chatModel.incognito.value = appPrefs.incognito.get()
+ }
+
+ suspend fun startChat(user: User) {
+ Log.d(TAG, "user: $user")
+ try {
+ if (chatModel.chatRunning.value == true) return
+ apiSetNetworkConfig(getNetCfg())
+ val justStarted = apiStartChat()
+ val users = listUsers()
+ chatModel.users.clear()
+ chatModel.users.addAll(users)
+ if (justStarted) {
+ chatModel.currentUser.value = user
+ chatModel.userCreated.value = true
+ apiSetFilesFolder(getAppFilesDirectory(appContext))
+ apiSetIncognito(chatModel.incognito.value)
+ getUserChatData()
+ chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
+ chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
+ chatModel.chatRunning.value = true
+ startReceiver()
+ Log.d(TAG, "startChat: started")
+ } else {
+ val chats = apiGetChats()
+ chatModel.updateChats(chats)
+ Log.d(TAG, "startChat: running")
+ }
+ } catch (e: Error) {
+ Log.e(TAG, "failed starting chat $e")
+ throw e
+ }
+ }
+
+ suspend fun changeActiveUser(toUserId: Long) {
+ try {
+ changeActiveUser_(toUserId)
+ } catch (e: Exception) {
+ Log.e(TAG, "Unable to set active user: ${e.stackTraceToString()}")
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_active_user_title), e.stackTraceToString())
+ }
+ }
+
+ suspend fun changeActiveUser_(toUserId: Long) {
+ chatModel.currentUser.value = apiSetActiveUser(toUserId)
+ val users = listUsers()
+ chatModel.users.clear()
+ chatModel.users.addAll(users)
+ getUserChatData()
+ }
+
+ suspend fun getUserChatData() {
+ chatModel.userAddress.value = apiGetUserAddress()
+ val smpServers = getUserSMPServers()
+ chatModel.userSMPServers.value = smpServers?.first
+ chatModel.presetSMPServers.value = smpServers?.second
+ chatModel.chatItemTTL.value = getChatItemTTL()
+ val chats = apiGetChats()
+ chatModel.updateChats(chats)
+ }
+
+ private fun startReceiver() {
+ Log.d(TAG, "ChatController startReceiver")
+ if (receiverStarted) return
+ receiverStarted = true
+ CoroutineScope(Dispatchers.IO).launch {
+ while (true) {
+ /** Global [ctrl] can be null. It's needed for having the same [ChatModel] that already made in [ChatController] without the need
+ * to change it everywhere in code after changing a database.
+ * Since it can be changed in background thread, making this check to prevent NullPointerException */
+ val ctrl = ctrl
+ if (ctrl == null) {
+ receiverStarted = false
+ break
+ }
+ val msg = recvMsg(ctrl)
+ if (msg != null) processReceivedMsg(msg)
+ }
+ }
+ }
+
+ suspend fun sendCmd(cmd: CC): CR {
+ val ctrl = ctrl ?: throw Exception("Controller is not initialized")
+
+ return withContext(Dispatchers.IO) {
+ val c = cmd.cmdString
+ if (cmd !is CC.ApiParseMarkdown) {
+ chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated))
+ Log.d(TAG, "sendCmd: ${cmd.cmdType}")
+ }
+ val json = chatSendCmd(ctrl, c)
+ val r = APIResponse.decodeStr(json)
+ Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
+ if (r.resp is CR.Response || r.resp is CR.Invalid) {
+ Log.d(TAG, "sendCmd response json $json")
+ }
+ if (r.resp !is CR.ParsedMarkdown) {
+ chatModel.addTerminalItem(TerminalItem.resp(r.resp))
+ }
+ r.resp
+ }
+ }
+
+ private fun recvMsg(ctrl: ChatCtrl): CR? {
+ val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
+ return if (json == "") {
+ null
+ } else {
+ val r = APIResponse.decodeStr(json).resp
+ Log.d(TAG, "chatRecvMsg: ${r.responseType}")
+ if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
+ r
+ }
+ }
+
+ suspend fun apiGetActiveUser(): User? {
+ val r = sendCmd(CC.ShowActiveUser())
+ if (r is CR.ActiveUser) return r.user
+ Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}")
+ chatModel.userCreated.value = false
+ return null
+ }
+
+ suspend fun apiCreateActiveUser(p: Profile): User? {
+ val r = sendCmd(CC.CreateActiveUser(p))
+ if (r is CR.ActiveUser) return r.user
+ else if (
+ r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName ||
+ r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.UserExists
+ ) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_create_user_duplicate_title), generalGetString(R.string.failed_to_create_user_duplicate_desc))
+ } else {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_create_user_title), r.details)
+ }
+ Log.d(TAG, "apiCreateActiveUser: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun listUsers(): List {
+ val r = sendCmd(CC.ListUsers())
+ if (r is CR.UsersList) return r.users.sortedBy { it.user.chatViewName }
+ Log.d(TAG, "listUsers: ${r.responseType} ${r.details}")
+ throw Exception("failed to list users ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiSetActiveUser(userId: Long): User {
+ val r = sendCmd(CC.ApiSetActiveUser(userId))
+ if (r is CR.ActiveUser) return r.user
+ Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}")
+ throw Exception("failed to set the user as active ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiDeleteUser(userId: Long, delSMPQueues: Boolean) {
+ val r = sendCmd(CC.ApiDeleteUser(userId, delSMPQueues))
+ if (r is CR.CmdOk) return
+ Log.d(TAG, "apiDeleteUser: ${r.responseType} ${r.details}")
+ throw Exception("failed to delete the user ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiStartChat(): Boolean {
+ val r = sendCmd(CC.StartChat(expire = true))
+ when (r) {
+ is CR.ChatStarted -> return true
+ is CR.ChatRunning -> return false
+ else -> throw Error("failed starting chat: ${r.responseType} ${r.details}")
+ }
+ }
+
+ suspend fun apiStopChat(): Boolean {
+ val r = sendCmd(CC.ApiStopChat())
+ when (r) {
+ is CR.ChatStopped -> return true
+ else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}")
+ }
+ }
+
+ private suspend fun apiSetFilesFolder(filesFolder: String) {
+ val r = sendCmd(CC.SetFilesFolder(filesFolder))
+ if (r is CR.CmdOk) return
+ throw Error("failed to set files folder: ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiSetIncognito(incognito: Boolean) {
+ val r = sendCmd(CC.SetIncognito(incognito))
+ if (r is CR.CmdOk) return
+ throw Exception("failed to set incognito: ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiExportArchive(config: ArchiveConfig) {
+ val r = sendCmd(CC.ApiExportArchive(config))
+ if (r is CR.CmdOk) return
+ throw Error("failed to export archive: ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiImportArchive(config: ArchiveConfig) {
+ val r = sendCmd(CC.ApiImportArchive(config))
+ if (r is CR.CmdOk) return
+ throw Error("failed to import archive: ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiDeleteStorage() {
+ val r = sendCmd(CC.ApiDeleteStorage())
+ if (r is CR.CmdOk) return
+ throw Error("failed to delete storage: ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): CR.ChatCmdError? {
+ val r = sendCmd(CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey)))
+ if (r is CR.CmdOk) return null
+ else if (r is CR.ChatCmdError) return r
+ throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiGetChats(): List {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "apiGetChats: no current user")
+ return emptyList()
+ }
+ val r = sendCmd(CC.ApiGetChats(userId))
+ if (r is CR.ApiChats) return r.chats
+ Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}")
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_parse_chats_title), generalGetString(R.string.contact_developers))
+ return emptyList()
+ }
+
+ suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
+ val r = sendCmd(CC.ApiGetChat(type, id, pagination, search))
+ if (r is CR.ApiChat) return r.chat
+ Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_parse_chat_title), generalGetString(R.string.contact_developers))
+ return null
+ }
+
+ suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false): AChatItem? {
+ val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live)
+ val r = sendCmd(cmd)
+ return when (r) {
+ is CR.NewChatItem -> r.chatItem
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiSendMessage", generalGetString(R.string.error_sending_message), r)
+ }
+ null
+ }
+ }
+ }
+
+ suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? {
+ val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc, live))
+ if (r is CR.ChatItemUpdated) return r.chatItem
+ Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? {
+ val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode))
+ if (r is CR.ChatItemDeleted) return r
+ Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiDeleteMemberChatItem(groupId: Long, groupMemberId: Long, itemId: Long): Pair? {
+ val r = sendCmd(CC.ApiDeleteMemberChatItem(groupId, groupMemberId, itemId))
+ if (r is CR.ChatItemDeleted) return r.deletedChatItem.chatItem to r.toChatItem?.chatItem
+ Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ private suspend fun getUserSMPServers(): Pair, List>? {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "getUserSMPServers: no current user")
+ return null
+ }
+ val r = sendCmd(CC.APIGetUserSMPServers(userId))
+ if (r is CR.UserSMPServers) return r.smpServers to r.presetSMPServers
+ Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun setUserSMPServers(smpServers: List): Boolean {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "setUserSMPServers: no current user")
+ return false
+ }
+ val r = sendCmd(CC.APISetUserSMPServers(userId, smpServers))
+ return when (r) {
+ is CR.CmdOk -> true
+ else -> {
+ Log.e(TAG, "setUserSMPServers bad response: ${r.responseType} ${r.details}")
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.error_saving_smp_servers),
+ generalGetString(R.string.ensure_smp_server_address_are_correct_format_and_unique)
+ )
+ false
+ }
+ }
+ }
+
+ suspend fun testSMPServer(smpServer: String): SMPTestFailure? {
+ val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("testSMPServer: no current user") }
+ val r = sendCmd(CC.APITestSMPServer(userId, smpServer))
+ return when (r) {
+ is CR.SmpTestResult -> r.smpTestFailure
+ else -> {
+ Log.e(TAG, "testSMPServer bad response: ${r.responseType} ${r.details}")
+ throw Exception("testSMPServer bad response: ${r.responseType} ${r.details}")
+ }
+ }
+ }
+
+ suspend fun getChatItemTTL(): ChatItemTTL {
+ val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("getChatItemTTL: no current user") }
+ val r = sendCmd(CC.APIGetChatItemTTL(userId))
+ if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL)
+ throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}")
+ }
+
+ suspend fun setChatItemTTL(chatItemTTL: ChatItemTTL) {
+ val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("setChatItemTTL: no current user") }
+ val r = sendCmd(CC.APISetChatItemTTL(userId, chatItemTTL.seconds))
+ if (r is CR.CmdOk) return
+ throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiGetNetworkConfig(): NetCfg? {
+ val r = sendCmd(CC.APIGetNetworkConfig())
+ if (r is CR.NetworkConfig) return r.networkConfig
+ Log.e(TAG, "apiGetNetworkConfig bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiSetNetworkConfig(cfg: NetCfg): Boolean {
+ val r = sendCmd(CC.APISetNetworkConfig(cfg))
+ return when (r) {
+ is CR.CmdOk -> true
+ else -> {
+ Log.e(TAG, "apiSetNetworkConfig bad response: ${r.responseType} ${r.details}")
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.error_setting_network_config),
+ "${r.responseType}: ${r.details}"
+ )
+ false
+ }
+ }
+ }
+
+ suspend fun apiSetSettings(type: ChatType,id: Long, settings: ChatSettings): Boolean {
+ val r = sendCmd(CC.APISetChatSettings(type, id, settings))
+ return when (r) {
+ is CR.CmdOk -> true
+ else -> {
+ Log.e(TAG, "apiSetSettings bad response: ${r.responseType} ${r.details}")
+ false
+ }
+ }
+ }
+
+ suspend fun apiContactInfo(contactId: Long): Pair? {
+ val r = sendCmd(CC.APIContactInfo(contactId))
+ if (r is CR.ContactInfo) return r.connectionStats to r.customUserProfile
+ Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): ConnectionStats? {
+ val r = sendCmd(CC.APIGroupMemberInfo(groupId, groupMemberId))
+ if (r is CR.GroupMemberInfo) return r.connectionStats_
+ Log.e(TAG, "apiGroupMemberInfo bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiSwitchContact(contactId: Long) {
+ return when (val r = sendCmd(CC.APISwitchContact(contactId))) {
+ is CR.CmdOk -> {}
+ else -> {
+ apiErrorAlert("apiSwitchContact", generalGetString(R.string.connection_error), r)
+ }
+ }
+ }
+
+ suspend fun apiSwitchGroupMember(groupId: Long, groupMemberId: Long) {
+ return when (val r = sendCmd(CC.APISwitchGroupMember(groupId, groupMemberId))) {
+ is CR.CmdOk -> {}
+ else -> {
+ apiErrorAlert("apiSwitchGroupMember", generalGetString(R.string.error_changing_address), r)
+ }
+ }
+ }
+
+ suspend fun apiGetContactCode(contactId: Long): Pair {
+ val r = sendCmd(CC.APIGetContactCode(contactId))
+ if (r is CR.ContactCode) return r.contact to r.connectionCode
+ throw Exception("failed to get contact code: ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiGetGroupMemberCode(groupId: Long, groupMemberId: Long): Pair {
+ val r = sendCmd(CC.APIGetGroupMemberCode(groupId, groupMemberId))
+ if (r is CR.GroupMemberCode) return r.member to r.connectionCode
+ throw Exception("failed to get group member code: ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiVerifyContact(contactId: Long, connectionCode: String?): Pair? {
+ return when (val r = sendCmd(CC.APIVerifyContact(contactId, connectionCode))) {
+ is CR.ConnectionVerified -> r.verified to r.expectedCode
+ else -> null
+ }
+ }
+
+ suspend fun apiVerifyGroupMember(groupId: Long, groupMemberId: Long, connectionCode: String?): Pair? {
+ return when (val r = sendCmd(CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) {
+ is CR.ConnectionVerified -> r.verified to r.expectedCode
+ else -> null
+ }
+ }
+
+
+
+ suspend fun apiAddContact(): String? {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "apiAddContact: no current user")
+ return null
+ }
+ val r = sendCmd(CC.APIAddContact(userId))
+ return when (r) {
+ is CR.Invitation -> r.connReqInvitation
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiAddContact", generalGetString(R.string.connection_error), r)
+ }
+ null
+ }
+ }
+ }
+
+ suspend fun apiConnect(connReq: String): Boolean {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "apiConnect: no current user")
+ return false
+ }
+ val r = sendCmd(CC.APIConnect(userId, connReq))
+ when {
+ r is CR.SentConfirmation || r is CR.SentInvitation -> return true
+ r is CR.ContactAlreadyExists -> {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.contact_already_exists),
+ String.format(generalGetString(R.string.you_are_already_connected_to_vName_via_this_link), r.contact.displayName)
+ )
+ return false
+ }
+ r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
+ && r.chatError.errorType is ChatErrorType.InvalidConnReq -> {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.invalid_connection_link),
+ generalGetString(R.string.please_check_correct_link_and_maybe_ask_for_a_new_one)
+ )
+ return false
+ }
+ r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
+ && r.chatError.agentError is AgentErrorType.SMP
+ && r.chatError.agentError.smpErr is SMPErrorType.AUTH -> {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.connection_error_auth),
+ generalGetString(R.string.connection_error_auth_desc)
+ )
+ return false
+ }
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiConnect", generalGetString(R.string.connection_error), r)
+ }
+ return false
+ }
+ }
+ }
+
+ suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean {
+ val r = sendCmd(CC.ApiDeleteChat(type, id))
+ when {
+ r is CR.ContactDeleted && type == ChatType.Direct -> return true
+ r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true
+ r is CR.GroupDeletedUser && type == ChatType.Group -> return true
+ else -> {
+ val titleId = when (type) {
+ ChatType.Direct -> R.string.error_deleting_contact
+ ChatType.Group -> R.string.error_deleting_group
+ ChatType.ContactRequest -> R.string.error_deleting_contact_request
+ ChatType.ContactConnection -> R.string.error_deleting_pending_contact_connection
+ }
+ apiErrorAlert("apiDeleteChat", generalGetString(titleId), r)
+ }
+ }
+ return false
+ }
+
+ suspend fun apiClearChat(type: ChatType, id: Long): ChatInfo? {
+ val r = sendCmd(CC.ApiClearChat(type, id))
+ if (r is CR.ChatCleared) return r.chatInfo
+ Log.e(TAG, "apiClearChat bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiListContacts(): List? {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "apiListContacts: no current user")
+ return null
+ }
+ val r = sendCmd(CC.ApiListContacts(userId))
+ if (r is CR.ContactsList) return r.contacts
+ Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiUpdateProfile(profile: Profile): Profile? {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "apiUpdateProfile: no current user")
+ return null
+ }
+ val r = sendCmd(CC.ApiUpdateProfile(userId, profile))
+ if (r is CR.UserProfileNoChange) return profile
+ if (r is CR.UserProfileUpdated) return r.toProfile
+ Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? {
+ val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs))
+ if (r is CR.ContactPrefsUpdated) return r.toContact
+ Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiSetContactAlias(contactId: Long, localAlias: String): Contact? {
+ val r = sendCmd(CC.ApiSetContactAlias(contactId, localAlias))
+ if (r is CR.ContactAliasUpdated) return r.toContact
+ Log.e(TAG, "apiSetContactAlias bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiSetConnectionAlias(connId: Long, localAlias: String): PendingContactConnection? {
+ val r = sendCmd(CC.ApiSetConnectionAlias(connId, localAlias))
+ if (r is CR.ConnectionAliasUpdated) return r.toConnection
+ Log.e(TAG, "apiSetConnectionAlias bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiCreateUserAddress(): String? {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "apiCreateUserAddress: no current user")
+ return null
+ }
+ val r = sendCmd(CC.ApiCreateMyAddress(userId))
+ return when (r) {
+ is CR.UserContactLinkCreated -> r.connReqContact
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiCreateUserAddress", generalGetString(R.string.error_creating_address), r)
+ }
+ null
+ }
+ }
+ }
+
+ suspend fun apiDeleteUserAddress(): Boolean {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "apiDeleteUserAddress: no current user")
+ return false
+ }
+ val r = sendCmd(CC.ApiDeleteMyAddress(userId))
+ if (r is CR.UserContactLinkDeleted) return true
+ Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}")
+ return false
+ }
+
+ private suspend fun apiGetUserAddress(): UserContactLinkRec? {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "apiGetUserAddress: no current user")
+ return null
+ }
+ val r = sendCmd(CC.ApiShowMyAddress(userId))
+ if (r is CR.UserContactLink) return r.contactLink
+ if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
+ && r.chatError.storeError is StoreError.UserContactLinkNotFound) {
+ return null
+ }
+ Log.e(TAG, "apiGetUserAddress bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun userAddressAutoAccept(autoAccept: AutoAccept?): UserContactLinkRec? {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "userAddressAutoAccept: no current user")
+ return null
+ }
+ val r = sendCmd(CC.ApiAddressAutoAccept(userId, autoAccept))
+ if (r is CR.UserContactLinkUpdated) return r.contactLink
+ if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
+ && r.chatError.storeError is StoreError.UserContactLinkNotFound) {
+ return null
+ }
+ Log.e(TAG, "userAddressAutoAccept bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiAcceptContactRequest(contactReqId: Long): Contact? {
+ val r = sendCmd(CC.ApiAcceptContact(contactReqId))
+ return when {
+ r is CR.AcceptingContactRequest -> r.contact
+ r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
+ && r.chatError.agentError is AgentErrorType.SMP
+ && r.chatError.agentError.smpErr is SMPErrorType.AUTH -> {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.connection_error_auth),
+ generalGetString(R.string.sender_may_have_deleted_the_connection_request)
+ )
+ null
+ }
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiAcceptContactRequest", generalGetString(R.string.error_accepting_contact_request), r)
+ }
+ null
+ }
+ }
+ }
+
+ suspend fun apiRejectContactRequest(contactReqId: Long): Boolean {
+ val r = sendCmd(CC.ApiRejectContact(contactReqId))
+ if (r is CR.ContactRequestRejected) return true
+ Log.e(TAG, "apiRejectContactRequest bad response: ${r.responseType} ${r.details}")
+ return false
+ }
+
+ suspend fun apiSendCallInvitation(contact: Contact, callType: CallType): Boolean {
+ val r = sendCmd(CC.ApiSendCallInvitation(contact, callType))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiRejectCall(contact: Contact): Boolean {
+ val r = sendCmd(CC.ApiRejectCall(contact))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean {
+ val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates)
+ val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession)
+ val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean {
+ val answer = WebRTCSession(rtcSession, rtcIceCandidates)
+ val r = sendCmd(CC.ApiSendCallAnswer(contact, answer))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: String): Boolean {
+ val extraInfo = WebRTCExtraInfo(rtcIceCandidates)
+ val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiEndCall(contact: Contact): Boolean {
+ val r = sendCmd(CC.ApiEndCall(contact))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiCallStatus(contact: Contact, status: WebRTCCallStatus): Boolean {
+ val r = sendCmd(CC.ApiCallStatus(contact, status))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
+ val r = sendCmd(CC.ApiChatRead(type, id, range))
+ if (r is CR.CmdOk) return true
+ Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}")
+ return false
+ }
+
+ suspend fun apiChatUnread(type: ChatType, id: Long, unreadChat: Boolean): Boolean {
+ val r = sendCmd(CC.ApiChatUnread(type, id, unreadChat))
+ if (r is CR.CmdOk) return true
+ Log.e(TAG, "apiChatUnread bad response: ${r.responseType} ${r.details}")
+ return false
+ }
+
+ suspend fun apiReceiveFile(fileId: Long, inline: Boolean? = null): AChatItem? {
+ val r = sendCmd(CC.ReceiveFile(fileId, inline))
+ return when (r) {
+ is CR.RcvFileAccepted -> r.chatItem
+ is CR.RcvFileAcceptedSndCancelled -> {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.cannot_receive_file),
+ generalGetString(R.string.sender_cancelled_file_transfer)
+ )
+ null
+ }
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
+ && r.chatError.errorType is ChatErrorType.FileAlreadyReceiving
+ ) {
+ Log.d(TAG, "apiReceiveFile ignoring FileAlreadyReceiving error")
+ } else {
+ apiErrorAlert("apiReceiveFile", generalGetString(R.string.error_receiving_file), r)
+ }
+ }
+ null
+ }
+ }
+ }
+
+ suspend fun apiNewGroup(p: GroupProfile): GroupInfo? {
+ val userId = chatModel.currentUser.value?.userId ?: run {
+ Log.e(TAG, "apiNewGroup: no current user")
+ return null
+ }
+ val r = sendCmd(CC.ApiNewGroup(userId, p))
+ if (r is CR.GroupCreated) return r.groupInfo
+ Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? {
+ val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole))
+ return when (r) {
+ is CR.SentGroupInvitation -> r.member
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiAddMember", generalGetString(R.string.error_adding_members), r)
+ }
+ null
+ }
+ }
+ }
+
+ suspend fun apiJoinGroup(groupId: Long) {
+ val r = sendCmd(CC.ApiJoinGroup(groupId))
+ when (r) {
+ is CR.UserAcceptedGroupSent ->
+ chatModel.updateGroup(r.groupInfo)
+ is CR.ChatCmdError -> {
+ val e = r.chatError
+ suspend fun deleteGroup() { if (apiDeleteChat(ChatType.Group, groupId)) { chatModel.removeChat("#$groupId") } }
+ if (e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH) {
+ deleteGroup()
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.alert_title_group_invitation_expired), generalGetString(R.string.alert_message_group_invitation_expired))
+ } else if (e is ChatError.ChatErrorStore && e.storeError is StoreError.GroupNotFound) {
+ deleteGroup()
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.alert_title_no_group), generalGetString(R.string.alert_message_no_group))
+ } else if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiJoinGroup", generalGetString(R.string.error_joining_group), r)
+ }
+ }
+ else -> apiErrorAlert("apiJoinGroup", generalGetString(R.string.error_joining_group), r)
+ }
+ }
+
+ suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? =
+ when (val r = sendCmd(CC.ApiRemoveMember(groupId, memberId))) {
+ is CR.UserDeletedMember -> r.member
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiRemoveMember", generalGetString(R.string.error_removing_member), r)
+ }
+ null
+ }
+ }
+
+ suspend fun apiMemberRole(groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember =
+ when (val r = sendCmd(CC.ApiMemberRole(groupId, memberId, memberRole))) {
+ is CR.MemberRoleUser -> r.member
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiMemberRole", generalGetString(R.string.error_changing_role), r)
+ }
+ throw Exception("failed to change member role: ${r.responseType} ${r.details}")
+ }
+ }
+
+ suspend fun apiLeaveGroup(groupId: Long): GroupInfo? {
+ val r = sendCmd(CC.ApiLeaveGroup(groupId))
+ if (r is CR.LeftMemberUser) return r.groupInfo
+ Log.e(TAG, "apiLeaveGroup bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiListMembers(groupId: Long): List {
+ val r = sendCmd(CC.ApiListMembers(groupId))
+ if (r is CR.GroupMembers) return r.group.members
+ Log.e(TAG, "apiListMembers bad response: ${r.responseType} ${r.details}")
+ return emptyList()
+ }
+
+ suspend fun apiUpdateGroup(groupId: Long, groupProfile: GroupProfile): GroupInfo? {
+ return when (val r = sendCmd(CC.ApiUpdateGroupProfile(groupId, groupProfile))) {
+ is CR.GroupUpdated -> r.toGroup
+ is CR.ChatCmdError -> {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.error_saving_group_profile), "$r.chatError")
+ null
+ }
+ else -> {
+ Log.e(TAG, "apiUpdateGroup bad response: ${r.responseType} ${r.details}")
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.error_saving_group_profile),
+ "${r.responseType}: ${r.details}"
+ )
+ null
+ }
+ }
+ }
+
+ suspend fun apiCreateGroupLink(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? {
+ return when (val r = sendCmd(CC.APICreateGroupLink(groupId, memberRole))) {
+ is CR.GroupLinkCreated -> r.connReqContact to r.memberRole
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiCreateGroupLink", generalGetString(R.string.error_creating_link_for_group), r)
+ }
+ null
+ }
+ }
+ }
+
+ suspend fun apiGroupLinkMemberRole(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? {
+ return when (val r = sendCmd(CC.APIGroupLinkMemberRole(groupId, memberRole))) {
+ is CR.GroupLink -> r.connReqContact to r.memberRole
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiGroupLinkMemberRole", generalGetString(R.string.error_updating_link_for_group), r)
+ }
+ null
+ }
+ }
+ }
+
+ suspend fun apiDeleteGroupLink(groupId: Long): Boolean {
+ return when (val r = sendCmd(CC.APIDeleteGroupLink(groupId))) {
+ is CR.GroupLinkDeleted -> true
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiDeleteGroupLink", generalGetString(R.string.error_deleting_link_for_group), r)
+ }
+ false
+ }
+ }
+ }
+
+ suspend fun apiGetGroupLink(groupId: Long): Pair? {
+ return when (val r = sendCmd(CC.APIGetGroupLink(groupId))) {
+ is CR.GroupLink -> r.connReqContact to r.memberRole
+ else -> {
+ Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}")
+ null
+ }
+ }
+ }
+
+ suspend fun allowFeatureToContact(contact: Contact, feature: ChatFeature, param: Int? = null) {
+ val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param)
+ val toContact = apiSetContactPrefs(contact.contactId, prefs)
+ if (toContact != null) {
+ chatModel.updateContact(toContact)
+ }
+ }
+
+ suspend fun apiGetVersion(): CoreVersionInfo? {
+ val r = sendCmd(CC.ShowVersion())
+ return if (r is CR.VersionInfo) {
+ r.versionInfo
+ } else {
+ Log.e(TAG, "apiGetVersion bad response: ${r.responseType} ${r.details}")
+ null
+ }
+ }
+
+ suspend fun apiParseMarkdown(text: String): List? {
+ val r = sendCmd(CC.ApiParseMarkdown(text))
+ if (r is CR.ParsedMarkdown) return r.formattedText
+ Log.e(TAG, "apiParseMarkdown bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ private fun networkErrorAlert(r: CR): Boolean {
+ return when {
+ r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
+ && r.chatError.agentError is AgentErrorType.BROKER
+ && r.chatError.agentError.brokerErr is BrokerErrorType.TIMEOUT -> {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.connection_timeout),
+ String.format(generalGetString(R.string.network_error_desc), serverHostname(r.chatError.agentError.brokerAddress))
+ )
+ true
+ }
+ r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
+ && r.chatError.agentError is AgentErrorType.BROKER
+ && r.chatError.agentError.brokerErr is BrokerErrorType.NETWORK -> {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.connection_error),
+ String.format(generalGetString(R.string.network_error_desc), serverHostname(r.chatError.agentError.brokerAddress))
+ )
+ true
+ }
+ else -> false
+ }
+ }
+
+ fun apiErrorAlert(method: String, title: String, r: CR) {
+ val errMsg = "${r.responseType}: ${r.details}"
+ Log.e(TAG, "$method bad response: $errMsg")
+ AlertManager.shared.showAlertMsg(title, errMsg)
+ }
+
+ suspend fun processReceivedMsg(r: CR) {
+ lastMsgReceivedTimestamp = System.currentTimeMillis()
+ chatModel.addTerminalItem(TerminalItem.resp(r))
+ when (r) {
+ is CR.NewContactConnection -> {
+ if (active(r.user)) {
+ chatModel.updateContactConnection(r.connection)
+ }
+ }
+ is CR.ContactConnectionDeleted -> {
+ if (active(r.user)) {
+ chatModel.removeChat(r.connection.id)
+ }
+ }
+ is CR.ContactConnected -> {
+ if (active(r.user) && r.contact.directOrUsed) {
+ chatModel.updateContact(r.contact)
+ chatModel.dismissConnReqView(r.contact.activeConn.id)
+ chatModel.removeChat(r.contact.activeConn.id)
+ }
+ if (r.contact.directOrUsed) {
+ ntfManager.notifyContactConnected(r.user, r.contact)
+ }
+ chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected())
+ }
+ is CR.ContactConnecting -> {
+ if (active(r.user) && r.contact.directOrUsed) {
+ chatModel.updateContact(r.contact)
+ chatModel.dismissConnReqView(r.contact.activeConn.id)
+ chatModel.removeChat(r.contact.activeConn.id)
+ }
+ }
+ is CR.ReceivedContactRequest -> {
+ val contactRequest = r.contactRequest
+ val cInfo = ChatInfo.ContactRequest(contactRequest)
+ if (active(r.user)) {
+ chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
+ }
+ ntfManager.notifyContactRequestReceived(r.user, cInfo)
+ }
+ is CR.ContactUpdated -> {
+ if (active(r.user) && chatModel.hasChat(r.toContact.id)) {
+ val cInfo = ChatInfo.Direct(r.toContact)
+ chatModel.updateChatInfo(cInfo)
+ }
+ }
+ is CR.ContactsMerged -> {
+ if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) {
+ if (chatModel.chatId.value == r.mergedContact.id) {
+ chatModel.chatId.value = r.intoContact.id
+ }
+ chatModel.removeChat(r.mergedContact.id)
+ }
+ }
+ is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected())
+ is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected())
+ is CR.ContactSubError -> {
+ if (active(r.user)) {
+ chatModel.updateContact(r.contact)
+ }
+ processContactSubError(r.contact, r.chatError)
+ }
+ is CR.ContactSubSummary -> {
+ for (sub in r.contactSubscriptions) {
+ if (active(r.user)) {
+ chatModel.updateContact(sub.contact)
+ }
+ val err = sub.contactError
+ if (err == null) {
+ chatModel.setContactNetworkStatus(sub.contact, NetworkStatus.Connected())
+ } else {
+ processContactSubError(sub.contact, sub.contactError)
+ }
+ }
+ }
+ is CR.NewChatItem -> {
+ val cInfo = r.chatItem.chatInfo
+ val cItem = r.chatItem.chatItem
+ if (active(r.user)) {
+ chatModel.addChatItem(cInfo, cItem)
+ } else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
+ chatModel.increaseUnreadCounter(r.user)
+ }
+ val file = cItem.file
+ val mc = cItem.content.msgContent
+ if (file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) {
+ val acceptImages = appPrefs.privacyAcceptImages.get()
+ if ((mc is MsgContent.MCImage && acceptImages)
+ || (mc is MsgContent.MCVoice && ((file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && acceptImages) || cInfo is ChatInfo.Group))) {
+ withApi { receiveFile(r.user, file.fileId) } // TODO check inlineFileMode != IFMSent
+ }
+ }
+ if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) {
+ ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
+ }
+ }
+ is CR.ChatItemStatusUpdated -> {
+ val cInfo = r.chatItem.chatInfo
+ val cItem = r.chatItem.chatItem
+ if (active(r.user) && !cItem.isDeletedContent && chatModel.upsertChatItem(cInfo, cItem)) {
+ ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
+ }
+ if (!active(r.user) && !cItem.isDeletedContent) {
+ ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
+ }
+ }
+ is CR.ChatItemUpdated ->
+ chatItemSimpleUpdate(r.user, r.chatItem)
+ is CR.ChatItemDeleted -> {
+ if (!active(r.user)) {
+ if (r.toChatItem == null && r.deletedChatItem.chatItem.isRcvNew && r.deletedChatItem.chatInfo.ntfsEnabled) {
+ chatModel.decreaseUnreadCounter(r.user)
+ }
+ return
+ }
+
+ val cInfo = r.deletedChatItem.chatInfo
+ val cItem = r.deletedChatItem.chatItem
+ AudioPlayer.stop(cItem)
+ val isLastChatItem = chatModel.getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id
+ if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) {
+ ntfManager.cancelNotificationsForChat(cInfo.id)
+ ntfManager.notifyMessageReceived(
+ r.user,
+ cInfo.id,
+ cInfo.displayName,
+ generalGetString(if (r.toChatItem != null) R.string.marked_deleted_description else R.string.deleted_description)
+ )
+ }
+ if (r.toChatItem == null) {
+ chatModel.removeChatItem(cInfo, cItem)
+ } else {
+ chatModel.upsertChatItem(cInfo, r.toChatItem.chatItem)
+ }
+ }
+ is CR.ReceivedGroupInvitation -> {
+ if (active(r.user)) {
+ chatModel.updateGroup(r.groupInfo) // update so that repeat group invitations are not duplicated
+ // TODO NtfManager.shared.notifyGroupInvitation
+ }
+ }
+ is CR.UserAcceptedGroupSent -> {
+ if (!active(r.user)) return
+
+ chatModel.updateGroup(r.groupInfo)
+ if (r.hostContact != null) {
+ chatModel.dismissConnReqView(r.hostContact.activeConn.id)
+ chatModel.removeChat(r.hostContact.activeConn.id)
+ }
+ }
+ is CR.JoinedGroupMemberConnecting ->
+ if (active(r.user)) {
+ chatModel.upsertGroupMember(r.groupInfo, r.member)
+ }
+ is CR.DeletedMemberUser -> // TODO update user member
+ if (active(r.user)) {
+ chatModel.updateGroup(r.groupInfo)
+ }
+ is CR.DeletedMember ->
+ if (active(r.user)) {
+ chatModel.upsertGroupMember(r.groupInfo, r.deletedMember)
+ }
+ is CR.LeftMember ->
+ if (active(r.user)) {
+ chatModel.upsertGroupMember(r.groupInfo, r.member)
+ }
+ is CR.MemberRole ->
+ if (active(r.user)) {
+ chatModel.upsertGroupMember(r.groupInfo, r.member)
+ }
+ is CR.MemberRoleUser ->
+ if (active(r.user)) {
+ chatModel.upsertGroupMember(r.groupInfo, r.member)
+ }
+ is CR.GroupDeleted -> // TODO update user member
+ if (active(r.user)) {
+ chatModel.updateGroup(r.groupInfo)
+ }
+ is CR.UserJoinedGroup ->
+ if (active(r.user)) {
+ chatModel.updateGroup(r.groupInfo)
+ }
+ is CR.JoinedGroupMember ->
+ if (active(r.user)) {
+ chatModel.upsertGroupMember(r.groupInfo, r.member)
+ }
+ is CR.ConnectedToGroupMember ->
+ if (active(r.user)) {
+ chatModel.upsertGroupMember(r.groupInfo, r.member)
+ }
+ is CR.GroupUpdated ->
+ if (active(r.user)) {
+ chatModel.updateGroup(r.toGroup)
+ }
+ is CR.MemberRole ->
+ if (active(r.user)) {
+ chatModel.updateGroup(r.groupInfo)
+ }
+ is CR.RcvFileStart ->
+ chatItemSimpleUpdate(r.user, r.chatItem)
+ is CR.RcvFileComplete ->
+ chatItemSimpleUpdate(r.user, r.chatItem)
+ is CR.SndFileStart ->
+ chatItemSimpleUpdate(r.user, r.chatItem)
+ is CR.SndFileComplete -> {
+ chatItemSimpleUpdate(r.user, r.chatItem)
+ val cItem = r.chatItem.chatItem
+ val mc = cItem.content.msgContent
+ val fileName = cItem.file?.fileName
+ if (
+ r.chatItem.chatInfo.chatType == ChatType.Direct
+ && mc is MsgContent.MCFile
+ && fileName != null
+ ) {
+ removeFile(appContext, fileName)
+ }
+ }
+ is CR.CallInvitation ->
+ chatModel.callManager.reportNewIncomingCall(r.callInvitation)
+ is CR.CallOffer -> {
+ // TODO askConfirmation?
+ // TODO check encryption is compatible
+ withCall(r, r.contact) { call ->
+ chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey)
+ val useRelay = chatModel.controller.appPrefs.webrtcPolicyRelay.get()
+ val iceServers = getIceServers()
+ Log.d(TAG, ".callOffer iceServers $iceServers")
+ chatModel.callCommand.value = WCallCommand.Offer(
+ offer = r.offer.rtcSession,
+ iceCandidates = r.offer.rtcIceCandidates,
+ media = r.callType.media,
+ aesKey = r.sharedKey,
+ iceServers = iceServers,
+ relay = useRelay
+ )
+ }
+ }
+ is CR.CallAnswer -> {
+ withCall(r, r.contact) { call ->
+ chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived)
+ chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)
+ }
+ }
+ is CR.CallExtraInfo -> {
+ withCall(r, r.contact) { _ ->
+ chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)
+ }
+ }
+ is CR.CallEnded -> {
+ val invitation = chatModel.callInvitations.remove(r.contact.id)
+ if (invitation != null) {
+ chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
+ }
+ withCall(r, r.contact) { _ ->
+ chatModel.callCommand.value = WCallCommand.End
+ withApi {
+ chatModel.activeCall.value = null
+ chatModel.showCallView.value = false
+ }
+ }
+ }
+ else ->
+ Log.d(TAG , "unsupported event: ${r.responseType}")
+ }
+ }
+
+ private fun active(user: User): Boolean = user.userId == chatModel.currentUser.value?.userId
+
+ private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
+ val call = chatModel.activeCall.value
+ if (call != null && call.contact.apiId == contact.apiId) {
+ perform(call)
+ } else {
+ Log.d(TAG, "processReceivedMsg: ignoring ${r.responseType}, not in call with the contact ${contact.id}")
+ }
+ }
+
+ suspend fun receiveFile(user: User, fileId: Long) {
+ val chatItem = apiReceiveFile(fileId)
+ if (chatItem != null) {
+ chatItemSimpleUpdate(user, chatItem)
+ }
+ }
+
+ suspend fun leaveGroup(groupId: Long) {
+ val groupInfo = apiLeaveGroup(groupId)
+ if (groupInfo != null) {
+ chatModel.updateGroup(groupInfo)
+ }
+ }
+
+ private suspend fun chatItemSimpleUpdate(user: User, aChatItem: AChatItem) {
+ val cInfo = aChatItem.chatInfo
+ val cItem = aChatItem.chatItem
+ val notify = { ntfManager.notifyMessageReceived(user, cInfo, cItem) }
+ if (!active(user)) {
+ notify()
+ } else if (chatModel.upsertChatItem(cInfo, cItem)) {
+ notify()
+ }
+ }
+
+ private fun updateContactsStatus(contactRefs: List, status: NetworkStatus) {
+ for (c in contactRefs) {
+ chatModel.networkStatuses[c.agentConnId] = status
+ }
+ }
+
+ private fun processContactSubError(contact: Contact, chatError: ChatError) {
+ val e = chatError
+ val err: String =
+ if (e is ChatError.ChatErrorAgent) {
+ val a = e.agentError
+ when {
+ a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network"
+ a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted"
+ else -> e.string
+ }
+ }
+ else e.string
+ chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err))
+ }
+
+ fun showBackgroundServiceNoticeIfNeeded() {
+ val mode = NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!)
+ Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
+ // Nothing to do if mode is OFF. Can be selected on on-boarding stage
+ if (mode == NotificationsMode.OFF) return
+
+ if (!appPrefs.backgroundServiceNoticeShown.get()) {
+ // the branch for the new users who have never seen service notice
+ if (!mode.requiresIgnoringBattery || isIgnoringBatteryOptimizations(appContext)) {
+ showBGServiceNotice(mode)
+ } else {
+ showBGServiceNoticeIgnoreOptimization(mode)
+ }
+ // set both flags, so that if the user doesn't allow ignoring optimizations, the service will be disabled without additional notice
+ appPrefs.backgroundServiceNoticeShown.set(true)
+ appPrefs.backgroundServiceBatteryNoticeShown.set(true)
+ } else if (mode.requiresIgnoringBattery && !isIgnoringBatteryOptimizations(appContext)) {
+ // the branch for users who have app installed, and have seen the service notice,
+ // but the battery optimization for the app is on (Android 12) AND the service is running
+ if (appPrefs.backgroundServiceBatteryNoticeShown.get()) {
+ // users have been presented with battery notice before - they did not allow ignoring optimizations -> disable service
+ showDisablingServiceNotice(mode)
+ appPrefs.notificationsMode.set(NotificationsMode.OFF.name)
+ chatModel.notificationsMode.value = NotificationsMode.OFF
+ SimplexService.StartReceiver.toggleReceiver(false)
+ MessagesFetcherWorker.cancelAll()
+ SimplexService.safeStopService(SimplexApp.context)
+ } else {
+ // show battery optimization notice
+ showBGServiceNoticeIgnoreOptimization(mode)
+ appPrefs.backgroundServiceBatteryNoticeShown.set(true)
+ }
+ } else {
+ // service or periodic mode was chosen and battery optimization is disabled
+ SimplexApp.context.schedulePeriodicServiceRestartWorker()
+ SimplexApp.context.schedulePeriodicWakeUp()
+ }
+ }
+
+ private fun showBGServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert {
+ AlertDialog(
+ onDismissRequest = AlertManager.shared::hideAlert,
+ title = {
+ Row {
+ Icon(
+ Icons.Outlined.Bolt,
+ contentDescription =
+ if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications),
+ )
+ Text(
+ if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications),
+ fontWeight = FontWeight.Bold
+ )
+ }
+ },
+ text = {
+ Column {
+ Text(
+ if (mode == NotificationsMode.SERVICE) annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(R.string.periodic_notifications_desc),
+ Modifier.padding(bottom = 8.dp)
+ )
+ Text(
+ annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown)
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
+ }
+ )
+ }
+
+ private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode) = AlertManager.shared.showAlert {
+ val ignoreOptimization = {
+ AlertManager.shared.hideAlert()
+ askAboutIgnoringBatteryOptimization(appContext)
+ }
+ AlertDialog(
+ onDismissRequest = ignoreOptimization,
+ title = {
+ Row {
+ Icon(
+ Icons.Outlined.Bolt,
+ contentDescription =
+ if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications),
+ )
+ Text(
+ if (mode == NotificationsMode.SERVICE) stringResource(R.string.service_notifications) else stringResource(R.string.periodic_notifications),
+ fontWeight = FontWeight.Bold
+ )
+ }
+ },
+ text = {
+ Column {
+ Text(
+ if (mode == NotificationsMode.SERVICE) annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(R.string.periodic_notifications_desc),
+ Modifier.padding(bottom = 8.dp)
+ )
+ Text(annotatedStringResource(R.string.turn_off_battery_optimization))
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = ignoreOptimization) { Text(stringResource(R.string.ok)) }
+ }
+ )
+ }
+
+ private fun showDisablingServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert {
+ AlertDialog(
+ onDismissRequest = AlertManager.shared::hideAlert,
+ title = {
+ Row {
+ Icon(
+ Icons.Outlined.Bolt,
+ contentDescription =
+ if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications),
+ )
+ Text(
+ if (mode == NotificationsMode.SERVICE) stringResource(R.string.service_notifications_disabled) else stringResource(R.string.periodic_notifications_disabled),
+ fontWeight = FontWeight.Bold
+ )
+ }
+ },
+ text = {
+ Column {
+ Text(
+ annotatedStringResource(R.string.turning_off_service_and_periodic),
+ Modifier.padding(bottom = 8.dp)
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
+ }
+ )
+ }
+
+ fun showLANotice(activity: FragmentActivity) {
+ Log.d(TAG, "showLANotice")
+ if (!appPrefs.laNoticeShown.get()) {
+ appPrefs.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 = {
+ authenticate(
+ generalGetString(R.string.auth_enable_simplex_lock),
+ generalGetString(R.string.auth_confirm_credential),
+ activity,
+ completed = { laResult ->
+ when (laResult) {
+ LAResult.Success -> {
+ chatModel.performLA.value = true
+ appPrefs.performLA.set(true)
+ laTurnedOnAlert()
+ }
+ is LAResult.Error, LAResult.Failed -> {
+ chatModel.performLA.value = false
+ appPrefs.performLA.set(false)
+ }
+ LAResult.Unavailable -> {
+ chatModel.performLA.value = false
+ appPrefs.performLA.set(false)
+ chatModel.showAdvertiseLAUnavailableAlert.value = true
+ }
+ }
+ }
+ )
+ }
+ )
+ }
+ }
+
+ fun isIgnoringBatteryOptimizations(context: Context): Boolean {
+ val powerManager = context.getSystemService(Application.POWER_SERVICE) as PowerManager
+ return powerManager.isIgnoringBatteryOptimizations(context.packageName)
+ }
+
+ private fun askAboutIgnoringBatteryOptimization(context: Context) {
+ Intent().apply {
+ @SuppressLint("BatteryLife")
+ action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
+ data = Uri.parse("package:${context.packageName}")
+ // This flag is needed when you start a new activity from non-Activity context
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(this)
+ }
+ }
+
+ fun getNetCfg(): NetCfg {
+ val useSocksProxy = appPrefs.networkUseSocksProxy.get()
+ val socksProxy = if (useSocksProxy) ":9050" else null
+ val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!)
+ val requiredHostMode = appPrefs.networkRequiredHostMode.get()
+ val sessionMode = appPrefs.networkSessionMode.get()
+ val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get()
+ val tcpTimeout = appPrefs.networkTCPTimeout.get()
+ val smpPingInterval = appPrefs.networkSMPPingInterval.get()
+ val smpPingCount = appPrefs.networkSMPPingCount.get()
+ val enableKeepAlive = appPrefs.networkEnableKeepAlive.get()
+ val tcpKeepAlive = if (enableKeepAlive) {
+ val keepIdle = appPrefs.networkTCPKeepIdle.get()
+ val keepIntvl = appPrefs.networkTCPKeepIntvl.get()
+ val keepCnt = appPrefs.networkTCPKeepCnt.get()
+ KeepAliveOpts(keepIdle = keepIdle, keepIntvl = keepIntvl, keepCnt = keepCnt)
+ } else {
+ null
+ }
+ return NetCfg(
+ socksProxy = socksProxy,
+ hostMode = hostMode,
+ requiredHostMode = requiredHostMode,
+ sessionMode = sessionMode,
+ tcpConnectTimeout = tcpConnectTimeout,
+ tcpTimeout = tcpTimeout,
+ tcpKeepAlive = tcpKeepAlive,
+ smpPingInterval = smpPingInterval,
+ smpPingCount = smpPingCount
+ )
+ }
+
+ fun setNetCfg(cfg: NetCfg) {
+ appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy)
+ appPrefs.networkHostMode.set(cfg.hostMode.name)
+ appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode)
+ appPrefs.networkSessionMode.set(cfg.sessionMode)
+ appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout)
+ appPrefs.networkTCPTimeout.set(cfg.tcpTimeout)
+ appPrefs.networkSMPPingInterval.set(cfg.smpPingInterval)
+ appPrefs.networkSMPPingCount.set(cfg.smpPingCount)
+ if (cfg.tcpKeepAlive != null) {
+ appPrefs.networkEnableKeepAlive.set(true)
+ appPrefs.networkTCPKeepIdle.set(cfg.tcpKeepAlive.keepIdle)
+ appPrefs.networkTCPKeepIntvl.set(cfg.tcpKeepAlive.keepIntvl)
+ appPrefs.networkTCPKeepCnt.set(cfg.tcpKeepAlive.keepCnt)
+ } else {
+ appPrefs.networkEnableKeepAlive.set(false)
+ }
+ }
+}
+
+class SharedPreference(val get: () -> T, set: (T) -> Unit) {
+ val set: (T) -> Unit
+ private val _state: MutableState by lazy { mutableStateOf(get()) }
+ val state: State by lazy { _state }
+
+ init {
+ this.set = { value ->
+ set(value)
+ _state.value = value
+ }
+ }
+}
+
+// ChatCommand
+sealed class CC {
+ class Console(val cmd: String): CC()
+ class ShowActiveUser: CC()
+ class CreateActiveUser(val profile: Profile): CC()
+ class ListUsers: CC()
+ class ApiSetActiveUser(val userId: Long): CC()
+ class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean): CC()
+ class StartChat(val expire: Boolean): CC()
+ class ApiStopChat: CC()
+ class SetFilesFolder(val filesFolder: String): CC()
+ class SetIncognito(val incognito: Boolean): CC()
+ class ApiExportArchive(val config: ArchiveConfig): CC()
+ class ApiImportArchive(val config: ArchiveConfig): CC()
+ class ApiDeleteStorage: CC()
+ class ApiStorageEncryption(val config: DBEncryptionConfig): CC()
+ class ApiGetChats(val userId: Long): CC()
+ class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
+ class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean): CC()
+ class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
+ class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
+ class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC()
+ class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC()
+ class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
+ class ApiJoinGroup(val groupId: Long): CC()
+ class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC()
+ class ApiRemoveMember(val groupId: Long, val memberId: Long): CC()
+ class ApiLeaveGroup(val groupId: Long): CC()
+ class ApiListMembers(val groupId: Long): CC()
+ class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
+ class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole): CC()
+ class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC()
+ class APIDeleteGroupLink(val groupId: Long): CC()
+ class APIGetGroupLink(val groupId: Long): CC()
+ class APIGetUserSMPServers(val userId: Long): CC()
+ class APISetUserSMPServers(val userId: Long, val smpServers: List): CC()
+ class APITestSMPServer(val userId: Long, val smpServer: String): CC()
+ class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC()
+ class APIGetChatItemTTL(val userId: Long): CC()
+ class APISetNetworkConfig(val networkConfig: NetCfg): CC()
+ class APIGetNetworkConfig: CC()
+ class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC()
+ class APIContactInfo(val contactId: Long): CC()
+ class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC()
+ class APISwitchContact(val contactId: Long): CC()
+ class APISwitchGroupMember(val groupId: Long, val groupMemberId: Long): CC()
+ class APIGetContactCode(val contactId: Long): CC()
+ class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC()
+ class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC()
+ class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC()
+ class APIAddContact(val userId: Long): CC()
+ class APIConnect(val userId: Long, val connReq: String): CC()
+ class ApiDeleteChat(val type: ChatType, val id: Long): CC()
+ class ApiClearChat(val type: ChatType, val id: Long): CC()
+ class ApiListContacts(val userId: Long): CC()
+ class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
+ class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
+ class ApiParseMarkdown(val text: String): CC()
+ class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
+ class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC()
+ class ApiCreateMyAddress(val userId: Long): CC()
+ class ApiDeleteMyAddress(val userId: Long): CC()
+ class ApiShowMyAddress(val userId: Long): CC()
+ class ApiAddressAutoAccept(val userId: Long, val autoAccept: AutoAccept?): CC()
+ class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC()
+ class ApiRejectCall(val contact: Contact): CC()
+ class ApiSendCallOffer(val contact: Contact, val callOffer: WebRTCCallOffer): CC()
+ class ApiSendCallAnswer(val contact: Contact, val answer: WebRTCSession): CC()
+ class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC()
+ class ApiEndCall(val contact: Contact): CC()
+ class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC()
+ class ApiAcceptContact(val contactReqId: Long): CC()
+ class ApiRejectContact(val contactReqId: Long): CC()
+ class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
+ class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
+ class ReceiveFile(val fileId: Long, val inline: Boolean?): CC()
+ class ShowVersion(): CC()
+
+ val cmdString: String get() = when (this) {
+ is Console -> cmd
+ is ShowActiveUser -> "/u"
+ is CreateActiveUser -> "/create user ${profile.displayName} ${profile.fullName}"
+ is ListUsers -> "/users"
+ is ApiSetActiveUser -> "/_user $userId"
+ is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}"
+ is StartChat -> "/_start subscribe=on expire=${onOff(expire)}"
+ is ApiStopChat -> "/_stop"
+ is SetFilesFolder -> "/_files_folder $filesFolder"
+ is SetIncognito -> "/incognito ${onOff(incognito)}"
+ is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
+ is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
+ is ApiDeleteStorage -> "/_db delete"
+ is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}"
+ is ApiGetChats -> "/_get chats $userId pcc=on"
+ is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
+ is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
+ is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}"
+ is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
+ is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId"
+ is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}"
+ is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
+ is ApiJoinGroup -> "/_join #$groupId"
+ is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}"
+ is ApiRemoveMember -> "/_remove #$groupId $memberId"
+ is ApiLeaveGroup -> "/_leave #$groupId"
+ is ApiListMembers -> "/_members #$groupId"
+ is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
+ is APICreateGroupLink -> "/_create link #$groupId ${memberRole.name.lowercase()}"
+ is APIGroupLinkMemberRole -> "/_set link role #$groupId ${memberRole.name.lowercase()}"
+ is APIDeleteGroupLink -> "/_delete link #$groupId"
+ is APIGetGroupLink -> "/_get link #$groupId"
+ is APIGetUserSMPServers -> "/_smp $userId"
+ is APISetUserSMPServers -> "/_smp $userId ${smpServersStr(smpServers)}"
+ is APITestSMPServer -> "/_smp test $userId $smpServer"
+ is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}"
+ is APIGetChatItemTTL -> "/_ttl $userId"
+ is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
+ is APIGetNetworkConfig -> "/network"
+ is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
+ is APIContactInfo -> "/_info @$contactId"
+ is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
+ is APISwitchContact -> "/_switch @$contactId"
+ is APISwitchGroupMember -> "/_switch #$groupId $groupMemberId"
+ is APIGetContactCode -> "/_get code @$contactId"
+ is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId"
+ is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else ""
+ is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else ""
+ is APIAddContact -> "/_connect $userId"
+ is APIConnect -> "/_connect $userId $connReq"
+ is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
+ is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
+ is ApiListContacts -> "/_contacts $userId"
+ is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
+ is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
+ is ApiParseMarkdown -> "/_parse $text"
+ is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
+ is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}"
+ is ApiCreateMyAddress -> "/_address $userId"
+ is ApiDeleteMyAddress -> "/_delete_address $userId"
+ is ApiShowMyAddress -> "/_show_address $userId"
+ is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}"
+ is ApiAcceptContact -> "/_accept $contactReqId"
+ is ApiRejectContact -> "/_reject $contactReqId"
+ is ApiSendCallInvitation -> "/_call invite @${contact.apiId} ${json.encodeToString(callType)}"
+ is ApiRejectCall -> "/_call reject @${contact.apiId}"
+ is ApiSendCallOffer -> "/_call offer @${contact.apiId} ${json.encodeToString(callOffer)}"
+ is ApiSendCallAnswer -> "/_call answer @${contact.apiId} ${json.encodeToString(answer)}"
+ is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}"
+ is ApiEndCall -> "/_call end @${contact.apiId}"
+ is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
+ is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
+ is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
+ is ReceiveFile -> if (inline == null) "/freceive $fileId" else "/freceive $fileId inline=${onOff(inline)}"
+ is ShowVersion -> "/version"
+ }
+
+ val cmdType: String get() = when (this) {
+ is Console -> "console command"
+ is ShowActiveUser -> "showActiveUser"
+ is CreateActiveUser -> "createActiveUser"
+ is ListUsers -> "listUsers"
+ is ApiSetActiveUser -> "apiSetActiveUser"
+ is ApiDeleteUser -> "apiDeleteUser"
+ is StartChat -> "startChat"
+ is ApiStopChat -> "apiStopChat"
+ is SetFilesFolder -> "setFilesFolder"
+ is SetIncognito -> "setIncognito"
+ is ApiExportArchive -> "apiExportArchive"
+ is ApiImportArchive -> "apiImportArchive"
+ is ApiDeleteStorage -> "apiDeleteStorage"
+ is ApiStorageEncryption -> "apiStorageEncryption"
+ is ApiGetChats -> "apiGetChats"
+ is ApiGetChat -> "apiGetChat"
+ is ApiSendMessage -> "apiSendMessage"
+ is ApiUpdateChatItem -> "apiUpdateChatItem"
+ is ApiDeleteChatItem -> "apiDeleteChatItem"
+ is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem"
+ is ApiNewGroup -> "apiNewGroup"
+ is ApiAddMember -> "apiAddMember"
+ is ApiJoinGroup -> "apiJoinGroup"
+ is ApiMemberRole -> "apiMemberRole"
+ is ApiRemoveMember -> "apiRemoveMember"
+ is ApiLeaveGroup -> "apiLeaveGroup"
+ is ApiListMembers -> "apiListMembers"
+ is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
+ is APICreateGroupLink -> "apiCreateGroupLink"
+ is APIGroupLinkMemberRole -> "apiGroupLinkMemberRole"
+ is APIDeleteGroupLink -> "apiDeleteGroupLink"
+ is APIGetGroupLink -> "apiGetGroupLink"
+ is APIGetUserSMPServers -> "apiGetUserSMPServers"
+ is APISetUserSMPServers -> "apiSetUserSMPServers"
+ is APITestSMPServer -> "testSMPServer"
+ is APISetChatItemTTL -> "apiSetChatItemTTL"
+ is APIGetChatItemTTL -> "apiGetChatItemTTL"
+ is APISetNetworkConfig -> "/apiSetNetworkConfig"
+ is APIGetNetworkConfig -> "/apiGetNetworkConfig"
+ is APISetChatSettings -> "/apiSetChatSettings"
+ is APIContactInfo -> "apiContactInfo"
+ is APIGroupMemberInfo -> "apiGroupMemberInfo"
+ is APISwitchContact -> "apiSwitchContact"
+ is APISwitchGroupMember -> "apiSwitchGroupMember"
+ is APIGetContactCode -> "apiGetContactCode"
+ is APIGetGroupMemberCode -> "apiGetGroupMemberCode"
+ is APIVerifyContact -> "apiVerifyContact"
+ is APIVerifyGroupMember -> "apiVerifyGroupMember"
+ is APIAddContact -> "apiAddContact"
+ is APIConnect -> "apiConnect"
+ is ApiDeleteChat -> "apiDeleteChat"
+ is ApiClearChat -> "apiClearChat"
+ is ApiListContacts -> "apiListContacts"
+ is ApiUpdateProfile -> "apiUpdateProfile"
+ is ApiSetContactPrefs -> "apiSetContactPrefs"
+ is ApiParseMarkdown -> "apiParseMarkdown"
+ is ApiSetContactAlias -> "apiSetContactAlias"
+ is ApiSetConnectionAlias -> "apiSetConnectionAlias"
+ is ApiCreateMyAddress -> "apiCreateMyAddress"
+ is ApiDeleteMyAddress -> "apiDeleteMyAddress"
+ is ApiShowMyAddress -> "apiShowMyAddress"
+ is ApiAddressAutoAccept -> "apiAddressAutoAccept"
+ is ApiAcceptContact -> "apiAcceptContact"
+ is ApiRejectContact -> "apiRejectContact"
+ is ApiSendCallInvitation -> "apiSendCallInvitation"
+ is ApiRejectCall -> "apiRejectCall"
+ is ApiSendCallOffer -> "apiSendCallOffer"
+ is ApiSendCallAnswer -> "apiSendCallAnswer"
+ is ApiSendCallExtraInfo -> "apiSendCallExtraInfo"
+ is ApiEndCall -> "apiEndCall"
+ is ApiCallStatus -> "apiCallStatus"
+ is ApiChatRead -> "apiChatRead"
+ is ApiChatUnread -> "apiChatUnread"
+ is ReceiveFile -> "receiveFile"
+ is ShowVersion -> "showVersion"
+ }
+
+ class ItemRange(val from: Long, val to: Long)
+
+ fun chatItemTTLStr(seconds: Long?): String {
+ if (seconds == null) return "none"
+ return seconds.toString()
+ }
+
+ val obfuscated: CC
+ get() = when (this) {
+ is ApiStorageEncryption -> ApiStorageEncryption(DBEncryptionConfig(obfuscate(config.currentKey), obfuscate(config.newKey)))
+ else -> this
+ }
+
+ private fun obfuscate(s: String): String = if (s.isEmpty()) "" else "***"
+
+ private fun onOff(b: Boolean): String = if (b) "on" else "off"
+
+ companion object {
+ fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
+
+ fun smpServersStr(smpServers: List) = if (smpServers.isEmpty()) "default" else json.encodeToString(SMPServersConfig(smpServers))
+ }
+}
+
+sealed class ChatPagination {
+ class Last(val count: Int): ChatPagination()
+ class After(val chatItemId: Long, val count: Int): ChatPagination()
+ class Before(val chatItemId: Long, val count: Int): ChatPagination()
+
+ val cmdString: String get() = when (this) {
+ is Last -> "count=${this.count}"
+ is After -> "after=${this.chatItemId} count=${this.count}"
+ is Before -> "before=${this.chatItemId} count=${this.count}"
+ }
+
+ companion object {
+ const val INITIAL_COUNT = 100
+ const val PRELOAD_COUNT = 100
+ const val UNTIL_PRELOAD_COUNT = 50
+ }
+}
+
+@Serializable
+class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
+
+@Serializable
+class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
+
+@Serializable
+class DBEncryptionConfig(val currentKey: String, val newKey: String)
+
+@Serializable
+data class SMPServersConfig(
+ val smpServers: List
+)
+
+@Serializable
+data class ServerCfg(
+ val server: String,
+ val preset: Boolean,
+ val tested: Boolean? = null,
+ val enabled: Boolean
+) {
+ @Transient
+ private val createdAt: Date = Date()
+ // val sendEnabled: Boolean // can we potentially want to prevent sending on the servers we use to receive?
+ // Even if we don't see the use case, it's probably better to allow it in the model
+ // In any case, "trusted/known" servers are out of scope of this change
+ val id: String
+ get() = "$server $createdAt"
+
+ val isBlank: Boolean
+ get() = server.isBlank()
+
+ companion object {
+ val empty = ServerCfg(server = "", preset = false, tested = null, enabled = true)
+
+ class SampleData(
+ val preset: ServerCfg,
+ val custom: ServerCfg,
+ val untested: ServerCfg
+ )
+
+ val sampleData = SampleData(
+ preset = ServerCfg(
+ server = "smp://abcd@smp8.simplex.im",
+ preset = true,
+ tested = true,
+ enabled = true
+ ),
+ custom = ServerCfg(
+ server = "smp://abcd@smp9.simplex.im",
+ preset = false,
+ tested = false,
+ enabled = false
+ ),
+ untested = ServerCfg(
+ server = "smp://abcd@smp10.simplex.im",
+ preset = false,
+ tested = null,
+ enabled = true
+ )
+ )
+ }
+}
+
+@Serializable
+enum class SMPTestStep {
+ @SerialName("connect") Connect,
+ @SerialName("createQueue") CreateQueue,
+ @SerialName("secureQueue") SecureQueue,
+ @SerialName("deleteQueue") DeleteQueue,
+ @SerialName("disconnect") Disconnect;
+
+ val text: String get() = when (this) {
+ Connect -> generalGetString(R.string.smp_server_test_connect)
+ CreateQueue -> generalGetString(R.string.smp_server_test_create_queue)
+ SecureQueue -> generalGetString(R.string.smp_server_test_secure_queue)
+ DeleteQueue -> generalGetString(R.string.smp_server_test_delete_queue)
+ Disconnect -> generalGetString(R.string.smp_server_test_disconnect)
+ }
+}
+
+@Serializable
+data class SMPTestFailure(
+ val testStep: SMPTestStep,
+ val testError: AgentErrorType
+) {
+ override fun equals(other: Any?): Boolean {
+ if (other !is SMPTestFailure) return false
+ return other.testStep == this.testStep
+ }
+
+ override fun hashCode(): Int {
+ return testStep.hashCode()
+ }
+
+ val localizedDescription: String get() {
+ val err = String.format(generalGetString(R.string.error_smp_test_failed_at_step), testStep.text)
+ return when {
+ testError is AgentErrorType.SMP && testError.smpErr is SMPErrorType.AUTH ->
+ err + " " + generalGetString(R.string.error_smp_test_server_auth)
+ testError is AgentErrorType.BROKER && testError.brokerErr is BrokerErrorType.NETWORK ->
+ err + " " + generalGetString(R.string.error_smp_test_certificate)
+ else -> err
+ }
+ }
+}
+
+@Serializable
+data class ServerAddress(
+ val hostnames: List,
+ val port: String,
+ val keyHash: String,
+ val basicAuth: String = ""
+) {
+ val uri: String
+ get() =
+ "smp://${keyHash}${if (basicAuth.isEmpty()) "" else ":$basicAuth"}@${hostnames.joinToString(",")}"
+
+ val valid: Boolean
+ get() = hostnames.isNotEmpty() && hostnames.toSet().size == hostnames.size
+
+ companion object {
+ val empty = ServerAddress(
+ hostnames = emptyList(),
+ port = "",
+ keyHash = "",
+ basicAuth = ""
+ )
+ val sampleData = ServerAddress(
+ hostnames = listOf("smp.simplex.im", "1234.onion"),
+ port = "",
+ keyHash = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=",
+ basicAuth = "server_password"
+ )
+
+ fun parseServerAddress(s: String): ServerAddress? {
+ val parsed = chatParseServer(s)
+ return runCatching { json.decodeFromString(ParsedServerAddress.serializer(), parsed) }
+ .onFailure { Log.d(TAG, "parseServerAddress decode error: $it") }
+ .getOrNull()?.serverAddress
+ }
+ }
+}
+
+@Serializable
+data class ParsedServerAddress (
+ var serverAddress: ServerAddress?,
+ var parseError: String
+)
+
+@Serializable
+data class NetCfg(
+ val socksProxy: String?,
+ val hostMode: HostMode,
+ val requiredHostMode: Boolean,
+ val sessionMode: TransportSessionMode,
+ val tcpConnectTimeout: Long, // microseconds
+ val tcpTimeout: Long, // microseconds
+ val tcpKeepAlive: KeepAliveOpts?,
+ val smpPingInterval: Long, // microseconds
+ val smpPingCount: Int,
+ val logTLSErrors: Boolean = false
+) {
+ val useSocksProxy: Boolean get() = socksProxy != null
+ val enableKeepAlive: Boolean get() = tcpKeepAlive != null
+
+ companion object {
+ val defaults: NetCfg =
+ NetCfg(
+ socksProxy = null,
+ hostMode = HostMode.OnionViaSocks,
+ requiredHostMode = false,
+ sessionMode = TransportSessionMode.User,
+ tcpConnectTimeout = 10_000_000,
+ tcpTimeout = 7_000_000,
+ tcpKeepAlive = KeepAliveOpts.defaults,
+ smpPingInterval = 1200_000_000,
+ smpPingCount = 3
+ )
+
+ val proxyDefaults: NetCfg =
+ NetCfg(
+ socksProxy = ":9050",
+ hostMode = HostMode.OnionViaSocks,
+ requiredHostMode = false,
+ sessionMode = TransportSessionMode.User,
+ tcpConnectTimeout = 20_000_000,
+ tcpTimeout = 15_000_000,
+ tcpKeepAlive = KeepAliveOpts.defaults,
+ smpPingInterval = 1200_000_000,
+ smpPingCount = 3
+ )
+ }
+
+ val onionHosts: OnionHosts get() = when {
+ hostMode == HostMode.Public && requiredHostMode -> OnionHosts.NEVER
+ hostMode == HostMode.OnionViaSocks && !requiredHostMode -> OnionHosts.PREFER
+ hostMode == HostMode.OnionViaSocks && requiredHostMode -> OnionHosts.REQUIRED
+ else -> OnionHosts.PREFER
+ }
+
+ fun withOnionHosts(mode: OnionHosts): NetCfg = when (mode) {
+ OnionHosts.NEVER ->
+ this.copy(hostMode = HostMode.Public, requiredHostMode = true)
+ OnionHosts.PREFER ->
+ this.copy(hostMode = HostMode.OnionViaSocks, requiredHostMode = false)
+ OnionHosts.REQUIRED ->
+ this.copy(hostMode = HostMode.OnionViaSocks, requiredHostMode = true)
+ }
+}
+
+enum class OnionHosts {
+ NEVER, PREFER, REQUIRED
+}
+
+@Serializable
+enum class HostMode {
+ @SerialName("onionViaSocks") OnionViaSocks,
+ @SerialName("onion") Onion,
+ @SerialName("public") Public;
+}
+
+@Serializable
+enum class TransportSessionMode {
+ @SerialName("user") User,
+ @SerialName("entity") Entity;
+
+ companion object {
+ val default = User
+ }
+}
+
+@Serializable
+data class KeepAliveOpts(
+ val keepIdle: Int, // seconds
+ val keepIntvl: Int, // seconds
+ val keepCnt: Int // times
+) {
+ companion object {
+ val defaults: KeepAliveOpts =
+ KeepAliveOpts(keepIdle = 30, keepIntvl = 15, keepCnt = 4)
+ }
+}
+
+@Serializable
+data class ChatSettings(
+ val enableNtfs: Boolean
+)
+
+@Serializable
+data class FullChatPreferences(
+ val timedMessages: TimedMessagesPreference,
+ val fullDelete: SimpleChatPreference,
+ val voice: SimpleChatPreference,
+) {
+ fun toPreferences(): ChatPreferences = ChatPreferences(timedMessages = timedMessages, fullDelete = fullDelete, voice = voice)
+
+ companion object {
+ val sampleData = FullChatPreferences(
+ timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO),
+ fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
+ voice = SimpleChatPreference(allow = FeatureAllowed.YES)
+ )
+ }
+}
+
+@Serializable
+data class ChatPreferences(
+ val timedMessages: TimedMessagesPreference?,
+ val fullDelete: SimpleChatPreference?,
+ val voice: SimpleChatPreference?,
+) {
+ fun setAllowed(feature: ChatFeature, allowed: FeatureAllowed = FeatureAllowed.YES, param: Int? = null): ChatPreferences =
+ when (feature) {
+ ChatFeature.TimedMessages -> this.copy(timedMessages = TimedMessagesPreference(allow = allowed, ttl = param ?: this.timedMessages?.ttl))
+ ChatFeature.FullDelete -> this.copy(fullDelete = SimpleChatPreference(allow = allowed))
+ ChatFeature.Voice -> this.copy(voice = SimpleChatPreference(allow = allowed))
+ }
+
+ companion object {
+ val sampleData = ChatPreferences(
+ timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO),
+ fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
+ voice = SimpleChatPreference(allow = FeatureAllowed.YES)
+ )
+ }
+}
+
+interface ChatPreference {
+ val allow: FeatureAllowed
+}
+
+@Serializable
+data class SimpleChatPreference(
+ override val allow: FeatureAllowed
+): ChatPreference
+
+@Serializable
+data class TimedMessagesPreference(
+ override val allow: FeatureAllowed,
+ val ttl: Int? = null
+): ChatPreference {
+ companion object {
+ val ttlValues: List
+ get() = listOf(30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400)
+
+ fun ttlText(ttl: Int?): String {
+ ttl ?: return generalGetString(R.string.feature_off)
+ if (ttl == 0) return String.format(generalGetString(R.string.ttl_sec), 0)
+ val (m_, s) = divMod(ttl, 60)
+ val (h_, m) = divMod(m_, 60)
+ val (d_, h) = divMod(h_, 24)
+ val (mm, d) = divMod(d_, 30)
+ return maybe(mm, if (mm == 1) String.format(generalGetString(R.string.ttl_month), 1) else String.format(generalGetString(R.string.ttl_months), mm)) +
+ maybe(d, if (d == 1) String.format(generalGetString(R.string.ttl_day), 1) else if (d == 7) String.format(generalGetString(R.string.ttl_week), 1) else if (d == 14) String.format(generalGetString(R.string.ttl_weeks), 2) else String.format(generalGetString(R.string.ttl_days), d)) +
+ maybe(h, if (h == 1) String.format(generalGetString(R.string.ttl_hour), 1) else String.format(generalGetString(R.string.ttl_hours), h)) +
+ maybe(m, String.format(generalGetString(R.string.ttl_min), m)) +
+ maybe(s, String.format(generalGetString(R.string.ttl_sec), s))
+ }
+
+ fun shortTtlText(ttl: Int?): String {
+ ttl ?: return generalGetString(R.string.feature_off)
+ val m = ttl / 60
+ if (m == 0) {
+ return String.format(generalGetString(R.string.ttl_s), ttl)
+ }
+ val h = m / 60
+ if (h == 0) {
+ return String.format(generalGetString(R.string.ttl_m), m)
+ }
+ val d = h / 24
+ if (d == 0) {
+ return String.format(generalGetString(R.string.ttl_h), h)
+ }
+ val mm = d / 30
+ if (mm > 0) {
+ return String.format(generalGetString(R.string.ttl_mth), mm)
+ }
+ val w = d / 7
+ return if (w == 0 || d % 7 != 0) String.format(generalGetString(R.string.ttl_d), d) else String.format(generalGetString(R.string.ttl_w), w)
+ }
+
+ fun divMod(n: Int, d: Int): Pair =
+ n / d to n % d
+
+ fun maybe(n: Int, s: String): String =
+ if (n == 0) "" else s
+ }
+}
+
+@Serializable
+data class ContactUserPreferences(
+ val timedMessages: ContactUserPreferenceTimed,
+ val fullDelete: ContactUserPreference,
+ val voice: ContactUserPreference,
+) {
+ fun toPreferences(): ChatPreferences = ChatPreferences(
+ timedMessages = timedMessages.userPreference.pref,
+ fullDelete = fullDelete.userPreference.pref,
+ voice = voice.userPreference.pref
+ )
+
+ companion object {
+ val sampleData = ContactUserPreferences(
+ timedMessages = ContactUserPreferenceTimed(
+ enabled = FeatureEnabled(forUser = false, forContact = false),
+ userPreference = ContactUserPrefTimed.User(preference = TimedMessagesPreference(allow = FeatureAllowed.NO)),
+ contactPreference = TimedMessagesPreference(allow = FeatureAllowed.NO)
+ ),
+ fullDelete = ContactUserPreference(
+ enabled = FeatureEnabled(forUser = false, forContact = false),
+ userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.NO)),
+ contactPreference = SimpleChatPreference(allow = FeatureAllowed.NO)
+ ),
+ voice = ContactUserPreference(
+ enabled = FeatureEnabled(forUser = true, forContact = true),
+ userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)),
+ contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES)
+ )
+ )
+ }
+}
+
+@Serializable
+data class ContactUserPreference (
+ val enabled: FeatureEnabled,
+ val userPreference: ContactUserPref,
+ val contactPreference: SimpleChatPreference,
+)
+
+@Serializable
+data class ContactUserPreferenceTimed (
+ val enabled: FeatureEnabled,
+ val userPreference: ContactUserPrefTimed,
+ val contactPreference: TimedMessagesPreference,
+)
+
+@Serializable
+data class FeatureEnabled(
+ val forUser: Boolean,
+ val forContact: Boolean
+) {
+ val text: String
+ get() = when {
+ forUser && forContact -> generalGetString(R.string.feature_enabled)
+ forUser -> generalGetString(R.string.feature_enabled_for_you)
+ forContact -> generalGetString(R.string.feature_enabled_for_contact)
+ else -> generalGetString(R.string.feature_off)
+ }
+
+ val iconColor: Color
+ get() = if (forUser) SimplexGreen else if (forContact) WarningYellow else HighOrLowlight
+
+ companion object {
+ fun enabled(asymmetric: Boolean, user: ChatPreference, contact: ChatPreference): FeatureEnabled =
+ when {
+ user.allow == FeatureAllowed.ALWAYS && contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = asymmetric)
+ user.allow == FeatureAllowed.NO && contact.allow == FeatureAllowed.ALWAYS -> FeatureEnabled(forUser = asymmetric, forContact = false)
+ contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
+ user.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
+ else -> FeatureEnabled(forUser = true, forContact = true)
+ }
+ }
+}
+
+@Serializable
+sealed class ContactUserPref {
+ abstract val pref: SimpleChatPreference
+
+ // contact override is set
+ @Serializable @SerialName("contact") data class Contact(val preference: SimpleChatPreference): ContactUserPref() {
+ override val pref get() = preference
+ }
+ // global user default is used
+ @Serializable @SerialName("user") data class User(val preference: SimpleChatPreference): ContactUserPref() {
+ override val pref get() = preference
+ }
+
+ val contactOverride: SimpleChatPreference?
+ get() = when(this) {
+ is Contact -> pref
+ is User -> null
+ }
+}
+
+@Serializable
+sealed class ContactUserPrefTimed {
+ abstract val pref: TimedMessagesPreference
+
+ // contact override is set
+ @Serializable @SerialName("contact") data class Contact(val preference: TimedMessagesPreference): ContactUserPrefTimed() {
+ override val pref get() = preference
+ }
+ // global user default is used
+ @Serializable @SerialName("user") data class User(val preference: TimedMessagesPreference): ContactUserPrefTimed() {
+ override val pref get() = preference
+ }
+
+ val contactOverride: TimedMessagesPreference?
+ get() = when(this) {
+ is Contact -> pref
+ is User -> null
+ }
+}
+
+interface Feature {
+// val icon: ImageVector
+ val text: String
+ val iconFilled: ImageVector
+ val hasParam: Boolean
+}
+
+@Serializable
+enum class ChatFeature: Feature {
+ @SerialName("timedMessages") TimedMessages,
+ @SerialName("fullDelete") FullDelete,
+ @SerialName("voice") Voice;
+
+ val asymmetric: Boolean get() = when (this) {
+ TimedMessages -> false
+ else -> true
+ }
+
+ override val hasParam: Boolean get() = when(this) {
+ TimedMessages -> true
+ else -> false
+ }
+
+ override val text: String
+ get() = when(this) {
+ TimedMessages -> generalGetString(R.string.timed_messages)
+ FullDelete -> generalGetString(R.string.full_deletion)
+ Voice -> generalGetString(R.string.voice_messages)
+ }
+
+ val icon: ImageVector
+ get() = when(this) {
+ TimedMessages -> Icons.Outlined.Timer
+ FullDelete -> Icons.Outlined.DeleteForever
+ Voice -> Icons.Outlined.KeyboardVoice
+ }
+
+ override val iconFilled: ImageVector
+ get() = when(this) {
+ TimedMessages -> Icons.Filled.Timer
+ FullDelete -> Icons.Filled.DeleteForever
+ Voice -> Icons.Filled.KeyboardVoice
+ }
+
+ fun allowDescription(allowed: FeatureAllowed): String =
+ when (this) {
+ TimedMessages -> when (allowed) {
+ FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_to_send_disappearing_messages)
+ FeatureAllowed.YES -> generalGetString(R.string.allow_disappearing_messages_only_if)
+ FeatureAllowed.NO -> generalGetString(R.string.prohibit_sending_disappearing_messages)
+ }
+ FullDelete -> when (allowed) {
+ FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_irreversibly_delete)
+ FeatureAllowed.YES -> generalGetString(R.string.allow_irreversible_message_deletion_only_if)
+ FeatureAllowed.NO -> generalGetString(R.string.contacts_can_mark_messages_for_deletion)
+ }
+ Voice -> when (allowed) {
+ FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_to_send_voice_messages)
+ FeatureAllowed.YES -> generalGetString(R.string.allow_voice_messages_only_if)
+ FeatureAllowed.NO -> generalGetString(R.string.prohibit_sending_voice_messages)
+ }
+ }
+
+ fun enabledDescription(enabled: FeatureEnabled): String =
+ when (this) {
+ TimedMessages -> when {
+ enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_send_disappearing)
+ enabled.forUser -> generalGetString(R.string.only_you_can_send_disappearing)
+ enabled.forContact -> generalGetString(R.string.only_your_contact_can_send_disappearing)
+ else -> generalGetString(R.string.disappearing_prohibited_in_this_chat)
+ }
+ FullDelete -> when {
+ enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contacts_can_delete)
+ enabled.forUser -> generalGetString(R.string.only_you_can_delete_messages)
+ enabled.forContact -> generalGetString(R.string.only_your_contact_can_delete)
+ else -> generalGetString(R.string.message_deletion_prohibited)
+ }
+ Voice -> when {
+ enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_send_voice)
+ enabled.forUser -> generalGetString(R.string.only_you_can_send_voice)
+ enabled.forContact -> generalGetString(R.string.only_your_contact_can_send_voice)
+ else -> generalGetString(R.string.voice_prohibited_in_this_chat)
+ }
+ }
+}
+
+@Serializable
+enum class GroupFeature: Feature {
+ @SerialName("timedMessages") TimedMessages,
+ @SerialName("directMessages") DirectMessages,
+ @SerialName("fullDelete") FullDelete,
+ @SerialName("voice") Voice;
+
+ override val hasParam: Boolean get() = when(this) {
+ TimedMessages -> true
+ else -> false
+ }
+
+ override val text: String
+ get() = when(this) {
+ TimedMessages -> generalGetString(R.string.timed_messages)
+ DirectMessages -> generalGetString(R.string.direct_messages)
+ FullDelete -> generalGetString(R.string.full_deletion)
+ Voice -> generalGetString(R.string.voice_messages)
+ }
+
+ val icon: ImageVector
+ get() = when(this) {
+ TimedMessages -> Icons.Outlined.Timer
+ DirectMessages -> Icons.Outlined.SwapHorizontalCircle
+ FullDelete -> Icons.Outlined.DeleteForever
+ Voice -> Icons.Outlined.KeyboardVoice
+ }
+
+ override val iconFilled: ImageVector
+ get() = when(this) {
+ TimedMessages -> Icons.Filled.Timer
+ DirectMessages -> Icons.Filled.SwapHorizontalCircle
+ FullDelete -> Icons.Filled.DeleteForever
+ Voice -> Icons.Filled.KeyboardVoice
+ }
+
+ fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String =
+ if (canEdit) {
+ when(this) {
+ TimedMessages -> when(enabled) {
+ GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_disappearing)
+ GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_disappearing)
+ }
+ DirectMessages -> when(enabled) {
+ GroupFeatureEnabled.ON -> generalGetString(R.string.allow_direct_messages)
+ GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_direct_messages)
+ }
+ FullDelete -> when(enabled) {
+ GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_delete_messages)
+ GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_deletion)
+ }
+ Voice -> when(enabled) {
+ GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_voice)
+ GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_voice)
+ }
+ }
+ } else {
+ when(this) {
+ TimedMessages -> when(enabled) {
+ GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_disappearing)
+ GroupFeatureEnabled.OFF -> generalGetString(R.string.disappearing_messages_are_prohibited)
+ }
+ DirectMessages -> when(enabled) {
+ GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_dms)
+ GroupFeatureEnabled.OFF -> generalGetString(R.string.direct_messages_are_prohibited_in_chat)
+ }
+ FullDelete -> when(enabled) {
+ GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_delete)
+ GroupFeatureEnabled.OFF -> generalGetString(R.string.message_deletion_prohibited_in_chat)
+ }
+ Voice -> when(enabled) {
+ GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_voice)
+ GroupFeatureEnabled.OFF -> generalGetString(R.string.voice_messages_are_prohibited)
+ }
+ }
+ }
+}
+
+@Serializable
+sealed class ContactFeatureAllowed {
+ @Serializable @SerialName("userDefault") data class UserDefault(val default: FeatureAllowed): ContactFeatureAllowed()
+ @Serializable @SerialName("always") object Always: ContactFeatureAllowed()
+ @Serializable @SerialName("yes") object Yes: ContactFeatureAllowed()
+ @Serializable @SerialName("no") object No: ContactFeatureAllowed()
+
+ companion object {
+ fun values(def: FeatureAllowed): List = listOf(UserDefault(def), Always, Yes, No)
+ }
+
+ val allowed: FeatureAllowed
+ get() = when (this) {
+ is UserDefault -> this.default
+ is Always -> FeatureAllowed.ALWAYS
+ is Yes -> FeatureAllowed.YES
+ is No -> FeatureAllowed.NO
+ }
+ val text: String
+ get() = when (this) {
+ is UserDefault -> String.format(generalGetString(R.string.chat_preferences_default), default.text)
+ is Always -> generalGetString(R.string.chat_preferences_always)
+ is Yes -> generalGetString(R.string.chat_preferences_yes)
+ is No -> generalGetString(R.string.chat_preferences_no)
+ }
+}
+
+@Serializable
+data class ContactFeaturesAllowed(
+ val timedMessagesAllowed: Boolean,
+ val timedMessagesTTL: Int?,
+ val fullDelete: ContactFeatureAllowed,
+ val voice: ContactFeatureAllowed
+) {
+ companion object {
+ val sampleData = ContactFeaturesAllowed(
+ timedMessagesAllowed = false,
+ timedMessagesTTL = null,
+ fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO),
+ voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES)
+ )
+ }
+}
+
+fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed {
+ val pref = contactUserPreferences.timedMessages.userPreference
+ val allow = pref.contactOverride?.allow
+ return ContactFeaturesAllowed(
+ timedMessagesAllowed = allow == FeatureAllowed.YES || allow == FeatureAllowed.ALWAYS,
+ timedMessagesTTL = pref.pref.ttl,
+ fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
+ voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
+ )
+}
+
+fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference): ContactFeatureAllowed =
+ when (val pref = contactUserPreference.userPreference) {
+ is ContactUserPref.User -> ContactFeatureAllowed.UserDefault(pref.preference.allow)
+ is ContactUserPref.Contact -> when (pref.preference.allow) {
+ FeatureAllowed.ALWAYS -> ContactFeatureAllowed.Always
+ FeatureAllowed.YES -> ContactFeatureAllowed.Yes
+ FeatureAllowed.NO -> ContactFeatureAllowed.No
+ }
+ }
+
+fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed): ChatPreferences =
+ ChatPreferences(
+ timedMessages = TimedMessagesPreference(if (contactFeaturesAllowed.timedMessagesAllowed) FeatureAllowed.YES else FeatureAllowed.NO, contactFeaturesAllowed.timedMessagesTTL),
+ fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
+ voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
+ )
+
+fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): SimpleChatPreference? =
+ when(contactFeatureAllowed) {
+ is ContactFeatureAllowed.UserDefault -> null
+ is ContactFeatureAllowed.Always -> SimpleChatPreference(allow = FeatureAllowed.ALWAYS)
+ is ContactFeatureAllowed.Yes -> SimpleChatPreference(allow = FeatureAllowed.YES)
+ is ContactFeatureAllowed.No -> SimpleChatPreference(allow = FeatureAllowed.NO)
+ }
+
+@Serializable
+enum class FeatureAllowed {
+ @SerialName("yes") YES,
+ @SerialName("no") NO,
+ @SerialName("always") ALWAYS;
+
+ val text: String
+ get() = when(this) {
+ ALWAYS -> generalGetString(R.string.chat_preferences_always)
+ YES -> generalGetString(R.string.chat_preferences_yes)
+ NO -> generalGetString(R.string.chat_preferences_no)
+ }
+}
+
+@Serializable
+data class FullGroupPreferences(
+ val timedMessages: TimedMessagesGroupPreference,
+ val directMessages: GroupPreference,
+ val fullDelete: GroupPreference,
+ val voice: GroupPreference
+) {
+ fun toGroupPreferences(): GroupPreferences =
+ GroupPreferences(timedMessages = timedMessages, directMessages = directMessages, fullDelete = fullDelete, voice = voice)
+
+ companion object {
+ val sampleData = FullGroupPreferences(
+ timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
+ directMessages = GroupPreference(GroupFeatureEnabled.OFF),
+ fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
+ voice = GroupPreference(GroupFeatureEnabled.ON)
+ )
+ }
+}
+
+@Serializable
+data class GroupPreferences(
+ val timedMessages: TimedMessagesGroupPreference?,
+ val directMessages: GroupPreference?,
+ val fullDelete: GroupPreference?,
+ val voice: GroupPreference?
+) {
+ companion object {
+ val sampleData = GroupPreferences(
+ timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
+ directMessages = GroupPreference(GroupFeatureEnabled.OFF),
+ fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
+ voice = GroupPreference(GroupFeatureEnabled.ON)
+ )
+ }
+}
+
+@Serializable
+data class GroupPreference(
+ val enable: GroupFeatureEnabled
+) {
+ val on: Boolean get() = enable == GroupFeatureEnabled.ON
+}
+
+@Serializable
+data class TimedMessagesGroupPreference(
+ val enable: GroupFeatureEnabled,
+ val ttl: Int? = null
+) {
+ val on: Boolean get() = enable == GroupFeatureEnabled.ON
+}
+
+@Serializable
+enum class GroupFeatureEnabled {
+ @SerialName("on") ON,
+ @SerialName("off") OFF;
+
+ val text: String
+ get() = when (this) {
+ ON -> generalGetString(R.string.chat_preferences_on)
+ OFF -> generalGetString(R.string.chat_preferences_off)
+ }
+
+ val iconColor: Color
+ get() = if (this == ON) SimplexGreen else HighOrLowlight
+
+}
+
+val json = Json {
+ prettyPrint = true
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ explicitNulls = false
+}
+
+@Serializable
+class APIResponse(val resp: CR, val corr: String? = null) {
+ companion object {
+ fun decodeStr(str: String): APIResponse {
+ return try {
+ json.decodeFromString(str)
+ } catch(e: Exception) {
+ try {
+ Log.d(TAG, e.localizedMessage ?: "")
+ val data = json.parseToJsonElement(str).jsonObject
+ val resp = data["resp"]!!.jsonObject
+ val type = resp["type"]?.jsonPrimitive?.content ?: "invalid"
+ try {
+ if (type == "apiChats") {
+ val user: User = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
+ val chats: List = resp["chats"]!!.jsonArray.map {
+ parseChatData(it)
+ }
+ return APIResponse(
+ resp = CR.ApiChats(user, chats),
+ corr = data["corr"]?.toString()
+ )
+ } else if (type == "apiChat") {
+ val user: User = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
+ val chat = parseChatData(resp["chat"]!!)
+ return APIResponse(
+ resp = CR.ApiChat(user, chat),
+ corr = data["corr"]?.toString()
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString())
+ }
+ APIResponse(
+ resp = CR.Response(type, json.encodeToString(data)),
+ corr = data["corr"]?.toString()
+ )
+ } catch(e: Exception) {
+ APIResponse(CR.Invalid(str))
+ }
+ }
+ }
+ }
+}
+
+private fun parseChatData(chat: JsonElement): Chat {
+ val chatInfo: ChatInfo = decodeObject(ChatInfo.serializer(), chat.jsonObject["chatInfo"])
+ ?: ChatInfo.InvalidJSON(json.encodeToString(chat.jsonObject["chatInfo"]))
+ val chatStats = decodeObject(Chat.ChatStats.serializer(), chat.jsonObject["chatStats"])!!
+ val chatItems: List = chat.jsonObject["chatItems"]!!.jsonArray.map {
+ decodeObject(ChatItem.serializer(), it) ?: ChatItem.invalidJSON(json.encodeToString(it))
+ }
+ return Chat(chatInfo, chatItems, chatStats)
+}
+
+private fun decodeObject(deserializer: DeserializationStrategy, obj: JsonElement?): T? =
+ runCatching { json.decodeFromJsonElement(deserializer, obj!!) }.getOrNull()
+
+// ChatResponse
+@Serializable
+sealed class CR {
+ @Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR()
+ @Serializable @SerialName("usersList") class UsersList(val users: List): CR()
+ @Serializable @SerialName("chatStarted") class ChatStarted: CR()
+ @Serializable @SerialName("chatRunning") class ChatRunning: CR()
+ @Serializable @SerialName("chatStopped") class ChatStopped: CR()
+ @Serializable @SerialName("apiChats") class ApiChats(val user: User, val chats: List): CR()
+ @Serializable @SerialName("apiChat") class ApiChat(val user: User, val chat: Chat): CR()
+ @Serializable @SerialName("userSMPServers") class UserSMPServers(val user: User, val smpServers: List, val presetSMPServers: List): CR()
+ @Serializable @SerialName("smpTestResult") class SmpTestResult(val user: User, val smpTestFailure: SMPTestFailure? = null): CR()
+ @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: User, val chatItemTTL: Long? = null): CR()
+ @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
+ @Serializable @SerialName("contactInfo") class ContactInfo(val user: User, val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR()
+ @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR()
+ @Serializable @SerialName("contactCode") class ContactCode(val user: User, val contact: Contact, val connectionCode: String): CR()
+ @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR()
+ @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: User, val verified: Boolean, val expectedCode: String): CR()
+ @Serializable @SerialName("invitation") class Invitation(val user: User, val connReqInvitation: String): CR()
+ @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: User): CR()
+ @Serializable @SerialName("sentInvitation") class SentInvitation(val user: User): CR()
+ @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: User, val contact: Contact): CR()
+ @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: User, val contact: Contact): CR()
+ @Serializable @SerialName("chatCleared") class ChatCleared(val user: User, val chatInfo: ChatInfo): CR()
+ @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
+ @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile): CR()
+ @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: User, val toContact: Contact): CR()
+ @Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: User, val toConnection: PendingContactConnection): CR()
+ @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: User, val fromContact: Contact, val toContact: Contact): CR()
+ @Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR()
+ @Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val user: User, val contactLink: UserContactLinkRec): CR()
+ @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val user: User, val connReqContact: String): CR()
+ @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted(val user: User): CR()
+ @Serializable @SerialName("contactConnected") class ContactConnected(val user: User, val contact: Contact, val userCustomProfile: Profile? = null): CR()
+ @Serializable @SerialName("contactConnecting") class ContactConnecting(val user: User, val contact: Contact): CR()
+ @Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val user: User, val contactRequest: UserContactRequest): CR()
+ @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: User, val contact: Contact): CR()
+ @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: User): CR()
+ @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: User, val toContact: Contact): CR()
+ @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR()
+ @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR()
+ @Serializable @SerialName("contactSubError") class ContactSubError(val user: User, val contact: Contact, val chatError: ChatError): CR()
+ @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: User, val contactSubscriptions: List): CR()
+ @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: User, val group: GroupInfo): CR()
+ @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: User, val memberSubErrors: List): CR()
+ @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: User, val group: GroupInfo): CR()
+ @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
+ @Serializable @SerialName("newChatItem") class NewChatItem(val user: User, val chatItem: AChatItem): CR()
+ @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: User, val chatItem: AChatItem): CR()
+ @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: User, val chatItem: AChatItem): CR()
+ @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: User, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR()
+ @Serializable @SerialName("contactsList") class ContactsList(val user: User, val contacts: List): CR()
+ // group events
+ @Serializable @SerialName("groupCreated") class GroupCreated(val user: User, val groupInfo: GroupInfo): CR()
+ @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: User, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
+ @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: User, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR()
+ @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: User, val groupInfo: GroupInfo): CR()
+ @Serializable @SerialName("groupMembers") class GroupMembers(val user: User, val group: Group): CR()
+ @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: User, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR()
+ @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: User, val groupInfo: GroupInfo): CR()
+ @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: User, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR()
+ @Serializable @SerialName("memberRole") class MemberRole(val user: User, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
+ @Serializable @SerialName("memberRoleUser") class MemberRoleUser(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
+ @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("deletedMember") class DeletedMember(val user: User, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR()
+ @Serializable @SerialName("leftMember") class LeftMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("contactsMerged") class ContactsMerged(val user: User, val intoContact: Contact, val mergedContact: Contact): CR()
+ @Serializable @SerialName("groupInvitation") class GroupInvitation(val user: User, val groupInfo: GroupInfo): CR() // unused
+ @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: User, val groupInfo: GroupInfo): CR()
+ @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("groupRemoved") class GroupRemoved(val user: User, val groupInfo: GroupInfo): CR() // unused
+ @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: User, val toGroup: GroupInfo): CR()
+ @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR()
+ @Serializable @SerialName("groupLink") class GroupLink(val user: User, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR()
+ @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: User, val groupInfo: GroupInfo): CR()
+ // receiving file events
+ @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: User, val chatItem: AChatItem): CR()
+ @Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: User, val rcvFileTransfer: RcvFileTransfer): CR()
+ @Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: User, val chatItem: AChatItem): CR()
+ @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: User, val chatItem: AChatItem): CR()
+ // sending file events
+ @Serializable @SerialName("sndFileStart") class SndFileStart(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
+ @Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
+ @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
+ @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
+ @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR()
+ @Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR()
+ @Serializable @SerialName("callOffer") class CallOffer(val user: User, val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
+ @Serializable @SerialName("callAnswer") class CallAnswer(val user: User, val contact: Contact, val answer: WebRTCSession): CR()
+ @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: User, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
+ @Serializable @SerialName("callEnded") class CallEnded(val user: User, val contact: Contact): CR()
+ @Serializable @SerialName("newContactConnection") class NewContactConnection(val user: User, val connection: PendingContactConnection): CR()
+ @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: User, val connection: PendingContactConnection): CR()
+ @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo): CR()
+ @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR()
+ @Serializable @SerialName("cmdOk") class CmdOk(val user: User?): CR()
+ @Serializable @SerialName("chatCmdError") class ChatCmdError(val user: User?, val chatError: ChatError): CR()
+ @Serializable @SerialName("chatError") class ChatRespError(val user: User?, val chatError: ChatError): CR()
+ @Serializable class Response(val type: String, val json: String): CR()
+ @Serializable class Invalid(val str: String): CR()
+
+ val responseType: String get() = when(this) {
+ is ActiveUser -> "activeUser"
+ is UsersList -> "usersList"
+ is ChatStarted -> "chatStarted"
+ is ChatRunning -> "chatRunning"
+ is ChatStopped -> "chatStopped"
+ is ApiChats -> "apiChats"
+ is ApiChat -> "apiChat"
+ is UserSMPServers -> "userSMPServers"
+ is SmpTestResult -> "smpTestResult"
+ is ChatItemTTL -> "chatItemTTL"
+ is NetworkConfig -> "networkConfig"
+ is ContactInfo -> "contactInfo"
+ is GroupMemberInfo -> "groupMemberInfo"
+ is ContactCode -> "contactCode"
+ is GroupMemberCode -> "groupMemberCode"
+ is ConnectionVerified -> "connectionVerified"
+ is Invitation -> "invitation"
+ is SentConfirmation -> "sentConfirmation"
+ is SentInvitation -> "sentInvitation"
+ is ContactAlreadyExists -> "contactAlreadyExists"
+ is ContactDeleted -> "contactDeleted"
+ is ChatCleared -> "chatCleared"
+ is UserProfileNoChange -> "userProfileNoChange"
+ is UserProfileUpdated -> "userProfileUpdated"
+ is ContactAliasUpdated -> "contactAliasUpdated"
+ is ConnectionAliasUpdated -> "connectionAliasUpdated"
+ is ContactPrefsUpdated -> "contactPrefsUpdated"
+ is UserContactLink -> "userContactLink"
+ is UserContactLinkUpdated -> "userContactLinkUpdated"
+ is UserContactLinkCreated -> "userContactLinkCreated"
+ is UserContactLinkDeleted -> "userContactLinkDeleted"
+ is ContactConnected -> "contactConnected"
+ is ContactConnecting -> "contactConnecting"
+ is ReceivedContactRequest -> "receivedContactRequest"
+ is AcceptingContactRequest -> "acceptingContactRequest"
+ is ContactRequestRejected -> "contactRequestRejected"
+ is ContactUpdated -> "contactUpdated"
+ is ContactsSubscribed -> "contactsSubscribed"
+ is ContactsDisconnected -> "contactsDisconnected"
+ is ContactSubError -> "contactSubError"
+ is ContactSubSummary -> "contactSubSummary"
+ is GroupSubscribed -> "groupSubscribed"
+ is MemberSubErrors -> "memberSubErrors"
+ is GroupEmpty -> "groupEmpty"
+ is UserContactLinkSubscribed -> "userContactLinkSubscribed"
+ is NewChatItem -> "newChatItem"
+ is ChatItemStatusUpdated -> "chatItemStatusUpdated"
+ is ChatItemUpdated -> "chatItemUpdated"
+ is ChatItemDeleted -> "chatItemDeleted"
+ is ContactsList -> "contactsList"
+ is GroupCreated -> "groupCreated"
+ is SentGroupInvitation -> "sentGroupInvitation"
+ is UserAcceptedGroupSent -> "userAcceptedGroupSent"
+ is UserDeletedMember -> "userDeletedMember"
+ is LeftMemberUser -> "leftMemberUser"
+ is GroupMembers -> "groupMembers"
+ is ReceivedGroupInvitation -> "receivedGroupInvitation"
+ is GroupDeletedUser -> "groupDeletedUser"
+ is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting"
+ is MemberRole -> "memberRole"
+ is MemberRoleUser -> "memberRoleUser"
+ is DeletedMemberUser -> "deletedMemberUser"
+ is DeletedMember -> "deletedMember"
+ is LeftMember -> "leftMember"
+ is GroupDeleted -> "groupDeleted"
+ is ContactsMerged -> "contactsMerged"
+ is GroupInvitation -> "groupInvitation"
+ is UserJoinedGroup -> "userJoinedGroup"
+ is JoinedGroupMember -> "joinedGroupMember"
+ is ConnectedToGroupMember -> "connectedToGroupMember"
+ is GroupRemoved -> "groupRemoved"
+ is GroupUpdated -> "groupUpdated"
+ is GroupLinkCreated -> "groupLinkCreated"
+ is GroupLink -> "groupLink"
+ is GroupLinkDeleted -> "groupLinkDeleted"
+ is RcvFileAcceptedSndCancelled -> "rcvFileAcceptedSndCancelled"
+ is RcvFileAccepted -> "rcvFileAccepted"
+ is RcvFileStart -> "rcvFileStart"
+ is RcvFileComplete -> "rcvFileComplete"
+ is SndFileCancelled -> "sndFileCancelled"
+ is SndFileComplete -> "sndFileComplete"
+ is SndFileRcvCancelled -> "sndFileRcvCancelled"
+ is SndFileStart -> "sndFileStart"
+ is SndGroupFileCancelled -> "sndGroupFileCancelled"
+ is CallInvitation -> "callInvitation"
+ is CallOffer -> "callOffer"
+ is CallAnswer -> "callAnswer"
+ is CallExtraInfo -> "callExtraInfo"
+ is CallEnded -> "callEnded"
+ is NewContactConnection -> "newContactConnection"
+ is ContactConnectionDeleted -> "contactConnectionDeleted"
+ is VersionInfo -> "versionInfo"
+ is ParsedMarkdown -> "apiParsedMarkdown"
+ is CmdOk -> "cmdOk"
+ is ChatCmdError -> "chatCmdError"
+ is ChatRespError -> "chatError"
+ is Response -> "* $type"
+ is Invalid -> "* invalid json"
+ }
+
+ val details: String get() = when(this) {
+ is ActiveUser -> withUser(user, json.encodeToString(user))
+ is UsersList -> json.encodeToString(users)
+ is ChatStarted -> noDetails()
+ is ChatRunning -> noDetails()
+ is ChatStopped -> noDetails()
+ is ApiChats -> withUser(user, json.encodeToString(chats))
+ is ApiChat -> withUser(user, json.encodeToString(chat))
+ is UserSMPServers -> withUser(user, "$smpServers: ${json.encodeToString(smpServers)}\n$presetSMPServers: ${json.encodeToString(presetSMPServers)}")
+ is SmpTestResult -> withUser(user, json.encodeToString(smpTestFailure))
+ is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL))
+ is NetworkConfig -> json.encodeToString(networkConfig)
+ is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}")
+ is GroupMemberInfo -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}")
+ is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode")
+ is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode")
+ is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
+ is Invitation -> withUser(user, connReqInvitation)
+ is SentConfirmation -> withUser(user, noDetails())
+ is SentInvitation -> withUser(user, noDetails())
+ is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
+ is ContactDeleted -> withUser(user, json.encodeToString(contact))
+ is ChatCleared -> withUser(user, json.encodeToString(chatInfo))
+ is UserProfileNoChange -> withUser(user, noDetails())
+ is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
+ is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact))
+ is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection))
+ is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}")
+ is ParsedMarkdown -> json.encodeToString(formattedText)
+ is UserContactLink -> withUser(user, contactLink.responseDetails)
+ is UserContactLinkUpdated -> withUser(user, contactLink.responseDetails)
+ is UserContactLinkCreated -> withUser(user, connReqContact)
+ is UserContactLinkDeleted -> withUser(user, noDetails())
+ is ContactConnected -> withUser(user, json.encodeToString(contact))
+ is ContactConnecting -> withUser(user, json.encodeToString(contact))
+ is ReceivedContactRequest -> withUser(user, json.encodeToString(contactRequest))
+ is AcceptingContactRequest -> withUser(user, json.encodeToString(contact))
+ is ContactRequestRejected -> withUser(user, noDetails())
+ is ContactUpdated -> withUser(user, json.encodeToString(toContact))
+ is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
+ is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
+ is ContactSubError -> withUser(user, "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}")
+ is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions))
+ is GroupSubscribed -> withUser(user, json.encodeToString(group))
+ is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors))
+ is GroupEmpty -> withUser(user, json.encodeToString(group))
+ is UserContactLinkSubscribed -> noDetails()
+ is NewChatItem -> withUser(user, json.encodeToString(chatItem))
+ is ChatItemStatusUpdated -> withUser(user, json.encodeToString(chatItem))
+ is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem))
+ is ChatItemDeleted -> withUser(user, "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser")
+ is ContactsList -> withUser(user, json.encodeToString(contacts))
+ is GroupCreated -> withUser(user, json.encodeToString(groupInfo))
+ is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
+ is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
+ is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
+ is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo))
+ is GroupMembers -> withUser(user, json.encodeToString(group))
+ is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole")
+ is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo))
+ is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member")
+ is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole")
+ is MemberRoleUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole")
+ is DeletedMemberUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
+ is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember")
+ is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
+ is GroupDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
+ is ContactsMerged -> withUser(user, "intoContact: $intoContact\nmergedContact: $mergedContact")
+ is GroupInvitation -> withUser(user, json.encodeToString(groupInfo))
+ is UserJoinedGroup -> withUser(user, json.encodeToString(groupInfo))
+ is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
+ is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
+ is GroupRemoved -> withUser(user, json.encodeToString(groupInfo))
+ is GroupUpdated -> withUser(user, json.encodeToString(toGroup))
+ is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole")
+ is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole")
+ is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo))
+ is RcvFileAcceptedSndCancelled -> withUser(user, noDetails())
+ is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem))
+ is RcvFileStart -> withUser(user, json.encodeToString(chatItem))
+ is RcvFileComplete -> withUser(user, json.encodeToString(chatItem))
+ is SndFileCancelled -> json.encodeToString(chatItem)
+ is SndFileComplete -> withUser(user, json.encodeToString(chatItem))
+ is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem))
+ is SndFileStart -> withUser(user, json.encodeToString(chatItem))
+ is SndGroupFileCancelled -> withUser(user, json.encodeToString(chatItem))
+ is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
+ is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}")
+ is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}")
+ is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}")
+ is CallEnded -> withUser(user, "contact: ${contact.id}")
+ is NewContactConnection -> withUser(user, json.encodeToString(connection))
+ is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection))
+ is VersionInfo -> json.encodeToString(versionInfo)
+ is CmdOk -> withUser(user, noDetails())
+ is ChatCmdError -> withUser(user, chatError.string)
+ is ChatRespError -> withUser(user, chatError.string)
+ is Response -> json
+ is Invalid -> str
+ }
+
+ fun noDetails(): String ="${responseType}: " + generalGetString(R.string.no_details)
+
+ private fun withUser(u: User?, s: String): String = if (u != null) "userId: ${u.userId}\n$s" else s
+}
+
+abstract class TerminalItem {
+ abstract val id: Long
+ val date: Instant = Clock.System.now()
+ abstract val label: String
+ abstract val details: String
+
+ class Cmd(override val id: Long, val cmd: CC): TerminalItem() {
+ override val label get() = "> ${cmd.cmdString}"
+ override val details get() = cmd.cmdString
+ }
+
+ class Resp(override val id: Long, val resp: CR): TerminalItem() {
+ override val label get() = "< ${resp.responseType}"
+ override val details get() = resp.details
+ }
+
+ companion object {
+ val sampleData = listOf(
+ Cmd(0, CC.ShowActiveUser()),
+ Resp(1, CR.ActiveUser(User.sampleData))
+ )
+
+ fun cmd(c: CC) = Cmd(System.currentTimeMillis(), c)
+ fun resp(r: CR) = Resp(System.currentTimeMillis(), r)
+ }
+}
+
+@Serializable
+class ConnectionStats(val rcvServers: List?, val sndServers: List?)
+
+@Serializable
+class UserContactLinkRec(val connReqContact: String, val autoAccept: AutoAccept? = null) {
+ val responseDetails: String get() = "connReqContact: ${connReqContact}\nautoAccept: ${AutoAccept.cmdString(autoAccept)}"
+}
+
+@Serializable
+class AutoAccept(val acceptIncognito: Boolean, val autoReply: MsgContent?) {
+ companion object {
+ fun cmdString(autoAccept: AutoAccept?): String {
+ if (autoAccept == null) return "off"
+ val s = "on" + if (autoAccept.acceptIncognito) " incognito=on" else ""
+ val msg = autoAccept.autoReply ?: return s
+ return s + " " + msg.cmdString
+ }
+ }
+}
+
+@Serializable
+data class CoreVersionInfo(
+ val version: String,
+ val buildTimestamp: String,
+ val simplexmqVersion: String,
+ val simplexmqCommit: String
+)
+
+@Serializable
+sealed class ChatError {
+ val string: String get() = when (this) {
+ is ChatErrorChat -> "chat ${errorType.string}"
+ is ChatErrorAgent -> "agent ${agentError.string}"
+ is ChatErrorStore -> "store ${storeError.string}"
+ is ChatErrorDatabase -> "database ${databaseError.string}"
+ }
+ @Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError()
+ @Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError()
+ @Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError()
+ @Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError()
+}
+
+@Serializable
+sealed class ChatErrorType {
+ val string: String get() = when (this) {
+ is NoActiveUser -> "noActiveUser"
+ is DifferentActiveUser -> "differentActiveUser"
+ is UserExists -> "userExists"
+ is InvalidConnReq -> "invalidConnReq"
+ is FileAlreadyReceiving -> "fileAlreadyReceiving"
+ is СommandError -> "commandError $message"
+ }
+ @Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType()
+ @Serializable @SerialName("differentActiveUser") class DifferentActiveUser: ChatErrorType()
+ @Serializable @SerialName("userExists") class UserExists(val contactName: String): ChatErrorType()
+ @Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
+ @Serializable @SerialName("fileAlreadyReceiving") class FileAlreadyReceiving: ChatErrorType()
+ @Serializable @SerialName("commandError") class СommandError(val message: String): ChatErrorType()
+}
+
+@Serializable
+sealed class StoreError {
+ val string: String get() = when (this) {
+ is UserContactLinkNotFound -> "userContactLinkNotFound"
+ is GroupNotFound -> "groupNotFound"
+ is DuplicateName -> "duplicateName"
+ }
+ @Serializable @SerialName("userContactLinkNotFound") class UserContactLinkNotFound: StoreError()
+ @Serializable @SerialName("groupNotFound") class GroupNotFound: StoreError()
+ @Serializable @SerialName("duplicateName") class DuplicateName: StoreError()
+}
+
+@Serializable
+sealed class DatabaseError {
+ val string: String get() = when (this) {
+ is ErrorEncrypted -> "errorEncrypted"
+ is ErrorPlaintext -> "errorPlaintext"
+ is ErrorNoFile -> "errorPlaintext"
+ is ErrorExport -> "errorNoFile"
+ is ErrorOpen -> "errorExport"
+ }
+ @Serializable @SerialName("errorEncrypted") object ErrorEncrypted: DatabaseError()
+ @Serializable @SerialName("errorPlaintext") object ErrorPlaintext: DatabaseError()
+ @Serializable @SerialName("errorNoFile") class ErrorNoFile(val dbFile: String): DatabaseError()
+ @Serializable @SerialName("errorExport") class ErrorExport(val sqliteError: SQLiteError): DatabaseError()
+ @Serializable @SerialName("errorOpen") class ErrorOpen(val sqliteError: SQLiteError): DatabaseError()
+}
+
+@Serializable
+sealed class SQLiteError {
+ @Serializable @SerialName("errorNotADatabase") object ErrorNotADatabase: SQLiteError()
+ @Serializable @SerialName("error") class Error(val error: String): SQLiteError()
+}
+
+@Serializable
+sealed class AgentErrorType {
+ val string: String get() = when (this) {
+ is CMD -> "CMD ${cmdErr.string}"
+ is CONN -> "CONN ${connErr.string}"
+ is SMP -> "SMP ${smpErr.string}"
+ is BROKER -> "BROKER ${brokerErr.string}"
+ is AGENT -> "AGENT ${agentErr.string}"
+ is INTERNAL -> "INTERNAL $internalErr"
+ }
+ @Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType()
+ @Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType()
+ @Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType()
+ @Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType()
+ @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType()
+ @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
+}
+
+@Serializable
+sealed class CommandErrorType {
+ val string: String get() = when (this) {
+ is PROHIBITED -> "PROHIBITED"
+ is SYNTAX -> "SYNTAX"
+ is NO_CONN -> "NO_CONN"
+ is SIZE -> "SIZE"
+ is LARGE -> "LARGE"
+ }
+ @Serializable @SerialName("PROHIBITED") class PROHIBITED: CommandErrorType()
+ @Serializable @SerialName("SYNTAX") class SYNTAX: CommandErrorType()
+ @Serializable @SerialName("NO_CONN") class NO_CONN: CommandErrorType()
+ @Serializable @SerialName("SIZE") class SIZE: CommandErrorType()
+ @Serializable @SerialName("LARGE") class LARGE: CommandErrorType()
+}
+
+@Serializable
+sealed class ConnectionErrorType {
+ val string: String get() = when (this) {
+ is NOT_FOUND -> "NOT_FOUND"
+ is DUPLICATE -> "DUPLICATE"
+ is SIMPLEX -> "SIMPLEX"
+ is NOT_ACCEPTED -> "NOT_ACCEPTED"
+ is NOT_AVAILABLE -> "NOT_AVAILABLE"
+ }
+ @Serializable @SerialName("NOT_FOUND") class NOT_FOUND: ConnectionErrorType()
+ @Serializable @SerialName("DUPLICATE") class DUPLICATE: ConnectionErrorType()
+ @Serializable @SerialName("SIMPLEX") class SIMPLEX: ConnectionErrorType()
+ @Serializable @SerialName("NOT_ACCEPTED") class NOT_ACCEPTED: ConnectionErrorType()
+ @Serializable @SerialName("NOT_AVAILABLE") class NOT_AVAILABLE: ConnectionErrorType()
+}
+
+@Serializable
+sealed class BrokerErrorType {
+ val string: String get() = when (this) {
+ is RESPONSE -> "RESPONSE ${smpErr.string}"
+ is UNEXPECTED -> "UNEXPECTED"
+ is NETWORK -> "NETWORK"
+ is TRANSPORT -> "TRANSPORT ${transportErr.string}"
+ is TIMEOUT -> "TIMEOUT"
+ }
+ @Serializable @SerialName("RESPONSE") class RESPONSE(val smpErr: SMPErrorType): BrokerErrorType()
+ @Serializable @SerialName("UNEXPECTED") class UNEXPECTED: BrokerErrorType()
+ @Serializable @SerialName("NETWORK") class NETWORK: BrokerErrorType()
+ @Serializable @SerialName("TRANSPORT") class TRANSPORT(val transportErr: SMPTransportError): BrokerErrorType()
+ @Serializable @SerialName("TIMEOUT") class TIMEOUT: BrokerErrorType()
+}
+
+@Serializable
+sealed class SMPErrorType {
+ val string: String get() = when (this) {
+ is BLOCK -> "BLOCK"
+ is SESSION -> "SESSION"
+ is CMD -> "CMD ${cmdErr.string}"
+ is AUTH -> "AUTH"
+ is QUOTA -> "QUOTA"
+ is NO_MSG -> "NO_MSG"
+ is LARGE_MSG -> "LARGE_MSG"
+ is INTERNAL -> "INTERNAL"
+ }
+ @Serializable @SerialName("BLOCK") class BLOCK: SMPErrorType()
+ @Serializable @SerialName("SESSION") class SESSION: SMPErrorType()
+ @Serializable @SerialName("CMD") class CMD(val cmdErr: SMPCommandError): SMPErrorType()
+ @Serializable @SerialName("AUTH") class AUTH: SMPErrorType()
+ @Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType()
+ @Serializable @SerialName("NO_MSG") class NO_MSG: SMPErrorType()
+ @Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType()
+ @Serializable @SerialName("INTERNAL") class INTERNAL: SMPErrorType()
+}
+
+@Serializable
+sealed class SMPCommandError {
+ val string: String get() = when (this) {
+ is UNKNOWN -> "UNKNOWN"
+ is SYNTAX -> "SYNTAX"
+ is NO_AUTH -> "NO_AUTH"
+ is HAS_AUTH -> "HAS_AUTH"
+ is NO_QUEUE -> "NO_QUEUE"
+ }
+ @Serializable @SerialName("UNKNOWN") class UNKNOWN: SMPCommandError()
+ @Serializable @SerialName("SYNTAX") class SYNTAX: SMPCommandError()
+ @Serializable @SerialName("NO_AUTH") class NO_AUTH: SMPCommandError()
+ @Serializable @SerialName("HAS_AUTH") class HAS_AUTH: SMPCommandError()
+ @Serializable @SerialName("NO_QUEUE") class NO_QUEUE: SMPCommandError()
+}
+
+@Serializable
+sealed class SMPTransportError {
+ val string: String get() = when (this) {
+ is BadBlock -> "badBlock"
+ is LargeMsg -> "largeMsg"
+ is BadSession -> "badSession"
+ is Handshake -> "handshake ${handshakeErr.string}"
+ }
+ @Serializable @SerialName("badBlock") class BadBlock: SMPTransportError()
+ @Serializable @SerialName("largeMsg") class LargeMsg: SMPTransportError()
+ @Serializable @SerialName("badSession") class BadSession: SMPTransportError()
+ @Serializable @SerialName("handshake") class Handshake(val handshakeErr: SMPHandshakeError): SMPTransportError()
+}
+
+@Serializable
+sealed class SMPHandshakeError {
+ val string: String get() = when (this) {
+ is PARSE -> "PARSE"
+ is VERSION -> "VERSION"
+ is IDENTITY -> "IDENTITY"
+ }
+ @Serializable @SerialName("PARSE") class PARSE: SMPHandshakeError()
+ @Serializable @SerialName("VERSION") class VERSION: SMPHandshakeError()
+ @Serializable @SerialName("IDENTITY") class IDENTITY: SMPHandshakeError()
+}
+
+@Serializable
+sealed class SMPAgentError {
+ val string: String get() = when (this) {
+ is A_MESSAGE -> "A_MESSAGE"
+ is A_PROHIBITED -> "A_PROHIBITED"
+ is A_VERSION -> "A_VERSION"
+ is A_ENCRYPTION -> "A_ENCRYPTION"
+ }
+ @Serializable @SerialName("A_MESSAGE") class A_MESSAGE: SMPAgentError()
+ @Serializable @SerialName("A_PROHIBITED") class A_PROHIBITED: SMPAgentError()
+ @Serializable @SerialName("A_VERSION") class A_VERSION: SMPAgentError()
+ @Serializable @SerialName("A_ENCRYPTION") class A_ENCRYPTION: SMPAgentError()
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
similarity index 64%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt
rename to apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
index c50ea5c349..6ca2ad5d7d 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
@@ -1,12 +1,6 @@
-package chat.simplex.common.ui.theme
+package chat.simplex.app.ui.theme
-import androidx.compose.material.LocalContentColor
-import androidx.compose.material.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.ui.graphics.*
-import chat.simplex.common.views.helpers.mixWith
-import kotlin.math.min
+import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
@@ -24,13 +18,12 @@ val MessagePreviewDark = Color(179, 175, 174, 255)
val MessagePreviewLight = Color(49, 45, 44, 255)
val ToolbarLight = Color(220, 220, 220, 12)
val ToolbarDark = Color(80, 80, 80, 12)
+val SettingsBackgroundLight = Color(220, 216, 215, 90)
val SettingsSecondaryLight = Color(200, 196, 195, 90)
val GroupDark = Color(80, 80, 80, 60)
val IncomingCallLight = Color(239, 237, 236, 255)
+val IncomingCallDark = Color(34, 30, 29, 255)
val WarningOrange = Color(255, 127, 0, 255)
val WarningYellow = Color(255, 192, 0, 255)
val FileLight = Color(183, 190, 199, 255)
val FileDark = Color(101, 101, 106, 255)
-
-val MenuTextColor: Color @Composable get () = if (isInDarkTheme()) LocalContentColor.current.copy(alpha = 0.8f) else Color.Black
-val NoteFolderIconColor: Color @Composable get() = MaterialTheme.appColors.primaryVariant2
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Shape.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Shape.kt
similarity index 87%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Shape.kt
rename to apps/android/app/src/main/java/chat/simplex/app/ui/theme/Shape.kt
index 293257c095..d0a00450f6 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Shape.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Shape.kt
@@ -1,4 +1,4 @@
-package chat.simplex.common.ui.theme
+package chat.simplex.app.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
@@ -8,4 +8,4 @@ val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
-)
+)
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt
new file mode 100644
index 0000000000..7590ecdcfb
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt
@@ -0,0 +1,77 @@
+package chat.simplex.app.ui.theme
+
+import android.app.UiModeManager
+import android.content.Context
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.SimplexApp
+import kotlinx.coroutines.flow.MutableStateFlow
+
+enum class DefaultTheme {
+ SYSTEM, DARK, LIGHT
+}
+
+val DEFAULT_PADDING = 16.dp
+val DEFAULT_SPACE_AFTER_ICON = 4.dp
+val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
+val DEFAULT_BOTTOM_PADDING = 48.dp
+
+val DarkColorPalette = darkColors(
+ primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
+ primaryVariant = SimplexGreen,
+ secondary = DarkGray,
+// background = Color.Black,
+// surface = Color.Black,
+// background = Color(0xFF121212),
+// surface = Color(0xFF121212),
+// error = Color(0xFFCF6679),
+ onBackground = Color(0xFFFFFBFA),
+ onSurface = Color(0xFFFFFBFA),
+// onError: Color = Color.Black,
+)
+val LightColorPalette = lightColors(
+ primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
+ primaryVariant = SimplexGreen,
+ secondary = LightGray,
+// background = Color.White,
+// surface = Color.White
+// onPrimary = Color.White,
+// onSecondary = Color.Black,
+// onBackground = Color.Black,
+// onSurface = Color.Black,
+)
+
+val CurrentColors: MutableStateFlow> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
+
+// Non-@Composable implementation
+private fun isInNightMode() =
+ (SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
+
+@Composable
+fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.first.isLight
+
+@Composable
+fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
+ LaunchedEffect(darkTheme) {
+ // For preview
+ if (darkTheme != null)
+ CurrentColors.value = ThemeManager.currentColors(darkTheme)
+ }
+ val systemDark = isSystemInDarkTheme()
+ LaunchedEffect(systemDark) {
+ if (CurrentColors.value.second == DefaultTheme.SYSTEM && CurrentColors.value.first.isLight == systemDark) {
+ // Change active colors from light to dark and back based on system theme
+ ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
+ }
+ }
+ val theme by CurrentColors.collectAsState()
+ MaterialTheme(
+ colors = theme.first,
+ typography = Typography,
+ shapes = Shapes,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/ThemeManager.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/ThemeManager.kt
new file mode 100644
index 0000000000..a1d5ee4526
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/ThemeManager.kt
@@ -0,0 +1,64 @@
+package chat.simplex.app.ui.theme
+
+import androidx.compose.material.Colors
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import chat.simplex.app.R
+import chat.simplex.app.SimplexApp
+import chat.simplex.app.model.AppPreferences
+import chat.simplex.app.views.helpers.generalGetString
+
+object ThemeManager {
+ private val appPrefs: AppPreferences by lazy {
+ AppPreferences(SimplexApp.context)
+ }
+
+ fun currentColors(darkForSystemTheme: Boolean): Pair {
+ val theme = appPrefs.currentTheme.get()!!
+ val systemThemeColors = if (darkForSystemTheme) DarkColorPalette else LightColorPalette
+ val res = when (theme) {
+ DefaultTheme.SYSTEM.name -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
+ DefaultTheme.DARK.name -> Pair(DarkColorPalette, DefaultTheme.DARK)
+ DefaultTheme.LIGHT.name -> Pair(LightColorPalette, DefaultTheme.LIGHT)
+ else -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
+ }
+ return res.copy(first = res.first.copy(primary = Color(appPrefs.primaryColor.get())))
+ }
+
+ // colors, default theme enum, localized name of theme
+ fun allThemes(darkForSystemTheme: Boolean): List> {
+ val allThemes = ArrayList>()
+ allThemes.add(
+ Triple(
+ if (darkForSystemTheme) DarkColorPalette else LightColorPalette,
+ DefaultTheme.SYSTEM,
+ generalGetString(R.string.theme_system)
+ )
+ )
+ allThemes.add(
+ Triple(
+ LightColorPalette,
+ DefaultTheme.LIGHT,
+ generalGetString(R.string.theme_light)
+ )
+ )
+ allThemes.add(
+ Triple(
+ DarkColorPalette,
+ DefaultTheme.DARK,
+ generalGetString(R.string.theme_dark)
+ )
+ )
+ return allThemes
+ }
+
+ fun applyTheme(name: String, darkForSystemTheme: Boolean) {
+ appPrefs.currentTheme.set(name)
+ CurrentColors.value = currentColors(darkForSystemTheme)
+ }
+
+ fun saveAndApplyPrimaryColor(color: Color) {
+ appPrefs.primaryColor.set(color.toArgb())
+ CurrentColors.value = currentColors(!CurrentColors.value.first.isLight)
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Type.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt
similarity index 69%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Type.kt
rename to apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt
index 9acfffb3ac..7297eeec45 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Type.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt
@@ -1,9 +1,20 @@
-package chat.simplex.common.ui.theme
+package chat.simplex.app.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+
+// https://github.com/rsms/inter
+val Inter = FontFamily(
+ Font(R.font.inter_regular),
+ Font(R.font.inter_italic, style = FontStyle.Italic),
+ Font(R.font.inter_bold, weight = FontWeight.Bold),
+ Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold),
+ Font(R.font.inter_medium, weight = FontWeight.Medium),
+ Font(R.font.inter_light, weight = FontWeight.Light),
+)
// Set of Material typography styles to start with
val Typography = Typography(
@@ -22,11 +33,6 @@ val Typography = Typography(
fontWeight = FontWeight.Normal,
fontSize = 18.5.sp
),
- h4 = TextStyle(
- fontFamily = Inter,
- fontWeight = FontWeight.Normal,
- fontSize = 17.5.sp
- ),
body1 = TextStyle(
fontFamily = Inter,
fontWeight = FontWeight.Normal,
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/SplashView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/SplashView.kt
new file mode 100644
index 0000000000..f37a6fca8e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/SplashView.kt
@@ -0,0 +1,25 @@
+package chat.simplex.app.views
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun SplashView() {
+ Surface(
+ Modifier
+ .background(MaterialTheme.colors.background)
+ .fillMaxSize()
+ ) {
+// Image(
+// painter = painterResource(R.drawable.logo),
+// contentDescription = "Simplex Icon",
+// modifier = Modifier
+// .height(230.dp)
+// .align(Alignment.Center)
+// )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
new file mode 100644
index 0000000000..8641b4a08a
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
@@ -0,0 +1,158 @@
+package chat.simplex.app.views
+
+import android.content.res.Configuration
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.*
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.*
+import chat.simplex.app.views.helpers.*
+import com.google.accompanist.insets.ProvideWindowInsets
+import com.google.accompanist.insets.navigationBarsWithImePadding
+
+@Composable
+fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
+ val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
+ BackHandler(onBack = {
+ close()
+ })
+ TerminalLayout(
+ remember { chatModel.terminalItems },
+ composeState,
+ sendCommand = { sendCommand(chatModel, composeState) },
+ close
+ )
+}
+
+private fun sendCommand(chatModel: ChatModel, composeState: MutableState) {
+ val developerTools = chatModel.controller.appPrefs.developerTools.get()
+ val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
+ val s = composeState.value.message
+ if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
+ val resp = CR.ChatCmdError(null, ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
+ chatModel.addTerminalItem(TerminalItem.cmd(CC.Console(s)))
+ chatModel.addTerminalItem(TerminalItem.resp(resp))
+ composeState.value = ComposeState(useLinkPreviews = false)
+ } else {
+ withApi {
+ // show "in progress"
+ chatModel.controller.sendCmd(CC.Console(s))
+ composeState.value = ComposeState(useLinkPreviews = false)
+ // hide "in progress"
+ }
+ }
+}
+
+@Composable
+fun TerminalLayout(
+ terminalItems: List,
+ composeState: MutableState,
+ sendCommand: () -> Unit,
+ close: () -> Unit
+) {
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
+
+ fun onMessageChange(s: String) {
+ composeState.value = composeState.value.copy(message = s)
+ }
+
+ ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
+ Scaffold(
+ topBar = { CloseSheetBar(close) },
+ bottomBar = {
+ Box(Modifier.padding(horizontal = 8.dp)) {
+ SendMsgView(
+ composeState = composeState,
+ showVoiceRecordIcon = false,
+ recState = remember { mutableStateOf(RecordingState.NotStarted) },
+ isDirectChat = false,
+ liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
+ needToAllowVoiceToContact = false,
+ allowedVoiceByPrefs = false,
+ userIsObserver = false,
+ userCanSend = true,
+ allowVoiceToContact = {},
+ sendMessage = sendCommand,
+ sendLiveMessage = null,
+ updateLiveMessage = null,
+ onMessageChange = ::onMessageChange,
+ textStyle = textStyle
+ )
+ }
+ },
+ modifier = Modifier.navigationBarsWithImePadding()
+ ) { contentPadding ->
+ Surface(
+ modifier = Modifier
+ .padding(contentPadding)
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.background)
+ ) {
+ TerminalLog(terminalItems)
+ }
+ }
+ }
+}
+
+private var lazyListState = 0 to 0
+
+@Composable
+fun TerminalLog(terminalItems: List) {
+ val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
+ DisposableEffect(Unit) {
+ onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
+ }
+ val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
+ val context = LocalContext.current
+ LazyColumn(state = listState, reverseLayout = true) {
+ items(reversedTerminalItems) { item ->
+ Text(
+ "${item.date.toString().subSequence(11, 19)} ${item.label}",
+ style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ ModalManager.shared.showModal(endButtons = { ShareButton { shareText(context, item.details) } }) {
+ SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
+ }
+ }
+ }.padding(horizontal = 8.dp, vertical = 4.dp)
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewTerminalLayout() {
+ SimpleXTheme {
+ TerminalLayout(
+ terminalItems = TerminalItem.sampleData,
+ composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
+ sendCommand = {},
+ close = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
new file mode 100644
index 0000000000..b60df05f9e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
@@ -0,0 +1,151 @@
+package chat.simplex.app.views
+
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ArrowBackIosNew
+import androidx.compose.material.icons.outlined.ArrowForwardIos
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.*
+import chat.simplex.app.R
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.model.Profile
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.AppBarTitle
+import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.onboarding.OnboardingStage
+import chat.simplex.app.views.onboarding.ReadableText
+import com.google.accompanist.insets.navigationBarsWithImePadding
+import kotlinx.coroutines.delay
+
+fun isValidDisplayName(name: String) : Boolean {
+ return (name.firstOrNull { it.isWhitespace() }) == null
+}
+
+@Composable
+fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
+ val displayName = remember { mutableStateOf("") }
+ val fullName = remember { mutableStateOf("") }
+ val focusRequester = remember { FocusRequester() }
+
+ Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
+ Column(
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
+ ) {
+ AppBarTitle(stringResource(R.string.create_profile), false)
+ ReadableText(R.string.your_profile_is_stored_on_your_device)
+ ReadableText(R.string.profile_is_only_shared_with_your_contacts)
+ Spacer(Modifier.height(10.dp))
+ Text(
+ stringResource(R.string.display_name),
+ style = MaterialTheme.typography.h6,
+ modifier = Modifier.padding(bottom = 3.dp)
+ )
+ ProfileNameField(displayName, focusRequester)
+ val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
+ Text(
+ errorText,
+ fontSize = 15.sp,
+ color = MaterialTheme.colors.error
+ )
+ Spacer(Modifier.height(3.dp))
+ Text(
+ stringResource(R.string.full_name_optional__prompt),
+ style = MaterialTheme.typography.h6,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ ProfileNameField(fullName)
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+ Row {
+ if (chatModel.users.isEmpty()) {
+ SimpleButton(
+ text = stringResource(R.string.about_simplex),
+ icon = Icons.Outlined.ArrowBackIosNew
+ ) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
+ }
+
+ Spacer(Modifier.fillMaxWidth().weight(1f))
+
+ val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
+ val createModifier: Modifier
+ val createColor: Color
+ if (enabled) {
+ createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp)
+ createColor = MaterialTheme.colors.primary
+ } else {
+ createModifier = Modifier.padding(8.dp)
+ createColor = HighOrLowlight
+ }
+ Surface(shape = RoundedCornerShape(20.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
+ Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor)
+ Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor)
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ delay(300)
+ focusRequester.requestFocus()
+ }
+ }
+ }
+}
+
+fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
+ withApi {
+ val user = chatModel.controller.apiCreateActiveUser(
+ Profile(displayName, fullName, null)
+ ) ?: return@withApi
+ chatModel.currentUser.value = user
+ if (chatModel.users.isEmpty()) {
+ chatModel.controller.startChat(user)
+ chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
+ SimplexApp.context.chatModel.controller.ntfManager.createNtfChannelsMaybeShowAlert()
+ } else {
+ val users = chatModel.controller.listUsers()
+ chatModel.users.clear()
+ chatModel.users.addAll(users)
+ chatModel.controller.getUserChatData()
+ close()
+ }
+ }
+}
+
+@Composable
+fun ProfileNameField(name: MutableState, focusRequester: FocusRequester? = null) {
+ val modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.secondary)
+ .height(40.dp)
+ .clip(RoundedCornerShape(5.dp))
+ .padding(8.dp)
+ .navigationBarsWithImePadding()
+ BasicTextField(
+ value = name.value,
+ onValueChange = { name.value = it },
+ modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
+ textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrect = false
+ ),
+ singleLine = true,
+ cursorBrush = SolidColor(HighOrLowlight)
+ )
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt
new file mode 100644
index 0000000000..d5d4406460
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt
@@ -0,0 +1,107 @@
+package chat.simplex.app.views.call
+
+import android.util.Log
+import chat.simplex.app.TAG
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.views.helpers.ModalManager
+import chat.simplex.app.views.helpers.withApi
+import kotlinx.datetime.Clock
+import kotlin.time.Duration.Companion.minutes
+
+class CallManager(val chatModel: ChatModel) {
+ fun reportNewIncomingCall(invitation: RcvCallInvitation) {
+ Log.d(TAG, "CallManager.reportNewIncomingCall")
+ with (chatModel) {
+ callInvitations[invitation.contact.id] = invitation
+ if (Clock.System.now() - invitation.callTs <= 3.minutes) {
+ activeCallInvitation.value = invitation
+ controller.ntfManager.notifyCallInvitation(invitation)
+ } else {
+ val contact = invitation.contact
+ controller.ntfManager.notifyMessageReceived(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
+ }
+ }
+ }
+
+ fun acceptIncomingCall(invitation: RcvCallInvitation) {
+ ModalManager.shared.closeModals()
+ val call = chatModel.activeCall.value
+ if (call == null) {
+ justAcceptIncomingCall(invitation = invitation)
+ } else {
+ withApi {
+ chatModel.switchingCall.value = true
+ try {
+ endCall(call = call)
+ justAcceptIncomingCall(invitation = invitation)
+ } finally {
+ withApi { chatModel.switchingCall.value = false }
+ }
+ }
+ }
+ }
+
+ private fun justAcceptIncomingCall(invitation: RcvCallInvitation) {
+ with (chatModel) {
+ activeCall.value = Call(
+ contact = invitation.contact,
+ callState = CallState.InvitationAccepted,
+ localMedia = invitation.callType.media,
+ sharedKey = invitation.sharedKey
+ )
+ showCallView.value = true
+ val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
+ val iceServers = getIceServers()
+ Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
+ callCommand.value = WCallCommand.Start(
+ media = invitation.callType.media,
+ aesKey = invitation.sharedKey,
+ iceServers = iceServers,
+ relay = useRelay
+ )
+ callInvitations.remove(invitation.contact.id)
+ if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
+ activeCallInvitation.value = null
+ controller.ntfManager.cancelCallNotification()
+ }
+ }
+ }
+
+ suspend fun endCall(call: Call) {
+ with (chatModel) {
+ if (call.callState == CallState.Ended) {
+ Log.d(TAG, "CallManager.endCall: call ended")
+ activeCall.value = null
+ showCallView.value = false
+ } else {
+ Log.d(TAG, "CallManager.endCall: ending call...")
+ callCommand.value = WCallCommand.End
+ showCallView.value = false
+ controller.apiEndCall(call.contact)
+ activeCall.value = null
+ }
+ }
+ }
+
+ fun endCall(invitation: RcvCallInvitation) {
+ with (chatModel) {
+ callInvitations.remove(invitation.contact.id)
+ if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
+ activeCallInvitation.value = null
+ controller.ntfManager.cancelCallNotification()
+ }
+ withApi {
+ if (!controller.apiRejectCall(invitation.contact)) {
+ Log.e(TAG, "apiRejectCall error")
+ }
+ }
+ }
+ }
+
+ fun reportCallRemoteEnded(invitation: RcvCallInvitation) {
+ if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) {
+ chatModel.activeCallInvitation.value = null
+ chatModel.controller.ntfManager.cancelCallNotification()
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt
new file mode 100644
index 0000000000..26a3c23c66
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt
@@ -0,0 +1,575 @@
+package chat.simplex.app.views.call
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.*
+import android.content.pm.ActivityInfo
+import android.media.*
+import android.os.Build
+import android.os.PowerManager
+import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK
+import android.util.Log
+import android.view.ViewGroup
+import android.webkit.*
+import androidx.activity.compose.BackHandler
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.*
+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.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.webkit.WebViewAssetLoader
+import androidx.webkit.WebViewClientCompat
+import chat.simplex.app.*
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.ProfileImage
+import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.usersettings.NotificationsMode
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import kotlinx.coroutines.*
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+
+@SuppressLint("SourceLockedOrientationActivity")
+@Composable
+fun ActiveCallView(chatModel: ChatModel) {
+ BackHandler(onBack = {
+ val call = chatModel.activeCall.value
+ if (call != null) withApi { chatModel.callManager.endCall(call) }
+ })
+ val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
+ val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
+ LaunchedEffect(Unit) {
+ // Start service when call happening since it's not already started.
+ // It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off
+ if (!ntfModeService) SimplexService.start(SimplexApp.context)
+ }
+ DisposableEffect(Unit) {
+ val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ var btDeviceCount = 0
+ val audioCallback = object: AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(addedDevices: Array) {
+ Log.d(TAG, "Added audio devices: ${addedDevices.map { it.type }}")
+ super.onAudioDevicesAdded(addedDevices)
+ val addedCount = addedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
+ btDeviceCount += addedCount
+ audioViaBluetooth.value = btDeviceCount > 0
+ if (addedCount > 0 && chatModel.activeCall.value?.callState == CallState.Connected) {
+ // Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12
+ setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth)
+ }
+ }
+ override fun onAudioDevicesRemoved(removedDevices: Array) {
+ Log.d(TAG, "Removed audio devices: ${removedDevices.map { it.type }}")
+ super.onAudioDevicesRemoved(removedDevices)
+ val removedCount = removedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
+ btDeviceCount -= removedCount
+ audioViaBluetooth.value = btDeviceCount > 0
+ if (btDeviceCount == 0 && chatModel.activeCall.value?.callState == CallState.Connected) {
+ // Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12
+ setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth)
+ }
+ }
+ }
+ am.registerAudioDeviceCallback(audioCallback, null)
+ val pm = (SimplexApp.context.getSystemService(Context.POWER_SERVICE) as PowerManager)
+ val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
+ pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, "proximityLock")
+ } else {
+ null
+ }
+ proximityLock?.acquire()
+ onDispose {
+ // Stop it when call ended
+ if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
+ dropAudioManagerOverrides()
+ am.unregisterAudioDeviceCallback(audioCallback)
+ proximityLock?.release()
+ }
+ }
+ val scope = rememberCoroutineScope()
+ Box(Modifier.fillMaxSize()) {
+ WebRTCView(chatModel.callCommand) { apiMsg ->
+ Log.d(TAG, "received from WebRTCView: $apiMsg")
+ val call = chatModel.activeCall.value
+ if (call != null) {
+ Log.d(TAG, "has active call $call")
+ when (val r = apiMsg.resp) {
+ is WCallResponse.Capabilities -> withApi {
+ val callType = CallType(call.localMedia, r.capabilities)
+ chatModel.controller.apiSendCallInvitation(call.contact, callType)
+ chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
+ }
+ is WCallResponse.Offer -> withApi {
+ chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
+ chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
+ }
+ is WCallResponse.Answer -> withApi {
+ chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
+ chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
+ }
+ is WCallResponse.Ice -> withApi {
+ chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
+ }
+ is WCallResponse.Connection ->
+ try {
+ val callStatus = json.decodeFromString("\"${r.state.connectionState}\"")
+ if (callStatus == WebRTCCallStatus.Connected) {
+ chatModel.activeCall.value = call.copy(callState = CallState.Connected)
+ setCallSound(call.soundSpeaker, audioViaBluetooth)
+ }
+ withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
+ } catch (e: Error) {
+ Log.d(TAG,"call status ${r.state.connectionState} not used")
+ }
+ is WCallResponse.Connected -> {
+ chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
+ scope.launch {
+ setCallSound(call.soundSpeaker, audioViaBluetooth)
+ }
+ }
+ is WCallResponse.Ended -> {
+ chatModel.activeCall.value = call.copy(callState = CallState.Ended)
+ withApi { chatModel.callManager.endCall(call) }
+ chatModel.showCallView.value = false
+ }
+ is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
+ is WCallCommand.Answer ->
+ chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
+ is WCallCommand.Media -> {
+ when (cmd.media) {
+ CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
+ CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
+ }
+ }
+ is WCallCommand.Camera -> {
+ chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
+ if (!call.audioEnabled) {
+ chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false)
+ }
+ }
+ is WCallCommand.End ->
+ chatModel.showCallView.value = false
+ else -> {}
+ }
+ is WCallResponse.Error -> {
+ Log.e(TAG, "ActiveCallView: command error ${r.message}")
+ }
+ }
+ }
+ }
+ val call = chatModel.activeCall.value
+ if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth)
+ }
+
+ val context = LocalContext.current
+ DisposableEffect(Unit) {
+ val activity = context as? Activity ?: return@DisposableEffect onDispose {}
+ val prevVolumeControlStream = activity.volumeControlStream
+ activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
+ // Lock orientation to portrait in order to have good experience with calls
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ onDispose {
+ activity.volumeControlStream = prevVolumeControlStream
+ // Unlock orientation
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+}
+
+@Composable
+private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetooth: MutableState) {
+ ActiveCallOverlayLayout(
+ call = call,
+ speakerCanBeEnabled = !audioViaBluetooth.value,
+ dismiss = { withApi { chatModel.callManager.endCall(call) } },
+ toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
+ toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
+ toggleSound = {
+ var call = chatModel.activeCall.value
+ if (call != null) {
+ call = call.copy(soundSpeaker = !call.soundSpeaker)
+ chatModel.activeCall.value = call
+ setCallSound(call.soundSpeaker, audioViaBluetooth)
+ }
+ },
+ flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
+ )
+}
+
+private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState) {
+ val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
+ am.mode = AudioManager.MODE_IN_COMMUNICATION
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val btDevice = am.availableCommunicationDevices.lastOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
+ val preferredSecondaryDevice = if (speaker) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
+ if (btDevice != null) {
+ am.setCommunicationDevice(btDevice)
+ } else if (am.communicationDevice?.type != preferredSecondaryDevice) {
+ am.availableCommunicationDevices.firstOrNull { it.type == preferredSecondaryDevice }?.let {
+ am.setCommunicationDevice(it)
+ }
+ }
+ } else {
+ if (audioViaBluetooth.value) {
+ am.isSpeakerphoneOn = false
+ am.startBluetoothSco()
+ } else {
+ am.stopBluetoothSco()
+ am.isSpeakerphoneOn = speaker
+ }
+ am.isBluetoothScoOn = am.isBluetoothScoAvailableOffCall && audioViaBluetooth.value
+ }
+}
+
+private fun dropAudioManagerOverrides() {
+ val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ am.mode = AudioManager.MODE_NORMAL
+ // Clear selected communication device to default value after we changed it in call
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ am.clearCommunicationDevice()
+ } else {
+ am.isSpeakerphoneOn = false
+ am.stopBluetoothSco()
+ }
+}
+
+@Composable
+private fun ActiveCallOverlayLayout(
+ call: Call,
+ speakerCanBeEnabled: Boolean,
+ dismiss: () -> Unit,
+ toggleAudio: () -> Unit,
+ toggleVideo: () -> Unit,
+ toggleSound: () -> Unit,
+ flipCamera: () -> Unit
+) {
+ Column(Modifier.padding(16.dp)) {
+ when (call.peerMedia ?: call.localMedia) {
+ CallMediaType.Video -> {
+ CallInfoView(call, alignment = Alignment.Start)
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+ Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ ToggleAudioButton(call, toggleAudio)
+ Spacer(Modifier.size(40.dp))
+ IconButton(onClick = dismiss) {
+ Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
+ }
+ if (call.videoEnabled) {
+ ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera)
+ ControlButton(call, Icons.Filled.Videocam, R.string.icon_descr_video_off, toggleVideo)
+ } else {
+ Spacer(Modifier.size(48.dp))
+ ControlButton(call, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo)
+ }
+ }
+ }
+ CallMediaType.Audio -> {
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ ProfileImage(size = 192.dp, image = call.contact.profile.image)
+ CallInfoView(call, alignment = Alignment.CenterHorizontally)
+ }
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+ Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
+ Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ IconButton(onClick = dismiss) {
+ Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
+ }
+ }
+ Box(Modifier.padding(start = 32.dp)) {
+ ToggleAudioButton(call, toggleAudio)
+ }
+ Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
+ Box(Modifier.padding(end = 32.dp)) {
+ ToggleSoundButton(call, speakerCanBeEnabled, toggleSound)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit, enabled: Boolean = true) {
+ if (call.hasMedia) {
+ IconButton(onClick = action, enabled = enabled) {
+ Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else HighOrLowlight, modifier = Modifier.size(40.dp))
+ }
+ } else {
+ Spacer(Modifier.size(40.dp))
+ }
+}
+
+@Composable
+private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
+ if (call.audioEnabled) {
+ ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_audio_off, toggleAudio)
+ } else {
+ ControlButton(call, Icons.Outlined.MicOff, R.string.icon_descr_audio_on, toggleAudio)
+ }
+}
+
+@Composable
+private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
+ if (call.soundSpeaker) {
+ ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound, enabled)
+ } else {
+ ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound, enabled)
+ }
+}
+
+@Composable
+fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
+ @Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
+ Text(text, color = Color(0xFFFFFFD8), style = style)
+ Column(horizontalAlignment = alignment) {
+ InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
+ InfoText(call.callState.text)
+
+ val connInfo = call.connectionInfo
+// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
+ val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
+ InfoText(call.encryptionStatus + connInfoText)
+ }
+}
+
+//@Composable
+//fun CallViewDebug(close: () -> Unit) {
+// val callCommand = remember { mutableStateOf(null)}
+// val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
+// val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
+//
+// BackHandler(onBack = close)
+// Column(
+// horizontalAlignment = Alignment.CenterHorizontally,
+// verticalArrangement = Arrangement.spacedBy(12.dp),
+// modifier = Modifier
+// .background(MaterialTheme.colors.background)
+// .fillMaxSize()
+// ) {
+// WebRTCView(callCommand) { apiMsg ->
+// // for debugging
+// // commandText.value = apiMsg
+// commandText.value = json.encodeToString(apiMsg)
+// }
+//
+// TextEditor(Modifier.height(180.dp), text = commandText)
+//
+// Row(
+// Modifier
+// .fillMaxWidth()
+// .padding(bottom = 6.dp),
+// horizontalArrangement = Arrangement.SpaceBetween
+// ) {
+// Button(onClick = {
+// val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
+// clipboard?.setPrimaryClip(clip)
+// }) { Text("Copy") }
+// Button(onClick = {
+// try {
+// val apiCall: WVAPICall = json.decodeFromString(commandText.value)
+// commandText.value = ""
+// println("sending: ${commandText.value}")
+// callCommand.value = apiCall.command
+// } catch(e: Error) {
+// println("error parsing command: ${commandText.value}")
+// println(e)
+// }
+// }) { Text("Send") }
+// Button(onClick = {
+// commandText.value = ""
+// }) { Text("Clear") }
+// }
+// }
+//}
+
+@Composable
+fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessage) -> Unit) {
+ val scope = rememberCoroutineScope()
+ val webView = remember { mutableStateOf(null) }
+ val permissionsState = rememberMultiplePermissionsState(
+ permissions = listOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO,
+ Manifest.permission.MODIFY_AUDIO_SETTINGS,
+ Manifest.permission.INTERNET
+ )
+ )
+ fun processCommand(wv: WebView, cmd: WCallCommand) {
+ val apiCall = WVAPICall(command = cmd)
+ wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null)
+ }
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME || event == Lifecycle.Event.ON_START) {
+ permissionsState.launchMultiplePermissionRequest()
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ val wv = webView.value
+ if (wv != null) processCommand(wv, WCallCommand.End)
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ webView.value?.destroy()
+ webView.value = null
+ }
+ }
+ LaunchedEffect(callCommand.value, webView.value) {
+ val cmd = callCommand.value
+ val wv = webView.value
+ if (cmd != null && wv != null) {
+ Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
+ processCommand(wv, cmd)
+ callCommand.value = null
+ }
+ }
+ val assetLoader = WebViewAssetLoader.Builder()
+ .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current))
+ .build()
+
+ if (permissionsState.allPermissionsGranted) {
+ Box(Modifier.fillMaxSize()) {
+ AndroidView(
+ factory = { AndroidViewContext ->
+ WebView(AndroidViewContext).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ this.webChromeClient = object: WebChromeClient() {
+ override fun onPermissionRequest(request: PermissionRequest) {
+ if (request.origin.toString().startsWith("file:/")) {
+ request.grant(request.resources)
+ } else {
+ Log.d(TAG, "Permission request from webview denied.")
+ request.deny()
+ }
+ }
+ }
+ this.webViewClient = LocalContentWebViewClient(assetLoader)
+ this.clearHistory()
+ this.clearCache(true)
+ this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface")
+ val webViewSettings = this.settings
+ webViewSettings.allowFileAccess = true
+ webViewSettings.allowContentAccess = true
+ webViewSettings.javaScriptEnabled = true
+ webViewSettings.mediaPlaybackRequiresUserGesture = false
+ webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
+ this.loadUrl("file:android_asset/www/call.html")
+ }
+ }
+ ) { wv ->
+ Log.d(TAG, "WebRTCView: webview ready")
+ // for debugging
+ // wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
+ scope.launch {
+ delay(2000L)
+ wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
+ webView.value = wv
+ }
+ }
+ }
+ }
+}
+
+// for debugging
+// class WebRTCInterface(private val onResponse: (String) -> Unit) {
+class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
+ @JavascriptInterface
+ fun postMessage(message: String) {
+ Log.d(TAG, "WebRTCInterface.postMessage")
+ try {
+ // for debugging
+ // onResponse(message)
+ onResponse(json.decodeFromString(message))
+ } catch (e: Error) {
+ Log.e(TAG, "failed parsing WebView message: $message")
+ }
+ }
+}
+
+private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
+ override fun shouldInterceptRequest(
+ view: WebView,
+ request: WebResourceRequest
+ ): WebResourceResponse? {
+ return assetLoader.shouldInterceptRequest(request.url)
+ }
+}
+
+@Preview
+@Composable
+fun PreviewActiveCallOverlayVideo() {
+ SimpleXTheme {
+ ActiveCallOverlayLayout(
+ call = Call(
+ contact = Contact.sampleData,
+ callState = CallState.Negotiated,
+ localMedia = CallMediaType.Video,
+ peerMedia = CallMediaType.Video,
+ connectionInfo = ConnectionInfo(
+ RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null),
+ RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null)
+ )
+ ),
+ speakerCanBeEnabled = true,
+ dismiss = {},
+ toggleAudio = {},
+ toggleVideo = {},
+ toggleSound = {},
+ flipCamera = {}
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PreviewActiveCallOverlayAudio() {
+ SimpleXTheme {
+ ActiveCallOverlayLayout(
+ call = Call(
+ contact = Contact.sampleData,
+ callState = CallState.Negotiated,
+ localMedia = CallMediaType.Audio,
+ peerMedia = CallMediaType.Audio,
+ connectionInfo = ConnectionInfo(
+ RTCIceCandidate(RTCIceCandidateType.Host, "udp", null),
+ RTCIceCandidate(RTCIceCandidateType.Host, "udp", null)
+ )
+ ),
+ speakerCanBeEnabled = true,
+ dismiss = {},
+ toggleAudio = {},
+ toggleVideo = {},
+ toggleSound = {},
+ flipCamera = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt
new file mode 100644
index 0000000000..5877bb6d4d
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt
@@ -0,0 +1,238 @@
+package chat.simplex.app.views.call
+
+import android.app.Activity
+import android.app.KeyguardManager
+import android.content.Context
+import android.content.Intent
+import android.content.res.Configuration
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.WindowManager
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.*
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.ProfileImage
+import chat.simplex.app.views.onboarding.SimpleXLogo
+import kotlinx.datetime.Clock
+
+class IncomingCallActivity: ComponentActivity() {
+ private val vm by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent { IncomingCallActivityView(vm.chatModel) }
+ unlockForIncomingCall()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ lockAfterIncomingCall()
+ }
+
+ private fun unlockForIncomingCall() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ setShowWhenLocked(true)
+ setTurnScreenOn(true)
+ } else {
+ window.addFlags(activityFlags)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ getKeyguardManager(this).requestDismissKeyguard(this, null)
+ }
+ }
+
+ private fun lockAfterIncomingCall() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ setShowWhenLocked(false)
+ setTurnScreenOn(false)
+ } else {
+ window.clearFlags(activityFlags)
+ }
+ }
+
+ companion object {
+ const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
+ }
+}
+
+fun getKeyguardManager(context: Context): KeyguardManager =
+ context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+
+@Composable
+fun IncomingCallActivityView(m: ChatModel) {
+ val switchingCall = m.switchingCall.value
+ val invitation = m.activeCallInvitation.value
+ val call = m.activeCall.value
+ val showCallView = m.showCallView.value
+ val activity = LocalContext.current as Activity
+ LaunchedEffect(invitation, call, switchingCall, showCallView) {
+ if (!switchingCall && invitation == null && (!showCallView || call == null)) {
+ Log.d(TAG, "IncomingCallActivityView: finishing activity")
+ activity.finish()
+ }
+ }
+ SimpleXTheme {
+ Surface(
+ Modifier
+ .background(MaterialTheme.colors.background)
+ .fillMaxSize()) {
+ if (showCallView) {
+ Box {
+ ActiveCallView(m)
+ if (invitation != null) IncomingCallAlertView(invitation, m)
+ }
+ } else if (invitation != null) {
+ IncomingCallLockScreenAlert(invitation, m)
+ }
+ }
+ }
+}
+
+@Composable
+fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
+ val cm = chatModel.callManager
+ val callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
+ val context = LocalContext.current
+ DisposableEffect(Unit) {
+ onDispose {
+ // Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
+ chatModel.controller.ntfManager.cancelCallNotification()
+ }
+ }
+ IncomingCallLockScreenAlertLayout(
+ invitation,
+ callOnLockScreen,
+ chatModel,
+ rejectCall = { cm.endCall(invitation = invitation) },
+ ignoreCall = {
+ chatModel.activeCallInvitation.value = null
+ chatModel.controller.ntfManager.cancelCallNotification()
+ },
+ acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
+ openApp = {
+ val intent = Intent(context, MainActivity::class.java)
+ .setAction(OpenChatAction)
+ .putExtra("userId", invitation.user.userId)
+ .putExtra("chatId", invitation.contact.id)
+ context.startActivity(intent)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ getKeyguardManager(context).requestDismissKeyguard((context as Activity), null)
+ }
+ (context as Activity).finish()
+ }
+ )
+}
+
+@Composable
+fun IncomingCallLockScreenAlertLayout(
+ invitation: RcvCallInvitation,
+ callOnLockScreen: CallOnLockScreen?,
+ chatModel: ChatModel,
+ rejectCall: () -> Unit,
+ ignoreCall: () -> Unit,
+ acceptCall: () -> Unit,
+ openApp: () -> Unit
+) {
+ Column(
+ Modifier
+ .padding(30.dp)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ IncomingCallInfo(invitation, chatModel)
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+ if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
+ ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
+ Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+ Row {
+ LockScreenCallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
+ Spacer(Modifier.size(48.dp))
+ LockScreenCallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
+ Spacer(Modifier.size(48.dp))
+ LockScreenCallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
+ }
+ } else if (callOnLockScreen == CallOnLockScreen.SHOW) {
+ SimpleXLogo()
+ Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
+ Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+ SimpleButton(text = stringResource(R.string.open_verb), icon = Icons.Filled.Check, click = openApp)
+ }
+ }
+}
+
+@Composable
+private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
+ Surface(
+ shape = RoundedCornerShape(10.dp),
+ color = Color.Transparent
+ ) {
+ Column(
+ Modifier
+ .defaultMinSize(minWidth = 50.dp)
+ .padding(4.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ IconButton(action) {
+ Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f))
+ }
+ Spacer(Modifier.height(16.dp))
+ Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
+ }
+ }
+}
+
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true
+)
+@Composable
+fun PreviewIncomingCallLockScreenAlert() {
+ SimpleXTheme(true) {
+ Surface(
+ Modifier
+ .background(MaterialTheme.colors.background)
+ .fillMaxSize()) {
+ IncomingCallLockScreenAlertLayout(
+ invitation = RcvCallInvitation(
+ user = User.sampleData,
+ contact = Contact.sampleData,
+ callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
+ sharedKey = null,
+ callTs = Clock.System.now()
+ ),
+ callOnLockScreen = null,
+ chatModel = SimplexApp.context.chatModel,
+ rejectCall = {},
+ ignoreCall = {},
+ acceptCall = {},
+ openApp = {},
+ )
+ }
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt
similarity index 55%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt
index 4d8c1fae46..267fd458e7 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt
@@ -1,44 +1,43 @@
-package chat.simplex.common.views.call
+package chat.simplex.app.views.call
-import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.painter.Painter
-import dev.icerock.moko.resources.compose.painterResource
-import dev.icerock.moko.resources.compose.stringResource
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import chat.simplex.common.model.*
-import chat.simplex.common.platform.*
-import chat.simplex.common.ui.theme.*
-import chat.simplex.common.views.helpers.ProfileImage
-import chat.simplex.common.views.usersettings.ProfilePreview
-import chat.simplex.res.MR
+import chat.simplex.app.R
+import chat.simplex.app.SimplexApp
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.ProfileImage
+import chat.simplex.app.views.usersettings.ProfilePreview
import kotlinx.datetime.Clock
@Composable
fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager
+ val cxt = LocalContext.current
val scope = rememberCoroutineScope()
- LaunchedEffect(Unit) {
- if (chatModel.activeCallInvitation.value?.sentNotification == false || appPlatform.isDesktop) {
- SoundPlayer.start(scope, sound = !chatModel.showCallView.value)
- }
- }
- DisposableEffect(true) { onDispose { SoundPlayer.stop() } }
+ LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = !chatModel.showCallView.value) }
+ DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
IncomingCallAlertLayout(
invitation,
chatModel,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = {
chatModel.activeCallInvitation.value = null
- ntfManager.cancelCallNotification()
+ chatModel.controller.ntfManager.cancelCallNotification()
},
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
)
@@ -52,18 +51,18 @@ fun IncomingCallAlertLayout(
ignoreCall: () -> Unit,
acceptCall: () -> Unit
) {
- val color = if (isInDarkTheme()) MaterialTheme.colors.surface else IncomingCallLight
- Column(Modifier.fillMaxWidth().background(color).statusBarsPadding().padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
+ val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
+ Column(Modifier.fillMaxWidth().background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
IncomingCallInfo(invitation, chatModel)
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
- ProfilePreview(profileOf = invitation.contact, size = 64.dp)
+ ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
}
Row(verticalAlignment = Alignment.CenterVertically) {
- CallButton(stringResource(MR.strings.reject), painterResource(MR.images.ic_call_end_filled), Color.Red, rejectCall)
- CallButton(stringResource(MR.strings.ignore), painterResource(MR.images.ic_close), MaterialTheme.colors.primary, ignoreCall)
- CallButton(stringResource(MR.strings.accept), painterResource(MR.images.ic_check_filled), SimplexGreen, acceptCall)
+ CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
+ CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
+ CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
}
}
}
@@ -71,25 +70,24 @@ fun IncomingCallAlertLayout(
@Composable
fun IncomingCallInfo(invitation: RcvCallInvitation, chatModel: ChatModel) {
- @Composable fun CallIcon(icon: Painter, descr: String) = Icon(icon, descr, tint = SimplexGreen)
+ @Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
Row(verticalAlignment = Alignment.CenterVertically) {
if (chatModel.users.size > 1) {
- ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
+ ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.secondary)
Spacer(Modifier.width(4.dp))
}
- if (invitation.callType.media == CallMediaType.Video) CallIcon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call))
- else CallIcon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call))
+ if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
+ else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
Spacer(Modifier.width(4.dp))
- Text(invitation.callTypeText, color = MaterialTheme.colors.onBackground)
+ Text(invitation.callTypeText)
}
}
@Composable
-private fun CallButton(text: String, icon: Painter, color: Color, action: () -> Unit) {
+private fun CallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
Surface(
shape = RoundedCornerShape(10.dp),
- color = Color.Transparent,
- contentColor = LocalContentColor.current
+ color = Color.Transparent
) {
Column(
Modifier
@@ -99,7 +97,7 @@ private fun CallButton(text: String, icon: Painter, color: Color, action: () ->
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
- Text(text, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary)
+ Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
}
}
}
@@ -110,15 +108,13 @@ fun PreviewIncomingCallAlertLayout() {
SimpleXTheme {
IncomingCallAlertLayout(
invitation = RcvCallInvitation(
- remoteHostId = null,
user = User.sampleData,
contact = Contact.sampleData,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
sharedKey = null,
- callUUID = "",
callTs = Clock.System.now()
),
- chatModel = ChatModel,
+ chatModel = SimplexApp.context.chatModel,
rejectCall = {},
ignoreCall = {},
acceptCall = {}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt
new file mode 100644
index 0000000000..009ffdf6e1
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt
@@ -0,0 +1,51 @@
+package chat.simplex.app.views.call
+
+import android.content.Context
+import android.media.*
+import android.net.Uri
+import android.os.VibrationEffect
+import android.os.Vibrator
+import androidx.core.content.ContextCompat
+import chat.simplex.app.R
+import chat.simplex.app.SimplexApp
+import chat.simplex.app.views.helpers.withScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+
+class SoundPlayer {
+ private var player: MediaPlayer? = null
+ var playing = false
+
+ fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) {
+ player?.reset()
+ player = MediaPlayer().apply {
+ setAudioAttributes(
+ AudioAttributes.Builder()
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
+ .build()
+ )
+ setDataSource(SimplexApp.context, Uri.parse("android.resource://" + SimplexApp.context.packageName + "/" + R.raw.ring_once))
+ prepare()
+ }
+ val vibrator = ContextCompat.getSystemService(cxt, Vibrator::class.java)
+ val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
+ playing = true
+ withScope(scope) {
+ while (playing) {
+ if (sound) player?.start()
+ vibrator?.vibrate(effect)
+ delay(3500)
+ }
+ }
+ }
+
+ fun stop() {
+ playing = false
+ player?.stop()
+ }
+
+ companion object {
+ val shared = SoundPlayer()
+ }
+}
\ No newline at end of file
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt
similarity index 56%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt
index 705fc6a28f..ac586b80b4 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt
@@ -1,49 +1,44 @@
-package chat.simplex.common.views.call
+package chat.simplex.app.views.call
-import chat.simplex.common.views.helpers.generalGetString
-import chat.simplex.common.model.*
-import chat.simplex.common.platform.appPlatform
-import chat.simplex.res.MR
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.toUpperCase
+import chat.simplex.app.*
+import chat.simplex.app.model.Contact
+import chat.simplex.app.model.User
+import chat.simplex.app.views.helpers.generalGetString
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-import java.io.Closeable
import java.net.URI
+import java.util.*
import kotlin.collections.ArrayList
data class Call(
- val remoteHostId: Long?,
- val userProfile: Profile,
val contact: Contact,
- val callUUID: String?,
val callState: CallState,
- val initialCallType: CallMediaType,
- val localMediaSources: CallMediaSources = CallMediaSources(mic = true, camera = initialCallType == CallMediaType.Video),
+ val localMedia: CallMediaType,
val localCapabilities: CallCapabilities? = null,
- val peerMediaSources: CallMediaSources = CallMediaSources(),
+ val peerMedia: CallMediaType? = null,
val sharedKey: String? = null,
+ val audioEnabled: Boolean = true,
+ val videoEnabled: Boolean = localMedia == CallMediaType.Video,
+ val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
var localCamera: VideoCamera = VideoCamera.User,
- val connectionInfo: ConnectionInfo? = null,
- var connectedAt: Instant? = null,
-
- // When a user has audio call, and then he wants to enable camera but didn't grant permissions for using camera yet,
- // we show permissions view without enabling camera before permissions are granted. After they are granted, enabling camera
- val wantsToEnableCamera: Boolean = false,
-
- val androidCallState: Closeable
+ val connectionInfo: ConnectionInfo? = null
) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null
- private val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
+ val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
- val encryptionStatus: String get() = when(callState) {
+ val encryptionStatus: String @Composable get() = when(callState) {
CallState.WaitCapabilities -> ""
- CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
- CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
- else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
+ CallState.InvitationSent -> stringResource(if (localEncrypted) R.string.status_e2e_encrypted else R.string.status_no_e2e_encryption)
+ CallState.InvitationAccepted -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption)
+ else -> stringResource(if (!localEncrypted) R.string.status_no_e2e_encryption else if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_e2e_encrypted)
}
- val hasVideo: Boolean
- get() = localMediaSources.hasVideo || peerMediaSources.hasVideo
+ val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
}
enum class CallState {
@@ -57,44 +52,31 @@ enum class CallState {
Connected,
Ended;
- val text: String get() = when(this) {
- WaitCapabilities -> generalGetString(MR.strings.callstate_starting)
- InvitationSent -> generalGetString(MR.strings.callstate_waiting_for_answer)
- InvitationAccepted -> generalGetString(MR.strings.callstate_starting)
- OfferSent -> generalGetString(MR.strings.callstate_waiting_for_confirmation)
- OfferReceived -> generalGetString(MR.strings.callstate_received_answer)
- AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation)
- Negotiated -> generalGetString(MR.strings.callstate_connecting)
- Connected -> generalGetString(MR.strings.callstate_connected)
- Ended -> generalGetString(MR.strings.callstate_ended)
+ val text: String @Composable get() = when(this) {
+ WaitCapabilities -> stringResource(R.string.callstate_starting)
+ InvitationSent -> stringResource(R.string.callstate_waiting_for_answer)
+ InvitationAccepted -> stringResource(R.string.callstate_starting)
+ OfferSent -> stringResource(R.string.callstate_waiting_for_confirmation)
+ OfferReceived -> stringResource(R.string.callstate_received_answer)
+ AnswerReceived -> stringResource(R.string.callstate_received_confirmation)
+ Negotiated -> stringResource(R.string.callstate_connecting)
+ Connected -> stringResource(R.string.callstate_connected)
+ Ended -> stringResource(R.string.callstate_ended)
}
}
@Serializable data class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
@Serializable data class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
-@Serializable data class CallMediaSources(
- val mic: Boolean = false,
- val camera: Boolean = false,
- val screenAudio: Boolean = false,
- val screenVideo: Boolean = false
-) {
- val hasVideo: Boolean
- get() = camera || screenVideo
-}
-
@Serializable
sealed class WCallCommand {
- @Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand()
- @Serializable @SerialName("permission") data class Permission(val title: String, val chrome: String, val safari: String): WCallCommand()
+ @Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
- @Serializable @SerialName("media") data class Media(val source: CallMediaSource, val enable: Boolean): WCallCommand()
+ @Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
- @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
- @Serializable @SerialName("layout") data class Layout(val layout: LayoutType): WCallCommand()
@Serializable @SerialName("end") object End: WCallCommand()
}
@@ -106,8 +88,6 @@ sealed class WCallResponse {
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
- @Serializable @SerialName("peerMedia") data class PeerMedia(val source: CallMediaSource, val enabled: Boolean): WCallResponse()
- @Serializable @SerialName("end") object End: WCallResponse()
@Serializable @SerialName("ended") object Ended: WCallResponse()
@Serializable @SerialName("ok") object Ok: WCallResponse()
@Serializable @SerialName("error") data class Error(val message: String): WCallResponse()
@@ -117,45 +97,42 @@ sealed class WCallResponse {
@Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String)
@Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
-@Serializable data class RcvCallInvitation(
- val remoteHostId: Long?,
- val user: User,
- val contact: Contact,
- val callType: CallType,
- val sharedKey: String? = null,
- val callUUID: String,
- val callTs: Instant
-) {
+@Serializable data class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
val callTypeText: String get() = generalGetString(when(callType.media) {
- CallMediaType.Video -> if (sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call
- CallMediaType.Audio -> if (sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call
+ CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
+ CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
})
val callTitle: String get() = generalGetString(when(callType.media) {
- CallMediaType.Video -> MR.strings.incoming_video_call
- CallMediaType.Audio -> MR.strings.incoming_audio_call
+ CallMediaType.Video -> R.string.incoming_video_call
+ CallMediaType.Audio -> R.string.incoming_audio_call
})
-
- // Shows whether notification was shown or not to prevent playing sound twice in both notification and in-app
- var sentNotification: Boolean = false
}
@Serializable data class CallCapabilities(val encryption: Boolean)
@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
- val text: String get() {
+ val text: String @Composable get() {
val local = localCandidate?.candidateType
val remote = remoteCandidate?.candidateType
return when {
local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host ->
- generalGetString(MR.strings.call_connection_peer_to_peer)
+ stringResource(R.string.call_connection_peer_to_peer)
local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay ->
- generalGetString(MR.strings.call_connection_via_relay)
+ stringResource(R.string.call_connection_via_relay)
else ->
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
}
}
+
+ val protocolText: String get() {
+ val local = localCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
+ val localRelay = localCandidate?.relayProtocol?.uppercase(Locale.ROOT) ?: "unknown"
+ val remote = remoteCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
+ val localText = if (localRelay == local || localCandidate?.relayProtocol == null) local else "$local ($localRelay)"
+ return if (local == remote) localText else "$localText / $remote"
+ }
}
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
-@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?)
+@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?, val relayProtocol: String?)
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
@Serializable data class RTCIceServer(val urls: List, val username: String? = null, val credential: String? = null)
@@ -182,14 +159,6 @@ enum class CallMediaType {
@SerialName("audio") Audio
}
-@Serializable
-enum class CallMediaSource {
- @SerialName("mic") Mic,
- @SerialName("camera") Camera,
- @SerialName("screenAudio") ScreenAudio,
- @SerialName("screenVideo") ScreenVideo
-}
-
@Serializable
enum class VideoCamera {
@SerialName("user") User,
@@ -197,13 +166,6 @@ enum class VideoCamera {
val flipped: VideoCamera get() = if (this == User) Environment else User
}
-@Serializable
-enum class LayoutType {
- @SerialName("default") Default,
- @SerialName("localVideo") LocalVideo,
- @SerialName("remoteVideo") RemoteVideo
-}
-
@Serializable
data class ConnectionState(
val connectionState: String,
@@ -213,11 +175,10 @@ data class ConnectionState(
)
// the servers are expected in this format:
-// stuns:stun.simplex.im:443?transport=tcp
-// turns:private2:Hxuq2QxUjnhj96Zq2r4HjqHRj@turn.simplex.im:443?transport=tcp
+// stun:stun.simplex.im:443?transport=tcp
+// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp
fun parseRTCIceServer(str: String): RTCIceServer? {
var s = replaceScheme(str, "stun:")
- s = replaceScheme(s, "stuns:")
s = replaceScheme(s, "turn:")
s = replaceScheme(s, "turns:")
val u = runCatching { URI(s) }.getOrNull()
@@ -225,7 +186,7 @@ fun parseRTCIceServer(str: String): RTCIceServer? {
val scheme = u.scheme
val host = u.host
val port = u.port
- if (u.path == "" && (scheme == "stun" || scheme == "stuns" || scheme == "turn" || scheme == "turns")) {
+ if (u.path == "" && (scheme == "stun" || scheme == "turn" || scheme == "turns")) {
val userInfo = u.userInfo?.split(":")
val query = if (u.query == null || u.query == "") "" else "?${u.query}"
return RTCIceServer(
@@ -254,7 +215,7 @@ fun parseRTCIceServers(servers: List): List? {
}
fun getIceServers(): List? {
- val value = ChatController.appPrefs.webrtcIceServers.get() ?: return null
+ val value = SimplexApp.context.chatController.appPrefs.webrtcIceServers.get() ?: return null
val servers: List = value.split("\n")
return parseRTCIceServers(servers)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt
new file mode 100644
index 0000000000..dc5286fd6e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt
@@ -0,0 +1,471 @@
+package chat.simplex.app.views.chat
+
+import InfoRow
+import InfoRowEllipsis
+import SectionDivider
+import SectionItemView
+import SectionSpacer
+import SectionView
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.*
+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.Color
+import androidx.compose.ui.platform.ClipboardManager
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.SimplexApp
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.usersettings.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.*
+import kotlinx.datetime.Clock
+
+@Composable
+fun ChatInfoView(
+ chatModel: ChatModel,
+ contact: Contact,
+ connStats: ConnectionStats?,
+ customUserProfile: Profile?,
+ localAlias: String,
+ connectionCode: String?,
+ close: () -> Unit,
+) {
+ BackHandler(onBack = close)
+ val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
+ val developerTools = chatModel.controller.appPrefs.developerTools.get()
+ if (chat != null) {
+ val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
+ mutableStateOf(chatModel.contactNetworkStatus(contact))
+ }
+ ChatInfoLayout(
+ chat,
+ contact,
+ connStats,
+ contactNetworkStatus.value,
+ customUserProfile,
+ localAlias,
+ connectionCode,
+ developerTools,
+ onLocalAliasChanged = {
+ setContactAlias(chat.chatInfo.apiId, it, chatModel)
+ },
+ openPreferences = {
+ ModalManager.shared.showCustomModal { close ->
+ val user = chatModel.currentUser.value
+ if (user != null) {
+ ContactPreferencesView(chatModel, user, contact.contactId, close)
+ }
+ }
+ },
+ deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
+ clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
+ switchContactAddress = {
+ showSwitchContactAddressAlert(chatModel, contact.contactId)
+ },
+ verifyClicked = {
+ ModalManager.shared.showModalCloseable { close ->
+ remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
+ VerifyCodeView(
+ ct.displayName,
+ connectionCode,
+ ct.verified,
+ verify = { code ->
+ chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r ->
+ val (verified, existingCode) = r
+ chatModel.updateContact(
+ ct.copy(
+ activeConn = ct.activeConn.copy(
+ connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
+ )
+ )
+ )
+ r
+ }
+ },
+ close,
+ )
+ }
+ }
+ }
+ )
+ }
+}
+
+fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.delete_contact_question),
+ text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
+ confirmText = generalGetString(R.string.delete_verb),
+ onConfirm = {
+ withApi {
+ val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
+ if (r) {
+ chatModel.removeChat(chatInfo.id)
+ chatModel.chatId.value = null
+ chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
+ close?.invoke()
+ }
+ }
+ }
+ )
+}
+
+fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.clear_chat_question),
+ text = generalGetString(R.string.clear_chat_warning),
+ confirmText = generalGetString(R.string.clear_verb),
+ onConfirm = {
+ withApi {
+ val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId)
+ if (updatedChatInfo != null) {
+ chatModel.clearChat(updatedChatInfo)
+ chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
+ close?.invoke()
+ }
+ }
+ }
+ )
+}
+
+@Composable
+fun ChatInfoLayout(
+ chat: Chat,
+ contact: Contact,
+ connStats: ConnectionStats?,
+ contactNetworkStatus: NetworkStatus,
+ customUserProfile: Profile?,
+ localAlias: String,
+ connectionCode: String?,
+ developerTools: Boolean,
+ onLocalAliasChanged: (String) -> Unit,
+ openPreferences: () -> Unit,
+ deleteContact: () -> Unit,
+ clearChat: () -> Unit,
+ switchContactAddress: () -> Unit,
+ verifyClicked: () -> Unit,
+) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ ChatInfoHeader(chat.chatInfo, contact)
+ }
+
+ LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
+
+ if (customUserProfile != null) {
+ SectionSpacer()
+ SectionView(generalGetString(R.string.incognito).uppercase()) {
+ InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
+ }
+ }
+
+ SectionSpacer()
+ SectionView {
+ if (connectionCode != null) {
+ VerifyCodeButton(contact.verified, verifyClicked)
+ SectionDivider()
+ }
+ ContactPreferencesButton(openPreferences)
+ }
+
+ SectionSpacer()
+
+ SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
+ SwitchAddressButton(switchContactAddress)
+ SectionDivider()
+ if (connStats != null) {
+ SectionItemView({
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.network_status),
+ contactNetworkStatus.statusExplanation
+ )}) {
+ NetworkStatusRow(contactNetworkStatus)
+ }
+ val rcvServers = connStats.rcvServers
+ if (rcvServers != null && rcvServers.isNotEmpty()) {
+ SectionDivider()
+ SimplexServers(stringResource(R.string.receiving_via), rcvServers)
+ }
+ val sndServers = connStats.sndServers
+ if (sndServers != null && sndServers.isNotEmpty()) {
+ SectionDivider()
+ SimplexServers(stringResource(R.string.sending_via), sndServers)
+ }
+ }
+ }
+ SectionSpacer()
+ SectionView {
+ ClearChatButton(clearChat)
+ SectionDivider()
+ DeleteContactButton(deleteContact)
+ }
+ SectionSpacer()
+
+ if (developerTools) {
+ SectionView(title = stringResource(R.string.section_title_for_console)) {
+ InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
+ SectionDivider()
+ InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
+ }
+ SectionSpacer()
+ }
+ }
+}
+
+@Composable
+fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
+ Column(
+ Modifier.padding(horizontal = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
+ Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ if (contact.verified) {
+ Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
+ }
+ Text(
+ contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
+ color = MaterialTheme.colors.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
+ Text(
+ cInfo.fullName, style = MaterialTheme.typography.h2,
+ color = MaterialTheme.colors.onBackground,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+}
+
+@Composable
+fun LocalAliasEditor(
+ initialValue: String,
+ center: Boolean = true,
+ leadingIcon: Boolean = false,
+ focus: Boolean = false,
+ updateValue: (String) -> Unit
+) {
+ var value by rememberSaveable { mutableStateOf(initialValue) }
+ val modifier = if (center)
+ Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp)
+ else
+ Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).fillMaxWidth()
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = if (center) Arrangement.Center else Arrangement.Start) {
+ DefaultBasicTextField(
+ modifier,
+ value,
+ {
+ Text(
+ generalGetString(R.string.text_field_set_contact_placeholder),
+ textAlign = if (center) TextAlign.Center else TextAlign.Start,
+ color = HighOrLowlight
+ )
+ },
+ leadingIcon = if (leadingIcon) {
+ { Icon(Icons.Default.Edit, null, Modifier.padding(start = 7.dp)) }
+ } else null,
+ color = HighOrLowlight,
+ focus = focus,
+ textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
+ keyboardActions = KeyboardActions(onDone = { updateValue(value) })
+ ) {
+ value = it
+ }
+ }
+ LaunchedEffect(Unit) {
+ snapshotFlow { value }
+ .onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing
+ .conflate() // get the latest value
+ .filter { it == value } // don't process old ones
+ .collect {
+ updateValue(value)
+ }
+ }
+ DisposableEffect(Unit) {
+ onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
+ }
+}
+
+@Composable
+private fun NetworkStatusRow(networkStatus: NetworkStatus) {
+ Row(
+ Modifier.fillMaxSize(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(stringResource(R.string.network_status))
+ Icon(
+ Icons.Outlined.Info,
+ stringResource(R.string.network_status),
+ tint = MaterialTheme.colors.primary
+ )
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ networkStatus.statusString,
+ color = HighOrLowlight
+ )
+ ServerImage(networkStatus)
+ }
+ }
+}
+
+@Composable
+private fun ServerImage(networkStatus: NetworkStatus) {
+ Box(Modifier.size(18.dp)) {
+ when (networkStatus) {
+ is NetworkStatus.Connected ->
+ Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
+ is NetworkStatus.Disconnected ->
+ Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
+ is NetworkStatus.Error ->
+ Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
+ else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
+ }
+ }
+}
+
+@Composable
+fun SimplexServers(text: String, servers: List) {
+ val info = servers.joinToString(separator = ", ") { it.substringAfter("@") }
+ val clipboardManager: ClipboardManager = LocalClipboardManager.current
+ InfoRowEllipsis(text, info) {
+ clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ",")))
+ Toast.makeText(SimplexApp.context, generalGetString(R.string.copied), Toast.LENGTH_SHORT).show()
+ }
+}
+
+@Composable
+fun SwitchAddressButton(onClick: () -> Unit) {
+ SectionItemView(onClick) {
+ Text(stringResource(R.string.switch_receiving_address), color = MaterialTheme.colors.primary)
+ }
+}
+
+@Composable
+fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
+ SettingsActionItem(
+ if (contactVerified) Icons.Outlined.VerifiedUser else Icons.Outlined.Shield,
+ stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
+ click = onClick,
+ iconColor = HighOrLowlight,
+ )
+}
+
+@Composable
+private fun ContactPreferencesButton(onClick: () -> Unit) {
+ SettingsActionItem(
+ Icons.Outlined.ToggleOn,
+ stringResource(R.string.contact_preferences),
+ click = onClick
+ )
+}
+
+@Composable
+fun ClearChatButton(onClick: () -> Unit) {
+ SettingsActionItem(
+ Icons.Outlined.Restore,
+ stringResource(R.string.clear_chat_button),
+ click = onClick,
+ textColor = WarningOrange,
+ iconColor = WarningOrange,
+ )
+}
+
+@Composable
+private fun DeleteContactButton(onClick: () -> Unit) {
+ SettingsActionItem(
+ Icons.Outlined.Delete,
+ stringResource(R.string.button_delete_contact),
+ click = onClick,
+ textColor = Color.Red,
+ iconColor = Color.Red,
+ )
+}
+
+private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel) = withApi {
+ chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let {
+ chatModel.updateContact(it)
+ }
+}
+
+private fun showSwitchContactAddressAlert(m: ChatModel, contactId: Long) {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.switch_receiving_address_question),
+ text = generalGetString(R.string.switch_receiving_address_desc),
+ confirmText = generalGetString(R.string.switch_verb),
+ onConfirm = {
+ switchContactAddress(m, contactId)
+ }
+ )
+}
+
+private fun switchContactAddress(m: ChatModel, contactId: Long) = withApi {
+ m.controller.apiSwitchContact(contactId)
+}
+
+@Preview
+@Composable
+fun PreviewChatInfoLayout() {
+ SimpleXTheme {
+ ChatInfoLayout(
+ chat = Chat(
+ chatInfo = ChatInfo.Direct.sampleData,
+ chatItems = arrayListOf()
+ ),
+ Contact.sampleData,
+ localAlias = "",
+ connectionCode = "123",
+ developerTools = false,
+ connStats = null,
+ contactNetworkStatus = NetworkStatus.Connected(),
+ onLocalAliasChanged = {},
+ customUserProfile = null,
+ openPreferences = {},
+ deleteContact = {},
+ clearChat = {},
+ switchContactAddress = {},
+ verifyClicked = {},
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
new file mode 100644
index 0000000000..f875f31ae7
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
@@ -0,0 +1,1133 @@
+package chat.simplex.app.views.chat
+
+import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.net.Uri
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.*
+import androidx.compose.foundation.gestures.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.mapSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.*
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.capitalize
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.*
+import androidx.core.content.FileProvider
+import chat.simplex.app.*
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.call.*
+import chat.simplex.app.views.chat.group.*
+import chat.simplex.app.views.chat.item.*
+import chat.simplex.app.views.chatlist.*
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.helpers.AppBarHeight
+import com.google.accompanist.insets.ProvideWindowInsets
+import com.google.accompanist.insets.navigationBarsWithImePadding
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import kotlinx.datetime.Clock
+import java.io.File
+import kotlin.math.sign
+
+@Composable
+fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
+ val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
+ val searchText = rememberSaveable { mutableStateOf("") }
+ val user = chatModel.currentUser.value
+ val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
+ val composeState = rememberSaveable(saver = ComposeState.saver()) {
+ mutableStateOf(
+ if (chatModel.draftChatId.value == chatId && chatModel.draft.value != null) {
+ chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
+ } else {
+ ComposeState(useLinkPreviews = useLinkPreviews)
+ }
+ )
+ }
+ val attachmentOption = rememberSaveable { mutableStateOf(null) }
+ val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
+ val scope = rememberCoroutineScope()
+ LaunchedEffect(Unit) {
+ // snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
+ // With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
+ launch {
+ snapshotFlow { chatModel.chatId.value }
+ .distinctUntilChanged()
+ .collect {
+ if (activeChat.value?.id != chatModel.chatId.value && chatModel.chatId.value != null) {
+ // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
+ // Also for situation when chatId changes after clicking in notification, etc
+ activeChat.value = chatModel.getChat(chatModel.chatId.value!!)
+ }
+ markUnreadChatAsRead(activeChat, chatModel)
+ }
+ }
+ launch {
+ snapshotFlow {
+ /**
+ * It's possible that in some cases concurrent modification can happen on [ChatModel.chats] list.
+ * In this case only error log will be printed here (no crash).
+ * TODO: Re-write [ChatModel.chats] logic to a new list assignment instead of changing content of mutableList to prevent that
+ * */
+ try {
+ chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
+ } catch (e: ConcurrentModificationException) {
+ Log.e(TAG, e.stackTraceToString())
+ null
+ }
+ }
+ .distinctUntilChanged()
+ // Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
+ .filter { it?.chatInfo != activeChat.value?.chatInfo && it != null }
+ .collect { activeChat.value = it }
+ }
+ }
+ val view = LocalView.current
+ if (activeChat.value == null || user == null) {
+ chatModel.chatId.value = null
+ } else {
+ val chat = activeChat.value!!
+ // We need to have real unreadCount value for displaying it inside top right button
+ // Having activeChat reloaded on every change in it is inefficient (UI lags)
+ val unreadCount = remember {
+ derivedStateOf {
+ chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
+ }
+ }
+
+ ChatLayout(
+ chat,
+ unreadCount,
+ composeState,
+ composeView = {
+ if (chat.chatInfo.sendMsgEnabled) {
+ ComposeView(
+ chatModel, chat, composeState, attachmentOption,
+ showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
+ )
+ }
+ },
+ attachmentOption,
+ attachmentBottomSheetState,
+ chatModel.chatItems,
+ searchText,
+ useLinkPreviews = useLinkPreviews,
+ linkMode = chatModel.simplexLinkMode.value,
+ chatModelIncognito = chatModel.incognito.value,
+ back = {
+ hideKeyboard(view)
+ AudioPlayer.stop()
+ chatModel.chatId.value = null
+ },
+ info = {
+ hideKeyboard(view)
+ withApi {
+ if (chat.chatInfo is ChatInfo.Direct) {
+ val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
+ val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
+ ModalManager.shared.showModalCloseable(true) { close ->
+ remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
+ ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
+ }
+ }
+ } else if (chat.chatInfo is ChatInfo.Group) {
+ setGroupMembers(chat.chatInfo.groupInfo, chatModel)
+ val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
+ var groupLink = link?.first
+ var groupLinkMemberRole = link?.second
+ ModalManager.shared.showModalCloseable(true) { close ->
+ GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
+ groupLink = it.first;
+ groupLinkMemberRole = it.second
+ }, close)
+ }
+ }
+ }
+ },
+ showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
+ hideKeyboard(view)
+ withApi {
+ val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
+ val (_, code) = if (member.memberActive) {
+ try {
+ chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
+ } catch (e: Exception) {
+ Log.e(TAG, e.stackTraceToString())
+ member to null
+ }
+ } else {
+ member to null
+ }
+ setGroupMembers(groupInfo, chatModel)
+ ModalManager.shared.showModalCloseable(true) { close ->
+ remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
+ GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
+ }
+ }
+ }
+ },
+ loadPrevMessages = { cInfo ->
+ val c = chatModel.getChat(cInfo.id)
+ val firstId = chatModel.chatItems.firstOrNull()?.id
+ if (c != null && firstId != null) {
+ withApi {
+ apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value)
+ }
+ }
+ },
+ deleteMessage = { itemId, mode ->
+ withApi {
+ val cInfo = chat.chatInfo
+ val toDeleteItem = chatModel.chatItems.firstOrNull { it.id == itemId }
+ val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo)
+ val groupInfo = toModerate?.first
+ val groupMember = toModerate?.second
+ val deletedChatItem: ChatItem?
+ val toChatItem: ChatItem?
+ if (groupInfo != null && groupMember != null) {
+ val r = chatModel.controller.apiDeleteMemberChatItem(
+ groupId = groupInfo.groupId,
+ groupMemberId = groupMember.groupMemberId,
+ itemId = itemId
+ )
+ deletedChatItem = r?.first
+ toChatItem = r?.second
+ } else {
+ val r = chatModel.controller.apiDeleteChatItem(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ itemId = itemId,
+ mode = mode
+ )
+ deletedChatItem = r?.deletedChatItem?.chatItem
+ toChatItem = r?.toChatItem?.chatItem
+ }
+ if (toChatItem == null && deletedChatItem != null) {
+ chatModel.removeChatItem(cInfo, deletedChatItem)
+ } else if (toChatItem != null) {
+ chatModel.upsertChatItem(cInfo, toChatItem)
+ }
+ }
+ },
+ receiveFile = { fileId ->
+ val user = chatModel.currentUser.value
+ if (user != null) {
+ withApi { chatModel.controller.receiveFile(user, fileId) }
+ }
+ },
+ joinGroup = { groupId ->
+ withApi { chatModel.controller.apiJoinGroup(groupId) }
+ },
+ startCall = { media ->
+ val cInfo = chat.chatInfo
+ if (cInfo is ChatInfo.Direct) {
+ chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
+ chatModel.showCallView.value = true
+ chatModel.callCommand.value = WCallCommand.Capabilities
+ }
+ },
+ acceptCall = { contact ->
+ hideKeyboard(view)
+ val invitation = chatModel.callInvitations.remove(contact.id)
+ if (invitation == null) {
+ AlertManager.shared.showAlertMsg("Call already ended!")
+ } else {
+ chatModel.callManager.acceptIncomingCall(invitation = invitation)
+ }
+ },
+ acceptFeature = { contact, feature, param ->
+ withApi {
+ chatModel.controller.allowFeatureToContact(contact, feature, param)
+ }
+ },
+ addMembers = { groupInfo ->
+ hideKeyboard(view)
+ withApi {
+ setGroupMembers(groupInfo, chatModel)
+ ModalManager.shared.showModalCloseable(true) { close ->
+ AddGroupMembersView(groupInfo, false, chatModel, close)
+ }
+ }
+ },
+ markRead = { range, unreadCountAfter ->
+ chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
+ chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
+ withBGApi {
+ chatModel.controller.apiChatRead(
+ chat.chatInfo.chatType,
+ chat.chatInfo.apiId,
+ range
+ )
+ }
+ },
+ changeNtfsState = { enabled, currentValue -> changeNtfsStatePerChat(enabled, currentValue, chat, chatModel) },
+ onSearchValueChanged = { value ->
+ if (searchText.value == value) return@ChatLayout
+ val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout
+ withApi {
+ apiFindMessages(c.chatInfo, chatModel, value)
+ searchText.value = value
+ }
+ },
+ onComposed,
+ )
+ }
+}
+
+@Composable
+fun ChatLayout(
+ chat: Chat,
+ unreadCount: State,
+ composeState: MutableState,
+ composeView: (@Composable () -> Unit),
+ attachmentOption: MutableState,
+ attachmentBottomSheetState: ModalBottomSheetState,
+ chatItems: List,
+ searchValue: State,
+ useLinkPreviews: Boolean,
+ linkMode: SimplexLinkMode,
+ chatModelIncognito: Boolean,
+ back: () -> Unit,
+ info: () -> Unit,
+ showMemberInfo: (GroupInfo, GroupMember) -> Unit,
+ loadPrevMessages: (ChatInfo) -> Unit,
+ deleteMessage: (Long, CIDeleteMode) -> Unit,
+ receiveFile: (Long) -> Unit,
+ joinGroup: (Long) -> Unit,
+ startCall: (CallMediaType) -> Unit,
+ acceptCall: (Contact) -> Unit,
+ acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
+ addMembers: (GroupInfo) -> Unit,
+ markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
+ changeNtfsState: (Boolean, currentValue: MutableState) -> Unit,
+ onSearchValueChanged: (String) -> Unit,
+ onComposed: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.background)
+ ) {
+ ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
+ ModalBottomSheetLayout(
+ scrimColor = Color.Black.copy(alpha = 0.12F),
+ modifier = Modifier.navigationBarsWithImePadding(),
+ sheetContent = {
+ ChooseAttachmentView(
+ attachmentOption,
+ hide = { scope.launch { attachmentBottomSheetState.hide() } }
+ )
+ },
+ sheetState = attachmentBottomSheetState,
+ sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
+ ) {
+ val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
+ val setFloatingButton = { button: @Composable () -> Unit ->
+ floatingButton.value = button
+ }
+
+ Scaffold(
+ topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, changeNtfsState, onSearchValueChanged) },
+ bottomBar = composeView,
+ modifier = Modifier.navigationBarsWithImePadding(),
+ floatingActionButton = { floatingButton.value() },
+ ) { contentPadding ->
+ BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
+ ChatItemsList(
+ chat, unreadCount, composeState, chatItems, searchValue,
+ useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
+ receiveFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ChatInfoToolbar(
+ chat: Chat,
+ back: () -> Unit,
+ info: () -> Unit,
+ startCall: (CallMediaType) -> Unit,
+ addMembers: (GroupInfo) -> Unit,
+ changeNtfsState: (Boolean, currentValue: MutableState) -> Unit,
+ onSearchValueChanged: (String) -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var showMenu by rememberSaveable { mutableStateOf(false) }
+ var showSearch by rememberSaveable { mutableStateOf(false) }
+ val onBackClicked = {
+ if (!showSearch) {
+ back()
+ } else {
+ onSearchValueChanged("")
+ showSearch = false
+ }
+ }
+ BackHandler(onBack = onBackClicked)
+ val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
+ val menuItems = arrayListOf<@Composable () -> Unit>()
+ menuItems.add {
+ ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), Icons.Outlined.Search, onClick = {
+ showMenu = false
+ showSearch = true
+ })
+ }
+
+ if (chat.chatInfo is ChatInfo.Direct) {
+ barButtons.add {
+ IconButton({
+ showMenu = false
+ startCall(CallMediaType.Audio)
+ }) {
+ Icon(Icons.Outlined.Phone, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
+ }
+ }
+ menuItems.add {
+ ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), Icons.Outlined.Videocam, onClick = {
+ showMenu = false
+ startCall(CallMediaType.Video)
+ })
+ }
+ } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
+ barButtons.add {
+ IconButton({
+ showMenu = false
+ addMembers(chat.chatInfo.groupInfo)
+ }) {
+ Icon(Icons.Outlined.PersonAdd, stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
+ }
+ }
+ }
+ val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
+ menuItems.add {
+ ItemAction(
+ if (ntfsEnabled.value) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
+ if (ntfsEnabled.value) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
+ onClick = {
+ showMenu = false
+ // Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu
+ scope.launch {
+ delay(200)
+ changeNtfsState(!ntfsEnabled.value, ntfsEnabled)
+ }
+ }
+ )
+ }
+
+ barButtons.add {
+ IconButton({ showMenu = true }) {
+ Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
+ }
+ }
+
+ DefaultTopAppBar(
+ navigationButton = { NavigationButtonBack(onBackClicked) },
+ title = { ChatInfoToolbarTitle(chat.chatInfo) },
+ onTitleClick = info,
+ showSearch = showSearch,
+ onSearchValueChanged = onSearchValueChanged,
+ buttons = barButtons
+ )
+
+ Divider(Modifier.padding(top = AppBarHeight))
+
+ Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false },
+ Modifier.widthIn(min = 220.dp)
+ ) {
+ menuItems.forEach { it() }
+ }
+ }
+}
+
+@Composable
+fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondary) {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (cInfo.incognito) {
+ IncognitoImage(size = 36.dp, Indigo)
+ }
+ ChatInfoImage(cInfo, size = imageSize, iconColor)
+ Column(
+ Modifier.padding(start = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) {
+ ContactVerifiedShield()
+ }
+ Text(
+ cInfo.displayName, fontWeight = FontWeight.SemiBold,
+ maxLines = 1, overflow = TextOverflow.Ellipsis
+ )
+ }
+ if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
+ Text(
+ cInfo.fullName,
+ maxLines = 1, overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ContactVerifiedShield() {
+ Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
+}
+
+data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
+
+val CIListStateSaver = run {
+ val scrolledKey = "scrolled"
+ val countKey = "itemCount"
+ val keyboardKey = "keyboardState"
+ mapSaver(
+ save = { mapOf(scrolledKey to it.scrolled, countKey to it.itemCount, keyboardKey to it.keyboardState) },
+ restore = { CIListState(it[scrolledKey] as Boolean, it[countKey] as Int, it[keyboardKey] as KeyboardState) }
+ )
+}
+
+@Composable
+fun BoxWithConstraintsScope.ChatItemsList(
+ chat: Chat,
+ unreadCount: State,
+ composeState: MutableState,
+ chatItems: List,
+ searchValue: State,
+ useLinkPreviews: Boolean,
+ linkMode: SimplexLinkMode,
+ chatModelIncognito: Boolean,
+ showMemberInfo: (GroupInfo, GroupMember) -> Unit,
+ loadPrevMessages: (ChatInfo) -> Unit,
+ deleteMessage: (Long, CIDeleteMode) -> Unit,
+ receiveFile: (Long) -> Unit,
+ joinGroup: (Long) -> Unit,
+ acceptCall: (Contact) -> Unit,
+ acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
+ markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
+ setFloatingButton: (@Composable () -> Unit) -> Unit,
+ onComposed: () -> Unit,
+) {
+ val listState = rememberLazyListState()
+ val scope = rememberCoroutineScope()
+ ScrollToBottom(chat.id, listState, chatItems)
+ var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
+ // Scroll to bottom when search value changes from something to nothing and back
+ LaunchedEffect(searchValue.value.isEmpty()) {
+ // They are equal when orientation was changed, don't need to scroll.
+ // LaunchedEffect unaware of this event since it uses remember, not rememberSaveable
+ if (prevSearchEmptiness == searchValue.value.isEmpty()) return@LaunchedEffect
+ prevSearchEmptiness = searchValue.value.isEmpty()
+
+ if (listState.firstVisibleItemIndex != 0) {
+ scope.launch { listState.scrollToItem(0) }
+ }
+ }
+
+ PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, chat, chatItems) { c ->
+ loadPrevMessages(c.chatInfo)
+ }
+
+ Spacer(Modifier.size(8.dp))
+ val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } }
+ val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
+ val scrollToItem: (Long) -> Unit = { itemId: Long ->
+ val index = reversedChatItems.indexOfFirst { it.id == itemId }
+ if (index != -1) {
+ scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
+ }
+ }
+ LaunchedEffect(Unit) {
+ var stopListening = false
+ snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
+ .distinctUntilChanged()
+ .filter { !stopListening }
+ .collect {
+ onComposed()
+ stopListening = true
+ }
+ }
+ LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
+ itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
+ CompositionLocalProvider(
+ // Makes horizontal and vertical scrolling to coexist nicely.
+ // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
+ LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
+ ) {
+ val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false }
+ val directions = setOf(DismissDirection.EndToStart)
+ val swipeableModifier = SwipeToDismissModifier(
+ state = dismissState,
+ directions = directions,
+ swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
+ )
+ val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd))
+ val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart))
+ if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
+ LaunchedEffect(Unit) {
+ scope.launch {
+ if (composeState.value.editing) {
+ composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
+ } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
+ composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
+ }
+ }
+ }
+ }
+ val provider = {
+ providerForGallery(i, chatItems, cItem.id) { indexInReversed ->
+ scope.launch {
+ listState.scrollToItem(
+ kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1),
+ -maxHeightRounded
+ )
+ }
+ }
+ }
+ if (chat.chatInfo is ChatInfo.Group) {
+ if (cItem.chatDir is CIDirection.GroupRcv) {
+ val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
+ val member = cItem.chatDir.groupMember
+ val showMember = showMemberImage(member, prevItem)
+ Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
+ if (showMember) {
+ val contactId = member.memberContactId
+ if (contactId == null) {
+ MemberImage(member)
+ } else {
+ Box(
+ Modifier
+ .clip(CircleShape)
+ .clickable {
+ showMemberInfo(chat.chatInfo.groupInfo, member)
+ }
+ ) {
+ MemberImage(member)
+ }
+ }
+ Spacer(Modifier.size(4.dp))
+ } else {
+ Spacer(Modifier.size(42.dp))
+ }
+ ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
+ }
+ } else {
+ Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
+ ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
+ }
+ }
+ } else { // direct message
+ val sent = cItem.chatDir.sent
+ Box(
+ Modifier.padding(
+ start = if (sent) 76.dp else 12.dp,
+ end = if (sent) 12.dp else 76.dp,
+ ).then(swipeableModifier)
+ ) {
+ ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
+ }
+ }
+
+ if (cItem.isRcvNew) {
+ LaunchedEffect(cItem.id) {
+ scope.launch {
+ delay(600)
+ markRead(CC.ItemRange(cItem.id, cItem.id), null)
+ }
+ }
+ }
+ }
+ }
+ }
+ FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState)
+}
+
+@Composable
+private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List) {
+ val scope = rememberCoroutineScope()
+ // Helps to scroll to bottom after moving from Group to Direct chat
+ // and prevents scrolling to bottom on orientation change
+ var shouldAutoScroll by rememberSaveable { mutableStateOf(true to chatId) }
+ LaunchedEffect(chatId, shouldAutoScroll) {
+ if ((shouldAutoScroll.first || shouldAutoScroll.second != chatId) && listState.firstVisibleItemIndex != 0) {
+ scope.launch { listState.scrollToItem(0) }
+ }
+ // Don't autoscroll next time until it will be needed
+ shouldAutoScroll = false to chatId
+ }
+ val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() }
+ /*
+ * Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves.
+ * When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise
+ * */
+ LaunchedEffect(Unit) {
+ snapshotFlow { chatItems.lastOrNull()?.id }
+ .distinctUntilChanged()
+ .filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
+ .collect {
+ try {
+ if (listState.firstVisibleItemIndex == 0) {
+ listState.animateScrollToItem(0)
+ } else {
+ listState.animateScrollBy(scrollDistance)
+ }
+ } catch (e: CancellationException) {
+ /**
+ * When you tap and hold a finger on a lazy column with chatItems, and then you receive a message,
+ * this coroutine will be canceled with the message "Current mutation had a higher priority" because of animatedScroll.
+ * Which breaks auto-scrolling to bottom. So just ignoring the exception
+ * */
+ }
+ }
+ }
+}
+
+@Composable
+fun BoxWithConstraintsScope.FloatingButtons(
+ chatItems: List,
+ unreadCount: State,
+ minUnreadItemId: Long,
+ searchValue: State,
+ markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
+ setFloatingButton: (@Composable () -> Unit) -> Unit,
+ listState: LazyListState
+) {
+ val scope = rememberCoroutineScope()
+ var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) }
+ var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) }
+ var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
+
+ LaunchedEffect(listState) {
+ snapshotFlow { listState.firstVisibleItemIndex }
+ .distinctUntilChanged()
+ .collect {
+ firstVisibleIndex = it
+ firstItemIsVisible = firstVisibleIndex == 0
+ }
+ }
+
+ LaunchedEffect(listState) {
+ // When both snapshotFlows located in one LaunchedEffect second block will never be called because coroutine is paused on first block
+ // so separate them into two LaunchedEffects
+ snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
+ .distinctUntilChanged()
+ .collect {
+ lastIndexOfVisibleItems = it
+ }
+ }
+ val bottomUnreadCount by remember {
+ derivedStateOf {
+ if (unreadCount.value == 0) return@derivedStateOf 0
+ val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
+ if (chatItems.size <= from || from < 0) return@derivedStateOf 0
+
+ chatItems.subList(from, chatItems.size).count { it.isRcvNew }
+ }
+ }
+ val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
+
+ LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
+ val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty()
+ val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
+ setFloatingButton(
+ bottomEndFloatingButton(
+ bottomUnreadCount,
+ showButtonWithCounter,
+ showButtonWithArrow,
+ onClickArrowDown = {
+ scope.launch { listState.animateScrollToItem(0) }
+ },
+ onClickCounter = {
+ scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
+ }
+ ))
+ }
+ // Don't show top FAB if is in search
+ if (searchValue.value.isNotEmpty()) return
+ val fabSize = 56.dp
+ val topUnreadCount by remember {
+ derivedStateOf { unreadCount.value - bottomUnreadCount }
+ }
+ val showButtonWithCounter = topUnreadCount > 0
+ val height = with(LocalDensity.current) { maxHeight.toPx() }
+ var showDropDown by remember { mutableStateOf(false) }
+
+ TopEndFloatingButton(
+ Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd),
+ topUnreadCount,
+ showButtonWithCounter,
+ onClick = { scope.launch { listState.animateScrollBy(height) } },
+ onLongClick = { showDropDown = true }
+ )
+
+ DropdownMenu(
+ expanded = showDropDown,
+ onDismissRequest = { showDropDown = false },
+ Modifier.width(220.dp),
+ offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)
+ ) {
+ DropdownMenuItem(
+ onClick = {
+ markRead(
+ CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
+ bottomUnreadCount
+ )
+ showDropDown = false
+ }
+ ) {
+ Text(
+ generalGetString(R.string.mark_read),
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+@Composable
+fun PreloadItems(
+ listState: LazyListState,
+ remaining: Int = 10,
+ chat: Chat,
+ items: List<*>,
+ onLoadMore: (chat: Chat) -> Unit,
+) {
+ LaunchedEffect(listState, chat, items) {
+ snapshotFlow { listState.layoutInfo }
+ .map {
+ val totalItemsNumber = it.totalItemsCount
+ val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
+ if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
+ totalItemsNumber
+ else
+ 0
+ }
+ .distinctUntilChanged()
+ .filter { it > 0 }
+ .collect {
+ onLoadMore(chat)
+ }
+ }
+}
+
+fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
+ return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd ||
+ (prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId)
+}
+
+@Composable
+fun MemberImage(member: GroupMember) {
+ ProfileImage(38.dp, member.memberProfile.image)
+}
+
+@Composable
+private fun TopEndFloatingButton(
+ modifier: Modifier = Modifier,
+ unreadCount: Int,
+ showButtonWithCounter: Boolean,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit
+) = when {
+ showButtonWithCounter -> {
+ val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
+ FloatingActionButton(
+ {}, // no action here
+ modifier.size(48.dp),
+ elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp),
+ interactionSource = interactionSource,
+ ) {
+ Text(
+ unreadCountStr(unreadCount),
+ color = MaterialTheme.colors.primary,
+ fontSize = 14.sp,
+ )
+ }
+ }
+ else -> {
+ }
+}
+
+private fun bottomEndFloatingButton(
+ unreadCount: Int,
+ showButtonWithCounter: Boolean,
+ showButtonWithArrow: Boolean,
+ onClickArrowDown: () -> Unit,
+ onClickCounter: () -> Unit
+): @Composable () -> Unit = when {
+ showButtonWithCounter -> {
+ {
+ FloatingActionButton(
+ onClick = onClickCounter,
+ elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
+ modifier = Modifier.size(48.dp)
+ ) {
+ Text(
+ unreadCountStr(unreadCount),
+ color = MaterialTheme.colors.primary,
+ fontSize = 14.sp,
+ )
+ }
+ }
+ }
+ showButtonWithArrow -> {
+ {
+ FloatingActionButton(
+ onClick = onClickArrowDown,
+ elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
+ modifier = Modifier.size(48.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ tint = MaterialTheme.colors.primary
+ )
+ }
+ }
+ }
+ else -> {
+ {}
+ }
+}
+
+private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: ChatModel) {
+ val chat = activeChat.value
+ if (chat?.chatStats?.unreadChat != true) return
+ withApi {
+ val success = chatModel.controller.apiChatUnread(
+ chat.chatInfo.chatType,
+ chat.chatInfo.apiId,
+ false
+ )
+ if (success && chat.id == activeChat.value?.id) {
+ activeChat.value = chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))
+ chatModel.replaceChat(chat.id, activeChat.value!!)
+ }
+ }
+}
+
+private fun providerForGallery(
+ listStateIndex: Int,
+ chatItems: List,
+ cItemId: Long,
+ scrollTo: (Int) -> Unit
+): ImageGalleryProvider {
+ fun canShowImage(item: ChatItem): Boolean =
+ item.content.msgContent is MsgContent.MCImage && item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null
+
+ fun item(skipInternalIndex: Int, initialChatId: Long): Pair? {
+ var processedInternalIndex = -skipInternalIndex.sign
+ val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId }
+ for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) {
+ val item = chatItems[chatItemsIndex]
+ if (canShowImage(item)) {
+ processedInternalIndex += skipInternalIndex.sign
+ }
+ if (processedInternalIndex == skipInternalIndex) {
+ return chatItemsIndex to item
+ }
+ }
+ return null
+ }
+
+ var initialIndex = Int.MAX_VALUE / 2
+ var initialChatId = cItemId
+ return object: ImageGalleryProvider {
+ override val initialIndex: Int = initialIndex
+ override val totalImagesSize = mutableStateOf(Int.MAX_VALUE)
+ override fun getImage(index: Int): Pair? {
+ val internalIndex = initialIndex - index
+ val file = item(internalIndex, initialChatId)?.second?.file
+ val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
+ val filePath = getLoadedFilePath(SimplexApp.context, file)
+ return if (imageBitmap != null && filePath != null) {
+ val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
+ imageBitmap to uri
+ } else null
+ }
+
+ override fun currentPageChanged(index: Int) {
+ val internalIndex = initialIndex - index
+ val item = item(internalIndex, initialChatId) ?: return
+ initialIndex = index
+ initialChatId = item.second.id
+ }
+
+ override fun scrollToStart() {
+ initialIndex = 0
+ initialChatId = chatItems.first { canShowImage(it) }.id
+ }
+
+ override fun onDismiss(index: Int) {
+ val internalIndex = initialIndex - index
+ val indexInChatItems = item(internalIndex, initialChatId)?.first ?: return
+ val indexInReversed = chatItems.lastIndex - indexInChatItems
+ // Do not scroll to active item, just to different items
+ if (indexInReversed == listStateIndex) return
+ scrollTo(indexInReversed)
+ }
+ }
+}
+
+private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
+ override val longPressTimeoutMillis
+ get() =
+ this@bigTouchSlop.longPressTimeoutMillis
+ override val doubleTapTimeoutMillis
+ get() =
+ this@bigTouchSlop.doubleTapTimeoutMillis
+ override val doubleTapMinTimeMillis
+ get() =
+ this@bigTouchSlop.doubleTapMinTimeMillis
+ override val touchSlop: Float get() = slop
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatLayout() {
+ SimpleXTheme {
+ val chatItems = listOf(
+ ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
+ ),
+ ChatItem.getSampleData(
+ 2, CIDirection.DirectRcv(), Clock.System.now(), "hello"
+ ),
+ ChatItem.getDeletedContentSampleData(3),
+ ChatItem.getSampleData(
+ 4, CIDirection.DirectSnd(), Clock.System.now(), "hello"
+ ),
+ ChatItem.getSampleData(
+ 5, CIDirection.DirectSnd(), Clock.System.now(), "hello"
+ ),
+ ChatItem.getSampleData(
+ 6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
+ )
+ )
+ val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
+ val searchValue = remember { mutableStateOf("") }
+ ChatLayout(
+ chat = Chat(
+ chatInfo = ChatInfo.Direct.sampleData,
+ chatItems = chatItems,
+ chatStats = Chat.ChatStats()
+ ),
+ unreadCount = unreadCount,
+ composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
+ composeView = {},
+ attachmentOption = remember { mutableStateOf(null) },
+ attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
+ chatItems = chatItems,
+ searchValue,
+ useLinkPreviews = true,
+ linkMode = SimplexLinkMode.DESCRIPTION,
+ chatModelIncognito = false,
+ back = {},
+ info = {},
+ showMemberInfo = { _, _ -> },
+ loadPrevMessages = { _ -> },
+ deleteMessage = { _, _ -> },
+ receiveFile = {},
+ joinGroup = {},
+ startCall = {},
+ acceptCall = { _ -> },
+ acceptFeature = { _, _, _ -> },
+ addMembers = { _ -> },
+ markRead = { _, _ -> },
+ changeNtfsState = { _, _ -> },
+ onSearchValueChanged = {},
+ onComposed = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewGroupChatLayout() {
+ SimpleXTheme {
+ val chatItems = listOf(
+ ChatItem.getSampleData(
+ 1, CIDirection.GroupSnd(), Clock.System.now(), "hello"
+ ),
+ ChatItem.getSampleData(
+ 2, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
+ ),
+ ChatItem.getDeletedContentSampleData(3),
+ ChatItem.getSampleData(
+ 4, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
+ ),
+ ChatItem.getSampleData(
+ 5, CIDirection.GroupSnd(), Clock.System.now(), "hello"
+ ),
+ ChatItem.getSampleData(
+ 6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
+ )
+ )
+ val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
+ val searchValue = remember { mutableStateOf("") }
+ ChatLayout(
+ chat = Chat(
+ chatInfo = ChatInfo.Group.sampleData,
+ chatItems = chatItems,
+ chatStats = Chat.ChatStats()
+ ),
+ unreadCount = unreadCount,
+ composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
+ composeView = {},
+ attachmentOption = remember { mutableStateOf(null) },
+ attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
+ chatItems = chatItems,
+ searchValue,
+ useLinkPreviews = true,
+ linkMode = SimplexLinkMode.DESCRIPTION,
+ chatModelIncognito = false,
+ back = {},
+ info = {},
+ showMemberInfo = { _, _ -> },
+ loadPrevMessages = { _ -> },
+ deleteMessage = { _, _ -> },
+ receiveFile = {},
+ joinGroup = {},
+ startCall = {},
+ acceptCall = { _ -> },
+ acceptFeature = { _, _, _ -> },
+ addMembers = { _ -> },
+ markRead = { _, _ -> },
+ changeNtfsState = { _, _ -> },
+ onSearchValueChanged = {},
+ onComposed = {},
+ )
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt
similarity index 63%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt
index 7ab7963547..dd7b628e1e 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt
@@ -1,33 +1,32 @@
-package chat.simplex.common.views.chat
-
-import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.InsertDriveFile
+import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import dev.icerock.moko.resources.compose.painterResource
-import dev.icerock.moko.resources.compose.stringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import chat.simplex.common.ui.theme.*
-import chat.simplex.res.MR
+import chat.simplex.app.R
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.item.SentColorLight
@Composable
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
- val sentColor = MaterialTheme.appColors.sentMessage
Row(
Modifier
.height(60.dp)
.fillMaxWidth()
.padding(top = 8.dp)
- .background(sentColor),
+ .background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
- painterResource(MR.images.ic_draft_filled),
- stringResource(MR.strings.icon_descr_file),
+ Icons.Filled.InsertDriveFile,
+ stringResource(R.string.icon_descr_file),
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
@@ -38,8 +37,8 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
if (cancelEnabled) {
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
Icon(
- painterResource(MR.images.ic_close),
- contentDescription = stringResource(MR.strings.icon_descr_cancel_file_preview),
+ Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt
new file mode 100644
index 0000000000..848cfc0488
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt
@@ -0,0 +1,53 @@
+package chat.simplex.app.views.chat
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.ui.theme.DEFAULT_PADDING_HALF
+import chat.simplex.app.views.chat.item.SentColorLight
+import chat.simplex.app.views.helpers.base64ToBitmap
+
+@Composable
+fun ComposeImageView(images: List, cancelImages: () -> Unit, cancelEnabled: Boolean) {
+ Row(
+ Modifier
+ .padding(top = 8.dp)
+ .background(SentColorLight),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LazyRow(
+ Modifier.weight(1f).padding(start = DEFAULT_PADDING_HALF, end = if (cancelEnabled) 0.dp else DEFAULT_PADDING_HALF),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF),
+ ) {
+ items(images.size) { index ->
+ val imageBitmap = base64ToBitmap(images[index]).asImageBitmap()
+ Image(
+ imageBitmap,
+ "preview image",
+ modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
+ )
+ }
+ }
+ if (cancelEnabled) {
+ IconButton(onClick = cancelImages) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
+ tint = MaterialTheme.colors.primary,
+ )
+ }
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
new file mode 100644
index 0000000000..7d0ebe38c3
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
@@ -0,0 +1,800 @@
+@file:UseSerializers(UriSerializer::class)
+package chat.simplex.app.views.chat
+
+import ComposeFileView
+import ComposeVoiceView
+import android.Manifest
+import android.app.Activity
+import android.content.*
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.graphics.ImageDecoder.DecodeException
+import android.graphics.drawable.AnimatedImageDrawable
+import android.net.Uri
+import android.provider.MediaStore
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AttachFile
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.outlined.Reply
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import chat.simplex.app.*
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.views.chat.item.*
+import chat.simplex.app.views.helpers.*
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.serialization.*
+import java.io.File
+import java.nio.file.Files
+
+@Serializable
+sealed class ComposePreview {
+ @Serializable object NoPreview: ComposePreview()
+ @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
+ @Serializable class ImagePreview(val images: List, val content: List): ComposePreview()
+ @Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
+ @Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview()
+}
+
+@Serializable
+sealed class ComposeContextItem {
+ @Serializable object NoContextItem: ComposeContextItem()
+ @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
+ @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
+}
+
+@Serializable
+data class LiveMessage(
+ val chatItem: ChatItem,
+ val typedMsg: String,
+ val sentMsg: String,
+ val sent: Boolean
+)
+
+@Serializable
+data class ComposeState(
+ val message: String = "",
+ val liveMessage: LiveMessage? = null,
+ val preview: ComposePreview = ComposePreview.NoPreview,
+ val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
+ val inProgress: Boolean = false,
+ val useLinkPreviews: Boolean
+) {
+ constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
+ editingItem.content.text,
+ liveMessage,
+ chatItemPreview(editingItem),
+ ComposeContextItem.EditingItem(editingItem),
+ useLinkPreviews = useLinkPreviews
+ )
+
+ val editing: Boolean
+ get() =
+ when (contextItem) {
+ is ComposeContextItem.EditingItem -> true
+ else -> false
+ }
+ val sendEnabled: () -> Boolean
+ get() = {
+ val hasContent = when (preview) {
+ is ComposePreview.ImagePreview -> true
+ is ComposePreview.VoicePreview -> true
+ is ComposePreview.FilePreview -> true
+ else -> message.isNotEmpty() || liveMessage != null
+ }
+ hasContent && !inProgress
+ }
+ val endLiveDisabled: Boolean
+ get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
+
+ val linkPreviewAllowed: Boolean
+ get() =
+ when (preview) {
+ is ComposePreview.ImagePreview -> false
+ is ComposePreview.VoicePreview -> false
+ is ComposePreview.FilePreview -> false
+ else -> useLinkPreviews
+ }
+ val linkPreview: LinkPreview?
+ get() =
+ when (preview) {
+ is ComposePreview.CLinkPreview -> preview.linkPreview
+ else -> null
+ }
+
+ val attachmentDisabled: Boolean
+ get() {
+ if (editing || liveMessage != null) return true
+ return when (preview) {
+ ComposePreview.NoPreview -> false
+ is ComposePreview.CLinkPreview -> false
+ else -> true
+ }
+ }
+
+ val empty: Boolean
+ get() = message.isEmpty() && preview is ComposePreview.NoPreview
+
+ companion object {
+ fun saver(): Saver, *> = Saver(
+ save = { json.encodeToString(serializer(), it.value) },
+ restore = {
+ mutableStateOf(json.decodeFromString(it))
+ }
+ )
+ }
+}
+
+sealed class RecordingState {
+ object NotStarted: RecordingState()
+ class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
+ class Finished(val filePath: String, val durationMs: Int): RecordingState()
+
+ val filePathNullable: String?
+ get() = (this as? Started)?.filePath
+}
+
+fun chatItemPreview(chatItem: ChatItem): ComposePreview {
+ val fileName = chatItem.file?.fileName ?: ""
+ return when (val mc = chatItem.content.msgContent) {
+ is MsgContent.MCText -> ComposePreview.NoPreview
+ is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
+ // TODO: include correct type
+ is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
+ is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true)
+ is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName))
+ is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
+ }
+}
+
+@Composable
+fun ComposeView(
+ chatModel: ChatModel,
+ chat: Chat,
+ composeState: MutableState,
+ attachmentOption: MutableState,
+ showChooseAttachment: () -> Unit
+) {
+ val context = LocalContext.current
+ val linkUrl = rememberSaveable { mutableStateOf(null) }
+ val prevLinkUrl = rememberSaveable { mutableStateOf(null) }
+ val pendingLinkUrl = rememberSaveable { mutableStateOf(null) }
+ val cancelledLinks = rememberSaveable { mutableSetOf() }
+ val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
+ val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
+ if (uri != null) {
+ val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
+ val bitmap = ImageDecoder.decodeBitmap(source)
+ val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
+ composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
+ }
+ }
+ val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
+ if (isGranted) {
+ cameraLauncher.launchWithFallback()
+ } else {
+ Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
+ }
+ }
+ val processPickedImage = { uris: List, text: String? ->
+ val content = ArrayList()
+ val imagesPreview = ArrayList()
+ uris.forEach { uri ->
+ val source = ImageDecoder.createSource(context.contentResolver, uri)
+ val drawable = try {
+ ImageDecoder.decodeDrawable(source)
+ } catch (e: DecodeException) {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.image_decoding_exception_title),
+ text = generalGetString(R.string.image_decoding_exception_desc)
+ )
+ Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
+ null
+ }
+ var bitmap: Bitmap? = if (drawable != null) ImageDecoder.decodeBitmap(source) else null
+ if (drawable is AnimatedImageDrawable) {
+ // It's a gif or webp
+ val fileSize = getFileSize(context, uri)
+ if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
+ content.add(UploadContent.AnimatedImage(uri))
+ } else {
+ bitmap = null
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.large_file),
+ String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
+ )
+ }
+ } else {
+ content.add(UploadContent.SimpleImage(uri))
+ }
+ if (bitmap != null) {
+ imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
+ }
+ }
+
+ if (imagesPreview.isNotEmpty()) {
+ composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview, content))
+ }
+ }
+ val processPickedFile = { uri: Uri?, text: String? ->
+ if (uri != null) {
+ val fileSize = getFileSize(context, uri)
+ if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
+ val fileName = getFileName(SimplexApp.context, uri)
+ if (fileName != null) {
+ composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri))
+ }
+ } else {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.large_file),
+ String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
+ )
+ }
+ }
+ }
+ val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
+ val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
+ val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
+ val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) }
+
+ LaunchedEffect(attachmentOption.value) {
+ when (attachmentOption.value) {
+ AttachmentOption.TakePhoto -> {
+ when (PackageManager.PERMISSION_GRANTED) {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
+ cameraLauncher.launchWithFallback()
+ }
+ else -> {
+ cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+ attachmentOption.value = null
+ }
+ AttachmentOption.PickImage -> {
+ try {
+ galleryLauncher.launch(0)
+ } catch (e: ActivityNotFoundException) {
+ galleryLauncherFallback.launch("image/*")
+ }
+ attachmentOption.value = null
+ }
+ AttachmentOption.PickFile -> {
+ filesLauncher.launch("*/*")
+ attachmentOption.value = null
+ }
+ else -> {}
+ }
+ }
+
+ fun isSimplexLink(link: String): Boolean =
+ link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
+
+ fun parseMessage(msg: String): String? {
+ val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) }
+ val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
+ return link?.text
+ }
+
+ fun loadLinkPreview(url: String, wait: Long? = null) {
+ if (pendingLinkUrl.value == url) {
+ composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
+ withApi {
+ if (wait != null) delay(wait)
+ val lp = getLinkPreview(url)
+ if (lp != null && pendingLinkUrl.value == url) {
+ composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp))
+ pendingLinkUrl.value = null
+ } else if (pendingLinkUrl.value == url) {
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ pendingLinkUrl.value = null
+ }
+ }
+ }
+ }
+
+ fun showLinkPreview(s: String) {
+ prevLinkUrl.value = linkUrl.value
+ linkUrl.value = parseMessage(s)
+ val url = linkUrl.value
+ if (url != null) {
+ if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) {
+ pendingLinkUrl.value = url
+ loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L)
+ }
+ } else {
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ }
+ }
+
+ fun resetLinkPreview() {
+ linkUrl.value = null
+ prevLinkUrl.value = null
+ pendingLinkUrl.value = null
+ cancelledLinks.clear()
+ }
+
+ fun clearState(live: Boolean = false) {
+ if (live) {
+ composeState.value = composeState.value.copy(inProgress = false)
+ } else {
+ composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
+ resetLinkPreview()
+ }
+ recState.value = RecordingState.NotStarted
+ textStyle.value = smallFont
+ chatModel.removeLiveDummy()
+ }
+
+ fun deleteUnusedFiles() {
+ chatModel.filesToDelete.forEach { it.delete() }
+ chatModel.filesToDelete.clear()
+ }
+
+ suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
+ val aChatItem = chatModel.controller.apiSendMessage(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ file = file,
+ quotedItemId = quoted,
+ mc = mc,
+ live = live
+ )
+ if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
+ return aChatItem?.chatItem
+ }
+
+
+
+ suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? {
+ val cInfo = chat.chatInfo
+ val cs = composeState.value
+ var sent: ChatItem?
+ val msgText = text ?: cs.message
+
+ fun sending() {
+ composeState.value = composeState.value.copy(inProgress = true)
+ }
+
+ fun checkLinkPreview(): MsgContent {
+ return when (val composePreview = cs.preview) {
+ is ComposePreview.CLinkPreview -> {
+ val url = parseMessage(msgText)
+ val lp = composePreview.linkPreview
+ if (lp != null && url == lp.uri) {
+ MsgContent.MCLink(msgText, preview = lp)
+ } else {
+ MsgContent.MCText(msgText)
+ }
+ }
+ else -> MsgContent.MCText(msgText)
+ }
+ }
+
+ fun updateMsgContent(msgContent: MsgContent): MsgContent {
+ return when (msgContent) {
+ is MsgContent.MCText -> checkLinkPreview()
+ is MsgContent.MCLink -> checkLinkPreview()
+ is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
+ is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
+ is MsgContent.MCFile -> MsgContent.MCFile(msgText)
+ is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
+ }
+ }
+
+ suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? {
+ val oldMsgContent = ei.content.msgContent
+ if (oldMsgContent != null) {
+ val updatedItem = chatModel.controller.apiUpdateChatItem(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ itemId = ei.meta.itemId,
+ mc = updateMsgContent(oldMsgContent),
+ live = live
+ )
+ if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
+ return updatedItem?.chatItem
+ }
+ return null
+ }
+
+ val liveMessage = cs.liveMessage
+ if (!live) {
+ if (liveMessage != null) composeState.value = cs.copy(liveMessage = null)
+ sending()
+ }
+
+ if (cs.contextItem is ComposeContextItem.EditingItem) {
+ val ei = cs.contextItem.chatItem
+ sent = updateMessage(ei, cInfo, live)
+ } else if (liveMessage != null && liveMessage.sent) {
+ sent = updateMessage(liveMessage.chatItem, cInfo, live)
+ } else {
+ val msgs: ArrayList = ArrayList()
+ val files: ArrayList = ArrayList()
+ when (val preview = cs.preview) {
+ ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
+ is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
+ is ComposePreview.ImagePreview -> {
+ preview.content.forEachIndexed { index, it ->
+ val file = when (it) {
+ is UploadContent.SimpleImage -> saveImage(context, it.uri)
+ is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
+ }
+ if (file != null) {
+ files.add(file)
+ msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index]))
+ }
+ }
+ }
+ is ComposePreview.VoicePreview -> {
+ val tmpFile = File(preview.voice)
+ AudioPlayer.stop(tmpFile.absolutePath)
+ val actualFile = File(getAppFilePath(SimplexApp.context, tmpFile.name.replaceAfter(RecorderNative.extension, "")))
+ withContext(Dispatchers.IO) {
+ Files.move(tmpFile.toPath(), actualFile.toPath())
+ }
+ files.add(actualFile.name)
+ deleteUnusedFiles()
+ msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
+ }
+ is ComposePreview.FilePreview -> {
+ val file = saveFileFromUri(context, preview.uri)
+ if (file != null) {
+ files.add((file))
+ msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
+ }
+ }
+ }
+ val quotedItemId: Long? = when (cs.contextItem) {
+ is ComposeContextItem.QuotedItem -> cs.contextItem.chatItem.id
+ else -> null
+ }
+ sent = null
+ msgs.forEachIndexed { index, content ->
+ if (index > 0) delay(100)
+ sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index),
+ if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
+ )
+ }
+ if (sent == null && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview)) {
+ sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
+ }
+ }
+ clearState(live)
+ return sent
+ }
+
+ fun sendMessage() {
+ withBGApi {
+ sendMessageAsync(null, false)
+ }
+ }
+
+ fun onMessageChange(s: String) {
+ composeState.value = composeState.value.copy(message = s)
+ if (isShortEmoji(s)) {
+ textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
+ } else {
+ textStyle.value = smallFont
+ if (composeState.value.linkPreviewAllowed) {
+ if (s.isNotEmpty()) showLinkPreview(s)
+ else resetLinkPreview()
+ }
+ }
+ }
+
+ fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
+ val file = File(filePath)
+ chatModel.filesToDelete.add(file)
+ composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
+ }
+
+ fun allowVoiceToContact() {
+ val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
+ withApi {
+ chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice)
+ }
+ }
+
+ fun cancelLinkPreview() {
+ val uri = composeState.value.linkPreview?.uri
+ if (uri != null) {
+ cancelledLinks.add(uri)
+ }
+ pendingLinkUrl.value = null
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ }
+
+ fun cancelImages() {
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ }
+
+ fun cancelVoice() {
+ val filePath = recState.value.filePathNullable
+ recState.value = RecordingState.NotStarted
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ withBGApi {
+ RecorderNative.stopRecording?.invoke()
+ AudioPlayer.stop(filePath)
+ filePath?.let { File(it).delete() }
+ }
+ }
+
+ fun cancelFile() {
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ }
+
+ fun truncateToWords(s: String): String {
+ var acc = ""
+ val word = StringBuilder()
+ for (c in s) {
+ if (c.isLetter() || c.isDigit()) {
+ word.append(c)
+ } else {
+ acc = acc + word.toString() + c
+ word.clear()
+ }
+ }
+ return acc
+ }
+
+ suspend fun sendLiveMessage() {
+ val cs = composeState.value
+ val typedMsg = cs.message
+ if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) {
+ val ci = sendMessageAsync(typedMsg, live = true)
+ if (ci != null) {
+ composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
+ }
+ } else if (cs.liveMessage == null) {
+ val cItem = chatModel.addLiveDummy(chat.chatInfo)
+ composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = typedMsg, sent = false))
+ }
+ }
+
+ fun liveMessageToSend(lm: LiveMessage, t: String): String? {
+ val s = if (t != lm.typedMsg) truncateToWords(t) else t
+ return if (s != lm.sentMsg) s else null
+ }
+
+ suspend fun updateLiveMessage() {
+ val typedMsg = composeState.value.message
+ val liveMessage = composeState.value.liveMessage
+ if (liveMessage != null) {
+ val sentMsg = liveMessageToSend(liveMessage, typedMsg)
+ if (sentMsg != null) {
+ val ci = sendMessageAsync(sentMsg, live = true)
+ if (ci != null) {
+ composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true))
+ }
+ } else if (liveMessage.typedMsg != typedMsg) {
+ composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg))
+ }
+ }
+ }
+
+ @Composable
+ fun previewView() {
+ when (val preview = composeState.value.preview) {
+ ComposePreview.NoPreview -> {}
+ is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
+ is ComposePreview.ImagePreview -> ComposeImageView(
+ preview.images,
+ ::cancelImages,
+ cancelEnabled = !composeState.value.editing
+ )
+ is ComposePreview.VoicePreview -> ComposeVoiceView(
+ preview.voice,
+ preview.durationMs,
+ preview.finished,
+ cancelEnabled = !composeState.value.editing,
+ ::cancelVoice
+ )
+ is ComposePreview.FilePreview -> ComposeFileView(
+ preview.fileName,
+ ::cancelFile,
+ cancelEnabled = !composeState.value.editing
+ )
+ }
+ }
+
+ @Composable
+ fun contextItemView() {
+ when (val contextItem = composeState.value.contextItem) {
+ ComposeContextItem.NoContextItem -> {}
+ is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) {
+ composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
+ }
+ is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) {
+ clearState()
+ }
+ }
+ }
+
+ LaunchedEffect(chatModel.sharedContent.value) {
+ // Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
+ if (chatModel.chatId.value == null) return@LaunchedEffect
+
+ when (val shared = chatModel.sharedContent.value) {
+ is SharedContent.Text -> onMessageChange(shared.text)
+ is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
+ is SharedContent.File -> processPickedFile(shared.uri, shared.text)
+ null -> {}
+ }
+ chatModel.sharedContent.value = null
+ }
+
+ val userCanSend = rememberUpdatedState(chat.userCanSend)
+ val userIsObserver = rememberUpdatedState(chat.userIsObserver)
+
+ Column {
+ contextItemView()
+ when {
+ composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
+ composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
+ else -> previewView()
+ }
+ Row(
+ modifier = Modifier.padding(end = 8.dp),
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
+ Icon(
+ Icons.Filled.AttachFile,
+ contentDescription = stringResource(R.string.attach),
+ tint = if (!composeState.value.attachmentDisabled && userCanSend.value) MaterialTheme.colors.primary else HighOrLowlight,
+ modifier = Modifier
+ .size(28.dp)
+ .clip(CircleShape)
+ )
+ }
+ val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
+ LaunchedEffect(allowedVoiceByPrefs) {
+ if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
+ // Voice was disabled right when this user records it, just cancel it
+ cancelVoice()
+ }
+ }
+ val needToAllowVoiceToContact = remember(chat.chatInfo) {
+ chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
+ ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
+ contactPreference.allow == FeatureAllowed.YES
+ }
+ }
+ LaunchedEffect(Unit) {
+ snapshotFlow { recState.value }
+ .distinctUntilChanged()
+ .collect {
+ when(it) {
+ is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
+ is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true)
+ is RecordingState.NotStarted -> {}
+ }
+ }
+ }
+
+ fun clearCurrentDraft() {
+ if (chatModel.draftChatId.value == chat.id) {
+ chatModel.draft.value = null
+ chatModel.draftChatId.value = null
+ }
+ }
+
+ LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
+ if (!chat.userCanSend) {
+ clearCurrentDraft()
+ clearState()
+ }
+ }
+
+ val activity = LocalContext.current as Activity
+ DisposableEffect(Unit) {
+ val orientation = activity.resources.configuration.orientation
+ onDispose {
+ if (orientation == activity.resources.configuration.orientation) {
+ val cs = composeState.value
+ if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
+ sendMessage()
+ resetLinkPreview()
+ clearCurrentDraft()
+ deleteUnusedFiles()
+ } else if (!composeState.value.empty) {
+ if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
+ composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
+ }
+ chatModel.draft.value = composeState.value
+ chatModel.draftChatId.value = chat.id
+ } else {
+ clearCurrentDraft()
+ deleteUnusedFiles()
+ }
+ chatModel.removeLiveDummy()
+ }
+ }
+ }
+
+ SendMsgView(
+ composeState,
+ showVoiceRecordIcon = true,
+ recState,
+ chat.chatInfo is ChatInfo.Direct,
+ liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
+ needToAllowVoiceToContact,
+ allowedVoiceByPrefs,
+ allowVoiceToContact = ::allowVoiceToContact,
+ userIsObserver = userIsObserver.value,
+ userCanSend = userCanSend.value,
+ sendMessage = {
+ sendMessage()
+ resetLinkPreview()
+ },
+ sendLiveMessage = ::sendLiveMessage,
+ updateLiveMessage = ::updateLiveMessage,
+ cancelLiveMessage = {
+ composeState.value = composeState.value.copy(liveMessage = null)
+ chatModel.removeLiveDummy()
+ },
+ onMessageChange = ::onMessageChange,
+ textStyle = textStyle
+ )
+ }
+ }
+}
+
+class PickFromGallery: ActivityResultContract() {
+ override fun createIntent(context: Context, input: Int) =
+ Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
+ type = "image/*"
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
+}
+
+class PickMultipleFromGallery: ActivityResultContract>() {
+ override fun createIntent(context: Context, input: Int) =
+ Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
+ putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
+ type = "image/*"
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?): List =
+ if (intent?.data != null)
+ listOf(intent.data!!)
+ else if (intent?.clipData != null)
+ with(intent.clipData!!) {
+ val uris = ArrayList()
+ for (i in 0 until kotlin.math.min(itemCount, 10)) {
+ val uri = getItemAt(i).uri
+ if (uri != null) uris.add(uri)
+ }
+ if (itemCount > 10) {
+ AlertManager.shared.showAlertMsg(R.string.images_limit_title, R.string.images_limit_desc)
+ }
+ uris
+ }
+ else
+ emptyList()
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt
new file mode 100644
index 0000000000..64bd3f43bb
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt
@@ -0,0 +1,133 @@
+import androidx.compose.animation.core.Animatable
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.durationText
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.item.SentColorLight
+import chat.simplex.app.views.helpers.*
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+@Composable
+fun ComposeVoiceView(
+ filePath: String,
+ recordedDurationMs: Int,
+ finishedRecording: Boolean,
+ cancelEnabled: Boolean,
+ cancelVoice: () -> Unit
+) {
+ BoxWithConstraints(Modifier
+ .fillMaxWidth()
+ ) {
+ val audioPlaying = rememberSaveable { mutableStateOf(false) }
+ val progress = rememberSaveable { mutableStateOf(0) }
+ val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
+ val progressBarWidth = remember { Animatable(0f) }
+ LaunchedEffect(recordedDurationMs, finishedRecording) {
+ snapshotFlow { progress.value }
+ .distinctUntilChanged()
+ .collect {
+ val startTime = when {
+ finishedRecording -> progress.value
+ else -> recordedDurationMs
+ }
+ val endTime = when {
+ finishedRecording -> duration.value
+ audioPlaying.value -> recordedDurationMs
+ else -> MAX_VOICE_MILLIS_FOR_SENDING
+ }
+ val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
+ progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
+ }
+ }
+ Spacer(
+ Modifier
+ .requiredWidth(progressBarWidth.value.dp)
+ .padding(top = 58.dp)
+ .height(3.dp)
+ .background(MaterialTheme.colors.primary)
+ )
+ Row(
+ Modifier
+ .height(60.dp)
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .background(SentColorLight),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ onClick = {
+ if (!audioPlaying.value) {
+ AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
+ } else {
+ AudioPlayer.pause(audioPlaying, progress)
+ }
+ },
+ enabled = finishedRecording) {
+ Icon(
+ if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+ stringResource(R.string.icon_descr_file),
+ Modifier
+ .padding(start = 4.dp, end = 2.dp)
+ .size(36.dp),
+ tint = if (finishedRecording) MaterialTheme.colors.primary else HighOrLowlight
+ )
+ }
+ val numberInText = remember(recordedDurationMs, progress.value) {
+ derivedStateOf {
+ when {
+ finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
+ finishedRecording -> progress.value / 1000
+ else -> recordedDurationMs / 1000
+ }
+ }
+ }
+ Text(
+ durationText(numberInText.value),
+ fontSize = 18.sp,
+ color = HighOrLowlight,
+ )
+ Spacer(Modifier.weight(1f))
+ if (cancelEnabled) {
+ IconButton(
+ onClick = {
+ AudioPlayer.stop(filePath)
+ cancelVoice()
+ },
+ modifier = Modifier.padding(0.dp)
+ ) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(10.dp)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewComposeAudioView() {
+ SimpleXTheme {
+ ComposeFileView(
+ "test.txt",
+ cancelFile = {},
+ cancelEnabled = true
+ )
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt
similarity index 57%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt
index 7c04c30f67..f48f44551b 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt
@@ -1,9 +1,9 @@
-package chat.simplex.common.views.chat
+package chat.simplex.app.views.chat
import InfoRow
-import SectionBottomSpacer
-import SectionDividerSpaced
+import SectionDivider
import SectionItemView
+import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
@@ -12,39 +12,35 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
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.Color
-import dev.icerock.moko.resources.compose.stringResource
-import chat.simplex.common.ui.theme.*
-import chat.simplex.common.views.helpers.*
-import chat.simplex.common.views.usersettings.PreferenceToggle
-import chat.simplex.common.model.*
-import chat.simplex.common.platform.ColumnWithScrollBar
-import chat.simplex.common.platform.chatModel
-import chat.simplex.res.MR
-import kotlinx.coroutines.*
+import androidx.compose.ui.res.stringResource
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.usersettings.PreferenceToggle
@Composable
fun ContactPreferencesView(
m: ChatModel,
user: User,
- rhId: Long?,
contactId: Long,
close: () -> Unit,
) {
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
val ct = contact.value ?: return
- var featuresAllowed by rememberSaveable(ct, user, stateSaver = serializableSaver()) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
- var currentFeaturesAllowed by rememberSaveable(ct, user, stateSaver = serializableSaver()) { mutableStateOf(featuresAllowed) }
+ var featuresAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
+ var currentFeaturesAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(featuresAllowed) }
fun savePrefs(afterSave: () -> Unit = {}) {
- withBGApi {
+ withApi {
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
- val toContact = m.controller.apiSetContactPrefs(rhId, ct.contactId, prefs)
+ val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
if (toContact != null) {
- withContext(Dispatchers.Main) {
- chatModel.chatsContext.updateContact(rhId, toContact)
- currentFeaturesAllowed = featuresAllowed
- }
+ m.updateContact(toContact)
+ currentFeaturesAllowed = featuresAllowed
}
afterSave()
}
@@ -54,6 +50,7 @@ fun ContactPreferencesView(
if (featuresAllowed == currentFeaturesAllowed) close()
else showUnsavedChangesAlert({ savePrefs(close) }, close)
},
+ background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
ContactPreferencesLayout(
featuresAllowed,
@@ -81,42 +78,37 @@ private fun ContactPreferencesLayout(
reset: () -> Unit,
savePrefs: () -> Unit,
) {
- ColumnWithScrollBar {
- AppBarTitle(stringResource(MR.strings.contact_preferences))
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(bottom = DEFAULT_PADDING),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ AppBarTitle(stringResource(R.string.contact_preferences))
val timedMessages: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
val onTTLUpdated = { ttl: Int? ->
- applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl))
+ applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl ?: 86400))
}
TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl ->
applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL))
}
- SectionDividerSpaced(true)
+ SectionSpacer()
val allowFullDeletion: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
applyPrefs(featuresAllowed.copy(fullDelete = it))
}
- SectionDividerSpaced(true)
- val allowReactions: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.reactions) }
- FeatureSection(ChatFeature.Reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, allowReactions) {
- applyPrefs(featuresAllowed.copy(reactions = it))
- }
- SectionDividerSpaced(true)
+ SectionSpacer()
val allowVoice: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
applyPrefs(featuresAllowed.copy(voice = it))
}
- SectionDividerSpaced(true)
- val allowCalls: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.calls) }
- FeatureSection(ChatFeature.Calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, allowCalls) {
- applyPrefs(featuresAllowed.copy(calls = it))
- }
- SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
+ SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = featuresAllowed == currentFeaturesAllowed
)
- SectionBottomSpacer()
}
}
@@ -136,19 +128,22 @@ private fun FeatureSection(
SectionView(
feature.text.uppercase(),
- icon = feature.iconFilled(),
+ icon = feature.iconFilled,
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
leadingIcon = true,
) {
- ExposedDropDownSettingRow(
- generalGetString(MR.strings.chat_preferences_you_allow),
- ContactFeatureAllowed.values(userDefault).map { it to it.text },
- allowFeature,
- icon = null,
- onSelected = onSelected
- )
+ SectionItemView {
+ ExposedDropDownSettingRow(
+ generalGetString(R.string.chat_preferences_you_allow),
+ ContactFeatureAllowed.values(userDefault).map { it to it.text },
+ allowFeature,
+ icon = null,
+ onSelected = onSelected
+ )
+ }
+ SectionDivider()
InfoRow(
- generalGetString(MR.strings.chat_preferences_contact_allows),
+ generalGetString(R.string.chat_preferences_contact_allows),
pref.contactPreference.allow.text
)
}
@@ -171,33 +166,29 @@ private fun TimedMessagesFeatureSection(
SectionView(
ChatFeature.TimedMessages.text.uppercase(),
- icon = ChatFeature.TimedMessages.iconFilled(),
+ icon = ChatFeature.TimedMessages.iconFilled,
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
leadingIcon = true,
) {
- PreferenceToggle(
- generalGetString(MR.strings.chat_preferences_you_allow),
- checked = allowFeature.value,
- ) { allow ->
- onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
+ SectionItemView {
+ PreferenceToggle(
+ generalGetString(R.string.chat_preferences_you_allow),
+ checked = allowFeature.value,
+ ) { allow ->
+ onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
+ }
}
+ SectionDivider()
InfoRow(
- generalGetString(MR.strings.chat_preferences_contact_allows),
+ generalGetString(R.string.chat_preferences_contact_allows),
pref.contactPreference.allow.text
)
+ SectionDivider()
if (featuresAllowed.timedMessagesAllowed) {
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
- DropdownCustomTimePickerSettingRow(
- selection = ttl,
- propagateExternalSelectionUpdate = true, // for Reset
- label = generalGetString(MR.strings.delete_after),
- dropdownValues = TimedMessagesPreference.ttlValues,
- customPickerTitle = generalGetString(MR.strings.delete_after),
- customPickerConfirmButtonText = generalGetString(MR.strings.custom_time_picker_select),
- onSelected = onTTLUpdated
- )
+ TimedMessagesTTLPicker(ttl, onTTLUpdated)
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
- InfoRow(generalGetString(MR.strings.delete_after), timeText(pref.contactPreference.ttl))
+ InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
}
}
SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled))
@@ -207,19 +198,34 @@ private fun TimedMessagesFeatureSection(
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
- Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
+ Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
+ SectionDivider()
SectionItemView(save, disabled = disabled) {
- Text(stringResource(MR.strings.save_and_notify_contact), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
+ Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
}
+@Composable
+fun TimedMessagesTTLPicker(selection: MutableState, onSelected: (Int?) -> Unit) {
+ val ttlValues = TimedMessagesPreference.ttlValues
+ val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
+ SectionItemView {
+ ExposedDropDownSettingRow(
+ generalGetString(R.string.delete_after),
+ values.map { it to TimedMessagesPreference.ttlText(it) },
+ selection,
+ onSelected = onSelected
+ )
+ }
+}
+
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
- title = generalGetString(MR.strings.save_preferences_question),
- confirmText = generalGetString(MR.strings.save_and_notify_contact),
- dismissText = generalGetString(MR.strings.exit_without_saving),
+ title = generalGetString(R.string.save_preferences_question),
+ confirmText = generalGetString(R.string.save_and_notify_contact),
+ dismissText = generalGetString(R.string.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt
new file mode 100644
index 0000000000..c07ff70aa1
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt
@@ -0,0 +1,80 @@
+package chat.simplex.app.views.chat
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.chat.item.*
+import kotlinx.datetime.Clock
+
+@Composable
+fun ContextItemView(
+ contextItem: ChatItem,
+ contextIcon: ImageVector,
+ cancelContextItem: () -> Unit
+) {
+ val sent = contextItem.chatDir.sent
+ Row(
+ Modifier
+ .padding(top = 8.dp)
+ .background(if (sent) SentColorLight else ReceivedColorLight),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ Modifier
+ .padding(vertical = 12.dp)
+ .fillMaxWidth()
+ .weight(1F),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ contextIcon,
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .height(20.dp)
+ .width(20.dp),
+ contentDescription = stringResource(R.string.icon_descr_context),
+ tint = HighOrLowlight,
+ )
+ MarkdownText(
+ contextItem.text, contextItem.formattedText,
+ sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
+ linkMode = SimplexLinkMode.DESCRIPTION,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ IconButton(onClick = cancelContextItem) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.cancel_verb),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(10.dp)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewContextItemView() {
+ SimpleXTheme {
+ ContextItemView(
+ contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
+ contextIcon = Icons.Filled.Edit,
+ cancelContextItem = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt
new file mode 100644
index 0000000000..e9e159137a
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt
@@ -0,0 +1,53 @@
+package chat.simplex.app.views.chat
+
+import android.Manifest
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import chat.simplex.app.R
+import chat.simplex.app.ui.theme.DEFAULT_PADDING
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.newchat.QRCodeScanner
+import com.google.accompanist.permissions.rememberPermissionState
+
+@Composable
+fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
+ val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
+ LaunchedEffect(Unit) {
+ cameraPermissionState.launchPermissionRequest()
+ }
+ ScanCodeLayout(verifyCode, close)
+}
+
+@Composable
+private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(horizontal = DEFAULT_PADDING)
+ ) {
+ AppBarTitle(stringResource(R.string.scan_code), false)
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(ratio = 1F)
+ .padding(bottom = DEFAULT_PADDING)
+ ) {
+ QRCodeScanner { text ->
+ verifyCode(text) {
+ if (it) {
+ close()
+ } else {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.incorrect_code)
+ )
+ }
+ }
+ }
+ }
+ Text(stringResource(R.string.scan_code_from_contacts_app))
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt
new file mode 100644
index 0000000000..d213296a3e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt
@@ -0,0 +1,660 @@
+package chat.simplex.app.views.chat
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.content.pm.ActivityInfo
+import android.content.res.Configuration
+import android.os.Build
+import android.text.InputType
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.view.inputmethod.*
+import android.widget.EditText
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.*
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.material.ripple.rememberRipple
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.*
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.*
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.*
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.*
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.graphics.drawable.DrawableCompat
+import androidx.core.view.inputmethod.EditorInfoCompat
+import androidx.core.view.inputmethod.InputConnectionCompat
+import androidx.core.widget.*
+import chat.simplex.app.R
+import chat.simplex.app.SimplexApp
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.chat.item.ItemAction
+import chat.simplex.app.views.helpers.*
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import kotlinx.coroutines.*
+
+@Composable
+fun SendMsgView(
+ composeState: MutableState,
+ showVoiceRecordIcon: Boolean,
+ recState: MutableState,
+ isDirectChat: Boolean,
+ liveMessageAlertShown: SharedPreference,
+ needToAllowVoiceToContact: Boolean,
+ allowedVoiceByPrefs: Boolean,
+ userIsObserver: Boolean,
+ userCanSend: Boolean,
+ allowVoiceToContact: () -> Unit,
+ sendMessage: () -> Unit,
+ sendLiveMessage: (suspend () -> Unit)? = null,
+ updateLiveMessage: (suspend () -> Unit)? = null,
+ cancelLiveMessage: (() -> Unit)? = null,
+ onMessageChange: (String) -> Unit,
+ textStyle: MutableState
+) {
+ Box(Modifier.padding(vertical = 8.dp)) {
+ val cs = composeState.value
+ val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
+ val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
+ cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
+ val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
+ NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
+ // Disable clicks on text field
+ if (cs.preview is ComposePreview.VoicePreview || !userCanSend) {
+ Box(Modifier
+ .matchParentSize()
+ .clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.observer_cant_send_message_title),
+ text = generalGetString(R.string.observer_cant_send_message_desc)
+ )
+ })
+ )
+ }
+ if (showDeleteTextButton.value) {
+ DeleteTextButton(composeState)
+ }
+ Box(Modifier.align(Alignment.BottomEnd)) {
+ val sendButtonSize = remember { Animatable(36f) }
+ val sendButtonAlpha = remember { Animatable(1f) }
+ val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
+ val scope = rememberCoroutineScope()
+ LaunchedEffect(Unit) {
+ // Making LiveMessage alive when screen orientation was changed
+ if (cs.liveMessage != null && sendLiveMessage != null && updateLiveMessage != null) {
+ startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
+ }
+ }
+ when {
+ showProgress -> ProgressIndicator()
+ showVoiceButton -> {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val stopRecOnNextClick = remember { mutableStateOf(false) }
+ when {
+ needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> {
+ DisallowedVoiceButton(userCanSend) {
+ if (needToAllowVoiceToContact) {
+ showNeedToAllowVoiceAlert(allowVoiceToContact)
+ } else if (!allowedVoiceByPrefs) {
+ showDisabledVoiceAlert(isDirectChat)
+ }
+ }
+ }
+ !permissionsState.allPermissionsGranted ->
+ VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
+ else ->
+ RecordVoiceView(recState, stopRecOnNextClick)
+ }
+ if (sendLiveMessage != null
+ && updateLiveMessage != null
+ && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
+ && cs.contextItem is ComposeContextItem.NoContextItem) {
+ Spacer(Modifier.width(10.dp))
+ StartLiveMessageButton(userCanSend) {
+ if (composeState.value.preview is ComposePreview.NoPreview) {
+ startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
+ }
+ }
+ }
+ }
+ }
+ cs.liveMessage?.sent == false && cs.message.isEmpty() -> {
+ CancelLiveMessageButton {
+ cancelLiveMessage?.invoke()
+ }
+ }
+ else -> {
+ val cs = composeState.value
+ val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward
+ val disabled = !cs.sendEnabled() ||
+ (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
+ cs.endLiveDisabled
+ if (cs.liveMessage == null &&
+ cs.preview !is ComposePreview.VoicePreview && !cs.editing &&
+ cs.contextItem is ComposeContextItem.NoContextItem &&
+ sendLiveMessage != null && updateLiveMessage != null
+ ) {
+ var showDropdown by rememberSaveable { mutableStateOf(false) }
+ SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown = true }
+
+ DropdownMenu(
+ expanded = showDropdown,
+ onDismissRequest = { showDropdown = false },
+ Modifier.width(220.dp),
+ ) {
+ ItemAction(
+ generalGetString(R.string.send_live_message),
+ Icons.Filled.Bolt,
+ onClick = {
+ startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
+ showDropdown = false
+ }
+ )
+ }
+ } else {
+ SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun NativeKeyboard(
+ composeState: MutableState,
+ textStyle: MutableState,
+ showDeleteTextButton: MutableState,
+ userIsObserver: Boolean,
+ onMessageChange: (String) -> Unit
+) {
+ val cs = composeState.value
+ val textColor = MaterialTheme.colors.onBackground
+ val tintColor = MaterialTheme.colors.secondary
+ val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
+ val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
+ val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
+ val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
+ val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
+ var showKeyboard by remember { mutableStateOf(false) }
+ LaunchedEffect(cs.contextItem) {
+ if (cs.contextItem is ComposeContextItem.QuotedItem) {
+ delay(100)
+ showKeyboard = true
+ } else if (cs.contextItem is ComposeContextItem.EditingItem) {
+ // Keyboard will not show up if we try to show it too fast
+ delay(300)
+ showKeyboard = true
+ }
+ }
+
+ AndroidView(modifier = Modifier, factory = {
+ val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
+ override fun setOnReceiveContentListener(
+ mimeTypes: Array?,
+ listener: android.view.OnReceiveContentListener?
+ ) {
+ super.setOnReceiveContentListener(mimeTypes, listener)
+ }
+
+ override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
+ val connection = super.onCreateInputConnection(editorInfo)
+ EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
+ val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
+ try {
+ inputContentInfo.requestPermission()
+ } catch (e: Exception) {
+ return@OnCommitContentListener false
+ }
+ SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
+ true
+ }
+ return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
+ }
+ }
+ editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+ editText.maxLines = 16
+ editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
+ editText.setTextColor(textColor.toArgb())
+ editText.textSize = textStyle.value.fontSize.value
+ val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
+ DrawableCompat.setTint(drawable, tintColor.toArgb())
+ editText.background = drawable
+ editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
+ editText.setText(cs.message)
+ editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
+ editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
+ editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
+ editText
+ }) {
+ it.setTextColor(textColor.toArgb())
+ it.textSize = textStyle.value.fontSize.value
+ DrawableCompat.setTint(it.background, tintColor.toArgb())
+ it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
+ it.isFocusableInTouchMode = it.isFocusable
+ if (cs.message != it.text.toString()) {
+ it.setText(cs.message)
+ // Set cursor to the end of the text
+ it.setSelection(it.text.length)
+ }
+ if (showKeyboard) {
+ it.requestFocus()
+ val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
+ showKeyboard = false
+ }
+ showDeleteTextButton.value = it.lineCount >= 4
+ }
+ if (composeState.value.preview is ComposePreview.VoicePreview) {
+ ComposeOverlay(R.string.voice_message_send_text, textStyle, padding)
+ } else if (userIsObserver) {
+ ComposeOverlay(R.string.you_are_observer, textStyle, padding)
+ }
+}
+
+@Composable
+private fun ComposeOverlay(textId: Int, textStyle: MutableState, padding: PaddingValues) {
+ Text(
+ generalGetString(textId),
+ Modifier.padding(padding),
+ color = HighOrLowlight,
+ style = textStyle.value.copy(fontStyle = FontStyle.Italic)
+ )
+}
+
+@Composable
+private fun BoxScope.DeleteTextButton(composeState: MutableState) {
+ IconButton(
+ { composeState.value = composeState.value.copy(message = "") },
+ Modifier.align(Alignment.TopEnd).size(36.dp)
+ ) {
+ Icon(Icons.Filled.Close, null, Modifier.padding(7.dp).size(36.dp), tint = HighOrLowlight)
+ }
+}
+
+@Composable
+private fun RecordVoiceView(recState: MutableState, stopRecOnNextClick: MutableState) {
+ val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
+ DisposableEffect(Unit) { onDispose { rec.stop() } }
+ val stopRecordingAndAddAudio: () -> Unit = {
+ recState.value.filePathNullable?.let {
+ recState.value = RecordingState.Finished(it, rec.stop())
+ }
+ }
+ if (stopRecOnNextClick.value) {
+ LaunchedEffect(recState.value) {
+ if (recState.value is RecordingState.NotStarted) {
+ stopRecOnNextClick.value = false
+ }
+ }
+ // Lock orientation to current orientation because screen rotation will break the recording
+ LockToCurrentOrientationUntilDispose()
+ StopRecordButton(stopRecordingAndAddAudio)
+ } else {
+ val startRecording: () -> Unit = {
+ recState.value = RecordingState.Started(
+ filePath = rec.start { progress: Int?, finished: Boolean ->
+ val state = recState.value
+ if (state is RecordingState.Started && progress != null) {
+ recState.value = if (!finished)
+ RecordingState.Started(state.filePath, progress)
+ else
+ RecordingState.Finished(state.filePath, progress)
+ }
+ },
+ )
+ }
+ val interactionSource = interactionSourceWithTapDetection(
+ onPress = { if (recState.value is RecordingState.NotStarted) startRecording() },
+ onClick = {
+ if (stopRecOnNextClick.value) {
+ stopRecordingAndAddAudio()
+ } else {
+ // tapped and didn't hold a finger
+ stopRecOnNextClick.value = true
+ }
+ },
+ onCancel = stopRecordingAndAddAudio,
+ onRelease = stopRecordingAndAddAudio
+ )
+ RecordVoiceButton(interactionSource)
+ }
+}
+
+@Composable
+private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
+ IconButton(onClick, Modifier.size(36.dp), enabled = enabled) {
+ Icon(
+ Icons.Outlined.KeyboardVoice,
+ stringResource(R.string.icon_descr_record_voice_message),
+ tint = HighOrLowlight,
+ modifier = Modifier
+ .size(36.dp)
+ .padding(4.dp)
+ )
+ }
+}
+
+@Composable
+private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
+ IconButton(onClick, Modifier.size(36.dp)) {
+ Icon(
+ Icons.Filled.KeyboardVoice,
+ stringResource(R.string.icon_descr_record_voice_message),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier
+ .size(34.dp)
+ .padding(4.dp)
+ )
+ }
+}
+
+@Composable
+private fun LockToCurrentOrientationUntilDispose() {
+ val context = LocalContext.current
+ DisposableEffect(Unit) {
+ val activity = context as Activity
+ val manager = context.getSystemService(Activity.WINDOW_SERVICE) as WindowManager
+ val rotation = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) manager.defaultDisplay.rotation else activity.display?.rotation
+ activity.requestedOrientation = when (rotation) {
+ android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
+ android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
+ else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ }
+ // Unlock orientation
+ onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
+ }
+}
+
+@Composable
+private fun StopRecordButton(onClick: () -> Unit) {
+ IconButton(onClick, Modifier.size(36.dp)) {
+ Icon(
+ Icons.Filled.Stop,
+ stringResource(R.string.icon_descr_record_voice_message),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier
+ .size(36.dp)
+ .padding(4.dp)
+ )
+ }
+}
+
+@Composable
+private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
+ IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
+ Icon(
+ Icons.Filled.KeyboardVoice,
+ stringResource(R.string.icon_descr_record_voice_message),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier
+ .size(34.dp)
+ .padding(4.dp)
+ )
+ }
+}
+
+@Composable
+private fun ProgressIndicator() {
+ CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
+}
+
+@Composable
+private fun CancelLiveMessageButton(
+ onClick: () -> Unit
+) {
+ IconButton(onClick, Modifier.size(36.dp)) {
+ Icon(
+ Icons.Filled.Close,
+ stringResource(R.string.icon_descr_cancel_live_message),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier
+ .size(36.dp)
+ .padding(4.dp)
+ )
+ }
+}
+
+@Composable
+private fun SendMsgButton(
+ icon: ImageVector,
+ sizeDp: Animatable,
+ alpha: Animatable,
+ enabled: Boolean,
+ sendMessage: () -> Unit,
+ onLongClick: (() -> Unit)? = null
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ Box(
+ modifier = Modifier.requiredSize(36.dp)
+ .combinedClickable(
+ onClick = sendMessage,
+ onLongClick = onLongClick,
+ enabled = enabled,
+ role = Role.Button,
+ interactionSource = interactionSource,
+ indication = rememberRipple(bounded = false, radius = 24.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ icon,
+ stringResource(R.string.icon_descr_send_message),
+ tint = Color.White,
+ modifier = Modifier
+ .size(sizeDp.value.dp)
+ .padding(4.dp)
+ .alpha(alpha.value)
+ .clip(CircleShape)
+ .background(if (enabled) MaterialTheme.colors.primary else HighOrLowlight)
+ .padding(3.dp)
+ )
+ }
+}
+
+@Composable
+private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
+ val interactionSource = remember { MutableInteractionSource() }
+ Box(
+ modifier = Modifier.requiredSize(36.dp)
+ .clickable(
+ onClick = onClick,
+ enabled = enabled,
+ role = Role.Button,
+ interactionSource = interactionSource,
+ indication = rememberRipple(bounded = false, radius = 24.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Filled.Bolt,
+ stringResource(R.string.icon_descr_send_message),
+ tint = if (enabled) MaterialTheme.colors.primary else HighOrLowlight,
+ modifier = Modifier
+ .size(36.dp)
+ .padding(4.dp)
+ )
+ }
+}
+
+private fun startLiveMessage(
+ scope: CoroutineScope,
+ send: suspend () -> Unit,
+ update: suspend () -> Unit,
+ sendButtonSize: Animatable,
+ sendButtonAlpha: Animatable,
+ composeState: MutableState,
+ liveMessageAlertShown: SharedPreference
+) {
+ fun run() {
+ scope.launch {
+ while (composeState.value.liveMessage != null) {
+ sendButtonSize.animateTo(if (sendButtonSize.value == 36f) 32f else 36f, tween(700, 50))
+ }
+ sendButtonSize.snapTo(36f)
+ }
+ scope.launch {
+ while (composeState.value.liveMessage != null) {
+ sendButtonAlpha.animateTo(if (sendButtonAlpha.value == 1f) 0.75f else 1f, tween(700, 50))
+ }
+ sendButtonAlpha.snapTo(1f)
+ }
+ scope.launch {
+ delay(3000)
+ while (composeState.value.liveMessage != null) {
+ update()
+ delay(3000)
+ }
+ }
+ }
+
+ fun start() = withBGApi {
+ if (composeState.value.liveMessage == null) {
+ send()
+ }
+ run()
+ }
+
+ if (liveMessageAlertShown.state.value) {
+ start()
+ } else {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.live_message),
+ text = generalGetString(R.string.send_live_message_desc),
+ confirmText = generalGetString(R.string.send_verb),
+ onConfirm = {
+ liveMessageAlertShown.set(true)
+ start()
+ })
+ }
+}
+
+private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.allow_voice_messages_question),
+ text = generalGetString(R.string.you_need_to_allow_to_send_voice),
+ confirmText = generalGetString(R.string.allow_verb),
+ dismissText = generalGetString(R.string.cancel_verb),
+ onConfirm = onConfirm,
+ )
+}
+
+private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.voice_messages_prohibited),
+ text = generalGetString(
+ if (isDirectChat)
+ R.string.ask_your_contact_to_enable_voice
+ else
+ R.string.only_group_owners_can_enable_voice
+ )
+ )
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewSendMsgView() {
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
+ SimpleXTheme {
+ SendMsgView(
+ composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
+ showVoiceRecordIcon = false,
+ recState = remember { mutableStateOf(RecordingState.NotStarted) },
+ isDirectChat = true,
+ liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
+ needToAllowVoiceToContact = false,
+ allowedVoiceByPrefs = true,
+ userIsObserver = false,
+ userCanSend = true,
+ allowVoiceToContact = {},
+ sendMessage = {},
+ onMessageChange = { _ -> },
+ textStyle = textStyle
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewSendMsgViewEditing() {
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
+ val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData(), useLinkPreviews = true)
+ SimpleXTheme {
+ SendMsgView(
+ composeState = remember { mutableStateOf(composeStateEditing) },
+ showVoiceRecordIcon = false,
+ recState = remember { mutableStateOf(RecordingState.NotStarted) },
+ isDirectChat = true,
+ liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
+ needToAllowVoiceToContact = false,
+ allowedVoiceByPrefs = true,
+ userIsObserver = false,
+ userCanSend = true,
+ allowVoiceToContact = {},
+ sendMessage = {},
+ onMessageChange = { _ -> },
+ textStyle = textStyle
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewSendMsgViewInProgress() {
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
+ val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt", getAppFileUri("test.txt")), inProgress = true, useLinkPreviews = true)
+ SimpleXTheme {
+ SendMsgView(
+ composeState = remember { mutableStateOf(composeStateInProgress) },
+ showVoiceRecordIcon = false,
+ recState = remember { mutableStateOf(RecordingState.NotStarted) },
+ isDirectChat = true,
+ liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
+ needToAllowVoiceToContact = false,
+ allowedVoiceByPrefs = true,
+ userIsObserver = false,
+ userCanSend = true,
+ allowVoiceToContact = {},
+ sendMessage = {},
+ onMessageChange = { _ -> },
+ textStyle = textStyle
+ )
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt
similarity index 51%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt
index e670fae5ef..fd88ccdc34 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt
@@ -1,26 +1,26 @@
-package chat.simplex.common.views.chat
+package chat.simplex.app.views.chat
-import SectionBottomSpacer
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalClipboardManager
-import dev.icerock.moko.resources.compose.painterResource
-import dev.icerock.moko.resources.compose.stringResource
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import chat.simplex.common.platform.*
-import chat.simplex.common.ui.theme.*
-import chat.simplex.common.views.helpers.*
-import chat.simplex.common.views.newchat.QRCode
-import chat.simplex.res.MR
+import chat.simplex.app.R
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.newchat.QRCode
@Composable
fun VerifyCodeView(
@@ -35,14 +35,14 @@ fun VerifyCodeView(
displayName,
connectionCode,
connectionVerified,
- verifyCode = { newCode ->
- val res = verify(newCode)
- if (res != null) {
- val (verified) = res
- if (verified) close()
- verified
- } else {
- false
+ verifyCode = { newCode, cb ->
+ withBGApi {
+ val res = verify(newCode)
+ if (res != null) {
+ val (verified) = res
+ cb(verified)
+ if (verified) close()
+ }
}
}
)
@@ -54,21 +54,28 @@ private fun VerifyCodeLayout(
displayName: String,
connectionCode: String,
connectionVerified: Boolean,
- verifyCode: suspend (String?) -> Boolean,
+ verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit,
) {
- ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
- AppBarTitle(stringResource(MR.strings.security_code), withPadding = false)
+ Column(
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = DEFAULT_PADDING)
+ ) {
+ AppBarTitle(stringResource(R.string.security_code), false)
val splitCode = splitToParts(connectionCode, 24)
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
if (connectionVerified) {
- Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 4.dp).size(22.dp), tint = MaterialTheme.colors.secondary)
- Text(String.format(stringResource(MR.strings.is_verified), displayName))
+ Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 4.dp).size(22.dp), tint = HighOrLowlight)
+ Text(String.format(stringResource(R.string.is_verified), displayName))
} else {
- Text(String.format(stringResource(MR.strings.is_not_verified), displayName))
+ Text(String.format(stringResource(R.string.is_not_verified), displayName))
}
}
- QRCode(connectionCode, padding = PaddingValues(vertical = DEFAULT_PADDING_HALF))
+ SectionView {
+ QRCode(connectionCode, Modifier.aspectRatio(1f))
+ }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.weight(2f))
@@ -80,17 +87,17 @@ private fun VerifyCodeLayout(
maxLines = 20
)
}
+ val context = LocalContext.current
Box(Modifier.weight(1f)) {
- val clipboard = LocalClipboardManager.current
- IconButton({ clipboard.shareText(connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
- Icon(painterResource(MR.images.ic_share_filled), null, tint = MaterialTheme.colors.primary)
+ IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
+ Icon(Icons.Filled.Share, null, tint = MaterialTheme.colors.primary)
}
}
Spacer(Modifier.weight(1f))
}
Text(
- generalGetString(MR.strings.to_verify_compare),
+ generalGetString(R.string.to_verify_compare),
Modifier.padding(bottom = DEFAULT_PADDING)
)
@@ -99,30 +106,26 @@ private fun VerifyCodeLayout(
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (connectionVerified) {
- SimpleButton(generalGetString(MR.strings.clear_verification), painterResource(MR.images.ic_shield)) {
- withApi { verifyCode(null) }
+ SimpleButton(generalGetString(R.string.clear_verification), Icons.Outlined.Shield) {
+ verifyCode(null) {}
}
} else {
- if (appPlatform.isAndroid) {
- SimpleButton(generalGetString(MR.strings.scan_code), painterResource(MR.images.ic_qr_code)) {
- ModalManager.end.showModal {
- ScanCodeView(verifyCode) { }
- }
+ SimpleButton(generalGetString(R.string.scan_code), Icons.Outlined.QrCode) {
+ ModalManager.shared.showModal {
+ ScanCodeView(verifyCode) { }
}
}
- SimpleButton(generalGetString(MR.strings.mark_code_verified), painterResource(MR.images.ic_verified_user)) {
- withApi {
- val verified = verifyCode(connectionCode)
+ SimpleButton(generalGetString(R.string.mark_code_verified), Icons.Outlined.VerifiedUser) {
+ verifyCode(connectionCode) { verified ->
if (!verified) {
AlertManager.shared.showAlertMsg(
- title = generalGetString(MR.strings.incorrect_code)
+ title = generalGetString(R.string.incorrect_code)
)
}
}
}
}
}
- SectionBottomSpacer()
}
}
@@ -131,4 +134,4 @@ private fun splitToParts(s: String, length: Int): String {
return (0..(s.length - 1) / length)
.map { s.drop(it * length).take(length) }
.joinToString(separator = "\n")
-}
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt
new file mode 100644
index 0000000000..1ff21e6651
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt
@@ -0,0 +1,335 @@
+package chat.simplex.app.views.chat.group
+
+import SectionCustomFooter
+import SectionDivider
+import SectionItemView
+import SectionSpacer
+import SectionView
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.TheaterComedy
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.ChatInfoToolbarTitle
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.usersettings.SettingsActionItem
+
+@Composable
+fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) {
+ val selectedContacts = remember { mutableStateListOf() }
+ val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
+ var allowModifyMembers by remember { mutableStateOf(true) }
+ BackHandler(onBack = close)
+ AddGroupMembersLayout(
+ groupInfo = groupInfo,
+ creatingGroup = creatingGroup,
+ contactsToAdd = getContactsToAdd(chatModel),
+ selectedContacts = selectedContacts,
+ selectedRole = selectedRole,
+ allowModifyMembers = allowModifyMembers,
+ openPreferences = {
+ ModalManager.shared.showCustomModal { close ->
+ GroupPreferencesView(chatModel, groupInfo.id, close)
+ }
+ },
+ inviteMembers = {
+ allowModifyMembers = false
+ withApi {
+ for (contactId in selectedContacts) {
+ val member = chatModel.controller.apiAddMember(groupInfo.groupId, contactId, selectedRole.value)
+ if (member != null) {
+ chatModel.upsertGroupMember(groupInfo, member)
+ } else {
+ break
+ }
+ }
+ close.invoke()
+ }
+ },
+ clearSelection = { selectedContacts.clear() },
+ addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
+ removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
+ close = close,
+ )
+}
+
+fun getContactsToAdd(chatModel: ChatModel): List {
+ val memberContactIds = chatModel.groupMembers
+ .filter { it.memberCurrent }
+ .mapNotNull { it.memberContactId }
+ return chatModel.chats
+ .asSequence()
+ .map { it.chatInfo }
+ .filterIsInstance()
+ .map { it.contact }
+ .filter { it.contactId !in memberContactIds }
+ .sortedBy { it.displayName.lowercase() }
+ .toList()
+}
+
+@Composable
+fun AddGroupMembersLayout(
+ groupInfo: GroupInfo,
+ creatingGroup: Boolean,
+ contactsToAdd: List,
+ selectedContacts: List,
+ selectedRole: MutableState,
+ allowModifyMembers: Boolean,
+ openPreferences: () -> Unit,
+ inviteMembers: () -> Unit,
+ clearSelection: () -> Unit,
+ addContact: (Long) -> Unit,
+ removeContact: (Long) -> Unit,
+ close: () -> Unit,
+) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ AppBarTitle(stringResource(R.string.button_add_members))
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ ChatInfoToolbarTitle(
+ ChatInfo.Group(groupInfo),
+ imageSize = 60.dp,
+ iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight
+ )
+ }
+ SectionSpacer()
+
+ if (contactsToAdd.isEmpty()) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ stringResource(R.string.no_contacts_to_add),
+ Modifier.padding(),
+ color = HighOrLowlight
+ )
+ }
+ } else {
+ SectionView {
+ if (creatingGroup) {
+ SectionItemView(openPreferences) {
+ Text(stringResource(R.string.set_group_preferences))
+ }
+ SectionDivider()
+ }
+ SectionItemView {
+ RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
+ }
+ SectionDivider()
+ if (creatingGroup && selectedContacts.isEmpty()) {
+ SkipInvitingButton(close)
+ } else {
+ InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
+ }
+ }
+ SectionCustomFooter {
+ InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
+ }
+ SectionSpacer()
+
+ SectionView(stringResource(R.string.select_contacts)) {
+ ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
+ }
+ SectionSpacer()
+ }
+ }
+}
+
+@Composable
+private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState, enabled: Boolean) {
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ val values = GroupMemberRole.values().filter { it <= groupInfo.membership.memberRole }.map { it to it.text }
+ ExposedDropDownSettingRow(
+ generalGetString(R.string.new_member_role),
+ values,
+ selectedRole,
+ icon = null,
+ enabled = rememberUpdatedState(enabled),
+ onSelected = { selectedRole.value = it }
+ )
+ }
+}
+
+@Composable
+fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
+ SettingsActionItem(
+ Icons.Outlined.Check,
+ stringResource(R.string.invite_to_group_button),
+ click = onClick,
+ textColor = MaterialTheme.colors.primary,
+ iconColor = MaterialTheme.colors.primary,
+ disabled = disabled,
+ )
+}
+
+@Composable
+fun SkipInvitingButton(onClick: () -> Unit) {
+ SettingsActionItem(
+ Icons.Outlined.Check,
+ stringResource(R.string.skip_inviting_button),
+ click = onClick,
+ textColor = MaterialTheme.colors.primary,
+ iconColor = MaterialTheme.colors.primary,
+ )
+}
+
+@Composable
+fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (selectedContactsCount >= 1) {
+ Text(
+ String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
+ color = HighOrLowlight,
+ fontSize = 12.sp
+ )
+ Box(
+ Modifier.clickable { if (enabled) clearSelection() }
+ ) {
+ Text(
+ stringResource(R.string.clear_contacts_selection_button),
+ color = if (enabled) MaterialTheme.colors.primary else HighOrLowlight,
+ fontSize = 12.sp
+ )
+ }
+ } else {
+ Text(
+ stringResource(R.string.no_contacts_selected),
+ color = HighOrLowlight,
+ fontSize = 12.sp
+ )
+ }
+ }
+}
+
+@Composable
+fun ContactList(
+ contacts: List,
+ selectedContacts: List,
+ groupInfo: GroupInfo,
+ enabled: Boolean,
+ addContact: (Long) -> Unit,
+ removeContact: (Long) -> Unit
+) {
+ Column {
+ contacts.forEachIndexed { index, contact ->
+ ContactCheckRow(
+ contact, groupInfo, addContact, removeContact,
+ checked = selectedContacts.contains(contact.apiId),
+ enabled = enabled,
+ )
+ if (index < contacts.lastIndex) {
+ SectionDivider()
+ }
+ }
+ }
+}
+
+@Composable
+fun ContactCheckRow(
+ contact: Contact,
+ groupInfo: GroupInfo,
+ addContact: (Long) -> Unit,
+ removeContact: (Long) -> Unit,
+ checked: Boolean,
+ enabled: Boolean,
+) {
+ val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
+ val icon: ImageVector
+ val iconColor: Color
+ if (prohibitedToInviteIncognito) {
+ icon = Icons.Filled.TheaterComedy
+ iconColor = HighOrLowlight
+ } else if (checked) {
+ icon = Icons.Filled.CheckCircle
+ iconColor = if (enabled) MaterialTheme.colors.primary else HighOrLowlight
+ } else {
+ icon = Icons.Outlined.Circle
+ iconColor = HighOrLowlight
+ }
+ SectionItemView(
+ click = if (enabled) {
+ {
+ if (prohibitedToInviteIncognito) {
+ showProhibitedToInviteIncognitoAlertDialog()
+ } else if (!checked)
+ addContact(contact.apiId)
+ else
+ removeContact(contact.apiId)
+ }
+ } else null
+ ) {
+ ProfileImage(size = 36.dp, contact.image)
+ Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
+ Text(
+ contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
+ color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified
+ )
+ Spacer(Modifier.fillMaxWidth().weight(1f))
+ Icon(
+ icon,
+ contentDescription = stringResource(R.string.icon_descr_contact_checked),
+ tint = iconColor
+ )
+ }
+}
+
+fun showProhibitedToInviteIncognitoAlertDialog() {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.invite_prohibited),
+ text = generalGetString(R.string.invite_prohibited_description),
+ confirmText = generalGetString(R.string.ok),
+ )
+}
+
+@Preview
+@Composable
+fun PreviewAddGroupMembersLayout() {
+ SimpleXTheme {
+ AddGroupMembersLayout(
+ groupInfo = GroupInfo.sampleData,
+ creatingGroup = false,
+ contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
+ selectedContacts = remember { mutableStateListOf() },
+ selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
+ allowModifyMembers = true,
+ openPreferences = {},
+ inviteMembers = {},
+ clearSelection = {},
+ addContact = {},
+ removeContact = {},
+ close = {},
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt
new file mode 100644
index 0000000000..263798990f
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt
@@ -0,0 +1,466 @@
+package chat.simplex.app.views.chat.group
+
+import InfoRow
+import SectionDivider
+import SectionItemView
+import SectionSpacer
+import SectionTextFooter
+import SectionView
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.TAG
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.*
+import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
+import chat.simplex.app.views.chatlist.setGroupMembers
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.usersettings.*
+
+@Composable
+fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair) -> Unit, close: () -> Unit) {
+ BackHandler(onBack = close)
+ val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
+ val developerTools = chatModel.controller.appPrefs.developerTools.get()
+ if (chat != null && chat.chatInfo is ChatInfo.Group) {
+ val groupInfo = chat.chatInfo.groupInfo
+ GroupChatInfoLayout(
+ chat,
+ groupInfo,
+ members = chatModel.groupMembers
+ .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
+ .sortedBy { it.displayName.lowercase() },
+ developerTools,
+ groupLink,
+ addMembers = {
+ withApi {
+ setGroupMembers(groupInfo, chatModel)
+ ModalManager.shared.showModalCloseable(true) { close ->
+ AddGroupMembersView(groupInfo, false, chatModel, close)
+ }
+ }
+ },
+ showMemberInfo = { member ->
+ withApi {
+ val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
+ val (_, code) = if (member.memberActive) {
+ try {
+ chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
+ } catch (e: Exception) {
+ Log.e(TAG, e.stackTraceToString())
+ member to null
+ }
+ } else {
+ member to null
+ }
+ ModalManager.shared.showModalCloseable(true) { closeCurrent ->
+ remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
+ GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
+ closeCurrent()
+ close()
+ }
+ }
+ }
+ }
+ },
+ editGroupProfile = {
+ ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
+ },
+ addOrEditWelcomeMessage = {
+ ModalManager.shared.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) }
+ },
+ openPreferences = {
+ ModalManager.shared.showCustomModal { close ->
+ GroupPreferencesView(
+ chatModel,
+ chat.id,
+ close
+ )
+ }
+ },
+ deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
+ clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
+ leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
+ manageGroupLink = {
+ ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
+ }
+ )
+ }
+}
+
+fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
+ val alertTextKey =
+ if (groupInfo.membership.memberCurrent) R.string.delete_group_for_all_members_cannot_undo_warning
+ else R.string.delete_group_for_self_cannot_undo_warning
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.delete_group_question),
+ text = generalGetString(alertTextKey),
+ confirmText = generalGetString(R.string.delete_verb),
+ onConfirm = {
+ withApi {
+ val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
+ if (r) {
+ chatModel.removeChat(chatInfo.id)
+ chatModel.chatId.value = null
+ chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
+ close?.invoke()
+ }
+ }
+ }
+ )
+}
+
+fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.leave_group_question),
+ text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
+ confirmText = generalGetString(R.string.leave_group_button),
+ onConfirm = {
+ withApi {
+ chatModel.controller.leaveGroup(groupInfo.groupId)
+ close?.invoke()
+ }
+ }
+ )
+}
+
+@Composable
+fun GroupChatInfoLayout(
+ chat: Chat,
+ groupInfo: GroupInfo,
+ members: List,
+ developerTools: Boolean,
+ groupLink: String?,
+ addMembers: () -> Unit,
+ showMemberInfo: (GroupMember) -> Unit,
+ editGroupProfile: () -> Unit,
+ addOrEditWelcomeMessage: () -> Unit,
+ openPreferences: () -> Unit,
+ deleteGroup: () -> Unit,
+ clearChat: () -> Unit,
+ leaveGroup: () -> Unit,
+ manageGroupLink: () -> Unit,
+) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ GroupChatInfoHeader(chat.chatInfo)
+ }
+ SectionSpacer()
+
+ SectionView {
+ if (groupInfo.canEdit) {
+ SectionItemView(editGroupProfile) { EditGroupProfileButton() }
+ SectionDivider()
+ SectionItemView(addOrEditWelcomeMessage) { AddOrEditWelcomeMessage(groupInfo.groupProfile.description) }
+ SectionDivider()
+ }
+ GroupPreferencesButton(openPreferences)
+ }
+ SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
+ SectionSpacer()
+
+ SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
+ if (groupInfo.canAddMembers) {
+ SectionItemView(manageGroupLink) {
+ if (groupLink == null) {
+ CreateGroupLinkButton()
+ } else {
+ GroupLinkButton()
+ }
+ }
+ SectionDivider()
+ val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
+ SectionItemView(onAddMembersClick) {
+ val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary
+ AddMembersButton(tint)
+ }
+ SectionDivider()
+ }
+ SectionItemView(minHeight = 50.dp) {
+ MemberRow(groupInfo.membership, user = true)
+ }
+ if (members.isNotEmpty()) {
+ SectionDivider()
+ }
+ MembersList(members, showMemberInfo)
+ }
+ SectionSpacer()
+ SectionView {
+ ClearChatButton(clearChat)
+ if (groupInfo.canDelete) {
+ SectionDivider()
+ SectionItemView(deleteGroup) { DeleteGroupButton() }
+ }
+ if (groupInfo.membership.memberCurrent) {
+ SectionDivider()
+ SectionItemView(leaveGroup) { LeaveGroupButton() }
+ }
+ }
+ SectionSpacer()
+
+ if (developerTools) {
+ SectionView(title = stringResource(R.string.section_title_for_console)) {
+ InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
+ SectionDivider()
+ InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
+ }
+ SectionSpacer()
+ }
+ }
+}
+
+@Composable
+private fun GroupChatInfoHeader(cInfo: ChatInfo) {
+ Column(
+ Modifier.padding(horizontal = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
+ Text(
+ cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
+ color = MaterialTheme.colors.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
+ Text(
+ cInfo.fullName, style = MaterialTheme.typography.h2,
+ color = MaterialTheme.colors.onBackground,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+}
+
+@Composable
+private fun GroupPreferencesButton(onClick: () -> Unit) {
+ SettingsActionItem(
+ Icons.Outlined.ToggleOn,
+ stringResource(R.string.group_preferences),
+ click = onClick
+ )
+}
+
+@Composable
+private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
+ Row(
+ Modifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Add,
+ stringResource(R.string.button_add_members),
+ tint = tint
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.button_add_members), color = tint)
+ }
+}
+
+@Composable
+private fun MembersList(members: List, showMemberInfo: (GroupMember) -> Unit) {
+ Column {
+ members.forEachIndexed { index, member ->
+ SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
+ MemberRow(member)
+ }
+ if (index < members.lastIndex) {
+ SectionDivider()
+ }
+ }
+ }
+}
+
+@Composable
+private fun MemberRow(member: GroupMember, user: Boolean = false) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ Modifier.weight(1f).padding(end = DEFAULT_PADDING),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ ProfileImage(size = 46.dp, member.image)
+ Column {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (member.verified) {
+ MemberVerifiedShield()
+ }
+ Text(
+ member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
+ color = if (member.memberIncognito) Indigo else Color.Unspecified
+ )
+ }
+ val s = member.memberStatus.shortText
+ val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
+ Text(
+ statusDescr,
+ color = HighOrLowlight,
+ fontSize = 12.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ val role = member.memberRole
+ if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) {
+ Text(role.text, color = HighOrLowlight)
+ }
+ }
+}
+
+@Composable
+private fun MemberVerifiedShield() {
+ Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 3.dp).size(16.dp), tint = HighOrLowlight)
+}
+
+@Composable
+private fun GroupLinkButton() {
+ Row(
+ Modifier
+ .fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Link,
+ stringResource(R.string.group_link),
+ tint = HighOrLowlight
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.group_link))
+ }
+}
+
+@Composable
+private fun CreateGroupLinkButton() {
+ Row(
+ Modifier
+ .fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.AddLink,
+ stringResource(R.string.create_group_link),
+ tint = HighOrLowlight
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.create_group_link))
+ }
+}
+
+@Composable
+fun EditGroupProfileButton() {
+ Row(
+ Modifier
+ .fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Edit,
+ stringResource(R.string.button_edit_group_profile),
+ tint = HighOrLowlight
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.button_edit_group_profile))
+ }
+}
+
+@Composable
+private fun AddOrEditWelcomeMessage(welcomeMessage: String?) {
+ val text = if (welcomeMessage == null) {
+ stringResource(R.string.button_add_welcome_message)
+ } else {
+ stringResource(R.string.button_welcome_message)
+ }
+ Row(
+ Modifier
+ .fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.MapsUgc,
+ text,
+ tint = HighOrLowlight
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(text)
+ }
+}
+
+@Composable
+private fun LeaveGroupButton() {
+ Row(
+ Modifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Logout,
+ stringResource(R.string.button_leave_group),
+ tint = Color.Red
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.button_leave_group), color = Color.Red)
+ }
+}
+
+@Composable
+private fun DeleteGroupButton() {
+ Row(
+ Modifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Delete,
+ stringResource(R.string.button_delete_group),
+ tint = Color.Red
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.button_delete_group), color = Color.Red)
+ }
+}
+
+@Preview
+@Composable
+fun PreviewGroupChatInfoLayout() {
+ SimpleXTheme {
+ GroupChatInfoLayout(
+ chat = Chat(
+ chatInfo = ChatInfo.Direct.sampleData,
+ chatItems = arrayListOf()
+ ),
+ groupInfo = GroupInfo.sampleData,
+ members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
+ developerTools = false,
+ groupLink = null,
+ addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt
new file mode 100644
index 0000000000..ab8e35a9c6
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt
@@ -0,0 +1,189 @@
+package chat.simplex.app.views.chat.group
+
+import SectionItemView
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.*
+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.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.newchat.QRCode
+
+@Composable
+fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair) -> Unit) {
+ var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
+ val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
+ var creatingLink by rememberSaveable { mutableStateOf(false) }
+ val cxt = LocalContext.current
+ fun createLink() {
+ creatingLink = true
+ withApi {
+ val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
+ if (link != null) {
+ groupLink = link.first
+ groupLinkMemberRole.value = link.second
+ onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
+ }
+ creatingLink = false
+ }
+ }
+ LaunchedEffect(Unit) {
+ if (groupLink == null && !creatingLink) {
+ createLink()
+ }
+ }
+ GroupLinkLayout(
+ groupLink = groupLink,
+ groupInfo,
+ groupLinkMemberRole,
+ creatingLink,
+ createLink = ::createLink,
+ share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
+ updateLink = {
+ val role = groupLinkMemberRole.value
+ if (role != null) {
+ withBGApi {
+ val link = chatModel.controller.apiGroupLinkMemberRole(groupInfo.groupId, role)
+ if (link != null) {
+ groupLink = link.first
+ groupLinkMemberRole.value = link.second
+ onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
+ }
+ }
+ }
+ },
+ deleteLink = {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.delete_link_question),
+ text = generalGetString(R.string.all_group_members_will_remain_connected),
+ confirmText = generalGetString(R.string.delete_verb),
+ onConfirm = {
+ withApi {
+ val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
+ if (r) {
+ groupLink = null
+ onGroupLinkUpdated(null to null)
+ }
+ }
+ }
+ )
+ }
+ )
+ if (creatingLink) {
+ ProgressIndicator()
+ }
+}
+
+@Composable
+fun GroupLinkLayout(
+ groupLink: String?,
+ groupInfo: GroupInfo,
+ groupLinkMemberRole: MutableState,
+ creatingLink: Boolean,
+ createLink: () -> Unit,
+ share: () -> Unit,
+ updateLink: () -> Unit,
+ deleteLink: () -> Unit
+) {
+ Column(
+ Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(start = DEFAULT_PADDING, bottom = DEFAULT_BOTTOM_PADDING, end = DEFAULT_PADDING),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.Top
+ ) {
+ AppBarTitle(stringResource(R.string.group_link), false)
+ Text(
+ stringResource(R.string.you_can_share_group_link_anybody_will_be_able_to_connect),
+ Modifier.padding(bottom = 12.dp),
+ lineHeight = 22.sp
+ )
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceEvenly
+ ) {
+ if (groupLink == null) {
+ SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink)
+ } else {
+ SectionItemView(padding = PaddingValues(bottom = DEFAULT_PADDING)) {
+ RoleSelectionRow(groupInfo, groupLinkMemberRole)
+ }
+ var initialLaunch by remember { mutableStateOf(true) }
+ LaunchedEffect(groupLinkMemberRole.value) {
+ if (!initialLaunch) {
+ updateLink()
+ }
+ initialLaunch = false
+ }
+ QRCode(groupLink, Modifier.aspectRatio(1f))
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = 10.dp)
+ ) {
+ SimpleButton(
+ stringResource(R.string.share_link),
+ icon = Icons.Outlined.Share,
+ click = share
+ )
+ SimpleButton(
+ stringResource(R.string.delete_link),
+ icon = Icons.Outlined.Delete,
+ color = Color.Red,
+ click = deleteLink
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState, enabled: Boolean = true) {
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text }
+ ExposedDropDownSettingRow(
+ generalGetString(R.string.initial_member_role),
+ values,
+ selectedRole,
+ icon = null,
+ enabled = rememberUpdatedState(enabled),
+ onSelected = { selectedRole.value = it }
+ )
+ }
+}
+
+@Composable
+fun ProgressIndicator() {
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ Modifier
+ .padding(horizontal = 2.dp)
+ .size(30.dp),
+ color = HighOrLowlight,
+ strokeWidth = 2.5.dp
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt
new file mode 100644
index 0000000000..3ec6d1ed38
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt
@@ -0,0 +1,375 @@
+package chat.simplex.app.views.chat.group
+
+import InfoRow
+import SectionDivider
+import SectionItemView
+import SectionSpacer
+import SectionView
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.*
+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.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.TAG
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.*
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.usersettings.SettingsActionItem
+import kotlinx.datetime.Clock
+
+@Composable
+fun GroupMemberInfoView(
+ groupInfo: GroupInfo,
+ member: GroupMember,
+ connStats: ConnectionStats?,
+ connectionCode: String?,
+ chatModel: ChatModel,
+ close: () -> Unit,
+ closeAll: () -> Unit, // Close all open windows up to ChatView
+) {
+ BackHandler(onBack = close)
+ val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
+ val developerTools = chatModel.controller.appPrefs.developerTools.get()
+ if (chat != null) {
+ val newRole = remember { mutableStateOf(member.memberRole) }
+ GroupMemberInfoLayout(
+ groupInfo,
+ member,
+ connStats,
+ newRole,
+ developerTools,
+ connectionCode,
+ getContactChat = { chatModel.getContactChat(it) },
+ knownDirectChat = {
+ withApi {
+ chatModel.chatItems.clear()
+ chatModel.chatItems.addAll(it.chatItems)
+ chatModel.chatId.value = it.chatInfo.id
+ closeAll()
+ }
+ },
+ newDirectChat = {
+ withApi {
+ val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
+ if (c != null) {
+ chatModel.addChat(c)
+ chatModel.chatItems.clear()
+ chatModel.chatId.value = c.id
+ closeAll()
+ }
+ }
+ },
+ removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
+ onRoleSelected = {
+ if (it == newRole.value) return@GroupMemberInfoLayout
+ val prevValue = newRole.value
+ newRole.value = it
+ updateMemberRoleDialog(it, member, onDismiss = {
+ newRole.value = prevValue
+ }) {
+ withApi {
+ kotlin.runCatching {
+ val mem = chatModel.controller.apiMemberRole(groupInfo.groupId, member.groupMemberId, it)
+ chatModel.upsertGroupMember(groupInfo, mem)
+ }.onFailure {
+ newRole.value = prevValue
+ }
+ }
+ }
+ },
+ switchMemberAddress = {
+ switchMemberAddress(chatModel, groupInfo, member)
+ },
+ verifyClicked = {
+ ModalManager.shared.showModalCloseable { close ->
+ remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
+ VerifyCodeView(
+ mem.displayName,
+ connectionCode,
+ mem.verified,
+ verify = { code ->
+ chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r ->
+ val (verified, existingCode) = r
+ chatModel.upsertGroupMember(
+ groupInfo,
+ mem.copy(
+ activeConn = mem.activeConn?.copy(
+ connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
+ )
+ )
+ )
+ r
+ }
+ },
+ close,
+ )
+ }
+ }
+ }
+ )
+ }
+}
+
+fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.button_remove_member),
+ text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
+ confirmText = generalGetString(R.string.remove_member_confirmation),
+ onConfirm = {
+ withApi {
+ val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
+ if (removedMember != null) {
+ chatModel.upsertGroupMember(groupInfo, removedMember)
+ }
+ close?.invoke()
+ }
+ }
+ )
+}
+
+@Composable
+fun GroupMemberInfoLayout(
+ groupInfo: GroupInfo,
+ member: GroupMember,
+ connStats: ConnectionStats?,
+ newRole: MutableState,
+ developerTools: Boolean,
+ connectionCode: String?,
+ getContactChat: (Long) -> Chat?,
+ knownDirectChat: (Chat) -> Unit,
+ newDirectChat: (Long) -> Unit,
+ removeMember: () -> Unit,
+ onRoleSelected: (GroupMemberRole) -> Unit,
+ switchMemberAddress: () -> Unit,
+ verifyClicked: () -> Unit,
+) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ GroupMemberInfoHeader(member)
+ }
+ SectionSpacer()
+
+ if (member.memberActive) {
+ val contactId = member.memberContactId
+ if (contactId != null) {
+ SectionView {
+ val chat = getContactChat(contactId)
+ if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
+ OpenChatButton(onClick = { knownDirectChat(chat) })
+ if (connectionCode != null) {
+ SectionDivider()
+ }
+ } else if (groupInfo.fullGroupPreferences.directMessages.on) {
+ OpenChatButton(onClick = { newDirectChat(contactId) })
+ if (connectionCode != null) {
+ SectionDivider()
+ }
+ }
+ if (connectionCode != null) {
+ VerifyCodeButton(member.verified, verifyClicked)
+ }
+ }
+ SectionSpacer()
+ }
+ }
+
+ SectionView(title = stringResource(R.string.member_info_section_title_member)) {
+ InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
+ SectionDivider()
+ val roles = remember { member.canChangeRoleTo(groupInfo) }
+ if (roles != null) {
+ SectionItemView {
+ RoleSelectionRow(roles, newRole, onRoleSelected)
+ }
+ } else {
+ InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
+ }
+ val conn = member.activeConn
+ if (conn != null) {
+ SectionDivider()
+ val connLevelDesc =
+ if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
+ else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
+ InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
+ }
+ }
+ SectionSpacer()
+ if (connStats != null) {
+ SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
+ SwitchAddressButton(switchMemberAddress)
+ SectionDivider()
+ val rcvServers = connStats.rcvServers
+ val sndServers = connStats.sndServers
+ if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
+ if (rcvServers != null && rcvServers.isNotEmpty()) {
+ SimplexServers(stringResource(R.string.receiving_via), rcvServers)
+ if (sndServers != null && sndServers.isNotEmpty()) {
+ SectionDivider()
+ SimplexServers(stringResource(R.string.sending_via), sndServers)
+ }
+ } else if (sndServers != null && sndServers.isNotEmpty()) {
+ SimplexServers(stringResource(R.string.sending_via), sndServers)
+ }
+ }
+ }
+ SectionSpacer()
+ }
+
+ if (member.canBeRemoved(groupInfo)) {
+ SectionView {
+ RemoveMemberButton(removeMember)
+ }
+ SectionSpacer()
+ }
+
+ if (developerTools) {
+ SectionView(title = stringResource(R.string.section_title_for_console)) {
+ InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
+ SectionDivider()
+ InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
+ }
+ SectionSpacer()
+ }
+ }
+}
+
+@Composable
+fun GroupMemberInfoHeader(member: GroupMember) {
+ Column(
+ Modifier.padding(horizontal = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (member.verified) {
+ Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
+ }
+ Text(
+ member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
+ color = MaterialTheme.colors.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ if (member.fullName != "" && member.fullName != member.displayName) {
+ Text(
+ member.fullName, style = MaterialTheme.typography.h2,
+ color = MaterialTheme.colors.onBackground,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+}
+
+@Composable
+fun RemoveMemberButton(onClick: () -> Unit) {
+ SettingsActionItem(
+ Icons.Outlined.Delete,
+ stringResource(R.string.button_remove_member),
+ click = onClick,
+ textColor = Color.Red,
+ iconColor = Color.Red,
+ )
+}
+
+@Composable
+fun OpenChatButton(onClick: () -> Unit) {
+ SettingsActionItem(
+ Icons.Outlined.Message,
+ stringResource(R.string.button_send_direct_message),
+ click = onClick,
+ textColor = MaterialTheme.colors.primary,
+ iconColor = MaterialTheme.colors.primary,
+ )
+}
+
+@Composable
+private fun RoleSelectionRow(
+ roles: List,
+ selectedRole: MutableState,
+ onSelected: (GroupMemberRole) -> Unit
+) {
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ val values = remember { roles.map { it to it.text } }
+ ExposedDropDownSettingRow(
+ generalGetString(R.string.change_role),
+ values,
+ selectedRole,
+ icon = null,
+ enabled = remember { mutableStateOf(true) },
+ onSelected = onSelected
+ )
+ }
+}
+
+private fun updateMemberRoleDialog(
+ newRole: GroupMemberRole,
+ member: GroupMember,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit
+) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.change_member_role_question),
+ text = if (member.memberCurrent)
+ String.format(generalGetString(R.string.member_role_will_be_changed_with_notification), newRole.text)
+ else
+ String.format(generalGetString(R.string.member_role_will_be_changed_with_invitation), newRole.text),
+ confirmText = generalGetString(R.string.change_verb),
+ onDismiss = onDismiss,
+ onConfirm = onConfirm,
+ onDismissRequest = onDismiss
+ )
+}
+
+private fun switchMemberAddress(m: ChatModel, groupInfo: GroupInfo, member: GroupMember) = withApi {
+ m.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
+}
+
+@Preview
+@Composable
+fun PreviewGroupMemberInfoLayout() {
+ SimpleXTheme {
+ GroupMemberInfoLayout(
+ groupInfo = GroupInfo.sampleData,
+ member = GroupMember.sampleData,
+ connStats = null,
+ newRole = remember { mutableStateOf(GroupMemberRole.Member) },
+ developerTools = false,
+ connectionCode = "123",
+ getContactChat = { Chat.sampleData },
+ knownDirectChat = {},
+ newDirectChat = {},
+ removeMember = {},
+ onRoleSelected = {},
+ switchMemberAddress = {},
+ verifyClicked = {},
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt
new file mode 100644
index 0000000000..48a5cd1117
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt
@@ -0,0 +1,183 @@
+package chat.simplex.app.views.chat.group
+
+import InfoRow
+import SectionDivider
+import SectionItemView
+import SectionSpacer
+import SectionTextFooter
+import SectionView
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.TimedMessagesTTLPicker
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
+
+@Composable
+fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
+ val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } }
+ val gInfo = groupInfo.value ?: return
+ var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
+ var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) }
+
+ fun savePrefs(afterSave: () -> Unit = {}) {
+ withApi {
+ val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
+ val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
+ if (gInfo != null) {
+ m.updateGroup(gInfo)
+ currentPreferences = preferences
+ }
+ afterSave()
+ }
+ }
+ ModalView(
+ close = {
+ if (preferences == currentPreferences) close()
+ else showUnsavedChangesAlert({ savePrefs(close) }, close)
+ },
+ background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
+ ) {
+ GroupPreferencesLayout(
+ preferences,
+ currentPreferences,
+ gInfo,
+ applyPrefs = { prefs ->
+ preferences = prefs
+ },
+ reset = {
+ preferences = currentPreferences
+ },
+ savePrefs = ::savePrefs,
+ )
+ }
+}
+
+@Composable
+private fun GroupPreferencesLayout(
+ preferences: FullGroupPreferences,
+ currentPreferences: FullGroupPreferences,
+ groupInfo: GroupInfo,
+ applyPrefs: (FullGroupPreferences) -> Unit,
+ reset: () -> Unit,
+ savePrefs: () -> Unit,
+) {
+ Column(
+ Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ AppBarTitle(stringResource(R.string.group_preferences))
+ val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
+ val onTTLUpdated = { ttl: Int? ->
+ applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl ?: 86400)))
+ }
+ FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
+ if (enable == GroupFeatureEnabled.ON) {
+ applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400)))
+ } else {
+ applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl)))
+ }
+ }
+ SectionSpacer()
+ val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
+ FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) {
+ applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
+ }
+ SectionSpacer()
+ val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
+ FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) {
+ applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
+ }
+ SectionSpacer()
+ val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
+ FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
+ applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
+ }
+ if (groupInfo.canEdit) {
+ SectionSpacer()
+ ResetSaveButtons(
+ reset = reset,
+ save = savePrefs,
+ disabled = preferences == currentPreferences
+ )
+ }
+ }
+}
+
+@Composable
+private fun FeatureSection(
+ feature: GroupFeature,
+ enableFeature: State,
+ groupInfo: GroupInfo,
+ preferences: FullGroupPreferences,
+ onTTLUpdated: (Int?) -> Unit,
+ onSelected: (GroupFeatureEnabled) -> Unit
+) {
+ SectionView {
+ val on = enableFeature.value == GroupFeatureEnabled.ON
+ val icon = if (on) feature.iconFilled else feature.icon
+ val iconTint = if (on) SimplexGreen else HighOrLowlight
+ val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON
+ if (groupInfo.canEdit) {
+ SectionItemView {
+ PreferenceToggleWithIcon(
+ feature.text,
+ icon,
+ iconTint,
+ enableFeature.value == GroupFeatureEnabled.ON,
+ ) { checked ->
+ onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF)
+ }
+ }
+ if (timedOn) {
+ SectionDivider()
+ val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
+ TimedMessagesTTLPicker(ttl, onTTLUpdated)
+ }
+ } else {
+ InfoRow(
+ feature.text,
+ enableFeature.value.text,
+ icon = icon,
+ iconTint = iconTint,
+ )
+ if (timedOn) {
+ SectionDivider()
+ InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
+ }
+ }
+ }
+ SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit))
+}
+
+@Composable
+private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
+ SectionView {
+ SectionItemView(reset, disabled = disabled) {
+ Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
+ }
+ SectionDivider()
+ SectionItemView(save, disabled = disabled) {
+ Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
+ }
+ }
+}
+
+private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
+ AlertManager.shared.showAlertDialogStacked(
+ title = generalGetString(R.string.save_preferences_question),
+ confirmText = generalGetString(R.string.save_and_notify_group_members),
+ dismissText = generalGetString(R.string.exit_without_saving),
+ onConfirm = save,
+ onDismiss = revert,
+ )
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt
new file mode 100644
index 0000000000..ce20848636
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt
@@ -0,0 +1,182 @@
+package chat.simplex.app.views.chat.group
+
+import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.net.Uri
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.focus.FocusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.ProfileNameField
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.isValidDisplayName
+import chat.simplex.app.views.usersettings.*
+import com.google.accompanist.insets.ProvideWindowInsets
+import com.google.accompanist.insets.navigationBarsWithImePadding
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Composable
+fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
+ GroupProfileLayout(
+ close = close,
+ groupProfile = groupInfo.groupProfile,
+ saveProfile = { p ->
+ withApi {
+ val gInfo = chatModel.controller.apiUpdateGroup(groupInfo.groupId, p)
+ if (gInfo != null) {
+ chatModel.updateGroup(gInfo)
+ close.invoke()
+ }
+ }
+ }
+ )
+}
+
+@Composable
+fun GroupProfileLayout(
+ close: () -> Unit,
+ groupProfile: GroupProfile,
+ saveProfile: (GroupProfile) -> Unit,
+) {
+ val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
+ val displayName = remember { mutableStateOf(groupProfile.displayName) }
+ val fullName = remember { mutableStateOf(groupProfile.fullName) }
+ val chosenImage = rememberSaveable { mutableStateOf(null) }
+ val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
+ val scope = rememberCoroutineScope()
+ val scrollState = rememberScrollState()
+ val focusRequester = remember { FocusRequester() }
+
+ ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
+ ModalBottomSheetLayout(
+ scrimColor = Color.Black.copy(alpha = 0.12F),
+ modifier = Modifier.navigationBarsWithImePadding(),
+ sheetContent = {
+ GetImageBottomSheet(
+ chosenImage,
+ onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
+ hideBottomSheet = {
+ scope.launch { bottomSheetModalState.hide() }
+ })
+ },
+ sheetState = bottomSheetModalState,
+ sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
+ ) {
+ ModalView(close = close) {
+ Column(
+ Modifier
+ .verticalScroll(scrollState)
+ .padding(horizontal = DEFAULT_PADDING),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Text(
+ stringResource(R.string.group_profile_is_stored_on_members_devices),
+ Modifier.padding(bottom = 24.dp),
+ color = MaterialTheme.colors.onBackground,
+ lineHeight = 22.sp
+ )
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(contentAlignment = Alignment.TopEnd) {
+ Box(contentAlignment = Alignment.Center) {
+ ProfileImage(192.dp, profileImage.value)
+ EditImageButton { scope.launch { bottomSheetModalState.show() } }
+ }
+ if (profileImage.value != null) {
+ DeleteImageButton { profileImage.value = null }
+ }
+ }
+ }
+ Text(
+ stringResource(R.string.group_display_name_field),
+ Modifier.padding(bottom = 3.dp)
+ )
+ ProfileNameField(displayName, focusRequester)
+ val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
+ Text(
+ errorText,
+ fontSize = 15.sp,
+ color = MaterialTheme.colors.error
+ )
+ Spacer(Modifier.height(3.dp))
+ Text(
+ stringResource(R.string.group_full_name_field),
+ Modifier.padding(bottom = 5.dp)
+ )
+ ProfileNameField(fullName)
+ Spacer(Modifier.height(16.dp))
+ Row {
+ TextButton(stringResource(R.string.cancel_verb)) {
+ close.invoke()
+ }
+ Spacer(Modifier.padding(horizontal = 8.dp))
+ val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
+ if (enabled) {
+ Text(
+ stringResource(R.string.save_group_profile),
+ modifier = Modifier.clickable {
+ saveProfile(groupProfile.copy(
+ displayName = displayName.value,
+ fullName = fullName.value,
+ image = profileImage.value
+ ))
+ },
+ color = MaterialTheme.colors.primary
+ )
+ } else {
+ Text(
+ stringResource(R.string.save_group_profile),
+ color = HighOrLowlight
+ )
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ delay(300)
+ focusRequester.requestFocus()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewGroupProfileLayout() {
+ SimpleXTheme {
+ GroupProfileLayout(
+ close = {},
+ groupProfile = GroupProfile.sampleData,
+ saveProfile = { _ -> }
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/WelcomeMessageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/WelcomeMessageView.kt
new file mode 100644
index 0000000000..f20630b94c
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/WelcomeMessageView.kt
@@ -0,0 +1,94 @@
+package chat.simplex.app.views.chat.group
+
+import SectionItemView
+import SectionSpacer
+import SectionView
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+
+@Composable
+fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
+ var groupInfo by remember { mutableStateOf(groupInfo) }
+ val welcomeText = remember { mutableStateOf(groupInfo.groupProfile.description ?: "") }
+
+ fun save(afterSave: () -> Unit = {}) {
+ withApi {
+ var welcome: String? = welcomeText.value.trim('\n', ' ')
+ if (welcome?.length == 0) {
+ welcome = null
+ }
+ val groupProfileUpdated = groupInfo.groupProfile.copy(description = welcome)
+ val res = m.controller.apiUpdateGroup(groupInfo.groupId, groupProfileUpdated)
+ if (res != null) {
+ groupInfo = res
+ m.updateGroup(res)
+ welcomeText.value = welcome ?: ""
+ }
+ afterSave()
+ }
+ }
+
+ ModalView(
+ close = {
+ if (welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)) close()
+ else showUnsavedChangesAlert({ save(close) }, close)
+ },
+ background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
+ ) {
+ GroupWelcomeLayout(
+ welcomeText,
+ groupInfo,
+ save = ::save
+ )
+ }
+}
+
+@Composable
+private fun GroupWelcomeLayout(
+ welcomeText: MutableState,
+ groupInfo: GroupInfo,
+ save: () -> Unit,
+) {
+ Column(
+ Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
+ ) {
+ AppBarTitle(stringResource(R.string.group_welcome_title))
+ val welcomeText = remember { welcomeText }
+ TextEditor(Modifier.padding(horizontal = DEFAULT_PADDING).height(160.dp), text = welcomeText)
+ SectionSpacer()
+ SaveButton(
+ save = save,
+ disabled = welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)
+ )
+ }
+}
+
+@Composable
+private fun SaveButton(save: () -> Unit, disabled: Boolean) {
+ SectionView {
+ SectionItemView(save, disabled = disabled) {
+ Text(stringResource(R.string.save_and_update_group_profile), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
+ }
+ }
+}
+
+private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
+ AlertManager.shared.showAlertDialogStacked(
+ title = generalGetString(R.string.save_welcome_message_question),
+ confirmText = generalGetString(R.string.save_and_update_group_profile),
+ dismissText = generalGetString(R.string.exit_without_saving),
+ onConfirm = save,
+ onDismiss = revert,
+ )
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt
similarity index 67%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt
index 744bcf7b66..5ab80774c6 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt
@@ -1,63 +1,63 @@
-package chat.simplex.common.views.chat.item
+package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
-import androidx.compose.material.*
+import androidx.compose.material.Icon
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PhoneInTalk
+import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import dev.icerock.moko.resources.compose.painterResource
-import dev.icerock.moko.resources.compose.stringResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import chat.simplex.common.ui.theme.*
-import chat.simplex.common.model.*
-import chat.simplex.common.views.helpers.SimpleButton
-import chat.simplex.res.MR
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
@Composable
-fun CICallItemView(
- cInfo: ChatInfo,
- cItem: ChatItem,
- status: CICallStatus,
- duration: Int,
- showTimestamp: Boolean,
- acceptCall: (Contact) -> Unit,
- timedMessagesTTL: Int?
-) {
+fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
val sent = cItem.chatDir.sent
Column(
Modifier
.padding(horizontal = 4.dp)
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
- @Composable fun ConnectingCallIcon() = Icon(painterResource(MR.images.ic_settings_phone), stringResource(MR.strings.icon_descr_call_connecting), tint = SimplexGreen)
+ @Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
when (status) {
CICallStatus.Pending -> if (sent) {
- Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_pending_sent))
+ Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
} else {
AcceptCallButton(cInfo, acceptCall)
}
- CICallStatus.Missed -> Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_missed), tint = Color.Red)
- CICallStatus.Rejected -> Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_rejected), tint = Color.Red)
+ CICallStatus.Missed -> Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
+ CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
CICallStatus.Accepted -> ConnectingCallIcon()
CICallStatus.Negotiated -> ConnectingCallIcon()
- CICallStatus.Progress -> Icon(painterResource(MR.images.ic_phone_in_talk_filled), stringResource(MR.strings.icon_descr_call_progress), tint = SimplexGreen)
+ CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
CICallStatus.Ended -> Row {
- Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp))
- Text(durationText(duration), color = MaterialTheme.colors.secondary)
+ Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
+ Text(durationText(duration), color = HighOrLowlight)
}
CICallStatus.Error -> {}
}
- CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp)
+ Text(
+ cItem.timestampText,
+ color = HighOrLowlight,
+ fontSize = 14.sp,
+ modifier = Modifier.padding(start = 3.dp)
+ )
}
}
@Composable
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
if (cInfo is ChatInfo.Direct) {
- SimpleButton(stringResource(MR.strings.answer_call), painterResource(MR.images.ic_ring_volume)) { acceptCall(cInfo.contact) }
+ SimpleButton(stringResource(R.string.answer_call), Icons.Outlined.RingVolume) { acceptCall(cInfo.contact) }
} else {
- Icon(painterResource(MR.images.ic_ring_volume), stringResource(MR.strings.answer_call), tint = MaterialTheme.colors.secondary)
+ Icon(Icons.Outlined.RingVolume, stringResource(R.string.answer_call), tint = HighOrLowlight)
}
// if case let .direct(contact) = chatInfo {
// Button {
@@ -70,7 +70,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
// sharedKey: invitation.sharedKey
// )
// m.showCallView = true
-// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true)
+// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
// } else {
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
// }
@@ -143,7 +143,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
// sharedKey: invitation.sharedKey
// )
// m.showCallView = true
-// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true)
+// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
// } else {
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
// }
@@ -154,4 +154,4 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
// }
// }
-//}
+//}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIChatFeatureView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIChatFeatureView.kt
new file mode 100644
index 0000000000..347dde80fc
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIChatFeatureView.kt
@@ -0,0 +1,34 @@
+package chat.simplex.app.views.chat.item
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.model.*
+
+@Composable
+fun CIChatFeatureView(
+ chatItem: ChatItem,
+ feature: Feature,
+ iconColor: Color,
+ icon: ImageVector? = null
+) {
+ Row(
+ Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(icon ?: feature.iconFilled, feature.text, Modifier.size(18.dp), tint = iconColor)
+ Text(
+ chatEventText(chatItem),
+ Modifier,
+ // this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp
+ fontSize = 12.sp
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIEventView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIEventView.kt
new file mode 100644
index 0000000000..5b37c402b7
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIEventView.kt
@@ -0,0 +1,65 @@
+package chat.simplex.app.views.chat.item
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.*
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.model.ChatItem
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleXTheme
+
+@Composable
+fun CIEventView(ci: ChatItem) {
+ @Composable
+ fun chatEventTextView(text: AnnotatedString) {
+ Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
+ }
+
+ Surface {
+ Row(
+ Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val memberDisplayName = ci.memberDisplayName
+ if (memberDisplayName != null) {
+ chatEventTextView(
+ buildAnnotatedString {
+ withStyle(chatEventStyle) { append(memberDisplayName) }
+ append(" ")
+ }.plus(chatEventText(ci))
+ )
+ } else {
+ chatEventTextView(chatEventText(ci))
+ }
+ }
+ }
+}
+
+val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
+
+fun chatEventText(ci: ChatItem): AnnotatedString =
+ buildAnnotatedString {
+ withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
+ }
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ name = "Dark Mode"
+)
+@Composable
+fun CIEventViewPreview() {
+ SimpleXTheme {
+ CIEventView(
+ ChatItem.getGroupEventSample()
+ )
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFeaturePreferenceView.kt
similarity index 85%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFeaturePreferenceView.kt
index 1a23dfc49a..bb3f400eab 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFeaturePreferenceView.kt
@@ -1,4 +1,4 @@
-package chat.simplex.common.views.chat.item
+package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -9,9 +9,10 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import chat.simplex.common.views.helpers.generalGetString
-import chat.simplex.common.model.*
-import chat.simplex.res.MR
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.views.helpers.generalGetString
@Composable
fun CIFeaturePreferenceView(
@@ -26,11 +27,11 @@ fun CIFeaturePreferenceView(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
- Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = MaterialTheme.colors.secondary)
+ Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = HighOrLowlight)
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
- val acceptTextId = if (setParam) MR.strings.accept_feature_set_1_day else MR.strings.accept_feature
+ val acceptTextId = if (setParam) R.string.accept_feature_set_1_day else R.string.accept_feature
val param = if (setParam) 86400 else null
val annotatedText = buildAnnotatedString {
withStyle(chatEventStyle) { append(chatItem.content.text + " ") }
@@ -47,7 +48,7 @@ fun CIFeaturePreferenceView(
)
} else {
Text(chatItem.content.text + " " + chatItem.timestampText,
- fontSize = 12.sp, fontWeight = FontWeight.Light, color = MaterialTheme.colors.secondary)
+ fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
}
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt
new file mode 100644
index 0000000000..b9e3bbc62d
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt
@@ -0,0 +1,210 @@
+package chat.simplex.app.views.chat.item
+
+import android.widget.Toast
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.InsertDriveFile
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.*
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+import kotlinx.datetime.Clock
+
+@Composable
+fun CIFileView(
+ file: CIFile?,
+ edited: Boolean,
+ receiveFile: (Long) -> Unit
+) {
+ val context = LocalContext.current
+ val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = file)
+
+ @Composable
+ fun fileIcon(
+ innerIcon: ImageVector? = null,
+ color: Color = if (isInDarkTheme()) FileDark else FileLight
+ ) {
+ Box(
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Filled.InsertDriveFile,
+ stringResource(R.string.icon_descr_file),
+ Modifier.fillMaxSize(),
+ tint = color
+ )
+ if (innerIcon != null) {
+ Icon(
+ innerIcon,
+ stringResource(R.string.icon_descr_file),
+ Modifier
+ .size(32.dp)
+ .padding(top = 12.dp),
+ tint = Color.White
+ )
+ }
+ }
+ }
+
+ fun fileSizeValid(): Boolean {
+ if (file != null) {
+ return file.fileSize <= MAX_FILE_SIZE
+ }
+ return false
+ }
+
+ fun fileAction() {
+ if (file != null) {
+ when (file.fileStatus) {
+ CIFileStatus.RcvInvitation -> {
+ if (fileSizeValid()) {
+ receiveFile(file.fileId)
+ } else {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.large_file),
+ String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
+ )
+ }
+ }
+ CIFileStatus.RcvAccepted ->
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.waiting_for_file),
+ String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
+ )
+ CIFileStatus.RcvComplete -> {
+ val filePath = getLoadedFilePath(context, file)
+ if (filePath != null) {
+ saveFileLauncher.launch(file.fileName)
+ } else {
+ Toast.makeText(context, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
+ }
+ }
+ else -> {}
+ }
+ }
+ }
+
+ @Composable
+ fun progressIndicator() {
+ CircularProgressIndicator(
+ Modifier.size(32.dp),
+ color = if (isInDarkTheme()) FileDark else FileLight,
+ strokeWidth = 4.dp
+ )
+ }
+
+ @Composable
+ fun fileIndicator() {
+ Box(
+ Modifier
+ .size(42.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .clickable(onClick = { fileAction() }),
+ contentAlignment = Alignment.Center
+ ) {
+ if (file != null) {
+ when (file.fileStatus) {
+ CIFileStatus.SndStored -> fileIcon()
+ CIFileStatus.SndTransfer -> progressIndicator()
+ CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
+ CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
+ CIFileStatus.RcvInvitation ->
+ if (fileSizeValid())
+ fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary)
+ else
+ fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
+ CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
+ CIFileStatus.RcvTransfer -> progressIndicator()
+ CIFileStatus.RcvComplete -> fileIcon()
+ CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
+ }
+ } else {
+ fileIcon()
+ }
+ }
+ }
+
+ Row(
+ Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp),
+ verticalAlignment = Alignment.Bottom,
+ horizontalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ fileIndicator()
+ val metaReserve = if (edited)
+ " "
+ else
+ " "
+ if (file != null) {
+ Column(
+ horizontalAlignment = Alignment.Start
+ ) {
+ Text(
+ file.fileName,
+ maxLines = 1
+ )
+ Text(
+ formatBytes(file.fileSize) + metaReserve,
+ color = HighOrLowlight,
+ fontSize = 14.sp,
+ maxLines = 1
+ )
+ }
+ } else {
+ Text(metaReserve)
+ }
+ }
+}
+
+class ChatItemProvider: PreviewParameterProvider {
+ private val sentFile = ChatItem(
+ chatDir = CIDirection.DirectSnd(),
+ meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemEdited = true),
+ content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
+ quotedItem = null,
+ file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
+ )
+ private val fileChatItemWtFile = ChatItem(
+ chatDir = CIDirection.DirectRcv(),
+ meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), ),
+ content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
+ quotedItem = null,
+ file = null
+ )
+ override val values = listOf(
+ sentFile,
+ ChatItem.getFileMsgContentSample(),
+ ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
+ ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
+ ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
+ ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
+ ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
+ ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
+ ChatItem.getFileMsgContentSample(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus = CIFileStatus.RcvInvitation),
+ fileChatItemWtFile
+ ).asSequence()
+}
+
+@Preview
+@Composable
+fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
+ val showMenu = remember { mutableStateOf(false) }
+ SimpleXTheme {
+ FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt
new file mode 100644
index 0000000000..3bdd453b41
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt
@@ -0,0 +1,167 @@
+package chat.simplex.app.views.chat.item
+
+import android.content.res.Configuration
+import android.util.Log
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.SupervisedUserCircle
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.*
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+
+@Composable
+fun CIGroupInvitationView(
+ ci: ChatItem,
+ groupInvitation: CIGroupInvitation,
+ memberRole: GroupMemberRole,
+ chatIncognito: Boolean = false,
+ joinGroup: (Long) -> Unit
+) {
+ val sent = ci.chatDir.sent
+ val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending
+
+ @Composable
+ fun groupInfoView() {
+ val p = groupInvitation.groupProfile
+ val iconColor =
+ if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary
+ else if (isInDarkTheme()) FileDark else FileLight
+
+ Row(
+ Modifier
+ .defaultMinSize(minWidth = 220.dp)
+ .padding(vertical = 4.dp)
+ .padding(end = 2.dp)
+ ) {
+ ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = Icons.Filled.SupervisedUserCircle, color = iconColor)
+ Spacer(Modifier.padding(horizontal = 3.dp))
+ Column(
+ Modifier.defaultMinSize(minHeight = 60.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(p.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ if (p.fullName != "" && p.displayName != p.fullName) {
+ Text(p.fullName, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun groupInvitationText() {
+ when {
+ sent -> Text(stringResource(R.string.you_sent_group_invitation))
+ !sent && groupInvitation.status == CIGroupInvitationStatus.Pending -> Text(stringResource(R.string.you_are_invited_to_group))
+ !sent && groupInvitation.status == CIGroupInvitationStatus.Accepted -> Text(stringResource(R.string.you_joined_this_group))
+ !sent && groupInvitation.status == CIGroupInvitationStatus.Rejected -> Text(stringResource(R.string.you_rejected_group_invitation))
+ !sent && groupInvitation.status == CIGroupInvitationStatus.Expired -> Text(stringResource(R.string.group_invitation_expired))
+ }
+ }
+
+ Surface(
+ modifier = if (action) Modifier.clickable(onClick = {
+ joinGroup(groupInvitation.groupId)
+ }) else Modifier,
+ shape = RoundedCornerShape(18.dp),
+ color = if (sent) SentColorLight else ReceivedColorLight,
+ ) {
+ Box(
+ Modifier
+ .width(IntrinsicSize.Min)
+ .padding(vertical = 3.dp)
+ .padding(start = 8.dp, end = 12.dp),
+ contentAlignment = Alignment.BottomEnd
+ ) {
+ Column(
+ Modifier
+ .defaultMinSize(minWidth = 220.dp)
+ .padding(bottom = 4.dp),
+ horizontalAlignment = Alignment.Start
+ ) {
+ groupInfoView()
+ Column(Modifier.padding(top = 2.dp, start = 5.dp)) {
+ Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp))
+ if (action) {
+ groupInvitationText()
+ Text(stringResource(
+ if (chatIncognito) R.string.group_invitation_tap_to_join_incognito else R.string.group_invitation_tap_to_join),
+ color = if (chatIncognito) Indigo else MaterialTheme.colors.primary)
+ } else {
+ Box(Modifier.padding(end = 48.dp)) {
+ groupInvitationText()
+ }
+ }
+ }
+ }
+ Text(
+ ci.timestampText,
+ color = HighOrLowlight,
+ fontSize = 14.sp,
+ modifier = Modifier.padding(start = 3.dp)
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ name = "Dark Mode"
+)
+@Composable
+fun PendingCIGroupInvitationViewPreview() {
+ SimpleXTheme {
+ CIGroupInvitationView(
+ ci = ChatItem.getGroupInvitationSample(),
+ groupInvitation = CIGroupInvitation.getSample(),
+ memberRole = GroupMemberRole.Admin,
+ joinGroup = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ name = "Dark Mode"
+)
+@Composable
+fun CIGroupInvitationViewAcceptedPreview() {
+ SimpleXTheme {
+ CIGroupInvitationView(
+ ci = ChatItem.getGroupInvitationSample(),
+ groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted),
+ memberRole = GroupMemberRole.Admin,
+ joinGroup = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun CIGroupInvitationViewLongNamePreview() {
+ SimpleXTheme {
+ CIGroupInvitationView(
+ ci = ChatItem.getGroupInvitationSample(),
+ groupInvitation = CIGroupInvitation.getSample(
+ groupProfile = GroupProfile("group_with_a_really_really_really_long_name", "Group With A Really Really Really Long Name"),
+ status = CIGroupInvitationStatus.Accepted
+ ),
+ memberRole = GroupMemberRole.Admin,
+ joinGroup = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt
new file mode 100644
index 0000000000..644bacfe6a
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt
@@ -0,0 +1,210 @@
+package chat.simplex.app.views.chat.item
+
+import android.graphics.Bitmap
+import android.os.Build.VERSION.SDK_INT
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Icon
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.outlined.ArrowDownward
+import androidx.compose.material.icons.outlined.MoreHoriz
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.platform.*
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.core.content.FileProvider
+import chat.simplex.app.*
+import chat.simplex.app.R
+import chat.simplex.app.model.CIFile
+import chat.simplex.app.model.CIFileStatus
+import chat.simplex.app.views.helpers.*
+import coil.ImageLoader
+import coil.compose.rememberAsyncImagePainter
+import coil.decode.GifDecoder
+import coil.decode.ImageDecoderDecoder
+import coil.request.ImageRequest
+import java.io.File
+
+@Composable
+fun CIImageView(
+ image: String,
+ file: CIFile?,
+ imageProvider: () -> ImageGalleryProvider,
+ showMenu: MutableState,
+ receiveFile: (Long) -> Unit
+) {
+ @Composable
+ fun loadingIndicator() {
+ if (file != null) {
+ Box(
+ Modifier
+ .padding(8.dp)
+ .size(20.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ when (file.fileStatus) {
+ CIFileStatus.SndTransfer ->
+ CircularProgressIndicator(
+ Modifier.size(16.dp),
+ color = Color.White,
+ strokeWidth = 2.dp
+ )
+ CIFileStatus.SndComplete ->
+ Icon(
+ Icons.Filled.Check,
+ stringResource(R.string.icon_descr_image_snd_complete),
+ Modifier.fillMaxSize(),
+ tint = Color.White
+ )
+ CIFileStatus.RcvAccepted ->
+ Icon(
+ Icons.Outlined.MoreHoriz,
+ stringResource(R.string.icon_descr_waiting_for_image),
+ Modifier.fillMaxSize(),
+ tint = Color.White
+ )
+ CIFileStatus.RcvTransfer ->
+ CircularProgressIndicator(
+ Modifier.size(16.dp),
+ color = Color.White,
+ strokeWidth = 2.dp
+ )
+ CIFileStatus.RcvInvitation ->
+ Icon(
+ Icons.Outlined.ArrowDownward,
+ stringResource(R.string.icon_descr_asked_to_receive),
+ Modifier.fillMaxSize(),
+ tint = Color.White
+ )
+ else -> {}
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun imageViewFullWidth(): Dp {
+ val approximatePadding = 100.dp
+ return with(LocalDensity.current) { minOf(1000.dp, LocalView.current.width.toDp() - approximatePadding) }
+ }
+
+ @Composable
+ fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
+ Image(
+ imageBitmap.asImageBitmap(),
+ contentDescription = stringResource(R.string.image_descr),
+ // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
+ // if text is short and take all available width if text is long
+ modifier = Modifier
+ .width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else 1000.dp)
+ .combinedClickable(
+ onLongClick = { showMenu.value = true },
+ onClick = onClick
+ ),
+ contentScale = ContentScale.FillWidth,
+ )
+ }
+
+ @Composable
+ fun imageView(painter: Painter, onClick: () -> Unit) {
+ Image(
+ painter,
+ contentDescription = stringResource(R.string.image_descr),
+ // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
+ // if text is short and take all available width if text is long
+ modifier = Modifier
+ .width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else 1000.dp)
+ .combinedClickable(
+ onLongClick = { showMenu.value = true },
+ onClick = onClick
+ ),
+ contentScale = ContentScale.FillWidth,
+ )
+ }
+
+ fun fileSizeValid(): Boolean {
+ if (file != null) {
+ return file.fileSize <= MAX_FILE_SIZE
+ }
+ return false
+ }
+
+ fun imageAndFilePath(file: CIFile?): Pair {
+ val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
+ val filePath = getLoadedFilePath(SimplexApp.context, file)
+ return imageBitmap to filePath
+ }
+
+ Box(
+ Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
+ contentAlignment = Alignment.TopEnd
+ ) {
+ val context = LocalContext.current
+ val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) }
+ if (imageBitmap != null && filePath != null) {
+ val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
+ val imagePainter = rememberAsyncImagePainter(
+ ImageRequest.Builder(context).data(data = uri).size(coil.size.Size.ORIGINAL).build(),
+ placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
+ imageLoader = imageLoader
+ )
+ val view = LocalView.current
+ imageView(imagePainter, onClick = {
+ hideKeyboard(view)
+ if (getLoadedFilePath(context, file) != null) {
+ ModalManager.shared.showCustomModal(animated = false) { close ->
+ ImageFullScreenView(imageProvider, close)
+ }
+ }
+ })
+ } else {
+ imageView(base64ToBitmap(image), onClick = {
+ if (file != null) {
+ when (file.fileStatus) {
+ CIFileStatus.RcvInvitation ->
+ if (fileSizeValid()) {
+ receiveFile(file.fileId)
+ } else {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.large_file),
+ String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
+ )
+ }
+ CIFileStatus.RcvAccepted ->
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.waiting_for_image),
+ generalGetString(R.string.image_will_be_received_when_contact_is_online)
+ )
+ CIFileStatus.RcvTransfer -> {} // ?
+ CIFileStatus.RcvComplete -> {} // ?
+ CIFileStatus.RcvCancelled -> {} // TODO
+ else -> {}
+ }
+ }
+ })
+ }
+ loadingIndicator()
+ }
+}
+
+private val imageLoader = ImageLoader.Builder(SimplexApp.context)
+ .components {
+ if (SDK_INT >= 28) {
+ add(ImageDecoderDecoder.Factory())
+ } else {
+ add(GifDecoder.Factory())
+ }
+ }
+ .build()
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIInvalidJSONView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIInvalidJSONView.kt
new file mode 100644
index 0000000000..51a6d92bce
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIInvalidJSONView.kt
@@ -0,0 +1,46 @@
+package chat.simplex.app.views.chat.item
+
+import SectionSpacer
+import SectionView
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Share
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.ui.theme.DEFAULT_PADDING
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.usersettings.SettingsActionItem
+
+@Composable
+fun CIInvalidJSONView(json: String) {
+ Row(Modifier
+ .clickable { ModalManager.shared.showModal(true) { InvalidJSONView(json) } }
+ .padding(horizontal = 10.dp, vertical = 6.dp)
+ ) {
+ Text(stringResource(R.string.invalid_data), color = Color.Red, fontStyle = FontStyle.Italic)
+ }
+}
+
+@Composable
+fun InvalidJSONView(json: String) {
+ Column {
+ Spacer(Modifier.height(DEFAULT_PADDING))
+ SectionView {
+ val context = LocalContext.current
+ SettingsActionItem(Icons.Outlined.Share, generalGetString(R.string.share_verb), click = {
+ shareText(context, json)
+ })
+ }
+ Column(Modifier.padding(DEFAULT_PADDING).fillMaxWidth().verticalScroll(rememberScrollState())) {
+ Text(json)
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
new file mode 100644
index 0000000000..67b4f54a4d
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
@@ -0,0 +1,190 @@
+package chat.simplex.app.views.chat.item
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Circle
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material.icons.outlined.Timer
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.HighOrLowlight
+import kotlinx.datetime.Clock
+
+@Composable
+fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = HighOrLowlight) {
+ Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
+ if (chatItem.isDeletedContent) {
+ Text(
+ chatItem.timestampText,
+ color = metaColor,
+ fontSize = 14.sp,
+ modifier = Modifier.padding(start = 3.dp)
+ )
+ } else {
+ CIMetaText(chatItem.meta, timedMessagesTTL, metaColor)
+ }
+ }
+}
+
+@Composable
+// changing this function requires updating reserveSpaceForMeta
+private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
+ if (meta.itemEdited) {
+ StatusIconText(Icons.Outlined.Edit, color)
+ Spacer(Modifier.width(3.dp))
+ }
+ if (meta.disappearing) {
+ StatusIconText(Icons.Outlined.Timer, color)
+ val ttl = meta.itemTimed?.ttl
+ if (ttl != chatTTL) {
+ Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
+ }
+ Spacer(Modifier.width(4.dp))
+ }
+ val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
+ if (statusIcon != null) {
+ val (icon, statusColor) = statusIcon
+ StatusIconText(icon, statusColor)
+ Spacer(Modifier.width(4.dp))
+ } else if (!meta.disappearing) {
+ StatusIconText(Icons.Filled.Circle, Color.Transparent)
+ Spacer(Modifier.width(4.dp))
+ }
+ Text(meta.timestampText, color = color, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
+}
+
+// the conditions in this function should match CIMetaText
+fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
+ val iconSpace = " "
+ var res = ""
+ if (meta.itemEdited) res += iconSpace
+ if (meta.itemTimed != null) {
+ res += iconSpace
+ val ttl = meta.itemTimed.ttl
+ if (ttl != chatTTL) {
+ res += TimedMessagesPreference.shortTtlText(ttl)
+ }
+ }
+ if (meta.statusIcon(HighOrLowlight) != null || !meta.disappearing) {
+ res += iconSpace
+ }
+ return res + meta.timestampText
+}
+
+@Composable
+private fun StatusIconText(icon: ImageVector, color: Color) {
+ Icon(icon, null, Modifier.height(12.dp), tint = color)
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaView() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
+ ),
+ null
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewUnread() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
+ status = CIStatus.RcvNew()
+ ),
+ null
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewSendFailed() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
+ status = CIStatus.SndError("CMD SYNTAX")
+ ),
+ null
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewSendNoAuth() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
+ ),
+ null
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewSendSent() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
+ ),
+ null
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewEdited() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
+ itemEdited = true
+ ),
+ null
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewEditedUnread() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
+ itemEdited = true,
+ status=CIStatus.RcvNew()
+ ),
+ null
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewEditedSent() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
+ itemEdited = true,
+ status=CIStatus.SndSent()
+ ),
+ null
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewDeletedContent() {
+ CIMetaView(
+ chatItem = ChatItem.getDeletedContentSampleData(),
+ null
+ )
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt
new file mode 100644
index 0000000000..a42acadea0
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt
@@ -0,0 +1,260 @@
+package chat.simplex.app.views.chat.item
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.*
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.*
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+
+// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
+
+@Composable
+fun CIVoiceView(
+ providedDurationSec: Int,
+ file: CIFile?,
+ edited: Boolean,
+ sent: Boolean,
+ hasText: Boolean,
+ ci: ChatItem,
+ timedMessagesTTL: Int?,
+ longClick: () -> Unit,
+) {
+ Row(
+ Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = 6.dp, end = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (file != null) {
+ val context = LocalContext.current
+ val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
+ var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
+ val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
+ val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
+ val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
+ val play = {
+ AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
+ brokenAudio = !audioPlaying.value
+ }
+ val pause = {
+ AudioPlayer.pause(audioPlaying, progress)
+ }
+ val text = remember {
+ derivedStateOf {
+ val time = when {
+ audioPlaying.value || progress.value != 0 -> progress.value
+ else -> duration.value
+ }
+ durationText(time / 1000)
+ }
+ }
+ VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick)
+ } else {
+ VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
+ val metaReserve = if (edited)
+ " "
+ else
+ " "
+ Text(metaReserve)
+ }
+ }
+}
+
+@Composable
+private fun VoiceLayout(
+ file: CIFile,
+ ci: ChatItem,
+ text: State,
+ audioPlaying: State,
+ progress: State,
+ duration: State,
+ brokenAudio: Boolean,
+ sent: Boolean,
+ hasText: Boolean,
+ timedMessagesTTL: Int?,
+ play: () -> Unit,
+ pause: () -> Unit,
+ longClick: () -> Unit
+) {
+ when {
+ hasText -> {
+ Spacer(Modifier.width(6.dp))
+ VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
+ DurationText(text, PaddingValues(start = 12.dp))
+ }
+ sent -> {
+ Row {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Spacer(Modifier.height(56.dp))
+ DurationText(text, PaddingValues(end = 12.dp))
+ }
+ Column {
+ VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
+ Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
+ CIMetaView(ci, timedMessagesTTL)
+ }
+ }
+ }
+ }
+ else -> {
+ Row {
+ Column {
+ VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
+ Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
+ CIMetaView(ci, timedMessagesTTL)
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ DurationText(text, PaddingValues(start = 12.dp))
+ Spacer(Modifier.height(56.dp))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DurationText(text: State, padding: PaddingValues) {
+ val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
+ Text(
+ text.value,
+ Modifier
+ .padding(padding)
+ .widthIn(min = minWidth),
+ color = HighOrLowlight,
+ fontSize = 16.sp,
+ maxLines = 1
+ )
+}
+
+@Composable
+private fun PlayPauseButton(
+ audioPlaying: Boolean,
+ sent: Boolean,
+ angle: Float,
+ strokeWidth: Float,
+ strokeColor: Color,
+ enabled: Boolean,
+ error: Boolean,
+ play: () -> Unit,
+ pause: () -> Unit,
+ longClick: () -> Unit
+) {
+ Surface(
+ Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
+ color = if (sent) SentColorLight else ReceivedColorLight,
+ shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
+ ) {
+ Box(
+ Modifier
+ .defaultMinSize(minWidth = 56.dp, minHeight = 56.dp)
+ .combinedClickable(
+ onClick = { if (!audioPlaying) play() else pause() },
+ onLongClick = longClick
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+ contentDescription = null,
+ Modifier.size(36.dp),
+ tint = if (error) WarningOrange else if (!enabled) HighOrLowlight else MaterialTheme.colors.primary
+ )
+ }
+ }
+}
+
+@Composable
+private fun VoiceMsgIndicator(
+ file: CIFile?,
+ audioPlaying: Boolean,
+ sent: Boolean,
+ hasText: Boolean,
+ progress: State?,
+ duration: State?,
+ error: Boolean,
+ play: () -> Unit,
+ pause: () -> Unit,
+ longClick: () -> Unit
+) {
+ val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
+ val strokeColor = MaterialTheme.colors.primary
+ if (file != null && file.loaded && progress != null && duration != null) {
+ val angle = 360f * (progress.value.toDouble() / duration.value).toFloat()
+ if (hasText) {
+ IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) {
+ Icon(
+ imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+ contentDescription = null,
+ Modifier.size(36.dp),
+ tint = MaterialTheme.colors.primary
+ )
+ }
+ } else {
+ PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
+ }
+ } else {
+ if (file?.fileStatus == CIFileStatus.RcvInvitation
+ || file?.fileStatus == CIFileStatus.RcvTransfer
+ || file?.fileStatus == CIFileStatus.RcvAccepted
+ ) {
+ Box(
+ Modifier
+ .size(56.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ ProgressIndicator()
+ }
+ } else {
+ PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {}, longClick)
+ }
+ }
+}
+
+private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
+ val brush = Brush.linearGradient(
+ 0f to Color.Transparent,
+ 0f to color,
+ start = Offset(0f, 0f),
+ end = Offset(strokeWidth, strokeWidth),
+ tileMode = TileMode.Clamp
+ )
+ onDrawWithContent {
+ drawContent()
+ drawArc(
+ brush = brush,
+ startAngle = -90f,
+ sweepAngle = angle,
+ useCenter = false,
+ topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
+ size = Size(size.width - strokeWidth, size.height - strokeWidth),
+ style = Stroke(width = strokeWidth, cap = StrokeCap.Square)
+ )
+ }
+}
+
+@Composable
+private fun ProgressIndicator() {
+ CircularProgressIndicator(
+ Modifier.size(32.dp),
+ color = if (isInDarkTheme()) FileDark else FileLight,
+ strokeWidth = 4.dp
+ )
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
new file mode 100644
index 0000000000..dee77e4a4d
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
@@ -0,0 +1,402 @@
+package chat.simplex.app.views.chat.item
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.*
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.*
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.chat.ComposeContextItem
+import chat.simplex.app.views.chat.ComposeState
+import chat.simplex.app.views.helpers.*
+import kotlinx.datetime.Clock
+
+// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
+
+@Composable
+fun ChatItemView(
+ cInfo: ChatInfo,
+ cItem: ChatItem,
+ composeState: MutableState,
+ imageProvider: (() -> ImageGalleryProvider)? = null,
+ showMember: Boolean = false,
+ useLinkPreviews: Boolean,
+ linkMode: SimplexLinkMode,
+ deleteMessage: (Long, CIDeleteMode) -> Unit,
+ receiveFile: (Long) -> Unit,
+ joinGroup: (Long) -> Unit,
+ acceptCall: (Contact) -> Unit,
+ scrollToItem: (Long) -> Unit,
+ acceptFeature: (Contact, ChatFeature, Int?) -> Unit
+) {
+ val context = LocalContext.current
+ val uriHandler = LocalUriHandler.current
+ val sent = cItem.chatDir.sent
+ val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
+ val showMenu = remember { mutableStateOf(false) }
+ val revealed = remember { mutableStateOf(false) }
+ val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
+ val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
+ val onLinkLongClick = { _: String -> showMenu.value = true }
+ val live = composeState.value.liveMessage != null
+
+ Box(
+ modifier = Modifier
+ .padding(bottom = 4.dp)
+ .fillMaxWidth(),
+ contentAlignment = alignment,
+ ) {
+ val onClick = {
+ when (cItem.meta.itemStatus) {
+ is CIStatus.SndErrorAuth -> {
+ showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
+ }
+ is CIStatus.SndError -> {
+ showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
+ }
+ else -> {}
+ }
+ }
+ Column(
+ Modifier
+ .clip(RoundedCornerShape(18.dp))
+ .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
+ ) {
+ @Composable
+ fun framedItemView() {
+ FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
+ }
+
+ fun deleteMessageQuestionText(): String {
+ return if (fullDeleteAllowed) {
+ generalGetString(R.string.delete_message_cannot_be_undone_warning)
+ } else {
+ generalGetString(R.string.delete_message_mark_deleted_warning)
+ }
+ }
+
+ fun moderateMessageQuestionText(): String {
+ return if (fullDeleteAllowed) {
+ generalGetString(R.string.moderate_message_will_be_deleted_warning)
+ } else {
+ generalGetString(R.string.moderate_message_will_be_marked_warning)
+ }
+ }
+
+ @Composable
+ fun MsgContentItemDropdownMenu() {
+ DropdownMenu(
+ expanded = showMenu.value,
+ onDismissRequest = { showMenu.value = false },
+ Modifier.width(220.dp)
+ ) {
+ if (cItem.meta.itemDeleted == null && !live) {
+ ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
+ if (composeState.value.editing) {
+ composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
+ } else {
+ composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
+ }
+ showMenu.value = false
+ })
+ }
+ ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
+ val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
+ when {
+ filePath != null -> shareFile(context, cItem.text, filePath)
+ else -> shareText(context, cItem.content.text)
+ }
+ showMenu.value = false
+ })
+ ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
+ copyText(context, cItem.content.text)
+ showMenu.value = false
+ })
+ if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
+ val filePath = getLoadedFilePath(context, cItem.file)
+ if (filePath != null) {
+ ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
+ when (cItem.content.msgContent) {
+ is MsgContent.MCImage -> saveImage(context, cItem.file)
+ is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
+ is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
+ else -> {}
+ }
+ showMenu.value = false
+ })
+ }
+ }
+ if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
+ ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
+ composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
+ showMenu.value = false
+ })
+ }
+ if (cItem.meta.itemDeleted != null && revealed.value) {
+ ItemAction(
+ stringResource(R.string.hide_verb),
+ Icons.Outlined.VisibilityOff,
+ onClick = {
+ revealed.value = false
+ showMenu.value = false
+ }
+ )
+ }
+ if (!(live && cItem.meta.isLive)) {
+ DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
+ }
+ val groupInfo = cItem.memberToModerate(cInfo)?.first
+ if (groupInfo != null) {
+ ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
+ }
+ }
+ }
+
+ @Composable
+ fun MarkedDeletedItemDropdownMenu() {
+ DropdownMenu(
+ expanded = showMenu.value,
+ onDismissRequest = { showMenu.value = false },
+ Modifier.width(220.dp)
+ ) {
+ if (!cItem.isDeletedContent) {
+ ItemAction(
+ stringResource(R.string.reveal_verb),
+ Icons.Outlined.Visibility,
+ onClick = {
+ revealed.value = true
+ showMenu.value = false
+ }
+ )
+ }
+ DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
+ }
+ }
+
+ @Composable
+ fun ContentItem() {
+ val mc = cItem.content.msgContent
+ if (cItem.meta.itemDeleted != null && !revealed.value) {
+ MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
+ MarkedDeletedItemDropdownMenu()
+ } else if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
+ if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
+ EmojiItemView(cItem, cInfo.timedMessagesTTL)
+ MsgContentItemDropdownMenu()
+ } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
+ CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
+ MsgContentItemDropdownMenu()
+ } else {
+ framedItemView()
+ MsgContentItemDropdownMenu()
+ }
+ } else {
+ framedItemView()
+ MsgContentItemDropdownMenu()
+ }
+ }
+
+ @Composable fun DeletedItem() {
+ DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
+ DropdownMenu(
+ expanded = showMenu.value,
+ onDismissRequest = { showMenu.value = false },
+ Modifier.width(220.dp)
+ ) {
+ DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
+ }
+ }
+
+ @Composable fun CallItem(status: CICallStatus, duration: Int) {
+ CICallItemView(cInfo, cItem, status, duration, acceptCall)
+ }
+
+ when (val c = cItem.content) {
+ is CIContent.SndMsgContent -> ContentItem()
+ is CIContent.RcvMsgContent -> ContentItem()
+ is CIContent.SndDeleted -> DeletedItem()
+ is CIContent.RcvDeleted -> DeletedItem()
+ is CIContent.SndCall -> CallItem(c.status, c.duration)
+ is CIContent.RcvCall -> CallItem(c.status, c.duration)
+ is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
+ is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
+ is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
+ is CIContent.RcvGroupEventContent -> CIEventView(cItem)
+ is CIContent.SndGroupEventContent -> CIEventView(cItem)
+ is CIContent.RcvConnEventContent -> CIEventView(cItem)
+ is CIContent.SndConnEventContent -> CIEventView(cItem)
+ is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
+ is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
+ is CIContent.RcvChatPreference -> {
+ val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
+ CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
+ }
+ is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, HighOrLowlight, icon = c.feature.icon,)
+ is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
+ is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
+ is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
+ is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
+ is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
+ is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
+ is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
+ }
+ }
+ }
+}
+
+@Composable
+fun DeleteItemAction(
+ cItem: ChatItem,
+ showMenu: MutableState,
+ questionText: String,
+ deleteMessage: (Long, CIDeleteMode) -> Unit
+) {
+ ItemAction(
+ stringResource(R.string.delete_verb),
+ Icons.Outlined.Delete,
+ onClick = {
+ showMenu.value = false
+ deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
+ },
+ color = Color.Red
+ )
+}
+
+@Composable
+fun ModerateItemAction(
+ cItem: ChatItem,
+ questionText: String,
+ showMenu: MutableState,
+ deleteMessage: (Long, CIDeleteMode) -> Unit
+) {
+ ItemAction(
+ stringResource(R.string.moderate_verb),
+ Icons.Outlined.Flag,
+ onClick = {
+ showMenu.value = false
+ moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
+ },
+ color = Color.Red
+ )
+}
+
+@Composable
+fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
+ DropdownMenuItem(onClick) {
+ Row {
+ Text(
+ text,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1F)
+ .padding(end = 15.dp),
+ color = color
+ )
+ Icon(icon, text, tint = color)
+ }
+ }
+}
+
+fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
+ AlertManager.shared.showAlertDialogButtons(
+ title = generalGetString(R.string.delete_message__question),
+ text = questionText,
+ buttons = {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(onClick = {
+ deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
+ AlertManager.shared.hideAlert()
+ }) { Text(stringResource(R.string.for_me_only)) }
+ if (chatItem.meta.editable) {
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ TextButton(onClick = {
+ deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
+ AlertManager.shared.hideAlert()
+ }) { Text(stringResource(R.string.for_everybody)) }
+ }
+ }
+ }
+ )
+}
+
+fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.delete_member_message__question),
+ text = questionText,
+ confirmText = generalGetString(R.string.delete_verb),
+ destructive = true,
+ onConfirm = {
+ deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
+ }
+ )
+}
+
+private fun showMsgDeliveryErrorAlert(description: String) {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.message_delivery_error_title),
+ text = description,
+ )
+}
+
+@Preview
+@Composable
+fun PreviewChatItemView() {
+ SimpleXTheme {
+ ChatItemView(
+ ChatInfo.Direct.sampleData,
+ ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
+ ),
+ useLinkPreviews = true,
+ linkMode = SimplexLinkMode.DESCRIPTION,
+ composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
+ deleteMessage = { _, _ -> },
+ receiveFile = {},
+ joinGroup = {},
+ acceptCall = { _ -> },
+ scrollToItem = {},
+ acceptFeature = { _, _, _ -> }
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PreviewChatItemViewDeletedContent() {
+ SimpleXTheme {
+ ChatItemView(
+ ChatInfo.Direct.sampleData,
+ ChatItem.getDeletedContentSampleData(),
+ useLinkPreviews = true,
+ linkMode = SimplexLinkMode.DESCRIPTION,
+ composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
+ deleteMessage = { _, _ -> },
+ receiveFile = {},
+ joinGroup = {},
+ acceptCall = { _ -> },
+ scrollToItem = {},
+ acceptFeature = { _, _, _ -> }
+ )
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt
similarity index 57%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt
index 17245c4e75..06499418b1 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt
@@ -1,29 +1,27 @@
-package chat.simplex.common.views.chat.item
+package chat.simplex.app.views.chat.item
+import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
-import androidx.compose.desktop.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import chat.simplex.common.model.ChatItem
-import chat.simplex.common.ui.theme.*
+import chat.simplex.app.model.ChatItem
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
-fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) {
+fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
val sent = ci.chatDir.sent
- val sentColor = MaterialTheme.appColors.sentMessage
- val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
shape = RoundedCornerShape(18.dp),
- color = if (sent) sentColor else receivedColor,
- contentColor = LocalContentColor.current
+ color = if (sent) SentColorLight else ReceivedColorLight,
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
@@ -31,28 +29,28 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean,
) {
Text(
buildAnnotatedString {
- withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) }
+ appendSender(this, if (showMember) ci.memberDisplayName else null, true)
+ withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(ci.content.text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
- CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
+ CIMetaView(ci, timedMessagesTTL)
}
}
}
-@Preview/*(
+@Preview(showBackground = true)
+@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
-)*/
+)
@Composable
fun PreviewDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getDeletedContentSampleData(),
- null,
- showViaProxy = false,
- showTimestamp = true
+ null
)
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt
similarity index 76%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt
index 7aca0466f9..86fffc6ae0 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt
@@ -1,4 +1,4 @@
-package chat.simplex.common.views.chat.item
+package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -9,27 +9,27 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import chat.simplex.common.model.ChatItem
-import chat.simplex.common.model.MREmojiChar
-import chat.simplex.common.ui.theme.EmojiFont
-import java.sql.Timestamp
+import chat.simplex.app.model.ChatItem
-val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont)
-val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiFont)
+val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
+val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
@Composable
-fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) {
+fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
Column(
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(chatItem.content.text)
- CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
+ CIMetaView(chatItem, timedMessagesTTL)
}
}
@Composable
-expect fun EmojiText(text: String)
+fun EmojiText(text: String) {
+ val s = text.trim()
+ Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
+}
// https://stackoverflow.com/a/46279500
private const val emojiStr = "^(" +
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
similarity index 63%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
index fd8a32af64..9400a1267e 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
@@ -1,29 +1,41 @@
-package chat.simplex.common.views.chat.item
+package chat.simplex.app.views.chat.item
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Flag
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.*
-import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.UriHandler
-import dev.icerock.moko.resources.compose.painterResource
-import dev.icerock.moko.resources.compose.stringResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.*
-import chat.simplex.common.model.*
-import chat.simplex.common.platform.*
-import chat.simplex.common.ui.theme.*
-import chat.simplex.common.views.helpers.*
-import chat.simplex.res.MR
-import kotlin.math.ceil
+import androidx.compose.ui.util.fastMap
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+import kotlinx.datetime.Clock
+import kotlin.math.min
+
+val SentColorLight = Color(0x1E45B8FF)
+val ReceivedColorLight = Color(0x20B1B0B5)
+val SentQuoteColorLight = Color(0x2545B8FF)
+val ReceivedQuoteColorLight = Color(0x25B1B0B5)
@Composable
fun FramedItemView(
@@ -31,15 +43,12 @@ fun FramedItemView(
ci: ChatItem,
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
+ showMember: Boolean = false,
linkMode: SimplexLinkMode,
- showViaProxy: Boolean,
showMenu: MutableState,
- showTimestamp: Boolean,
- tailVisible: Boolean = false,
receiveFile: (Long) -> Unit,
onLinkLongClick: (link: String) -> Unit = {},
scrollToItem: (Long) -> Unit = {},
- scrollToQuotedItemFromItem: (Long) -> Unit = {},
) {
val sent = ci.chatDir.sent
val chatTTL = chatInfo.timedMessagesTTL
@@ -48,72 +57,40 @@ fun FramedItemView(
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
}
- @Composable
- fun ciQuotedMsgTextView(qi: CIQuote, lines: Int, showTimestamp: Boolean) {
- MarkdownText(
- qi.text,
- qi.formattedText,
- toggleSecrets = true,
- maxLines = lines,
- overflow = TextOverflow.Ellipsis,
- style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
- linkMode = linkMode,
- uriHandler = if (appPlatform.isDesktop) uriHandler else null,
- showTimestamp = showTimestamp,
- )
- }
-
@Composable
fun ciQuotedMsgView(qi: CIQuote) {
Box(
- Modifier
- // this width limitation prevents crash on calculating constraints that may happen if you post veeeery long message and then quote it.
- // Top level layout wants `IntrinsicWidth.Max` and very long layout makes the crash in this case
- .widthIn(max = 50000.dp)
- .padding(vertical = 6.dp, horizontal = 12.dp),
+ Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopStart
) {
- val sender = qi.sender(membership())
- if (sender != null) {
- Column(
- horizontalAlignment = Alignment.Start
- ) {
- Text(
- sender,
- style = TextStyle(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary),
- maxLines = 1
- )
- ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp)
- }
- } else {
- ciQuotedMsgTextView(qi, lines = 3, showTimestamp = showTimestamp)
- }
+ MarkdownText(
+ qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
+ style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
+ linkMode = linkMode
+ )
}
}
@Composable
- fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) {
- val sentColor = MaterialTheme.appColors.sentQuote
- val receivedColor = MaterialTheme.appColors.receivedQuote
+ fun FramedItemHeader(caption: String, italic: Boolean, icon: ImageVector? = null) {
Row(
Modifier
- .background(if (sent) sentColor else receivedColor)
+ .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
.fillMaxWidth()
- .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp),
+ .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
- verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
icon,
caption,
Modifier.size(18.dp),
- tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight
+ tint = if (isInDarkTheme()) FileDark else FileLight
)
}
Text(
buildAnnotatedString {
- withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) {
+ withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = HighOrLowlight)) {
append(caption)
}
},
@@ -126,34 +103,24 @@ fun FramedItemView(
@Composable
fun ciQuoteView(qi: CIQuote) {
- val sentColor = MaterialTheme.appColors.sentQuote
- val receivedColor = MaterialTheme.appColors.receivedQuote
Row(
Modifier
- .background(if (sent) sentColor else receivedColor)
+ .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
.fillMaxWidth()
+ .combinedClickable(
+ onLongClick = { showMenu.value = true },
+ onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
+ )
) {
when (qi.content) {
is MsgContent.MCImage -> {
Box(Modifier.fillMaxWidth().weight(1f)) {
ciQuotedMsgView(qi)
}
- val imageBitmap = base64ToBitmap(qi.content.image)
+ val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
Image(
imageBitmap,
- contentDescription = stringResource(MR.strings.image_descr),
- contentScale = ContentScale.Crop,
- modifier = Modifier.size(68.dp).clipToBounds()
- )
- }
- is MsgContent.MCVideo -> {
- Box(Modifier.fillMaxWidth().weight(1f)) {
- ciQuotedMsgView(qi)
- }
- val imageBitmap = base64ToBitmap(qi.content.image)
- Image(
- imageBitmap,
- contentDescription = stringResource(MR.strings.video_descr),
+ contentDescription = stringResource(R.string.image_descr),
contentScale = ContentScale.Crop,
modifier = Modifier.size(68.dp).clipToBounds()
)
@@ -163,8 +130,8 @@ fun FramedItemView(
ciQuotedMsgView(qi)
}
Icon(
- if (qi.content is MsgContent.MCFile) painterResource(MR.images.ic_draft_filled) else painterResource(MR.images.ic_mic_filled),
- if (qi.content is MsgContent.MCFile) stringResource(MR.strings.icon_descr_file) else stringResource(MR.strings.voice_message),
+ if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.Mic,
+ if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
Modifier
.padding(top = 6.dp, end = 4.dp)
.size(22.dp),
@@ -178,98 +145,37 @@ fun FramedItemView(
@Composable
fun ciFileView(ci: ChatItem, text: String) {
- CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile)
+ CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (text != "" || ci.meta.isLive) {
- CIMarkdownText(ci, chatInfo, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
+ CIMarkdownText(ci, chatTTL, showMember, linkMode = linkMode, uriHandler)
}
}
- val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) &&
- !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null && ci.meta.itemForwarded == null
+ val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
- val sentColor = MaterialTheme.appColors.sentMessage
- val receivedColor = MaterialTheme.appColors.receivedMessage
Box(Modifier
- .clipChatItem(ci, tailVisible, revealed = true)
+ .clip(RoundedCornerShape(18.dp))
.background(
when {
transparentBackground -> Color.Transparent
- sent -> sentColor
- else -> receivedColor
+ sent -> SentColorLight
+ else -> ReceivedColorLight
}
)) {
- var metaColor = MaterialTheme.colors.secondary
+ var metaColor = HighOrLowlight
Box(contentAlignment = Alignment.BottomEnd) {
- val chatItemTail = remember { appPreferences.chatItemTail.state }
- val style = shapeStyle(ci, chatItemTail.value, tailVisible, revealed = true)
- val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
- Column(
- Modifier
- .width(IntrinsicSize.Max)
- .padding(start = if (!sent && tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp)
- ) {
+ Column(Modifier.width(IntrinsicSize.Max)) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
- @Composable
- fun Header() {
- if (ci.isReport) {
- if (ci.meta.itemDeleted == null) {
- FramedItemHeader(
- stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators),
- true,
- painterResource(MR.images.ic_flag),
- iconColor = Color.Red
- )
- } else {
- val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) {
- stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName)
- } else {
- stringResource(MR.strings.report_item_archived)
- }
- FramedItemHeader(text, true, painterResource(MR.images.ic_flag))
- }
- } else if (ci.meta.itemDeleted != null) {
- when (ci.meta.itemDeleted) {
- is CIDeleted.Moderated -> {
- FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
- }
- is CIDeleted.Blocked -> {
- FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand))
- }
- is CIDeleted.BlockedByAdmin -> {
- FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand))
- }
- is CIDeleted.Deleted -> {
- FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
- }
- }
- } else if (ci.meta.isLive) {
- FramedItemHeader(stringResource(MR.strings.live), false)
- }
- }
- if (ci.quotedItem != null) {
- Column(
- Modifier
- .combinedClickable(
- onLongClick = { showMenu.value = true },
- onClick = {
- if (ci.quotedItem.itemId != null) {
- scrollToItem(ci.quotedItem.itemId)
- } else {
- scrollToQuotedItemFromItem(ci.id)
- }
- }
- )
- .onRightClick { showMenu.value = true }
- ) {
- Header()
- ciQuoteView(ci.quotedItem)
- }
- } else {
- Header()
- if (ci.meta.itemForwarded != null) {
- FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true)
+ if (ci.meta.itemDeleted != null) {
+ if (ci.meta.itemDeleted is CIDeleted.Moderated) {
+ FramedItemHeader(String.format(stringResource(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, Icons.Outlined.Flag)
+ } else {
+ FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete)
}
+ } else if (ci.meta.isLive) {
+ FramedItemHeader(stringResource(R.string.live), false)
}
+ ci.quotedItem?.let { ciQuoteView(it) }
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
Column(
@@ -285,61 +191,37 @@ fun FramedItemView(
} else {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
- CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile)
+ CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
- CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
- }
- }
- is MsgContent.MCVideo -> {
- CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile)
- if (mc.text == "" && !ci.meta.isLive) {
- metaColor = Color.White
- } else {
- CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
+ CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
}
}
is MsgContent.MCVoice -> {
- CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile)
+ CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
if (mc.text != "") {
- CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
+ CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
}
}
is MsgContent.MCFile -> ciFileView(ci, mc.text)
is MsgContent.MCUnknown ->
if (ci.file == null) {
- CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
+ CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
} else {
ciFileView(ci, mc.text)
}
is MsgContent.MCLink -> {
- ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true })
- Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) {
- CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
- }
+ ChatItemLinkView(mc.preview)
+ CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
}
- is MsgContent.MCReport -> {
- val prefix = buildAnnotatedString {
- withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
- append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
- }
- }
- CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix)
- }
- else -> CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
+ else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
}
}
}
}
- Box(
- Modifier
- .padding(
- bottom = 6.dp,
- end = 12.dp + if (tailRendered && sent) msgTailWidthDp else 0.dp,
- )
- ) {
- CIMetaView(ci, chatTTL, metaColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
+ Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
+ CIMetaView(ci, chatTTL, metaColor)
}
}
}
@@ -348,43 +230,29 @@ fun FramedItemView(
@Composable
fun CIMarkdownText(
ci: ChatItem,
- chatInfo: ChatInfo,
chatTTL: Int?,
+ showMember: Boolean,
linkMode: SimplexLinkMode,
uriHandler: UriHandler?,
- onLinkLongClick: (link: String) -> Unit = {},
- showViaProxy: Boolean,
- showTimestamp: Boolean,
- prefix: AnnotatedString? = null
+ onLinkLongClick: (link: String) -> Unit = {}
) {
- Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) {
+ Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
MarkdownText(
- text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
+ text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null,
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
- mentions = ci.mentions, userMemberId = when {
- chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId
- else -> null
- },
- uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix
+ uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
)
}
}
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
-const val CHAT_BUBBLE_LAYOUT_ID = "chatBubble"
-const val CHAT_COMPOSE_LAYOUT_ID = "chatCompose"
-const val CONSOLE_COMPOSE_LAYOUT_ID = "consoleCompose"
-
/**
- * Compose shows "Can't represent a width of ... and height ... in Constraints" even when using built-in method for measuring max
- * available size. It seems like padding around such layout prevents showing them in parent layout when such child layouts are placed.
- * So calculating the expected padding here based on the values Compose printed in the exception (removing some pixels from
- * [Constraints.fitPrioritizingHeight] result makes it working well)
-*/
-private fun horizontalPaddingAroundCustomLayouts(density: Float): Int =
- // currently, it's 18. Doubling it just to cover possible changes in the future
- 36 * ceil(density).toInt()
+ * Equal to [androidx.compose.ui.unit.Constraints.MaxFocusMask], which is 0x3FFFF - 1
+ * Other values make a crash `java.lang.IllegalArgumentException: Can't represent a width of 123456 and height of 9909 in Constraints`
+ * See [androidx.compose.ui.unit.Constraints.createConstraints]
+ * */
+const val MAX_SAFE_WIDTH = 0x3FFFF - 1
@Composable
fun PriorityLayout(
@@ -392,6 +260,17 @@ fun PriorityLayout(
priorityLayoutId: String,
content: @Composable () -> Unit
) {
+ /**
+ * Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints]
+ * */
+ fun maxSafeHeight(width: Int) = when { // width bits + height bits should be <= 31
+ width < 0x1FFF /*MaxNonFocusMask*/ -> 0x3FFFF - 1 /* MaxFocusMask */ // 13 bits width + 18 bits height
+ width < 0x7FFF /*MinNonFocusMask*/ -> 0xFFFF - 1 /* MinFocusMask */ // 15 bits width + 16 bits height
+ width < 0xFFFF /*MinFocusMask*/ -> 0x7FFF - 1 /* MinFocusMask */ // 16 bits width + 15 bits height
+ width < 0x3FFFF /*MaxFocusMask*/ -> 0x1FFF - 1 /* MaxNonFocusMask */ // 18 bits width + 13 bits height
+ else -> 0x1FFF // shouldn't happen since width is limited already
+ }
+
Layout(
content = content,
modifier = modifier
@@ -399,19 +278,15 @@ fun PriorityLayout(
// Find important element which should tell what max width other elements can use
// Expecting only one such element. Can be less than one but not more
val imagePlaceable = measureable.firstOrNull { it.layoutId == priorityLayoutId }?.measure(constraints)
- val placeables: List = measureable.map {
+ val placeables: List = measureable.fastMap {
if (it.layoutId == priorityLayoutId)
imagePlaceable!!
else
- it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: constraints.maxWidth)) }
+ it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: min(MAX_SAFE_WIDTH, constraints.maxWidth))) }
// Limit width for every other element to width of important element and height for a sum of all elements.
- val width = imagePlaceable?.measuredWidth ?: placeables.maxOf { it.width }
- val height = placeables.sumOf { it.height }
- val adjustedConstraints = Constraints.fitPrioritizingHeight(constraints.minWidth, width, constraints.minHeight, height)
- layout(
- if (width > adjustedConstraints.maxWidth) adjustedConstraints.maxWidth - horizontalPaddingAroundCustomLayouts(density) else adjustedConstraints.maxWidth,
- adjustedConstraints.maxHeight
- ) {
+ val width = imagePlaceable?.measuredWidth ?: min(MAX_SAFE_WIDTH, placeables.maxOf { it.width })
+ val height = minOf(maxSafeHeight(width), placeables.sumOf { it.height })
+ layout(width, height) {
var y = 0
placeables.forEach {
it.place(0, y)
@@ -421,112 +296,6 @@ fun PriorityLayout(
}
}
-@Composable
-fun DependentLayout(
- modifier: Modifier = Modifier,
- mainLayoutId: String,
- content: @Composable () -> Unit
-) {
- Layout(
- content = content,
- modifier = modifier
- ) { measureable, constraints ->
- // Find important element which should tell what min width it needs to draw itself.
- // Expecting only one such element. Can be less than one but not more
- val mainPlaceable = measureable.firstOrNull { it.layoutId == mainLayoutId }?.measure(constraints)
- val placeables: List = measureable.map {
- if (it.layoutId == mainLayoutId)
- mainPlaceable!!
- else
- it.measure(constraints.copy(minWidth = mainPlaceable?.width ?: 0, maxWidth = constraints.maxWidth)) }
- val width = mainPlaceable?.measuredWidth ?: placeables.maxOf { it.width }
- val height = placeables.sumOf { it.height }
- val adjustedConstraints = Constraints.fitPrioritizingHeight(constraints.minWidth, width, constraints.minHeight, height)
- layout(
- if (width > adjustedConstraints.maxWidth) adjustedConstraints.maxWidth - horizontalPaddingAroundCustomLayouts(density) else adjustedConstraints.maxWidth,
- adjustedConstraints.maxHeight
- ) {
- var y = 0
- placeables.forEach {
- it.place(0, y)
- y += it.measuredHeight
- }
- }
- }
-}
-
-// The purpose of this layout is to make measuring of bottom compose view and adapt top lazy column to its size in the same frame (not on the next frame as you would expect).
-// So, steps are:
-// - measuring the layout: measured height of compose view before this step is 0, it's added to content padding of lazy column (so it's == 0)
-// - measured the layout: measured height of compose view now is correct, but it's not yet applied to lazy column content padding (so it's == 0) and lazy column is placed higher than compose view in view with respect to compose view's height
-// - on next frame measured height is correct and content padding is the same, lazy column placed to occupy all parent view's size
-// - every added/removed line in compose view goes through the same process.
-@Composable
-fun AdaptingBottomPaddingLayout(
- modifier: Modifier = Modifier,
- mainLayoutId: String,
- expectedHeight: MutableState,
- content: @Composable () -> Unit
-) {
- val expected = with(LocalDensity.current) { expectedHeight.value.roundToPx() }
- Layout(
- content = content,
- modifier = modifier
- ) { measureable, constraints ->
- require(measureable.size <= 2) { "Should be exactly one or two elements in this layout, you have ${measureable.size}" }
- val mainPlaceable = measureable.firstOrNull { it.layoutId == mainLayoutId }!!.measure(constraints)
- val placeables: List = measureable.map {
- if (it.layoutId == mainLayoutId)
- mainPlaceable
- else
- it.measure(constraints.copy(maxHeight = if (expected != mainPlaceable.measuredHeight) constraints.maxHeight - mainPlaceable.measuredHeight + expected else constraints.maxHeight)) }
- expectedHeight.value = mainPlaceable.measuredHeight.toDp()
- layout(constraints.maxWidth, constraints.maxHeight) {
- var y = 0
- placeables.forEach {
- if (it !== mainPlaceable) {
- it.place(0, y)
- y += it.measuredHeight
- } else {
- it.place(0, constraints.maxHeight - mainPlaceable.measuredHeight)
- y += it.measuredHeight
- }
- }
- }
- }
-}
-
-@Composable
-fun CenteredRowLayout(
- modifier: Modifier = Modifier,
- content: @Composable () -> Unit
-) {
- Layout(
- content = content,
- modifier = modifier
- ) { measureable, constraints ->
- require(measureable.size == 3) { "Should be exactly three elements in this layout, you have ${measureable.size}" }
- val first = measureable[0].measure(constraints.copy(minWidth = 0, minHeight = 0))
- val third = measureable[2].measure(constraints.copy(minWidth = first.measuredWidth, minHeight = 0))
- val second = measureable[1].measure(constraints.copy(minWidth = 0, minHeight = 0, maxWidth = (constraints.maxWidth - first.measuredWidth - third.measuredWidth).coerceAtLeast(0)))
- // Limit width for every other element to width of important element and height for a sum of all elements.
- layout(constraints.maxWidth, constraints.maxHeight) {
- first.place(0, ((constraints.maxHeight - first.measuredHeight) / 2).coerceAtLeast(0))
- second.place((constraints.maxWidth - second.measuredWidth) / 2, ((constraints.maxHeight - second.measuredHeight) / 2).coerceAtLeast(0))
- third.place(constraints.maxWidth - third.measuredWidth, ((constraints.maxHeight - third.measuredHeight) / 2).coerceAtLeast(0))
- }
- }
-}
-
-fun showQuotedItemDoesNotExistAlert() {
- AlertManager.shared.showAlertMsg(
- title = generalGetString(MR.strings.message_deleted_or_not_received_error_title),
- text = generalGetString(MR.strings.message_deleted_or_not_received_error_desc)
- )
-}
-
-/*
-
class EditedProvider: PreviewParameterProvider