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..20c0dbbd58 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,75 +5,22 @@ on:
branches:
- master
- stable
+ - sqlcipher
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,113 @@ 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:/sr
+ # 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: 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'
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}
- run: |
- scripts/desktop/build-lib-linux.sh
- cd apps/multiplatform
- ./gradlew packageDeb
+ # Unix /
- - 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
+ # / Windows
- - name: Upload Desktop
- uses: ./.github/actions/prepare-release
- if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
- with:
- bin_path: ${{ steps.linux_desktop_build.outputs.package_path }}
- bin_name: ${{ matrix.desktop_asset_name }}
- bin_hash: ${{ steps.linux_desktop_build.outputs.package_hash }}
- github_ref: ${{ github.ref }}
- github_token: ${{ secrets.GITHUB_TOKEN }}
+ # * 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
+ # * So we're running a separate set of actions for Windows build
- - 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
+ # TODO run tests on Windows
+ # - name: Windows build
+ # id: windows_build
+ # if: matrix.os == 'windows-latest'
+ # shell: cmd
+ # run: |
+ # stack build
+ # stack path --local-install-root > tmp_file
+ # set /p local_install_root= < tmp_file
+ # echo ::set-output name=local_install_root::%local_install_root%
- - 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: Windows upload binary to release
+ # if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
+ # uses: svenstaro/upload-release-action@v2
+ # with:
+ # repo_token: ${{ secrets.GITHUB_TOKEN }}
+ # file: ${{ steps.windows_build.outputs.local_install_root }}\bin\simplex-chat.exe
+ # asset_name: ${{ matrix.asset_name }}
+ # tag: ${{ github.ref }}
- - 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..7dde754ab4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,26 +42,20 @@ stack.yaml.lock
# Temporary test files
tests/tmp
-tests/tmp*
logs/
-*.devcontainer
+
# for website
website/node_modules/
website/src/blog/
-website/src/docs/
-website/translations.json
website/src/img/images/
website/src/images/
-website/src/js/lottie.min.js
-website/src/privacy.md
# Generated files
website/package/generated*
# Ignore build tool output, e.g. code coverage
website/.nyc_output/
website/coverage/
-result
# Ignore API documentation
website/api-docs/
@@ -78,4 +72,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..9554d69fff 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,41 +1,10 @@
-ARG TAG=22.04
-
-FROM ubuntu:${TAG} AS build
-
-### Build stage
-
-# Install curl and git and simplex-chat dependencies
-RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
-
-# Specify bootstrap Haskell versions
-ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
-ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
-
-# Install ghcup
-RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
-
-# Adjust PATH
-ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
-
-# Set both as default
-RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
- ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
-
+FROM haskell:8.10.4 AS build-stage
+# if you encounter "version `GLIBC_2.28' not found" error when running
+# chat client executable, build with the following base image instead:
+# FROM haskell:8.10.4-stretch AS build-stage
COPY . /project
WORKDIR /project
+RUN stack install
-# 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'
-
-# 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-stage /root/.local/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..e30a67f475 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,246 +1,90 @@
----
-layout: layouts/privacy.html
-permalink: /privacy/index.html
----
+# SimpleX Chat Terms & Privacy Policy
-# SimpleX Chat Operators Privacy Policy and Conditions of Use
-
-## 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).
+SimpleX Chat is the first chat platform that is 100% private by design - 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 do not have access to your connections graph.
## 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).
+### Information you provide
-SimpleX software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
+We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
-SimpleX software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server operators, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the servers operated by SimpleX Chat Ltd, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
+Messages. SimpleX Chat cannot decrypt or otherwise access the content or 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 temporarily offline. Your message history is stored only on your own devices.
-SimpleX network operators are not communication service provider, and provide public relays "as is", as experimental, without any guarantees of availability or data retention. The operators of the relay servers preset in the app ("Preset Server Operators"), including SimpleX Chat Ltd, are committed to maintain a high level of availability, reliability and security. SimpleX client apps can have multiple preset relay server operators that you can opt-in or opt-out of using. You are and will continue to be able to use any other operators or your own servers.
+Connections with other users. When you create a connection with another user, two messaging queues are created on our servers (we use separate queues for direct and response messages, that can be on two different servers), or on the servers that you configured in the app, in case it allows such configuration. At the time of updating this document only our terminal app allows configuring the servers, our mobile apps will allow such configuration in the near future. Our servers do not store information about which queues are linked to your profile on the device, and they do not have any information in common that 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 unique encryption keys, different for each queue, and separate for sender and recipient of the messages that are transmitted through the queue.
-SimpleX network design is based on the principles of users and data sovereignty, and device and operator portability.
+Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
-The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
+User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support via chat, when it is possible.
-The cryptographic review of SimpleX protocols design was done in July 2024 by Trail of Bits – see [the announcement](/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
+### Information we may share
-### Your information
+We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
-#### User profiles
+We use Third party to provide email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according their privacy policies and terms of service.
-Servers used by SimpleX Chat apps do not create, store or identify user chat profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
+The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
-When you create the local profile, no records are created on any of the relay servers, and infrastructure operators, whether preset in the app or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
-
-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.
-
-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.
+- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law.
### 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 users - 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 March 1, 2022
diff --git a/README.md b/README.md
index 554c6068d9..922517a4e4 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +1,12 @@
-[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
-[](https://github.com/simplex-chat/simplex-chat/releases)
-[](https://github.com/simplex-chat/simplex-chat/releases)
-[](https://www.reddit.com/r/SimpleXChat)
-
-
-| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md), [PL](/docs/lang/pl/README.md) |
-
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
-[ ](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [ ](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [ ](https://www.whonix.org/wiki/Chat#Recommendation) [ ](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
-
-## Welcome to SimpleX Chat!
-
-1. 📲 [Install the app](#install-the-app).
-2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
-3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
-4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
-5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
-
-[Learn more about SimpleX Chat](#contents).
-
-## Install the app
+[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
+[](https://github.com/simplex-chat/simplex-chat/releases)
+[](https://github.com/simplex-chat/simplex-chat/releases)
+[](https://twitter.com/SimpleXChat)
+[](https://www.reddit.com/r/SimpleXChat)
[ ](https://apps.apple.com/us/app/simplex-chat/id1605771084)
@@ -32,153 +16,13 @@
[ ](https://testflight.apple.com/join/DWuT2LQu)
-[ ](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
+[ ](https://github.com/simplex-chat/website/raw/master/simplex.apk)
- 🖲 Protects your messages and metadata - who you talk to and when.
- 🔐 Double ratchet end-to-end encryption, with additional encryption layer.
-- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
+- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
- 🚀 [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:
-
-- 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.
-
-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).
-
-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.
-
-The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
-
-
-
-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.
-
-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/) -|||
-|🇮🇹 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) [Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/) [](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/) | [](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
-
-Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
-
-## Please support us with your donations
-
-Huge thank you to everybody who donated to SimpleX Chat!
-
-We are prioritizing users privacy and security - it would be impossible without your support.
-
-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.
-
-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.
-
-Thank you,
-
-Evgeny
-
-SimpleX Chat founder
+- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
## Contents
@@ -190,12 +34,14 @@ SimpleX Chat founder
- [Users own SimpleX network](#users-own-simplex-network)
- [Frequently asked questions](#frequently-asked-questions)
- [News and updates](#news-and-updates)
+- [Make a private connection](#make-a-private-connection)
- [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)
+- [Help us pay for 3rd party security audit](#help-us-pay-for-3rd-party-security-audit)
+- [Disclaimer, License](#disclaimer)
## Why privacy matters
@@ -225,40 +71,32 @@ You can use SimpleX with your own servers and still communicate with people usin
## Frequently asked questions
-1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release announcement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
+1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release annoucement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
-2. _Why should I not just use Signal?_ Signal is a centralized platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
+2. _Why should I not just use Signal?_ Signal is a centralised platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
-3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identities?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages – it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
+3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identites?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages – it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
## 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)
+[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
-[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
+[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
-[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
-
-[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
-
-[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
-
-[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
-
-[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
-
-[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
-
-[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
-
-[Mar 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).
+[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
[All updates](./blog)
+## Make a private connection
+
+You need to share a link or scan a QR code (in person or during a video call) to make a connection and start messaging.
+
+The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
+
+
+
## :zap: Quick installation of a terminal app
```sh
@@ -285,39 +123,28 @@ 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 notificaitons 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.
-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. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
+2. Local database encryption. Currently the local chat database stored on your device is not encrypted.
+3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
+4. Independent implementation audit.
## For developers
@@ -325,13 +152,11 @@ You can:
- use SimpleX Chat library to integrate chat functionality into your mobile apps.
- create chat bots and services in Haskell - see [simple](./apps/simplex-bot/) and more [advanced chat bot example](./apps/simplex-bot-advanced/).
-- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
+- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScipt chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
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.
@@ -345,88 +170,60 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Manual chat history deletion.
- ✅ End-to-end encrypted WebRTC audio and video calls via the mobile apps.
- ✅ Privacy preserving instant notifications for iOS using Apple Push Notification service.
-- ✅ Chat database export and import.
+- ✅ Chat database export and import
- ✅ Chat groups in mobile apps.
- ✅ Connecting to messaging servers via Tor.
- ✅ Dual server addresses to access messaging servers as v3 hidden services.
- ✅ Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (ready for announcement).
- ✅ Incognito mode to share a new random name with each contact.
-- ✅ Chat database encryption.
-- ✅ Automatic chat history deletion.
-- ✅ Links to join groups and improve groups stability.
-- ✅ Voice messages (with recipient opt-out per contact).
-- ✅ Basic authentication for SMP servers (to authorize creating new queues).
-- ✅ View deleted messages, full message deletion by sender (with recipient opt-in per contact).
-- ✅ Block screenshots and view in recent apps.
-- ✅ Advanced server configuration.
-- ✅ Disappearing messages (with recipient opt-in per-contact).
-- ✅ "Live" messages.
-- ✅ Contact verification via a separate out-of-band channel.
-- ✅ 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.
-- Ephemeral/disappearing/OTR conversations with the existing contacts.
-- Privately share your location.
+- 🏗 Chat database encryption.
+- 🏗 Links to join groups and improve groups stability.
+- Disappearing messages, with mutual agreement.
+- Voice messages
+- Video messages
- Web widgets for custom interactivity in the chats.
-- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
+- SMP protocol improvements:
+ - SMP queue redundancy and rotation.
+ - Message delivery confirmation.
+ - Supporting the same profile on multiple devices.
- 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.
-- High capacity multi-node SMP relays.
+- Channels server for large groups and broadcast channels.
+- Media server to optimize sending large files to groups.
+- Desktop client.
+- Using the same profile on multiple devices.
-## Disclaimers
+## Help us pay for 3rd party security audit
-[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.
+I will get straight to the point: I ask you to support SimpleX Chat with donations.
-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).
+We are prioritizing users privacy and security - it would be impossible without your support we were lucky to have so far.
-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).
+We are planning a 3rd party security audit for the app, and it would hugely help us if some part of this $20,000+ expense could be covered with donations.
-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.
+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.
-The default servers configured in the app are provided on the best effort basis. We are currently not guaranteeing any SLAs, although historically our servers had over 99.9% uptime each.
+If you are already using SimpleX Chat, or plan to use it in the future when it has more features, please consider making a donation - it will help us to raise more funds. Donating any amount, even the price of the cup of coffee, would make a huge difference for us.
-We have never provided or have been requested 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 be following due legal process.
+It is possible to donate via:
-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.
+- [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 wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
+- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
-Please read more in [Privacy Policy](./PRIVACY.md).
+Thank you,
-## Security contact
+Evgeny
-Please see our [Security Policy](./docs/SECURITY.md) on how to report security vulnerabilities to us. We will coordinate the fix and disclosure.
+SimpleX Chat founder
-Please do NOT report security vulnerabilities via GitHub issues.
+## Disclaimer
+
+[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed and had many improvements in v1.0.0; we are currently arranging for the independent implementation audit.
+
+You are likely to discover some bugs - we would really appreciate if you use it and let us know anything that needs to be fixed or improved.
## License
@@ -440,4 +237,4 @@ Please do NOT report security vulnerabilities via GitHub issues.
[ ](https://testflight.apple.com/join/DWuT2LQu)
-[ ](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
+[ ](https://github.com/simplex-chat/website/raw/master/simplex.apk)
diff --git a/apps/android/.gitignore b/apps/android/.gitignore
new file mode 100644
index 0000000000..644d967fb1
--- /dev/null
+++ b/apps/android/.gitignore
@@ -0,0 +1,19 @@
+*.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
+.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/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..8c36b5ae50
--- /dev/null
+++ b/apps/android/app/build.gradle
@@ -0,0 +1,198 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+ id 'org.jetbrains.kotlin.plugin.serialization'
+}
+
+android {
+ compileSdk 32
+
+ defaultConfig {
+ applicationId "chat.simplex.app"
+ minSdk 29
+ targetSdk 32
+ versionCode 59
+ versionName "4.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"
+ }
+}
+
+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"
+
+ // 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 53%
rename from apps/multiplatform/android/src/main/AndroidManifest.xml
rename to apps/android/app/src/main/AndroidManifest.xml
index 0470977bcd..3f04b64fcb 100644
--- a/apps/multiplatform/android/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -6,7 +6,6 @@
-
@@ -15,36 +14,21 @@
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -77,44 +60,8 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -142,13 +89,9 @@
-
+
+
-
-
-
+ 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/website/src/call/call.js b/apps/android/app/src/main/assets/www/call.js
similarity index 100%
rename from website/src/call/call.js
rename to apps/android/app/src/main/assets/www/call.js
diff --git a/website/src/call/lz-string.min.js b/apps/android/app/src/main/assets/www/lz-string.min.js
similarity index 100%
rename from website/src/call/lz-string.min.js
rename to apps/android/app/src/main/assets/www/lz-string.min.js
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..bfa259008a
--- /dev/null
+++ b/apps/android/app/src/main/cpp/simplex-api.c
@@ -0,0 +1,78 @@
+#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);
+
+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;
+}
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/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
new file mode 100644
index 0000000000..7d0e7ed911
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
@@ -0,0 +1,417 @@
+package chat.simplex.app
+
+import android.app.Application
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.os.SystemClock.elapsedRealtime
+import android.util.Log
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+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.Replay
+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.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.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.ChatListView
+import chat.simplex.app.views.chatlist.openChat
+import chat.simplex.app.views.database.DatabaseErrorView
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.newchat.connectViaUri
+import chat.simplex.app.views.newchat.withUriAction
+import chat.simplex.app.views.onboarding.*
+import kotlinx.coroutines.delay
+
+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
+ // 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)
+ }
+ 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)
+ }
+
+ override fun onStart() {
+ super.onStart()
+ val enteredBackgroundVal = enteredBackground.value
+ if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
+ runAuthenticate()
+ }
+ }
+
+ override fun onStop() {
+ super.onStop()
+ enteredBackground.value = elapsedRealtime()
+ }
+
+ override fun onBackPressed() {
+ 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
+ }
+ }
+
+ 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 -> {
+ laFailed.value = true
+ laErrorToast(applicationContext, laResult.errString)
+ }
+ LAResult.Failed -> {
+ laFailed.value = true
+ laFailedToast(applicationContext)
+ }
+ 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 -> {
+ m.performLA.value = false
+ prefPerformLA.set(false)
+ laErrorToast(applicationContext, laResult.errString)
+ }
+ LAResult.Failed -> {
+ m.performLA.value = false
+ prefPerformLA.set(false)
+ laFailedToast(applicationContext)
+ }
+ 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 -> {
+ m.performLA.value = true
+ prefPerformLA.set(true)
+ laErrorToast(applicationContext, laResult.errString)
+ }
+ LAResult.Failed -> {
+ m.performLA.value = true
+ prefPerformLA.set(true)
+ laFailedToast(applicationContext)
+ }
+ 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 retryAuthView() {
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ SimpleButton(
+ stringResource(R.string.auth_retry),
+ icon = Icons.Outlined.Replay,
+ 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) {
+ retryAuthView()
+ } else {
+ SplashView()
+ }
+ }
+ onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
+ Box {
+ if (chatModel.showCallView.value) ActiveCallView(chatModel)
+ else {
+ showAdvertiseLAAlert = true
+ val stopped = chatModel.chatRunning.value == false
+ if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA, stopped)
+ else ChatView(chatModel)
+ }
+ }
+ }
+ onboarding == OnboardingStage.Step1_SimpleXInfo ->
+ Box(Modifier.padding(horizontal = 20.dp)) {
+ SimpleXInfo(chatModel, onboarding = true)
+ }
+ onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
+ }
+ ModalManager.shared.showInView()
+ val invitation = chatModel.activeCallInvitation.value
+ if (invitation != null) IncomingCallAlertView(invitation, chatModel)
+ AlertManager.shared.showInView()
+ }
+}
+
+fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
+ when (intent?.action) {
+ NtfManager.OpenChatAction -> {
+ val chatId = intent.getStringExtra("chatId")
+ Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
+ if (chatId != null) {
+ val cInfo = chatModel.getChat(chatId)?.chatInfo
+ chatModel.clearOverlays.value = true
+ if (cInfo != null) withApi { openChat(cInfo, chatModel) }
+ }
+ }
+ NtfManager.ShowChatsAction -> {
+ Log.d(TAG, "processNotificationIntent: ShowChatsAction")
+ 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 connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
+ Log.d(TAG, "connectIfOpenedViaUri: opened via link")
+ if (chatModel.currentUser.value == null) {
+ // TODO open from chat list view
+ chatModel.appOpenUrl.value = uri
+ } else {
+ withUriAction(uri) { action ->
+ val title = when (action) {
+ "contact" -> generalGetString(R.string.connect_via_contact_link)
+ "invitation" -> generalGetString(R.string.connect_via_invitation_link)
+ else -> {
+ Log.e(TAG, "URI has unexpected action. Alert shown.")
+ action
+ }
+ }
+ AlertManager.shared.showAlertMsg(
+ title = title,
+ text = 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, action, 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..369addf822
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -0,0 +1,198 @@
+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.BufferedReader
+import java.io.InputStreamReader
+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
+
+class SimplexApp: Application(), LifecycleEventObserver {
+ lateinit var chatController: ChatController
+
+ 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)
+ }
+
+ override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+ Log.d(TAG, "onStateChanged: $event")
+ withApi {
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> {
+ 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 && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
+ SimplexService.start(applicationContext)
+ }
+ else -> {}
+ }
+ }
+ }
+
+ 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")
+ val server = LocalServerSocket(socketName)
+ 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..b567e0d8c7
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt
@@ -0,0 +1,314 @@
+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 chat.simplex.app.views.onboarding.OnboardingStage
+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 isServiceStarted = false
+ 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)
+ }
+
+ override fun onDestroy() {
+ Log.d(TAG, "Simplex service destroyed")
+ stopService()
+
+ // 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 (isServiceStarted || 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)
+ stopService()
+ return@withApi
+ }
+ isServiceStarted = true
+ 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 stopService() {
+ Log.d(TAG, "Stopping foreground service")
+ try {
+ wakeLock?.let {
+ while (it.isHeld) it.release() // release all, in case acquired more than once
+ }
+ wakeLock = null
+ stopForeground(true)
+ stopSelf()
+ } catch (e: Exception) {
+ Log.d(TAG, "Service stopped without being started: ${e.message}")
+ }
+ isServiceStarted = false
+ saveServiceState(this, ServiceState.STOPPED)
+ }
+
+ 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"
+
+ 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)
+
+ fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java))
+
+ 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..37affd8bd3
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
@@ -0,0 +1,1523 @@
+package chat.simplex.app.model
+
+import android.net.Uri
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.*
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.*
+import androidx.compose.ui.text.style.TextDecoration
+import chat.simplex.app.R
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.call.*
+import chat.simplex.app.views.helpers.DBMigrationResult
+import chat.simplex.app.views.helpers.generalGetString
+import chat.simplex.app.views.onboarding.OnboardingStage
+import chat.simplex.app.views.usersettings.NotificationPreviewMode
+import chat.simplex.app.views.usersettings.NotificationsMode
+import kotlinx.datetime.*
+import kotlinx.serialization.*
+import kotlinx.serialization.descriptors.*
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.*
+
+class ChatModel(val controller: ChatController) {
+ val onboardingStage = mutableStateOf(null)
+ val currentUser = mutableStateOf(null)
+ 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()
+
+ // current chat
+ val chatId = mutableStateOf(null)
+ val chatItems = mutableStateListOf()
+ val groupMembers = mutableStateListOf()
+
+ val terminalItems = mutableStateListOf()
+ val userAddress = mutableStateOf(null)
+ val userSMPServers = mutableStateOf<(List)?>(null)
+
+ // 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)
+
+ fun updateUserProfile(profile: LocalProfile) {
+ val user = currentUser.value
+ if (user != null) {
+ currentUser.value = user.copy(profile = profile)
+ }
+ }
+
+ 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.isIndirectContact)
+
+ 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) {
+ val mergedChats = arrayListOf()
+ for (newChat in newChats) {
+ val i = getChatIndex(newChat.chatInfo.id)
+ if (i >= 0) {
+ mergedChats.add(newChat.copy(serverInfo = chats[i].serverInfo))
+ } else {
+ mergedChats.add(newChat)
+ }
+ }
+ chats.clear()
+ chats.addAll(mergedChats)
+ }
+
+ fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) {
+ val i = getChatIndex(id)
+ if (i >= 0) {
+ val chat = chats[i]
+ chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status))
+ }
+ }
+
+ 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)
+ }
+ }
+
+ 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
+ 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) {
+ chatItems.add(cItem)
+ }
+ }
+
+ 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 {
+ chatItems.add(cItem)
+ return true
+ }
+ } else {
+ return res
+ }
+ }
+
+ fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
+ // 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(cItem))
+ }
+ }
+ // remove from current chat
+ if (chatId.value == cInfo.id) {
+ val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
+ if (itemIndex >= 0) {
+ chatItems.removeAt(itemIndex)
+ }
+ }
+ }
+
+ fun clearChat(cInfo: ChatInfo) {
+ // clear preview
+ val i = getChatIndex(cInfo.id)
+ if (i >= 0) {
+ chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo)
+ }
+ // clear current chat
+ if (chatId.value == cInfo.id) {
+ chatItems.clear()
+ }
+ }
+
+ 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) {
+ chats[chatIdx] = chat.copy(
+ chatStats = chat.chatStats.copy(
+ unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0,
+ // 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))) {
+ chatItems[i] = item.withStatus(CIStatus.RcvRead())
+ markedRead++
+ }
+ i += 1
+ }
+ }
+ return markedRead
+ }
+
+ private fun decreaseCounterInChat(chatId: ChatId) {
+ val chatIndex = getChatIndex(chatId)
+ if (chatIndex == -1) return
+
+ val chat = chats[chatIndex]
+ chats[chatIndex] = chat.copy(
+ chatStats = chat.chatStats.copy(
+ unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0),
+ )
+ )
+ }
+
+// 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 removeChat(id: String) {
+ chats.removeAll { it.id == id }
+ }
+
+ fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean {
+ // 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
+ }
+ }
+}
+
+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 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,
+ activeUser = true
+ )
+ }
+}
+
+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 createdAt: Instant
+ val updatedAt: Instant
+}
+
+@Serializable
+data class Chat (
+ val chatInfo: ChatInfo,
+ val chatItems: List,
+ val chatStats: ChatStats = ChatStats(),
+ val serverInfo: ServerInfo = ServerInfo(NetworkStatus.Unknown())
+) {
+ val id: String get() = chatInfo.id
+
+ @Serializable
+ data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0)
+
+ @Serializable
+ data class ServerInfo(val networkStatus: NetworkStatus)
+
+ @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()
+ }
+
+ companion object {
+ val sampleData = Chat(
+ chatInfo = ChatInfo.Direct.sampleData,
+ chatItems = arrayListOf(ChatItem.getSampleData())
+ )
+ }
+}
+
+@Serializable
+sealed class ChatInfo: SomeChat, NamedChat {
+ abstract val incognito: Boolean
+
+ @Serializable @SerialName("direct")
+ 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.chatSettings.enableNtfs
+ override val incognito get() = contact.contactConnIncognito
+ 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")
+ 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.chatSettings.enableNtfs
+ override val incognito get() = groupInfo.membership.memberIncognito
+ 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() = false
+ override val incognito get() = false
+ 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() = false
+ override val incognito get() = contactConnection.incognito
+ 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
+data class Contact(
+ val contactId: Long,
+ override val localDisplayName: String,
+ val profile: LocalProfile,
+ val activeConn: Connection,
+ val viaGroup: Long? = null,
+ val chatSettings: ChatSettings,
+ 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 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 isIndirectContact: Boolean get() =
+ activeConn.connLevel > 0 || viaGroup != null
+
+ val contactConnIncognito =
+ activeConn.customUserProfileId != null
+
+ companion object {
+ val sampleData = Contact(
+ contactId = 1,
+ localDisplayName = "alice",
+ profile = LocalProfile.sampleData,
+ activeConn = Connection.sampleData,
+ chatSettings = ChatSettings(true),
+ createdAt = Clock.System.now(),
+ updatedAt = Clock.System.now()
+ )
+ }
+}
+
+@Serializable
+class ContactRef(
+ val contactId: Long,
+ var localDisplayName: String
+) {
+ val id: ChatId get() = "@$contactId"
+}
+
+@Serializable
+class ContactSubStatus(
+ val contact: Contact,
+ val contactError: ChatError? = null
+)
+
+@Serializable
+class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val customUserProfileId: Long? = null) {
+ val id: ChatId get() = ":$connId"
+ companion object {
+ val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, customUserProfileId = null)
+ }
+}
+
+@Serializable
+class Profile(
+ override val displayName: String,
+ override val fullName: String,
+ override val image: String? = null,
+ override val localAlias : String = ""
+): 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)
+
+ 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,
+): NamedChat {
+ val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
+
+ fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias)
+
+ companion object {
+ val sampleData = LocalProfile(
+ profileId = 1L,
+ displayName = "alice",
+ fullName = "Alice",
+ 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 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() = true
+ override val sendMsgEnabled get() = membership.memberActive
+ override val ntfsEnabled get() = chatSettings.enableNtfs
+ 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,
+ membership = GroupMember.sampleData,
+ hostConnCustomUserProfileId = null,
+ chatSettings = ChatSettings(true),
+ createdAt = Clock.System.now(),
+ updatedAt = Clock.System.now()
+ )
+ }
+}
+
+@Serializable
+class GroupProfile (
+ override val displayName: String,
+ override val fullName: String,
+ override val image: String? = null,
+ override val localAlias: String = "",
+): NamedChat {
+ companion object {
+ val sampleData = GroupProfile(
+ displayName = "team",
+ fullName = "My Team"
+ )
+ }
+}
+
+@Serializable
+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 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(membership: GroupMember): Boolean {
+ val userRole = membership.memberRole
+ return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft
+ && userRole >= GroupMemberRole.Admin && userRole >= memberRole && membership.memberCurrent
+ }
+
+ 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
+enum class GroupMemberRole(val memberRole: String) {
+ @SerialName("member") Member("member"), // order matters in comparisons
+ @SerialName("admin") Admin("admin"),
+ @SerialName("owner") Owner("owner");
+
+ val text: String get() = when (this) {
+ 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 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 customUserProfileId: Long? = null,
+ 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 localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
+ override val displayName: String get() {
+ 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
+ override val localAlias get() = ""
+
+ val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
+
+ val incognito = customUserProfileId != null
+
+ 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 (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,
+ 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
+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() =
+ when {
+ content.text == "" && file != null -> file.fileName
+ else -> content.text
+ }
+
+ val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
+
+ 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
+ else -> false
+ }
+
+ val isCall: Boolean get() =
+ when (content) {
+ is CIContent.SndCall -> true
+ is CIContent.RcvCall -> true
+ else -> 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: Boolean = false,
+ itemEdited: Boolean = false,
+ editable: Boolean = true
+ ) =
+ ChatItem(
+ chatDir = dir,
+ meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, 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(), itemDeleted = false, itemEdited = false, editable = false),
+ 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, itemDeleted = false, itemEdited = false, editable = false),
+ 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(), itemDeleted = false, itemEdited = false, editable = false),
+ 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(), itemDeleted = false, itemEdited = false, editable = false),
+ content = CIContent.RcvGroupEventContent(rcvGroupEvent = RcvGroupEvent.MemberAdded(groupMemberId = 1, profile = Profile.sampleData)),
+ 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 itemDeleted: Boolean,
+ val itemEdited: Boolean,
+ val editable: Boolean
+) {
+ val timestampText: String get() = getTimestampText(itemTs)
+
+ companion object {
+ fun getSample(
+ id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
+ itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true
+ ): CIMeta =
+ CIMeta(
+ itemId = id,
+ itemTs = ts,
+ itemText = text,
+ itemStatus = status,
+ createdAt = ts,
+ itemDeleted = itemDeleted,
+ itemEdited = itemEdited,
+ editable = editable
+ )
+ }
+}
+
+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: AgentErrorType): CIStatus()
+ @Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
+ @Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
+}
+
+@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 }
+
+ 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
+ }
+}
+
+@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 get() = 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 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() = when (this) {
+ is MCText -> "text $text"
+ is MCLink -> "json ${json.encodeToString(this)}"
+ is MCImage -> "json ${json.encodeToString(this)}"
+ is MCFile -> "json ${json.encodeToString(this)}"
+ is MCUnknown -> "json $json"
+ }
+}
+
+@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)
+ }
+ "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.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) {
+ val link: String? = when (format) {
+ is Format.Uri -> text
+ is Format.Email -> "mailto:$text"
+ is Format.Phone -> "tel:$text"
+ else -> null
+ }
+}
+
+@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("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 Email -> linkStyle
+ is Phone -> linkStyle
+ }
+
+ companion object {
+ val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline)
+ }
+}
+
+@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 -> Color.Yellow
+ 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), duration(sec))
+ Error -> generalGetString(R.string.callstatus_error)
+ }
+
+ fun duration(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("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()
+
+ 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 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)
+ }
+}
+
+@Serializable
+sealed class 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 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)
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
new file mode 100644
index 0000000000..070523353a
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
@@ -0,0 +1,192 @@
+package chat.simplex.app.model
+
+import android.app.*
+import android.content.*
+import android.graphics.BitmapFactory
+import android.media.AudioAttributes
+import android.net.Uri
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import chat.simplex.app.*
+import chat.simplex.app.views.call.*
+import chat.simplex.app.views.helpers.base64ToBitmap
+import chat.simplex.app.views.helpers.generalGetString
+import chat.simplex.app.views.usersettings.NotificationPreviewMode
+import kotlinx.datetime.Clock
+
+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"
+ const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
+ const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
+ const val CallNotificationId: Int = -1
+ }
+
+ private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ private var prevNtfTime = mutableMapOf()
+ private val msgNtfTimeoutMs = 30000L
+
+ init {
+ manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
+ manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
+ manager.createNotificationChannel(callNotificationChannel())
+ }
+
+ private fun callNotificationChannel(): NotificationChannel {
+ val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
+ val attrs = AudioAttributes.Builder()
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION)
+ .build()
+ 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)
+ return callChannel
+ }
+
+ fun cancelNotificationsForChat(chatId: String) {
+ prevNtfTime.remove(chatId)
+ manager.cancel(chatId.hashCode())
+ val msgNtfs = manager.activeNotifications.filter {
+ ntf -> ntf.notification.channelId == MessageChannel
+ }
+ if (msgNtfs.count() == 1) {
+ // Have a group notification with no children so cancel it
+ manager.cancel(0)
+ }
+ }
+
+ fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
+ if (!cInfo.ntfsEnabled) return
+
+ notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
+ }
+
+ fun notifyMessageReceived(chatId: String, displayName: String, msgText: String) {
+ Log.d(TAG, "notifyMessageReceived $chatId")
+ val now = Clock.System.now().toEpochMilliseconds()
+ val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
+ prevNtfTime[chatId] = now
+
+ val previewMode = appPreferences.notificationPreviewMode.get()
+ 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 notification = NotificationCompat.Builder(context, MessageChannel)
+ .setContentTitle(title)
+ .setContentText(content)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setGroup(MessageGroup)
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
+ .setSmallIcon(R.drawable.ntf_icon)
+ .setColor(0x88FFFF)
+ .setAutoCancel(true)
+ .setContentIntent(chatPendingIntent(OpenChatAction, chatId))
+ .setSilent(recentNotification)
+ .build()
+
+ 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))
+ .build()
+
+ with(NotificationManagerCompat.from(context)) {
+ // using cInfo.id only shows one notification per chat and updates it when the message arrives
+ notify(chatId.hashCode(), notification)
+ notify(0, summary)
+ }
+ }
+
+ fun notifyCallInvitation(invitation: RcvCallInvitation) {
+ if (isAppOnForeground(context)) return
+ val contactId = invitation.contact.id
+ Log.d(TAG, "notifyCallInvitation $contactId")
+ val keyguardManager = getKeyguardManager(context)
+ val image = invitation.contact.image
+ var ntfBuilder =
+ if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
+ 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, LockScreenCallChannel)
+ .setFullScreenIntent(fullScreenPendingIntent, true)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setSilent(true)
+ } else {
+ val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
+ NotificationCompat.Builder(context, CallChannel)
+ .setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
+ .addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
+ .setSound(soundUri)
+ }
+ val text = generalGetString(
+ if (invitation.callType.media == CallMediaType.Video) {
+ if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
+ } else {
+ 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(R.string.notification_preview_somebody)
+ else
+ invitation.contact.displayName
+
+ ntfBuilder = ntfBuilder
+ .setContentTitle(title)
+ .setContentText(text)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_CALL)
+ .setSmallIcon(R.drawable.ntf_icon)
+ .setLargeIcon(if (image == null) BitmapFactory.decodeResource(context.resources, R.drawable.icon) else base64ToBitmap(image))
+ .setColor(0x88FFFF)
+ .setAutoCancel(true)
+ with(NotificationManagerCompat.from(context)) {
+ notify(CallNotificationId, ntfBuilder.build())
+ }
+ }
+
+ fun cancelCallNotification() {
+ manager.cancel(CallNotificationId)
+ }
+
+ private fun hideSecrets(cItem: ChatItem) : String {
+ val md = cItem.formattedText
+ return if (md == null) {
+ if (cItem.content.text != "") {
+ cItem.content.text
+ } else {
+ cItem.file?.fileName ?: ""
+ }
+ } else {
+ var res = ""
+ for (ft in md) {
+ res += if (ft.format is Format.Secret) "..." else ft.text
+ }
+ res
+ }
+ }
+
+ private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
+ Log.d(TAG, "chatPendingIntent for $intentAction")
+ val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
+ var intent = Intent(context, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ .setAction(intentAction)
+ if (chatId != null) intent = intent.putExtra("chatId", chatId)
+ return TaskStackBuilder.create(context).run {
+ addNextIntentWithParentStack(intent)
+ getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
+ }
+ }
+}
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..dd4aacf5b2
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
@@ -0,0 +1,2116 @@
+package chat.simplex.app.model
+
+import android.annotation.SuppressLint
+import android.app.ActivityManager
+import android.app.ActivityManager.RunningAppProcessInfo
+import android.app.Application
+import android.content.*
+import android.net.Uri
+import android.os.Build
+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.outlined.Bolt
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.toArgb
+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.NotificationPreviewMode
+import chat.simplex.app.views.usersettings.NotificationsMode
+import kotlinx.coroutines.*
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.*
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlin.concurrent.thread
+
+typealias ChatCtrl = Long
+
+fun isAppOnForeground(context: Context): Boolean {
+ val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ val appProcesses = activityManager.runningAppProcesses ?: return false
+ val packageName = context.packageName
+ for (appProcess in appProcesses) {
+ if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) {
+ return true
+ }
+ }
+ return false
+}
+
+enum class CallOnLockScreen {
+ DISABLE,
+ SHOW,
+ ACCEPT;
+
+ companion object {
+ val default = SHOW
+ }
+}
+
+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: Preference = Preference(
+ 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 privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
+ val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
+ val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
+ val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
+ val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
+ val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
+ val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
+ val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
+ val 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 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 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())
+
+ private fun mkIntPreference(prefName: String, default: Int) =
+ Preference(
+ get = fun() = sharedPreferences.getInt(prefName, default),
+ set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
+ )
+
+ private fun mkLongPreference(prefName: String, default: Long) =
+ Preference(
+ get = fun() = sharedPreferences.getLong(prefName, default),
+ set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
+ )
+
+ private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): Preference {
+ val d = if (networkUseSocksProxy.get()) proxyDefault else default
+ return Preference(
+ get = fun() = sharedPreferences.getLong(prefName, d),
+ set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
+ )
+ }
+
+ private fun mkBoolPreference(prefName: String, default: Boolean) =
+ Preference(
+ get = fun() = sharedPreferences.getBoolean(prefName, default),
+ set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply()
+ )
+
+ private fun mkStrPreference(prefName: String, default: String?): Preference =
+ Preference(
+ 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): Preference =
+ Preference(
+ 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 {
+ private 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_ACCEPT_IMAGES = "PrivacyAcceptImages"
+ private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
+ 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_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_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_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_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 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()
+ if (justStarted) {
+ apiSetFilesFolder(getAppFilesDirectory(appContext))
+ apiSetIncognito(chatModel.incognito.value)
+ chatModel.userAddress.value = apiGetUserAddress()
+ chatModel.userSMPServers.value = getUserSMPServers()
+ val chats = apiGetChats()
+ chatModel.updateChats(chats)
+ chatModel.currentUser.value = user
+ chatModel.userCreated.value = true
+ 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
+ }
+ }
+
+ private fun startReceiver() {
+ Log.d(TAG, "ChatController startReceiver")
+ if (receiverStarted) return
+ thread(name="receiver") {
+ GlobalScope.launch { withContext(Dispatchers.IO) { recvMspLoop() } }
+ }
+ }
+
+ 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.terminalItems.add(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.terminalItems.add(TerminalItem.resp(r.resp))
+ }
+ r.resp
+ }
+ }
+
+ private suspend fun recvMsg(ctrl: ChatCtrl): CR? {
+ return withContext(Dispatchers.IO) {
+ val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
+ 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
+ }
+ }
+ }
+
+ private suspend fun recvMspLoop() {
+ val msg = recvMsg(ctrl ?: return)
+ if (msg != null) processReceivedMsg(msg)
+ recvMspLoop()
+ }
+
+ 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
+ Log.d(TAG, "apiCreateActiveUser: ${r.responseType} ${r.details}")
+ throw Error("user not created ${r.responseType} ${r.details}")
+ }
+
+ suspend fun apiStartChat(): Boolean {
+ val r = sendCmd(CC.StartChat())
+ 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}")
+ }
+
+ private suspend fun apiGetChats(): List {
+ val r = sendCmd(CC.ApiGetChats())
+ if (r is CR.ApiChats ) return r.chats
+ throw Error("failed getting the list of chats: ${r.responseType} ${r.details}")
+ }
+
+ 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}")
+ return null
+ }
+
+ suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent): AChatItem? {
+ val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc)
+ 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): AChatItem? {
+ val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc))
+ 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): AChatItem? {
+ val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode))
+ if (r is CR.ChatItemDeleted) return r.toChatItem
+ Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ private suspend fun getUserSMPServers(): List? {
+ val r = sendCmd(CC.GetUserSMPServers())
+ if (r is CR.UserSMPServers) return r.smpServers
+ Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun setUserSMPServers(smpServers: List): Boolean {
+ val r = sendCmd(CC.SetUserSMPServers(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 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 apiAddContact(): String? {
+ val r = sendCmd(CC.AddContact())
+ 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 r = sendCmd(CC.Connect(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
+ r is CR.ChatCmdError -> {
+ val e = r.chatError
+ if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.cannot_delete_contact),
+ String.format(generalGetString(R.string.contact_cannot_be_deleted_as_they_are_in_groups), e.errorType.contact.displayName, e.errorType.groupNames.joinToString(", "))
+ )
+ }
+ }
+ 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 r = sendCmd(CC.ListContacts())
+ 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 r = sendCmd(CC.ApiUpdateProfile(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 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
+ }
+
+ 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 apiCreateUserAddress(): String? {
+ val r = sendCmd(CC.CreateMyAddress())
+ 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 r = sendCmd(CC.DeleteMyAddress())
+ if (r is CR.UserContactLinkDeleted) return true
+ Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}")
+ return false
+ }
+
+ private suspend fun apiGetUserAddress(): String? {
+ val r = sendCmd(CC.ShowMyAddress())
+ if (r is CR.UserContactLink) return r.connReqContact
+ 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 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 apiReceiveFile(fileId: Long): AChatItem? {
+ val r = sendCmd(CC.ReceiveFile(fileId))
+ 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))) {
+ apiErrorAlert("apiReceiveFile", generalGetString(R.string.error_receiving_file), r)
+ }
+ null
+ }
+ }
+ }
+
+ suspend fun apiNewGroup(p: GroupProfile): GroupInfo? {
+ val r = sendCmd(CC.NewGroup(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? {
+ val r = sendCmd(CC.ApiRemoveMember(groupId, memberId))
+ if (r is CR.UserDeletedMember) return r.member
+ Log.e(TAG, "apiRemoveMember bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ 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
+ }
+ }
+ }
+
+ 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),
+ generalGetString(R.string.network_error_desc)
+ )
+ 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),
+ generalGetString(R.string.network_error_desc)
+ )
+ 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)
+ }
+
+ fun processReceivedMsg(r: CR) {
+ lastMsgReceivedTimestamp = System.currentTimeMillis()
+ chatModel.terminalItems.add(TerminalItem.resp(r))
+ when (r) {
+ is CR.NewContactConnection -> {
+ chatModel.updateContactConnection(r.connection)
+ }
+ is CR.ContactConnectionDeleted -> {
+ chatModel.removeChat(r.connection.id)
+ }
+ is CR.ContactConnected -> {
+ chatModel.updateContact(r.contact)
+ chatModel.removeChat(r.contact.activeConn.id)
+ chatModel.updateNetworkStatus(r.contact.id, Chat.NetworkStatus.Connected())
+// NtfManager.shared.notifyContactConnected(contact)
+ }
+ is CR.ContactConnecting -> {
+ chatModel.updateContact(r.contact)
+ chatModel.removeChat(r.contact.activeConn.id)
+ }
+ is CR.ReceivedContactRequest -> {
+ val contactRequest = r.contactRequest
+ val cInfo = ChatInfo.ContactRequest(contactRequest)
+ chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
+// NtfManager.shared.notifyContactRequest(contactRequest)
+ }
+ is CR.ContactUpdated -> {
+ val cInfo = ChatInfo.Direct(r.toContact)
+ if (chatModel.hasChat(r.toContact.id)) {
+ chatModel.updateChatInfo(cInfo)
+ }
+ }
+ is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Connected())
+ is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Disconnected())
+ is CR.ContactSubError -> processContactSubError(r.contact, r.chatError)
+ is CR.ContactSubSummary -> {
+ for (sub in r.contactSubscriptions) {
+ val err = sub.contactError
+ if (err == null) {
+ chatModel.updateContact(sub.contact)
+ chatModel.updateNetworkStatus(sub.contact.id, Chat.NetworkStatus.Connected())
+ } else {
+ processContactSubError(sub.contact, sub.contactError)
+ }
+ }
+ }
+ is CR.NewChatItem -> {
+ val cInfo = r.chatItem.chatInfo
+ val cItem = r.chatItem.chatItem
+ chatModel.addChatItem(cInfo, cItem)
+ val file = cItem.file
+ if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
+ withApi { receiveFile(file.fileId) }
+ }
+ if (!cItem.chatDir.sent && !cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
+ ntfManager.notifyMessageReceived(cInfo, cItem)
+ }
+ }
+ is CR.ChatItemStatusUpdated -> {
+ val cInfo = r.chatItem.chatInfo
+ val cItem = r.chatItem.chatItem
+ var res = false
+ if (!cItem.isDeletedContent) {
+ res = chatModel.upsertChatItem(cInfo, cItem)
+ }
+ if (res) {
+ ntfManager.notifyMessageReceived(cInfo, cItem)
+ }
+ }
+ is CR.ChatItemUpdated ->
+ chatItemSimpleUpdate(r.chatItem)
+ is CR.ChatItemDeleted -> {
+ val cInfo = r.toChatItem.chatInfo
+ val cItem = r.toChatItem.chatItem
+ if (cItem.meta.itemDeleted) {
+ chatModel.removeChatItem(cInfo, cItem)
+ } else {
+ // currently only broadcast deletion of rcv message can be received, and only this case should happen
+ chatModel.upsertChatItem(cInfo, cItem)
+ }
+ }
+ is CR.ReceivedGroupInvitation -> {
+ chatModel.addChat(Chat(chatInfo = ChatInfo.Group(r.groupInfo), chatItems = listOf()))
+ // TODO NtfManager.shared.notifyGroupInvitation
+ }
+ is CR.JoinedGroupMemberConnecting ->
+ chatModel.upsertGroupMember(r.groupInfo, r.member)
+ is CR.DeletedMemberUser -> // TODO update user member
+ chatModel.updateGroup(r.groupInfo)
+ is CR.DeletedMember ->
+ chatModel.upsertGroupMember(r.groupInfo, r.deletedMember)
+ is CR.LeftMember ->
+ chatModel.upsertGroupMember(r.groupInfo, r.member)
+ is CR.GroupDeleted -> // TODO update user member
+ chatModel.updateGroup(r.groupInfo)
+ is CR.UserJoinedGroup ->
+ chatModel.updateGroup(r.groupInfo)
+ is CR.JoinedGroupMember ->
+ chatModel.upsertGroupMember(r.groupInfo, r.member)
+ is CR.ConnectedToGroupMember ->
+ chatModel.upsertGroupMember(r.groupInfo, r.member)
+ is CR.GroupUpdated ->
+ chatModel.updateGroup(r.toGroup)
+ is CR.RcvFileStart ->
+ chatItemSimpleUpdate(r.chatItem)
+ is CR.RcvFileComplete ->
+ chatItemSimpleUpdate(r.chatItem)
+ is CR.SndFileStart ->
+ chatItemSimpleUpdate(r.chatItem)
+ is CR.SndFileComplete -> {
+ chatItemSimpleUpdate(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 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(fileId: Long) {
+ val chatItem = apiReceiveFile(fileId)
+ if (chatItem != null) {
+ chatItemSimpleUpdate(chatItem)
+ }
+ }
+
+ suspend fun leaveGroup(groupId: Long) {
+ val groupInfo = apiLeaveGroup(groupId)
+ if (groupInfo != null) {
+ chatModel.updateGroup(groupInfo)
+ }
+ }
+
+ private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
+ val cInfo = aChatItem.chatInfo
+ val cItem = aChatItem.chatItem
+ if (chatModel.upsertChatItem(cInfo, cItem)) {
+ ntfManager.notifyMessageReceived(cInfo, cItem)
+ }
+ }
+
+ fun updateContactsStatus(contactRefs: List, status: Chat.NetworkStatus) {
+ for (c in contactRefs) {
+ chatModel.updateNetworkStatus(c.id, status)
+ }
+ }
+
+ fun processContactSubError(contact: Contact, chatError: ChatError) {
+ chatModel.updateContact(contact)
+ 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.updateNetworkStatus(contact.id, Chat.NetworkStatus.Error(err))
+ }
+
+ fun showBackgroundServiceNoticeIfNeeded() {
+ val mode = NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!)
+ Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
+ 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.stop(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 -> {
+ chatModel.performLA.value = false
+ appPrefs.performLA.set(false)
+ laErrorToast(appContext, laResult.errString)
+ }
+ LAResult.Failed -> {
+ chatModel.performLA.value = false
+ appPrefs.performLA.set(false)
+ laFailedToast(appContext)
+ }
+ 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 tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get()
+ val tcpTimeout = appPrefs.networkTCPTimeout.get()
+ val smpPingInterval = appPrefs.networkSMPPingInterval.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,
+ tcpConnectTimeout = tcpConnectTimeout,
+ tcpTimeout = tcpTimeout,
+ tcpKeepAlive = tcpKeepAlive,
+ smpPingInterval = smpPingInterval
+ )
+ }
+
+ fun setNetCfg(cfg: NetCfg) {
+ appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy)
+ appPrefs.networkHostMode.set(cfg.hostMode.name)
+ appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode)
+ appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout)
+ appPrefs.networkTCPTimeout.set(cfg.tcpTimeout)
+ appPrefs.networkSMPPingInterval.set(cfg.smpPingInterval)
+ 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 Preference(val get: () -> T, val set: (T) -> Unit)
+
+// ChatCommand
+sealed class CC {
+ class Console(val cmd: String): CC()
+ class ShowActiveUser: CC()
+ class CreateActiveUser(val profile: Profile): CC()
+ class StartChat: 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: 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): CC()
+ class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
+ class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
+ class NewGroup(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 GetUserSMPServers: CC()
+ class SetUserSMPServers(val smpServers: List): 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 AddContact: CC()
+ class Connect(val connReq: String): CC()
+ class ApiDeleteChat(val type: ChatType, val id: Long): CC()
+ class ApiClearChat(val type: ChatType, val id: Long): CC()
+ class ListContacts: CC()
+ class ApiUpdateProfile(val profile: Profile): CC()
+ class ApiParseMarkdown(val text: String): CC()
+ class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
+ class CreateMyAddress: CC()
+ class DeleteMyAddress: CC()
+ class ShowMyAddress: 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 ReceiveFile(val fileId: Long): CC()
+
+ val cmdString: String get() = when (this) {
+ is Console -> cmd
+ is ShowActiveUser -> "/u"
+ is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}"
+ is StartChat -> "/_start"
+ is ApiStopChat -> "/_stop"
+ is SetFilesFolder -> "/_files_folder $filesFolder"
+ is SetIncognito -> "/incognito ${if (incognito) "on" else "off"}"
+ 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 pcc=on"
+ is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
+ is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
+ is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
+ is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
+ is NewGroup -> "/_group ${json.encodeToString(groupProfile)}"
+ is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
+ is ApiJoinGroup -> "/_join #$groupId"
+ is ApiRemoveMember -> "/_remove #$groupId $memberId"
+ is ApiLeaveGroup -> "/_leave #$groupId"
+ is ApiListMembers -> "/_members #$groupId"
+ is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
+ is GetUserSMPServers -> "/smp_servers"
+ is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
+ 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 AddContact -> "/connect"
+ is Connect -> "/connect $connReq"
+ is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
+ is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
+ is ListContacts -> "/contacts"
+ is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
+ is ApiParseMarkdown -> "/_parse $text"
+ is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
+ is CreateMyAddress -> "/address"
+ is DeleteMyAddress -> "/delete_address"
+ is ShowMyAddress -> "/show_address"
+ 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 ReceiveFile -> "/freceive $fileId"
+ }
+
+ val cmdType: String get() = when (this) {
+ is Console -> "console command"
+ is ShowActiveUser -> "showActiveUser"
+ is CreateActiveUser -> "createActiveUser"
+ 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 NewGroup -> "newGroup"
+ is ApiAddMember -> "apiAddMember"
+ is ApiJoinGroup -> "apiJoinGroup"
+ is ApiRemoveMember -> "apiRemoveMember"
+ is ApiLeaveGroup -> "apiLeaveGroup"
+ is ApiListMembers -> "apiListMembers"
+ is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
+ is GetUserSMPServers -> "getUserSMPServers"
+ is SetUserSMPServers -> "setUserSMPServers"
+ is APISetNetworkConfig -> "/apiSetNetworkConfig"
+ is APIGetNetworkConfig -> "/apiGetNetworkConfig"
+ is APISetChatSettings -> "/apiSetChatSettings"
+ is APIContactInfo -> "apiContactInfo"
+ is APIGroupMemberInfo -> "apiGroupMemberInfo"
+ is AddContact -> "addContact"
+ is Connect -> "connect"
+ is ApiDeleteChat -> "apiDeleteChat"
+ is ApiClearChat -> "apiClearChat"
+ is ListContacts -> "listContacts"
+ is ApiUpdateProfile -> "updateProfile"
+ is ApiParseMarkdown -> "apiParseMarkdown"
+ is ApiSetContactAlias -> "apiSetContactAlias"
+ is CreateMyAddress -> "createMyAddress"
+ is DeleteMyAddress -> "deleteMyAddress"
+ is ShowMyAddress -> "showMyAddress"
+ 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 ReceiveFile -> "receiveFile"
+ }
+
+ class ItemRange(val from: Long, val to: Long)
+
+ 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 "***"
+
+ companion object {
+ fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
+
+ fun smpServersStr(smpServers: List) = if (smpServers.isEmpty()) "default" else smpServers.joinToString(separator = ",")
+ }
+}
+
+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 NetCfg(
+ val socksProxy: String? = null,
+ val hostMode: HostMode = HostMode.OnionViaSocks,
+ val requiredHostMode: Boolean = false,
+ val tcpConnectTimeout: Long, // microseconds
+ val tcpTimeout: Long, // microseconds
+ val tcpKeepAlive: KeepAliveOpts?,
+ val smpPingInterval: Long // microseconds
+) {
+ val useSocksProxy: Boolean get() = socksProxy != null
+ val enableKeepAlive: Boolean get() = tcpKeepAlive != null
+
+ companion object {
+ val defaults: NetCfg =
+ NetCfg(
+ socksProxy = null,
+ tcpConnectTimeout = 7_500_000,
+ tcpTimeout = 5_000_000,
+ tcpKeepAlive = KeepAliveOpts.defaults,
+ smpPingInterval = 600_000_000
+ )
+
+ val proxyDefaults: NetCfg =
+ NetCfg(
+ socksProxy = ":9050",
+ tcpConnectTimeout = 15_000_000,
+ tcpTimeout = 10_000_000,
+ tcpKeepAlive = KeepAliveOpts.defaults,
+ smpPingInterval = 600_000_000
+ )
+ }
+
+ 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
+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
+)
+
+val json = Json {
+ prettyPrint = true
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+}
+
+@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
+ APIResponse(
+ resp = CR.Response(data["resp"]!!.jsonObject["type"]?.toString() ?: "invalid", json.encodeToString(data)),
+ corr = data["corr"]?.toString()
+ )
+ } catch(e: Exception) {
+ APIResponse(CR.Invalid(str))
+ }
+ }
+ }
+ }
+}
+
+// ChatResponse
+@Serializable
+sealed class CR {
+ @Serializable @SerialName("activeUser") class ActiveUser(val user: User): 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 chats: List): CR()
+ @Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
+ @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List): CR()
+ @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
+ @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR()
+ @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR()
+ @Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR()
+ @Serializable @SerialName("sentConfirmation") class SentConfirmation: CR()
+ @Serializable @SerialName("sentInvitation") class SentInvitation: CR()
+ @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val contact: Contact): CR()
+ @Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR()
+ @Serializable @SerialName("chatCleared") class ChatCleared(val chatInfo: ChatInfo): CR()
+ @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR()
+ @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
+ @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR()
+ @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR()
+ @Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR()
+ @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR()
+ @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR()
+ @Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact): CR()
+ @Serializable @SerialName("contactConnecting") class ContactConnecting(val contact: Contact): CR()
+ @Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val contactRequest: UserContactRequest): CR()
+ @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR()
+ @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected: CR()
+ @Serializable @SerialName("contactUpdated") class ContactUpdated(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 contact: Contact, val chatError: ChatError): CR()
+ @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List): CR()
+ @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR()
+ @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val memberSubErrors: List): CR()
+ @Serializable @SerialName("groupEmpty") class GroupEmpty(val group: GroupInfo): CR()
+ @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
+ @Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR()
+ @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
+ @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
+ @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR()
+ @Serializable @SerialName("contactsList") class ContactsList(val contacts: List): CR()
+ // group events
+ @Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR()
+ @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
+ @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val groupInfo: GroupInfo): CR()
+ @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val groupInfo: GroupInfo): CR()
+ @Serializable @SerialName("groupMembers") class GroupMembers(val group: Group): CR()
+ @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR()
+ @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val groupInfo: GroupInfo): CR()
+ @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR()
+ @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("deletedMember") class DeletedMember(val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR()
+ @Serializable @SerialName("leftMember") class LeftMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("groupDeleted") class GroupDeleted(val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("contactsMerged") class ContactsMerged(val intoContact: Contact, val mergedContact: Contact): CR()
+ @Serializable @SerialName("groupInvitation") class GroupInvitation(val groupInfo: GroupInfo): CR() // unused
+ @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val groupInfo: GroupInfo): CR()
+ @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
+ @Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR() // unused
+ @Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR()
+ // receiving file events
+ @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
+ @Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val rcvFileTransfer: RcvFileTransfer): CR()
+ @Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR()
+ @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR()
+ // sending file events
+ @Serializable @SerialName("sndFileStart") class SndFileStart(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
+ @Serializable @SerialName("sndFileComplete") class SndFileComplete(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 chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
+ @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(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 contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
+ @Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
+ @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
+ @Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR()
+ @Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR()
+ @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR()
+ @Serializable @SerialName("cmdOk") class CmdOk: CR()
+ @Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR()
+ @Serializable @SerialName("chatError") class ChatRespError(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 ChatStarted -> "chatStarted"
+ is ChatRunning -> "chatRunning"
+ is ChatStopped -> "chatStopped"
+ is ApiChats -> "apiChats"
+ is ApiChat -> "apiChat"
+ is UserSMPServers -> "userSMPServers"
+ is NetworkConfig -> "networkConfig"
+ is ContactInfo -> "contactInfo"
+ is GroupMemberInfo -> "groupMemberInfo"
+ 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 ParsedMarkdown -> "apiParsedMarkdown"
+ is UserContactLink -> "userContactLink"
+ 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 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 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 CmdOk -> "cmdOk"
+ is ChatCmdError -> "chatCmdError"
+ is ChatRespError -> "chatError"
+ is Response -> "* $type"
+ is Invalid -> "* invalid json"
+ }
+
+ val details: String get() = when(this) {
+ is ActiveUser -> json.encodeToString(user)
+ is ChatStarted -> noDetails()
+ is ChatRunning -> noDetails()
+ is ChatStopped -> noDetails()
+ is ApiChats -> json.encodeToString(chats)
+ is ApiChat -> json.encodeToString(chat)
+ is UserSMPServers -> json.encodeToString(smpServers)
+ is NetworkConfig -> json.encodeToString(networkConfig)
+ is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}"
+ is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}"
+ is Invitation -> connReqInvitation
+ is SentConfirmation -> noDetails()
+ is SentInvitation -> noDetails()
+ is ContactAlreadyExists -> json.encodeToString(contact)
+ is ContactDeleted -> json.encodeToString(contact)
+ is ChatCleared -> json.encodeToString(chatInfo)
+ is UserProfileNoChange -> noDetails()
+ is UserProfileUpdated -> json.encodeToString(toProfile)
+ is ContactAliasUpdated -> json.encodeToString(toContact)
+ is ParsedMarkdown -> json.encodeToString(formattedText)
+ is UserContactLink -> connReqContact
+ is UserContactLinkCreated -> connReqContact
+ is UserContactLinkDeleted -> noDetails()
+ is ContactConnected -> json.encodeToString(contact)
+ is ContactConnecting -> json.encodeToString(contact)
+ is ReceivedContactRequest -> json.encodeToString(contactRequest)
+ is AcceptingContactRequest -> json.encodeToString(contact)
+ is ContactRequestRejected -> noDetails()
+ is ContactUpdated -> json.encodeToString(toContact)
+ is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
+ is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
+ is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}"
+ is ContactSubSummary -> json.encodeToString(contactSubscriptions)
+ is GroupSubscribed -> json.encodeToString(group)
+ is MemberSubErrors -> json.encodeToString(memberSubErrors)
+ is GroupEmpty -> json.encodeToString(group)
+ is UserContactLinkSubscribed -> noDetails()
+ is NewChatItem -> json.encodeToString(chatItem)
+ is ChatItemStatusUpdated -> json.encodeToString(chatItem)
+ is ChatItemUpdated -> json.encodeToString(chatItem)
+ is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
+ is ContactsList -> json.encodeToString(contacts)
+ is GroupCreated -> json.encodeToString(groupInfo)
+ is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmember: $member"
+ is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
+ is UserDeletedMember -> "groupInfo: $groupInfo\nmember: $member"
+ is LeftMemberUser -> json.encodeToString(groupInfo)
+ is GroupMembers -> json.encodeToString(group)
+ is ReceivedGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole"
+ is GroupDeletedUser -> json.encodeToString(groupInfo)
+ is JoinedGroupMemberConnecting -> "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member"
+ is DeletedMemberUser -> "groupInfo: $groupInfo\nmember: $member"
+ is DeletedMember -> "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember"
+ is LeftMember -> "groupInfo: $groupInfo\nmember: $member"
+ is GroupDeleted -> "groupInfo: $groupInfo\nmember: $member"
+ is ContactsMerged -> "intoContact: $intoContact\nmergedContact: $mergedContact"
+ is GroupInvitation -> json.encodeToString(groupInfo)
+ is UserJoinedGroup -> json.encodeToString(groupInfo)
+ is JoinedGroupMember -> "groupInfo: $groupInfo\nmember: $member"
+ is ConnectedToGroupMember -> "groupInfo: $groupInfo\nmember: $member"
+ is GroupRemoved -> json.encodeToString(groupInfo)
+ is GroupUpdated -> json.encodeToString(toGroup)
+ is RcvFileAcceptedSndCancelled -> noDetails()
+ is RcvFileAccepted -> json.encodeToString(chatItem)
+ is RcvFileStart -> json.encodeToString(chatItem)
+ is RcvFileComplete -> json.encodeToString(chatItem)
+ is SndFileCancelled -> json.encodeToString(chatItem)
+ is SndFileComplete -> json.encodeToString(chatItem)
+ is SndFileRcvCancelled -> json.encodeToString(chatItem)
+ is SndFileStart -> json.encodeToString(chatItem)
+ is SndGroupFileCancelled -> json.encodeToString(chatItem)
+ is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
+ is CallOffer -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}"
+ is CallAnswer -> "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}"
+ is CallExtraInfo -> "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}"
+ is CallEnded -> "contact: ${contact.id}"
+ is NewContactConnection -> json.encodeToString(connection)
+ is ContactConnectionDeleted -> json.encodeToString(connection)
+ is CmdOk -> noDetails()
+ is ChatCmdError -> chatError.string
+ is ChatRespError -> chatError.string
+ is Response -> json
+ is Invalid -> str
+ }
+
+ fun noDetails(): String ="${responseType}: " + generalGetString(R.string.no_details)
+}
+
+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
+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 InvalidConnReq -> "invalidConnReq"
+ is ContactGroups -> "groupNames $groupNames"
+ is СommandError -> "commandError $message"
+ }
+ @Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType()
+ @Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
+ @Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List): 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"
+ }
+ @Serializable @SerialName("userContactLinkNotFound") class UserContactLinkNotFound: StoreError()
+ @Serializable @SerialName("groupNotFound") class GroupNotFound: 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 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 62%
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..8ac28570f5 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,19 +1,13 @@
-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)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val Gray = Color(0x22222222)
-val Indigo = Color(0xFF9966FF)
+val Indigo = Color(0xff330099)
val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files
val SimplexGreen = Color(77, 218, 103, 255)
val SecretColor = Color(0x40808080)
@@ -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..e5b7bba032
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt
@@ -0,0 +1,71 @@
+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 chat.simplex.app.SimplexApp
+import kotlinx.coroutines.flow.MutableStateFlow
+
+enum class DefaultTheme {
+ SYSTEM, DARK, LIGHT
+}
+
+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..608cbb2bfc
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
@@ -0,0 +1,182 @@
+package chat.simplex.app.views
+
+import android.content.Context
+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.material.icons.Icons
+import androidx.compose.material.icons.outlined.Lock
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+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 androidx.fragment.app.FragmentActivity
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.SimpleButton
+import chat.simplex.app.ui.theme.SimpleXTheme
+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)
+ val authorized = remember { mutableStateOf(!chatModel.controller.appPrefs.performLA.get()) }
+ val context = LocalContext.current
+ LaunchedEffect(authorized.value) {
+ if (!authorized.value) {
+ runAuth(authorized = authorized, context)
+ }
+ }
+ if (authorized.value) {
+ TerminalLayout(
+ chatModel.terminalItems,
+ composeState,
+ sendCommand = { sendCommand(chatModel, composeState) },
+ close
+ )
+ } else {
+ Surface(Modifier.fillMaxSize()) {
+ Column(Modifier.background(MaterialTheme.colors.background)) {
+ CloseSheetBar(close)
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ SimpleButton(
+ stringResource(R.string.auth_unlock),
+ icon = Icons.Outlined.Lock,
+ click = {
+ runAuth(authorized = authorized, context)
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun runAuth(authorized: MutableState, context: Context) {
+ authenticate(
+ generalGetString(R.string.auth_open_chat_console),
+ generalGetString(R.string.auth_log_in_using_credential),
+ context as FragmentActivity,
+ completed = { laResult ->
+ when (laResult) {
+ LAResult.Success, LAResult.Unavailable -> authorized.value = true
+ is LAResult.Error, LAResult.Failed -> authorized.value = false
+ }
+ }
+ )
+}
+
+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(ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
+ chatModel.terminalItems.add(TerminalItem.cmd(CC.Console(s)))
+ chatModel.terminalItems.add(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, sendCommand, ::onMessageChange, textStyle)
+ }
+ },
+ modifier = Modifier.navigationBarsWithImePadding()
+ ) { contentPadding ->
+ Surface(
+ modifier = Modifier
+ .padding(contentPadding)
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.background)
+ ) {
+ TerminalLog(terminalItems)
+ }
+ }
+ }
+}
+
+@Composable
+fun TerminalLog(terminalItems: List) {
+ val listState = rememberLazyListState()
+ val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
+ 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
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ .clickable {
+ ModalManager.shared.showModal {
+ SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ Text(item.details)
+ }
+ }
+ }
+ )
+ }
+ }
+}
+
+@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..b7faf8e8b6
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
@@ -0,0 +1,144 @@
+package chat.simplex.app.views
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.R
+import chat.simplex.app.SimplexService
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.model.Profile
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleButton
+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
+
+fun isValidDisplayName(name: String) : Boolean {
+ return (name.firstOrNull { it.isWhitespace() }) == null
+}
+
+@Composable
+fun CreateProfilePanel(chatModel: ChatModel) {
+ val displayName = remember { mutableStateOf("") }
+ val fullName = remember { mutableStateOf("") }
+ val focusRequester = remember { FocusRequester() }
+
+ Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Text(
+ stringResource(R.string.create_profile),
+ style = MaterialTheme.typography.h4,
+ modifier = Modifier.padding(vertical = 5.dp)
+ )
+ 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 {
+ 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) }.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) {
+ focusRequester.requestFocus()
+ }
+ }
+ }
+}
+
+fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
+ withApi {
+ val user = chatModel.controller.apiCreateActiveUser(
+ Profile(displayName, fullName, null)
+ )
+ chatModel.controller.startChat(user)
+ chatModel.controller.showBackgroundServiceNoticeIfNeeded()
+ SimplexService.start(chatModel.controller.appContext)
+ chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
+ }
+}
+
+@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..60f67b9337
--- /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(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..2b0f39a346
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt
@@ -0,0 +1,483 @@
+package chat.simplex.app.views.call
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.content.pm.ActivityInfo
+import android.media.AudioManager
+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.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.R
+import chat.simplex.app.TAG
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.helpers.ProfileImage
+import chat.simplex.app.views.helpers.withApi
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+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 cxt = LocalContext.current
+ 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)
+ }
+ 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 {
+ delay(2000L)
+ setCallSound(cxt, call)
+ }
+ }
+ 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)
+ }
+
+ val context = LocalContext.current
+ DisposableEffect(Unit) {
+ val activity = context as? Activity ?: return@DisposableEffect onDispose {}
+ // Lock orientation to portrait in order to have good experience with calls
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ onDispose {
+ // Unlock orientation
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+}
+
+@Composable
+private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
+ var cxt = LocalContext.current
+ ActiveCallOverlayLayout(
+ call = call,
+ 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(cxt, call)
+ }
+ },
+ flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
+ )
+}
+
+private fun setCallSound(cxt: Context, call: Call) {
+ Log.d(TAG, "setCallSound: set audio mode")
+ val am = cxt.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ if (call.soundSpeaker) {
+ am.mode = AudioManager.MODE_NORMAL
+ am.isSpeakerphoneOn = true
+ } else {
+ am.mode = AudioManager.MODE_IN_CALL
+ am.isSpeakerphoneOn = false
+ }
+}
+
+@Composable
+private fun ActiveCallOverlayLayout(
+ call: Call,
+ 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 = 48.dp), 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, toggleSound)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit) {
+ if (call.hasMedia) {
+ IconButton(onClick = action) {
+ Icon(icon, stringResource(iconText), tint = Color(0xFFFFFFD8), 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, toggleSound: () -> Unit) {
+ if (call.soundSpeaker) {
+ ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound)
+ } else {
+ ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound)
+ }
+}
+
+@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 =
+ if (call.connectionInfo == null) ""
+ else " (${call.connectionInfo.text})"
+ InfoText(call.encryptionStatus + connInfo)
+ }
+}
+
+//@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 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)
+ withApi {
+ 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), RTCIceCandidate(RTCIceCandidateType.Host))
+ ),
+ 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), RTCIceCandidate(RTCIceCandidateType.Host))
+ ),
+ 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..283b6be684
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt
@@ -0,0 +1,227 @@
+package chat.simplex.app.views.call
+
+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)
+ val activity = this
+ setContent { IncomingCallActivityView(vm.chatModel, activity) }
+ 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, activity: IncomingCallActivity) {
+ val switchingCall = m.switchingCall.value
+ val invitation = m.activeCallInvitation.value
+ val call = m.activeCall.value
+ val showCallView = m.showCallView.value
+ 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, activity)
+ }
+ }
+ }
+}
+
+@Composable
+fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
+ val cm = chatModel.callManager
+ val cxt = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
+ LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
+ DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
+ IncomingCallLockScreenAlertLayout(
+ invitation,
+ callOnLockScreen,
+ rejectCall = { cm.endCall(invitation = invitation) },
+ ignoreCall = { chatModel.activeCallInvitation.value = null },
+ acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
+ openApp = {
+ SoundPlayer.shared.stop()
+ var intent = Intent(activity, MainActivity::class.java)
+ .setAction(OpenChatAction)
+ .putExtra("chatId", invitation.contact.id)
+ activity.startActivity(intent)
+ activity.finish()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ getKeyguardManager(activity).requestDismissKeyguard(activity, null)
+ }
+ }
+ )
+}
+
+@Composable
+fun IncomingCallLockScreenAlertLayout(
+ invitation: RcvCallInvitation,
+ callOnLockScreen: CallOnLockScreen?,
+ rejectCall: () -> Unit,
+ ignoreCall: () -> Unit,
+ acceptCall: () -> Unit,
+ openApp: () -> Unit
+) {
+ Column(
+ Modifier
+ .padding(30.dp)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ IncomingCallInfo(invitation)
+ 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(
+ contact = Contact.sampleData,
+ callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
+ sharedKey = null,
+ callTs = Clock.System.now()
+ ),
+ callOnLockScreen = null,
+ rejectCall = {},
+ ignoreCall = {},
+ acceptCall = {},
+ openApp = {},
+ )
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt
new file mode 100644
index 0000000000..455fc915f3
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt
@@ -0,0 +1,111 @@
+package chat.simplex.app.views.call
+
+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.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.app.R
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.model.Contact
+import chat.simplex.app.ui.theme.*
+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(true) { SoundPlayer.shared.start(cxt, scope, sound = !chatModel.showCallView.value) }
+ DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
+ IncomingCallAlertLayout(
+ invitation,
+ rejectCall = { cm.endCall(invitation = invitation) },
+ ignoreCall = { chatModel.activeCallInvitation.value = null },
+ acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
+ )
+}
+
+@Composable
+fun IncomingCallAlertLayout(
+ invitation: RcvCallInvitation,
+ rejectCall: () -> Unit,
+ ignoreCall: () -> Unit,
+ acceptCall: () -> Unit
+) {
+ 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)
+ 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, color = Color.White)
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ 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)
+ }
+ }
+ }
+}
+
+@Composable
+fun IncomingCallInfo(invitation: RcvCallInvitation) {
+ @Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
+ Row {
+ 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)
+ }
+}
+
+@Composable
+private fun CallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
+ Surface(
+ shape = RoundedCornerShape(10.dp),
+ color = Color.Transparent
+ ) {
+ Column(
+ Modifier
+ .clickable(onClick = action)
+ .defaultMinSize(minWidth = 50.dp)
+ .padding(4.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
+ Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewIncomingCallAlertLayout() {
+ SimpleXTheme {
+ IncomingCallAlertLayout(
+ invitation = RcvCallInvitation(
+ contact = Contact.sampleData,
+ callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
+ sharedKey = null,
+ callTs = Clock.System.now()
+ ),
+ 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..f451330a7a
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt
@@ -0,0 +1,39 @@
+package chat.simplex.app.views.call
+
+import android.content.Context
+import android.media.MediaPlayer
+import android.os.VibrationEffect
+import android.os.Vibrator
+import androidx.core.content.ContextCompat
+import chat.simplex.app.R
+import chat.simplex.app.views.helpers.withScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+
+class SoundPlayer {
+ var player: MediaPlayer? = null
+ var playing = false
+
+ fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) {
+ if (sound) player = MediaPlayer.create(cxt, R.raw.ring_once)
+ 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/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt
new file mode 100644
index 0000000000..94ab0ad4c6
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt
@@ -0,0 +1,202 @@
+package chat.simplex.app.views.call
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import chat.simplex.app.R
+import chat.simplex.app.SimplexApp
+import chat.simplex.app.model.Contact
+import chat.simplex.app.views.helpers.generalGetString
+import kotlinx.datetime.Instant
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import java.net.URI
+
+data class Call(
+ val contact: Contact,
+ val callState: CallState,
+ val localMedia: CallMediaType,
+ val localCapabilities: CallCapabilities? = null,
+ 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
+) {
+ val encrypted: Boolean get() = localEncrypted && sharedKey != null
+ val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
+
+ val encryptionStatus: String @Composable get() = when(callState) {
+ CallState.WaitCapabilities -> ""
+ 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 hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
+}
+
+enum class CallState {
+ WaitCapabilities,
+ InvitationSent,
+ InvitationAccepted,
+ OfferSent,
+ OfferReceived,
+ AnswerReceived,
+ Negotiated,
+ Connected,
+ 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 class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
+@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
+
+@Serializable
+sealed class WCallCommand {
+ @Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
+ @Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand()
+ @Serializable @SerialName("offer") 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") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
+ @Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
+ @Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
+ @Serializable @SerialName("camera") class Camera(val camera: VideoCamera): WCallCommand()
+ @Serializable @SerialName("end") object End: WCallCommand()
+}
+
+@Serializable
+sealed class WCallResponse {
+ @Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
+ @Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
+ @Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
+ @Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
+ @Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
+ @Serializable @SerialName("connected") class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
+ @Serializable @SerialName("ended") object Ended: WCallResponse()
+ @Serializable @SerialName("ok") object Ok: WCallResponse()
+ @Serializable @SerialName("error") class Error(val message: String): WCallResponse()
+}
+
+@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
+@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
+@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
+@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
+@Serializable class RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?, val callTs: Instant) {
+ val callTypeText: String get() = generalGetString(when(callType.media) {
+ 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 -> R.string.incoming_video_call
+ CallMediaType.Audio -> R.string.incoming_audio_call
+ })
+}
+@Serializable class CallCapabilities(val encryption: Boolean)
+@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
+ val text: String @Composable get() = when {
+ localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
+ stringResource(R.string.call_connection_peer_to_peer)
+ localCandidate?.candidateType == RTCIceCandidateType.Relay && remoteCandidate?.candidateType == RTCIceCandidateType.Relay ->
+ stringResource(R.string.call_connection_via_relay)
+ else ->
+ "${localCandidate?.candidateType?.value ?: "unknown"} / ${remoteCandidate?.candidateType?.value ?: "unknown"}"
+ }
+}
+// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
+@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
+// 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)
+
+// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type
+@Serializable
+enum class RTCIceCandidateType(val value: String) {
+ @SerialName("host") Host("host"),
+ @SerialName("srflx") ServerReflexive("srflx"),
+ @SerialName("prflx") PeerReflexive("prflx"),
+ @SerialName("relay") Relay("relay")
+}
+
+@Serializable
+enum class WebRTCCallStatus(val value: String) {
+ @SerialName("connected") Connected("connected"),
+ @SerialName("connecting") Connecting("connecting"),
+ @SerialName("disconnected") Disconnected("disconnected"),
+ @SerialName("failed") Failed("failed")
+}
+
+@Serializable
+enum class CallMediaType {
+ @SerialName("video") Video,
+ @SerialName("audio") Audio
+}
+
+@Serializable
+enum class VideoCamera {
+ @SerialName("user") User,
+ @SerialName("environment") Environment;
+ val flipped: VideoCamera get() = if (this == User) Environment else User
+}
+
+@Serializable
+class ConnectionState(
+ val connectionState: String,
+ val iceConnectionState: String,
+ val iceGatheringState: String,
+ val signalingState: String
+)
+
+// the servers are expected in this format:
+// stun:stun.simplex.im:443
+// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443
+fun parseRTCIceServer(str: String): RTCIceServer? {
+ var s = replaceScheme(str, "stun:")
+ s = replaceScheme(s, "turn:")
+ val u = runCatching { URI(s) }.getOrNull()
+ if (u != null) {
+ val scheme = u.scheme
+ val host = u.host
+ val port = u.port
+ if (u.path == "" && (scheme == "stun" || scheme == "turn")) {
+ val userInfo = u.userInfo?.split(":")
+ return RTCIceServer(
+ urls = listOf("$scheme:$host:$port"),
+ username = userInfo?.getOrNull(0),
+ credential = userInfo?.getOrNull(1)
+ )
+ }
+ }
+ return null
+}
+
+private fun replaceScheme(s: String, scheme: String): String = if (s.startsWith(scheme)) s.replace(scheme, "$scheme//") else s
+
+fun parseRTCIceServers(servers: List): List? {
+ val iceServers: ArrayList = ArrayList()
+ for (s in servers) {
+ val server = parseRTCIceServer(s)
+ if (server != null) {
+ iceServers.add(server)
+ } else {
+ return null
+ }
+ }
+ return if (iceServers.isEmpty()) null else iceServers
+}
+
+fun getIceServers(): List? {
+ 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..dc7deb6b3d
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt
@@ -0,0 +1,370 @@
+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 kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.*
+
+@Composable
+fun ChatInfoView(
+ chatModel: ChatModel,
+ contact: Contact,
+ connStats: ConnectionStats?,
+ customUserProfile: Profile?,
+ localAlias: String,
+ close: () -> Unit,
+ onChatUpdated: (Chat) -> Unit,
+) {
+ BackHandler(onBack = close)
+ val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
+ val developerTools = chatModel.controller.appPrefs.developerTools.get()
+ if (chat != null) {
+ ChatInfoLayout(
+ chat,
+ contact,
+ connStats,
+ customUserProfile,
+ localAlias,
+ developerTools,
+ onLocalAliasChanged = {
+ setContactAlias(chat.chatInfo.apiId, it, chatModel, onChatUpdated)
+ },
+ deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
+ clearChat = { clearChatDialog(chat.chatInfo, chatModel, 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?,
+ customUserProfile: Profile?,
+ localAlias: String,
+ developerTools: Boolean,
+ onLocalAliasChanged: (String) -> Unit,
+ deleteContact: () -> Unit,
+ clearChat: () -> 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()
+
+ if (connStats != null) {
+ SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
+ SectionItemView {
+ NetworkStatusRow(chat.serverInfo.networkStatus)
+ }
+ 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 {
+ SectionItemView {
+ ClearChatButton(clearChat)
+ }
+ SectionDivider()
+ SectionItemView {
+ 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)
+ Text(
+ contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
+ color = MaterialTheme.colors.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ 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
+private fun LocalAliasEditor(initialValue: String, updateValue: (String) -> Unit) {
+ var value by rememberSaveable { mutableStateOf(initialValue) }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
+ DefaultBasicTextField(
+ Modifier.padding(horizontal = 10.dp).widthIn(min = 100.dp),
+ value,
+ {
+ Text(
+ generalGetString(R.string.text_field_set_contact_placeholder),
+ textAlign = TextAlign.Center,
+ color = HighOrLowlight
+ )
+ },
+ color = HighOrLowlight,
+ textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty()) 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
+fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
+ Row(
+ Modifier
+ .fillMaxSize()
+ .clickable {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.network_status),
+ networkStatus.statusExplanation
+ )
+ },
+ 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
+fun ServerImage(networkStatus: Chat.NetworkStatus) {
+ Box(Modifier.size(18.dp)) {
+ when (networkStatus) {
+ is Chat.NetworkStatus.Connected ->
+ Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
+ is Chat.NetworkStatus.Disconnected ->
+ Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
+ is Chat.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 ClearChatButton(clearChat: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxSize()
+ .clickable { clearChat() },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Restore,
+ stringResource(R.string.clear_chat_button),
+ tint = WarningOrange
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.clear_chat_button), color = WarningOrange)
+ }
+}
+
+@Composable
+fun DeleteContactButton(deleteContact: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxSize()
+ .clickable { deleteContact() },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Delete,
+ stringResource(R.string.button_delete_contact),
+ tint = Color.Red
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.button_delete_contact), color = Color.Red)
+ }
+}
+
+private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel, onChatUpdated: (Chat) -> Unit) = withApi {
+ chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let {
+ chatModel.updateContact(it)
+ onChatUpdated(chatModel.getChat(chatModel.chatId.value ?: return@withApi) ?: return@withApi)
+ }
+}
+
+@Preview
+@Composable
+fun PreviewChatInfoLayout() {
+ SimpleXTheme {
+ ChatInfoLayout(
+ chat = Chat(
+ chatInfo = ChatInfo.Direct.sampleData,
+ chatItems = arrayListOf(),
+ serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
+ ),
+ Contact.sampleData,
+ localAlias = "",
+ developerTools = false,
+ connStats = null,
+ onLocalAliasChanged = {},
+ customUserProfile = null,
+ deleteContact = {}, clearChat = {}
+ )
+ }
+}
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..7453bdd6ae
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
@@ -0,0 +1,925 @@
+package chat.simplex.app.views.chat
+
+import android.content.res.Configuration
+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.KeyboardArrowDown
+import androidx.compose.material.icons.filled.MoreVert
+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 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.ChatItemView
+import chat.simplex.app.views.chat.item.ItemAction
+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
+
+@Composable
+fun ChatView(chatModel: ChatModel) {
+ var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
+ val searchText = rememberSaveable { mutableStateOf("") }
+ val user = chatModel.currentUser.value
+ val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
+ val composeState = rememberSaveable(saver = ComposeState.saver()) {
+ mutableStateOf(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
+ snapshotFlow { chatModel.chatId.value }
+ .distinctUntilChanged()
+ .collect {
+ activeChat = if (chatModel.chatId.value == null) {
+ null
+ } else {
+ // 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
+ chatModel.getChat(chatModel.chatId.value!!)
+ }
+ }
+ }
+
+ if (activeChat == null || user == null) {
+ chatModel.chatId.value = null
+ } else {
+ val chat = activeChat!!
+ BackHandler { chatModel.chatId.value = null }
+
+ // 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 == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
+ }
+ }
+
+ ChatLayout(
+ user,
+ chat,
+ unreadCount,
+ composeState,
+ composeView = {
+ if (chat.chatInfo.sendMsgEnabled) {
+ ComposeView(
+ chatModel, chat, composeState, attachmentOption,
+ showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
+ )
+ }
+ },
+ attachmentOption,
+ scope,
+ attachmentBottomSheetState,
+ chatModel.chatItems,
+ searchText,
+ useLinkPreviews = useLinkPreviews,
+ chatModelIncognito = chatModel.incognito.value,
+ back = { chatModel.chatId.value = null },
+ info = {
+ withApi {
+ val cInfo = chat.chatInfo
+ if (cInfo is ChatInfo.Direct) {
+ val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
+ ModalManager.shared.showCustomModal { close ->
+ ModalView(
+ close = close, modifier = Modifier,
+ background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
+ ) {
+ ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) {
+ activeChat = it
+ }
+ }
+ }
+ } else if (cInfo is ChatInfo.Group) {
+ setGroupMembers(cInfo.groupInfo, chatModel)
+ ModalManager.shared.showCustomModal { close ->
+ ModalView(
+ close = close, modifier = Modifier,
+ background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
+ ) {
+ GroupChatInfoView(chatModel, close)
+ }
+ }
+ }
+ }
+ },
+ showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
+ withApi {
+ val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
+ ModalManager.shared.showCustomModal { close ->
+ ModalView(
+ close = close, modifier = Modifier,
+ background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
+ ) {
+ GroupMemberInfoView(groupInfo, member, stats, 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 toItem = chatModel.controller.apiDeleteChatItem(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ itemId = itemId,
+ mode = mode
+ )
+ if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
+ }
+ },
+ receiveFile = { fileId ->
+ withApi { chatModel.controller.receiveFile(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 ->
+ val invitation = chatModel.callInvitations.remove(contact.id)
+ if (invitation == null) {
+ AlertManager.shared.showAlertMsg("Call already ended!")
+ } else {
+ chatModel.callManager.acceptIncomingCall(invitation = invitation)
+ }
+ },
+ addMembers = { groupInfo ->
+ withApi {
+ setGroupMembers(groupInfo, chatModel)
+ ModalManager.shared.showCustomModal { close ->
+ ModalView(
+ close = close, modifier = Modifier,
+ background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
+ ) {
+ AddGroupMembersView(groupInfo, chatModel, close)
+ }
+ }
+ }
+ },
+ markRead = { range, unreadCountAfter ->
+ chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
+ chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
+ withApi {
+ 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
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun ChatLayout(
+ user: User,
+ chat: Chat,
+ unreadCount: State,
+ composeState: MutableState,
+ composeView: (@Composable () -> Unit),
+ attachmentOption: MutableState,
+ scope: CoroutineScope,
+ attachmentBottomSheetState: ModalBottomSheetState,
+ chatItems: List,
+ searchValue: State,
+ useLinkPreviews: Boolean,
+ 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,
+ addMembers: (GroupInfo) -> Unit,
+ markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
+ changeNtfsState: (Boolean, currentValue: MutableState) -> Unit,
+ onSearchValueChanged: (String) -> Unit,
+) {
+ Surface(
+ 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(
+ user, chat, unreadCount, composeState, chatItems, searchValue,
+ useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
+ receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@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
+ ) {
+ 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
+ )
+ }
+ }
+ }
+}
+
+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(
+ user: User,
+ chat: Chat,
+ unreadCount: State,
+ composeState: MutableState,
+ chatItems: List,
+ searchValue: State,
+ useLinkPreviews: Boolean,
+ chatModelIncognito: Boolean,
+ showMemberInfo: (GroupInfo, GroupMember) -> Unit,
+ loadPrevMessages: (ChatInfo) -> Unit,
+ deleteMessage: (Long, CIDeleteMode) -> Unit,
+ receiveFile: (Long) -> Unit,
+ joinGroup: (Long) -> Unit,
+ acceptCall: (Contact) -> Unit,
+ markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
+ setFloatingButton: (@Composable () -> Unit) -> Unit,
+) {
+ val listState = rememberLazyListState()
+ val scope = rememberCoroutineScope()
+ val uriHandler = LocalUriHandler.current
+ val cxt = LocalContext.current
+
+ // 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) }
+ LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType, shouldAutoScroll) {
+ if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) {
+ scope.launch { listState.scrollToItem(0) }
+ }
+ // Don't autoscroll next time until it will be needed
+ shouldAutoScroll = false
+ }
+ 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() } }
+ LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
+ itemsIndexed(reversedChatItems) { 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 {
+ composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
+ }
+ }
+ }
+ }
+
+ 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(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
+ }
+ } else {
+ Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) {
+ ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
+ }
+ }
+ } 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(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
+ }
+ }
+
+ 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
+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
+ 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 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(
+ user = User.sampleData,
+ chat = Chat(
+ chatInfo = ChatInfo.Direct.sampleData,
+ chatItems = chatItems,
+ chatStats = Chat.ChatStats()
+ ),
+ unreadCount = unreadCount,
+ composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
+ composeView = {},
+ attachmentOption = remember { mutableStateOf(null) },
+ scope = rememberCoroutineScope(),
+ attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
+ chatItems = chatItems,
+ searchValue,
+ useLinkPreviews = true,
+ chatModelIncognito = false,
+ back = {},
+ info = {},
+ showMemberInfo = {_, _ -> },
+ loadPrevMessages = { _ -> },
+ deleteMessage = { _, _ -> },
+ receiveFile = {},
+ joinGroup = {},
+ startCall = {},
+ acceptCall = { _ -> },
+ addMembers = { _ -> },
+ markRead = { _, _ -> },
+ changeNtfsState = { _, _ -> },
+ onSearchValueChanged = {},
+ )
+ }
+}
+
+@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(
+ user = User.sampleData,
+ chat = Chat(
+ chatInfo = ChatInfo.Group.sampleData,
+ chatItems = chatItems,
+ chatStats = Chat.ChatStats()
+ ),
+ unreadCount = unreadCount,
+ composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
+ composeView = {},
+ attachmentOption = remember { mutableStateOf(null) },
+ scope = rememberCoroutineScope(),
+ attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
+ chatItems = chatItems,
+ searchValue,
+ useLinkPreviews = true,
+ chatModelIncognito = false,
+ back = {},
+ info = {},
+ showMemberInfo = {_, _ -> },
+ loadPrevMessages = { _ -> },
+ deleteMessage = { _, _ -> },
+ receiveFile = {},
+ joinGroup = {},
+ startCall = {},
+ acceptCall = { _ -> },
+ addMembers = { _ -> },
+ markRead = { _, _ -> },
+ changeNtfsState = { _, _ -> },
+ onSearchValueChanged = {},
+ )
+ }
+}
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..d139ba90bd
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt
@@ -0,0 +1,47 @@
+import androidx.compose.foundation.Image
+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.outlined.Close
+import androidx.compose.runtime.Composable
+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.views.chat.item.SentColorLight
+import chat.simplex.app.views.helpers.base64ToBitmap
+
+@Composable
+fun ComposeImageView(image: String, cancelImage: () -> Unit, cancelEnabled: Boolean) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .background(SentColorLight),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val imageBitmap = base64ToBitmap(image).asImageBitmap()
+ Image(
+ imageBitmap,
+ "preview image",
+ modifier = Modifier
+ .width(80.dp)
+ .height(60.dp)
+ .padding(end = 8.dp)
+ )
+ Spacer(Modifier.weight(1f))
+ if (cancelEnabled) {
+ IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(10.dp)
+ )
+ }
+ }
+ }
+}
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..90da13cef4
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
@@ -0,0 +1,542 @@
+package chat.simplex.app.views.chat
+
+import ComposeFileView
+import ComposeImageView
+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.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.annotation.CallSuper
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+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.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import chat.simplex.app.*
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.views.chat.item.*
+import chat.simplex.app.views.helpers.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import java.io.File
+
+@Serializable
+sealed class ComposePreview {
+ @Serializable object NoPreview: ComposePreview()
+ @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
+ @Serializable class ImagePreview(val image: String): ComposePreview()
+ @Serializable class FilePreview(val fileName: String): 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 ComposeState(
+ val message: String = "",
+ val preview: ComposePreview = ComposePreview.NoPreview,
+ val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
+ val inProgress: Boolean = false,
+ val useLinkPreviews: Boolean
+) {
+ constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this (
+ editingItem.content.text,
+ 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.FilePreview -> true
+ else -> message.isNotEmpty()
+ }
+ hasContent && !inProgress
+ }
+ val linkPreviewAllowed: Boolean
+ get() =
+ when (preview) {
+ is ComposePreview.ImagePreview -> false
+ is ComposePreview.FilePreview -> false
+ else -> useLinkPreviews
+ }
+ val linkPreview: LinkPreview?
+ get() =
+ when (preview) {
+ is ComposePreview.CLinkPreview -> preview.linkPreview
+ else -> null
+ }
+
+ companion object {
+ fun saver(): Saver, *> = Saver(
+ save = { json.encodeToString(serializer(), it.value) },
+ restore = {
+ mutableStateOf(json.decodeFromString(it))
+ }
+ )
+ }
+}
+
+fun chatItemPreview(chatItem: ChatItem): ComposePreview {
+ return when (val mc = chatItem.content.msgContent) {
+ is MsgContent.MCText -> ComposePreview.NoPreview
+ is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
+ is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image)
+ is MsgContent.MCFile -> {
+ val fileName = chatItem.file?.fileName ?: ""
+ ComposePreview.FilePreview(fileName)
+ }
+ else -> ComposePreview.NoPreview
+ }
+}
+
+@Composable
+fun ComposeView(
+ chatModel: ChatModel,
+ chat: Chat,
+ composeState: MutableState,
+ attachmentOption: MutableState,
+ showChooseAttachment: () -> Unit
+) {
+ val context = LocalContext.current
+ val linkUrl = remember { mutableStateOf(null) }
+ val prevLinkUrl = remember { mutableStateOf(null) }
+ val pendingLinkUrl = remember { mutableStateOf(null) }
+ val cancelledLinks = remember { mutableSetOf() }
+ val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
+ // attachments
+ val chosenImage = remember { mutableStateOf(null) }
+ val chosenAnimImage = remember { mutableStateOf(null) }
+ val chosenFile = remember { mutableStateOf(null) }
+ val photoUri = remember { mutableStateOf(null) }
+ val photoTmpFile = remember { mutableStateOf(null) }
+
+ class ComposeTakePicturePreview: ActivityResultContract() {
+ @CallSuper
+ override fun createIntent(context: Context, input: Void?): Intent {
+ photoTmpFile.value = File.createTempFile("image", ".bmp", SimplexApp.context.filesDir)
+ photoUri.value = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", photoTmpFile.value!!)
+ return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ .putExtra(MediaStore.EXTRA_OUTPUT, photoUri.value)
+ }
+
+ override fun getSynchronousResult(
+ context: Context,
+ input: Void?
+ ): SynchronousResult? = null
+
+ override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
+ val photoUriVal = photoUri.value
+ val photoTmpFileVal = photoTmpFile.value
+ return if (resultCode == Activity.RESULT_OK && photoUriVal != null && photoTmpFileVal != null) {
+ val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, photoUriVal)
+ val bitmap = ImageDecoder.decodeBitmap(source)
+ photoTmpFileVal.delete()
+ bitmap
+ } else {
+ Log.e(TAG, "Getting image from camera cancelled or failed.")
+ photoTmpFile.value?.delete()
+ null
+ }
+ }
+ }
+
+ val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? ->
+ if (bitmap != null) {
+ chosenImage.value = bitmap
+ val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
+ composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
+ }
+ }
+ val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
+ if (isGranted) {
+ cameraLauncher.launch(null)
+ } else {
+ Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
+ }
+ }
+ val processPickedImage = { uri: Uri? ->
+ if (uri != null) {
+ val source = ImageDecoder.createSource(context.contentResolver, uri)
+ val drawable = ImageDecoder.decodeDrawable(source)
+ val bitmap = ImageDecoder.decodeBitmap(source)
+ if (drawable is AnimatedImageDrawable) {
+ // It's a gif or webp
+ val fileSize = getFileSize(context, uri)
+ if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
+ chosenAnimImage.value = uri
+ } else {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.large_file),
+ String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
+ )
+ }
+ } else {
+ chosenImage.value = bitmap
+ }
+
+ if (chosenImage.value != null || chosenAnimImage.value != null) {
+ val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
+ composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
+ }
+ }
+ }
+ val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery(), processPickedImage)
+ val galleryLauncherFallback = rememberGetContentLauncher(processPickedImage)
+ val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
+ if (uri != null) {
+ val fileSize = getFileSize(context, uri)
+ if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
+ val fileName = getFileName(SimplexApp.context, uri)
+ if (fileName != null) {
+ chosenFile.value = uri
+ composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
+ }
+ } else {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.large_file),
+ String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
+ )
+ }
+ }
+ }
+
+ LaunchedEffect(attachmentOption.value) {
+ when (attachmentOption.value) {
+ AttachmentOption.TakePhoto -> {
+ when (PackageManager.PERMISSION_GRANTED) {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
+ cameraLauncher.launch(null)
+ }
+ 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
+ }
+ }
+ }
+ }
+
+ 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 checkLinkPreview(): MsgContent {
+ val cs = composeState.value
+ return when (val composePreview = cs.preview) {
+ is ComposePreview.CLinkPreview -> {
+ val url = parseMessage(cs.message)
+ val lp = composePreview.linkPreview
+ if (lp != null && url == lp.uri) {
+ MsgContent.MCLink(cs.message, preview = lp)
+ } else {
+ MsgContent.MCText(cs.message)
+ }
+ }
+ else -> MsgContent.MCText(cs.message)
+ }
+ }
+
+ fun updateMsgContent(msgContent: MsgContent): MsgContent {
+ val cs = composeState.value
+ return when (msgContent) {
+ is MsgContent.MCText -> checkLinkPreview()
+ is MsgContent.MCLink -> checkLinkPreview()
+ is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
+ is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
+ is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
+ }
+ }
+
+ fun clearState() {
+ composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
+ textStyle.value = smallFont
+ chosenImage.value = null
+ chosenAnimImage.value = null
+ chosenFile.value = null
+ linkUrl.value = null
+ prevLinkUrl.value = null
+ pendingLinkUrl.value = null
+ cancelledLinks.clear()
+ }
+
+ fun sendMessage() {
+ composeState.value = composeState.value.copy(inProgress = true)
+ val cInfo = chat.chatInfo
+ val cs = composeState.value
+ when (val contextItem = cs.contextItem) {
+ is ComposeContextItem.EditingItem -> {
+ val ei = contextItem.chatItem
+ val oldMsgContent = ei.content.msgContent
+ if (oldMsgContent != null) {
+ withApi {
+ val updatedItem = chatModel.controller.apiUpdateChatItem(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ itemId = ei.meta.itemId,
+ mc = updateMsgContent(oldMsgContent)
+ )
+ if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
+ clearState()
+ }
+ }
+ }
+ else -> {
+ var mc: MsgContent? = null
+ var file: String? = null
+ when (val preview = cs.preview) {
+ ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
+ is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
+ is ComposePreview.ImagePreview -> {
+ val chosenImageVal = chosenImage.value
+ if (chosenImageVal != null) {
+ file = saveImage(context, chosenImageVal)
+ if (file != null) {
+ mc = MsgContent.MCImage(cs.message, preview.image)
+ }
+ }
+ val chosenGifImageVal = chosenAnimImage.value
+ if (chosenGifImageVal != null) {
+ file = saveAnimImage(context, chosenGifImageVal)
+ if (file != null) {
+ mc = MsgContent.MCImage(cs.message, preview.image)
+ }
+ }
+ }
+ is ComposePreview.FilePreview -> {
+ val chosenFileVal = chosenFile.value
+ if (chosenFileVal != null) {
+ file = saveFileFromUri(context, chosenFileVal)
+ if (file != null) {
+ mc = MsgContent.MCFile(cs.message)
+ }
+ }
+ }
+ }
+ val quotedItemId: Long? = when (contextItem) {
+ is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
+ else -> null
+ }
+
+ if (mc != null) {
+ withApi {
+ val aChatItem = chatModel.controller.apiSendMessage(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ file = file,
+ quotedItemId = quotedItemId,
+ mc = mc
+ )
+ if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
+ clearState()
+ }
+ } else {
+ clearState()
+ }
+ }
+ }
+ }
+
+ 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 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 cancelImage() {
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ chosenImage.value = null
+ chosenAnimImage.value = null
+ }
+
+ fun cancelFile() {
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ chosenFile.value = null
+ }
+
+ @Composable
+ fun previewView() {
+ when (val preview = composeState.value.preview) {
+ ComposePreview.NoPreview -> {}
+ is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
+ is ComposePreview.ImagePreview -> ComposeImageView(
+ preview.image,
+ ::cancelImage,
+ cancelEnabled = !composeState.value.editing
+ )
+ 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()
+ }
+ }
+ }
+
+ Column {
+ contextItemView()
+ when {
+ composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
+ else -> previewView()
+ }
+ Row(
+ modifier = Modifier.padding(start = 4.dp, end = 8.dp),
+ verticalAlignment = Alignment.Bottom,
+ horizontalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ val attachEnabled = !composeState.value.editing
+ Box(Modifier.padding(bottom = 12.dp)) {
+ Icon(
+ Icons.Filled.AttachFile,
+ contentDescription = stringResource(R.string.attach),
+ tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
+ modifier = Modifier
+ .size(28.dp)
+ .clip(CircleShape)
+ .clickable {
+ if (attachEnabled) {
+ showChooseAttachment()
+ }
+ }
+ )
+ }
+ SendMsgView(
+ composeState,
+ sendMessage = {
+ sendMessage()
+ resetLinkPreview()
+ },
+ ::onMessageChange,
+ textStyle
+ )
+ }
+ }
+}
+
+class PickFromGallery: ActivityResultContract() {
+ override fun createIntent(context: Context, input: Int) = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI)
+
+ override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
+}
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..00e5ab894f
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt
@@ -0,0 +1,79 @@
+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.CIDirection
+import chat.simplex.app.model.ChatItem
+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
+ )
+ }
+ 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/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt
new file mode 100644
index 0000000000..e456f05766
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt
@@ -0,0 +1,176 @@
+package chat.simplex.app.views.chat
+
+import android.content.res.Configuration
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+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.filled.Check
+import androidx.compose.material.icons.outlined.ArrowUpward
+import androidx.compose.runtime.*
+import androidx.compose.ui.*
+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.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.model.ChatItem
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleXTheme
+import kotlinx.coroutines.delay
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun SendMsgView(
+ composeState: MutableState,
+ sendMessage: () -> Unit,
+ onMessageChange: (String) -> Unit,
+ textStyle: MutableState
+) {
+ val cs = composeState.value
+ val focusRequester = remember { FocusRequester() }
+ val keyboard = LocalSoftwareKeyboardController.current
+ LaunchedEffect(cs.contextItem) {
+ if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect
+ // In replying state
+ focusRequester.requestFocus()
+ delay(50)
+ keyboard?.show()
+ }
+
+ BasicTextField(
+ value = cs.message,
+ onValueChange = onMessageChange,
+ textStyle = textStyle.value,
+ maxLines = 16,
+ keyboardOptions = KeyboardOptions.Default.copy(
+ capitalization = KeyboardCapitalization.Sentences,
+ autoCorrect = true
+ ),
+ modifier = Modifier.padding(vertical = 8.dp).focusRequester(focusRequester),
+ cursorBrush = SolidColor(HighOrLowlight),
+ decorationBox = { innerTextField ->
+ Surface(
+ shape = RoundedCornerShape(18.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
+ ) {
+ Row(
+ Modifier.background(MaterialTheme.colors.background),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Box(
+ Modifier
+ .weight(1f)
+ .padding(horizontal = 12.dp)
+ .padding(top = 5.dp)
+ .padding(bottom = 7.dp)
+ ) {
+ innerTextField()
+ }
+ val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
+ val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
+ if (cs.inProgress
+ && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
+ ) {
+ CircularProgressIndicator(
+ Modifier
+ .size(36.dp)
+ .padding(4.dp),
+ color = HighOrLowlight,
+ strokeWidth = 3.dp
+ )
+ } else {
+ Icon(
+ icon,
+ stringResource(R.string.icon_descr_send_message),
+ tint = Color.White,
+ modifier = Modifier
+ .size(36.dp)
+ .padding(4.dp)
+ .clip(CircleShape)
+ .background(color)
+ .clickable {
+ if (cs.sendEnabled()) {
+ sendMessage()
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ )
+}
+
+@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)) },
+ 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) },
+ 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"), inProgress = true, useLinkPreviews = true)
+ SimpleXTheme {
+ SendMsgView(
+ composeState = remember { mutableStateOf(composeStateInProgress) },
+ sendMessage = {},
+ onMessageChange = { _ -> },
+ textStyle = textStyle
+ )
+ }
+}
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..cfe22f96b8
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt
@@ -0,0 +1,358 @@
+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.runtime.snapshots.SnapshotStateList
+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.*
+
+@Composable
+fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
+ val selectedContacts = remember { mutableStateListOf() }
+ val selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) }
+
+ BackHandler(onBack = close)
+ AddGroupMembersLayout(
+ groupInfo = groupInfo,
+ contactsToAdd = getContactsToAdd(chatModel),
+ selectedContacts = selectedContacts,
+ selectedRole = selectedRole,
+ inviteMembers = {
+ 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 } },
+ )
+}
+
+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,
+ contactsToAdd: List,
+ selectedContacts: SnapshotStateList,
+ selectedRole: MutableState,
+ inviteMembers: () -> Unit,
+ clearSelection: () -> Unit,
+ addContact: (Long) -> Unit,
+ removeContact: (Long) -> Unit,
+) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ 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 {
+ SectionItemView {
+ RoleSelectionRow(groupInfo, selectedRole)
+ }
+ SectionDivider()
+ SectionItemView {
+ InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty())
+ }
+ }
+ SectionCustomFooter {
+ InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection)
+ }
+ SectionSpacer()
+
+ SectionView {
+ ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, addContact, removeContact)
+ }
+ SectionSpacer()
+ }
+ }
+}
+
+@Composable
+fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState) {
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(stringResource(R.string.new_member_role))
+ RoleDropdownMenu(groupInfo, selectedRole)
+ }
+}
+
+@Composable
+fun RoleDropdownMenu(groupInfo: GroupInfo, selectedRole: MutableState) {
+ val options = GroupMemberRole.values()
+ .filter { it <= groupInfo.membership.memberRole }
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = {
+ expanded = !expanded
+ }
+ ) {
+ Row(
+ Modifier.fillMaxWidth(0.7f),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End
+ ) {
+ Text(
+ selectedRole.value.text,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = HighOrLowlight
+ )
+ Spacer(Modifier.size(4.dp))
+ Icon(
+ if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
+ generalGetString(R.string.invite_to_group_button),
+ modifier = Modifier.padding(start = 8.dp),
+ tint = HighOrLowlight
+ )
+ }
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = {
+ expanded = false
+ }
+ ) {
+ options.forEach { selectionOption ->
+ DropdownMenuItem(
+ onClick = {
+ selectedRole.value = selectionOption
+ expanded = false
+ }
+ ) {
+ Text(
+ selectionOption.text,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun InviteMembersButton(inviteMembers: () -> Unit, disabled: Boolean) {
+ val modifier = if (disabled) Modifier else Modifier.clickable { inviteMembers() }
+ Row(
+ modifier.fillMaxSize(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
+ Text(stringResource(R.string.invite_to_group_button), color = color)
+ Spacer(Modifier.size(8.dp))
+ Icon(
+ Icons.Outlined.Check,
+ stringResource(R.string.invite_to_group_button),
+ tint = color
+ )
+ }
+}
+
+@Composable
+fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = if (selectedContactsCount >= 1) Arrangement.SpaceBetween else Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (selectedContactsCount >= 1) {
+ Box(
+ Modifier.clickable { clearSelection() }
+ ) {
+ Text(
+ stringResource(R.string.clear_contacts_selection_button),
+ color = MaterialTheme.colors.primary,
+ fontSize = 12.sp
+ )
+ }
+
+ Text(
+ String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
+ color = HighOrLowlight,
+ fontSize = 12.sp
+ )
+ } else {
+ Text(
+ stringResource(R.string.no_contacts_selected),
+ color = HighOrLowlight,
+ fontSize = 12.sp
+ )
+ }
+ }
+}
+
+@Composable
+fun ContactList(
+ contacts: List,
+ selectedContacts: SnapshotStateList,
+ groupInfo: GroupInfo,
+ addContact: (Long) -> Unit,
+ removeContact: (Long) -> Unit
+) {
+ Column {
+ contacts.forEachIndexed { index, contact ->
+ SectionItemView {
+ ContactCheckRow(
+ contact, groupInfo, addContact, removeContact,
+ checked = selectedContacts.contains(contact.apiId)
+ )
+ }
+ if (index < contacts.lastIndex) {
+ SectionDivider()
+ }
+ }
+ }
+}
+
+@Composable
+fun ContactCheckRow(
+ contact: Contact,
+ groupInfo: GroupInfo,
+ addContact: (Long) -> Unit,
+ removeContact: (Long) -> Unit,
+ checked: 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 = MaterialTheme.colors.primary
+ } else {
+ icon = Icons.Outlined.Circle
+ iconColor = HighOrLowlight
+ }
+ Row(
+ Modifier
+ .fillMaxSize()
+ .clickable {
+ if (prohibitedToInviteIncognito) {
+ showProhibitedToInviteIncognitoAlertDialog()
+ } else if (!checked)
+ addContact(contact.apiId)
+ else
+ removeContact(contact.apiId)
+ },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ ProfileImage(size = 36.dp, contact.image)
+ Text(
+ contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
+ color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified
+ )
+ }
+ 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,
+ contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
+ selectedContacts = remember { mutableStateListOf() },
+ selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
+ inviteMembers = {},
+ clearSelection = {},
+ addContact = {},
+ removeContact = {}
+ )
+ }
+}
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..538cd5a3cd
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt
@@ -0,0 +1,362 @@
+package chat.simplex.app.views.chat.group
+
+import InfoRow
+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.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.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.*
+
+@Composable
+fun GroupChatInfoView(chatModel: ChatModel, 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,
+ addMembers = {
+ withApi {
+ setGroupMembers(groupInfo, chatModel)
+ ModalManager.shared.showCustomModal { close ->
+ ModalView(
+ close = close, modifier = Modifier,
+ background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
+ ) {
+ AddGroupMembersView(groupInfo, chatModel, close)
+ }
+ }
+ }
+ },
+ showMemberInfo = { member ->
+ withApi {
+ val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
+ ModalManager.shared.showCustomModal { closeCurrent ->
+ ModalView(
+ close = closeCurrent, modifier = Modifier,
+ background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
+ ) {
+ GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
+ }
+ }
+ }
+ },
+ editGroupProfile = {
+ ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
+ },
+ deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
+ clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
+ leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
+ )
+ }
+}
+
+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,
+ addMembers: () -> Unit,
+ showMemberInfo: (GroupMember) -> Unit,
+ editGroupProfile: () -> Unit,
+ deleteGroup: () -> Unit,
+ clearChat: () -> Unit,
+ leaveGroup: () -> Unit,
+) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ GroupChatInfoHeader(chat.chatInfo)
+ }
+ SectionSpacer()
+
+ SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
+ if (groupInfo.canAddMembers) {
+ SectionItemView {
+ val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary
+ val onClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
+ AddMembersButton(tint, onClick)
+ }
+ SectionDivider()
+ }
+ SectionItemView(minHeight = 50.dp) {
+ MemberRow(groupInfo.membership, user = true)
+ }
+ if (members.isNotEmpty()) {
+ SectionDivider()
+ }
+ MembersList(members, showMemberInfo)
+ }
+ SectionSpacer()
+
+ SectionView {
+ if (groupInfo.canEdit) {
+ SectionItemView {
+ EditGroupProfileButton(editGroupProfile)
+ }
+ SectionDivider()
+ }
+ SectionItemView {
+ ClearChatButton(clearChat)
+ }
+ if (groupInfo.canDelete) {
+ SectionDivider()
+ SectionItemView {
+ DeleteGroupButton(deleteGroup)
+ }
+ }
+ if (groupInfo.membership.memberCurrent) {
+ SectionDivider()
+ SectionItemView {
+ LeaveGroupButton(leaveGroup)
+ }
+ }
+ }
+ 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
+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
+fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, addMembers: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxSize()
+ .clickable { addMembers() },
+ 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
+fun MembersList(members: List, showMemberInfo: (GroupMember) -> Unit) {
+ Column {
+ members.forEachIndexed { index, member ->
+ SectionItemView(minHeight = 50.dp) {
+ MemberRow(member, showMemberInfo)
+ }
+ if (index < members.lastIndex) {
+ SectionDivider()
+ }
+ }
+ }
+}
+
+@Composable
+fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = null, user: Boolean = false) {
+ val modifier = if (showMemberInfo != null) Modifier.clickable { showMemberInfo(member) } else Modifier
+ Row(
+ modifier.fillMaxSize(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ ProfileImage(size = 46.dp, member.image)
+ Column {
+ 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
+fun EditGroupProfileButton(editGroupProfile: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxSize()
+ .clickable { editGroupProfile() },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Edit,
+ stringResource(R.string.button_edit_group_profile),
+ tint = MaterialTheme.colors.primary
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
+ }
+}
+
+@Composable
+fun LeaveGroupButton(leaveGroup: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxSize()
+ .clickable { leaveGroup() },
+ 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
+fun DeleteGroupButton(deleteGroup: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxSize()
+ .clickable { deleteGroup() },
+ 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(),
+ serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
+ ),
+ groupInfo = GroupInfo.sampleData,
+ members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
+ developerTools = false,
+ addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {},
+ )
+ }
+}
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..53a19528fe
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt
@@ -0,0 +1,242 @@
+package chat.simplex.app.views.chat.group
+
+import InfoRow
+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.outlined.*
+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.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.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.SimplexServers
+import chat.simplex.app.views.chatlist.openChat
+import chat.simplex.app.views.helpers.*
+
+@Composable
+fun GroupMemberInfoView(
+ groupInfo: GroupInfo,
+ member: GroupMember,
+ connStats: ConnectionStats?,
+ 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) {
+ GroupMemberInfoLayout(
+ groupInfo,
+ member,
+ connStats,
+ developerTools,
+ openDirectChat = {
+ withApi {
+ val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
+ if (oldChat != null) {
+ openChat(oldChat.chatInfo, chatModel)
+ } else {
+ var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
+ // TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
+ newChat = newChat.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
+ chatModel.addChat(newChat)
+ chatModel.chatItems.clear()
+ chatModel.chatId.value = newChat.id
+ }
+ closeAll()
+ }
+ },
+ removeMember = { removeMemberDialog(groupInfo, member, chatModel, 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?,
+ developerTools: Boolean,
+ openDirectChat: () -> Unit,
+ removeMember: () -> Unit,
+) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ GroupMemberInfoHeader(member)
+ }
+ SectionSpacer()
+
+ SectionView {
+ SectionItemView {
+ OpenChatButton(openDirectChat)
+ }
+ }
+ SectionSpacer()
+
+ SectionView(title = stringResource(R.string.member_info_section_title_member)) {
+ InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
+ 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) {
+ val rcvServers = connStats.rcvServers
+ val sndServers = connStats.sndServers
+ if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
+ SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
+ 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.membership)) {
+ SectionView {
+ SectionItemView {
+ 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)
+ 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(removeMember: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxSize()
+ .clickable { removeMember() },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Delete,
+ stringResource(R.string.button_remove_member),
+ tint = Color.Red
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.button_remove_member), color = Color.Red)
+ }
+}
+
+@Composable
+fun OpenChatButton(onClick: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxSize()
+ .clickable { onClick() },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Message,
+ stringResource(R.string.button_send_direct_message),
+ Modifier.padding(top = 5.dp),
+ tint = MaterialTheme.colors.primary
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.button_send_direct_message), color = MaterialTheme.colors.primary)
+ }
+}
+
+@Preview
+@Composable
+fun PreviewGroupMemberInfoLayout() {
+ SimpleXTheme {
+ GroupMemberInfoLayout(
+ groupInfo = GroupInfo.sampleData,
+ member = GroupMember.sampleData,
+ connStats = null,
+ developerTools = false,
+ openDirectChat = {},
+ removeMember = {}
+ )
+ }
+}
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..52995d30a8
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt
@@ -0,0 +1,173 @@
+package chat.simplex.app.views.chat.group
+
+import android.content.res.Configuration
+import android.graphics.Bitmap
+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.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.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleXTheme
+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.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 = remember { mutableStateOf(null) }
+ val profileImage = remember { 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(bottom = 16.dp),
+ 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(displayName.value, fullName.value, profileImage.value)) },
+ color = MaterialTheme.colors.primary
+ )
+ } else {
+ Text(
+ stringResource(R.string.save_group_profile),
+ color = HighOrLowlight
+ )
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ 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/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..75799c6818 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 = Color.Green)
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(status.duration(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/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt
new file mode 100644
index 0000000000..f12bbcfc6d
--- /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(), itemDeleted = false, itemEdited = true, editable = false),
+ 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(), itemDeleted = false, itemEdited = false, editable = false),
+ 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, showMenu = showMenu, receiveFile = {})
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupEventView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupEventView.kt
new file mode 100644
index 0000000000..1915c97746
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupEventView.kt
@@ -0,0 +1,59 @@
+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 CIGroupEventView(ci: ChatItem) {
+ fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
+ return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
+ }
+
+ Surface {
+ Row(
+ Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Text(
+ buildAnnotatedString {
+ val memberDisplayName = ci.memberDisplayName
+ if (memberDisplayName != null) {
+ withGroupEventStyle(this, memberDisplayName)
+ append(" ")
+ }
+ withGroupEventStyle(this, ci.content.text)
+ append(" ")
+ withGroupEventStyle(this, ci.timestampText)
+ },
+ style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ name = "Dark Mode"
+)
+@Composable
+fun CIGroupEventViewPreview() {
+ SimpleXTheme {
+ CIGroupEventView(
+ ChatItem.getGroupEventSample()
+ )
+ }
+}
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..5a63805049
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt
@@ -0,0 +1,188 @@
+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.filled.Download
+import androidx.compose.material.icons.outlined.ArrowDownward
+import androidx.compose.material.icons.outlined.MoreHoriz
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.content.FileProvider
+import chat.simplex.app.BuildConfig
+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?,
+ 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 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(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(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
+ }
+
+ Box(contentAlignment = Alignment.TopEnd) {
+ val context = LocalContext.current
+ val imageBitmap: Bitmap? = getLoadedImage(context, file)
+ val filePath = getLoadedFilePath(context, file)
+ if (imageBitmap != null && filePath != null) {
+ val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
+ val imageLoader = ImageLoader.Builder(context)
+ .components {
+ if (SDK_INT >= 28) {
+ add(ImageDecoderDecoder.Factory())
+ } else {
+ add(GifDecoder.Factory())
+ }
+ }
+ .build()
+ 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
+ )
+ imageView(imagePainter, onClick = {
+ if (getLoadedFilePath(context, file) != null) {
+ ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, uri, 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()
+ }
+}
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..f5566b6f19
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
@@ -0,0 +1,156 @@
+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.*
+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.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.HighOrLowlight
+import kotlinx.datetime.Clock
+
+@Composable
+fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (!chatItem.isDeletedContent) {
+ if (chatItem.meta.itemEdited) {
+ Icon(
+ Icons.Filled.Edit,
+ modifier = Modifier.height(12.dp).padding(end = 1.dp),
+ contentDescription = stringResource(R.string.icon_descr_edited),
+ tint = metaColor,
+ )
+ }
+ CIStatusView(chatItem.meta.itemStatus, metaColor)
+ }
+ Text(
+ chatItem.timestampText,
+ color = metaColor,
+ fontSize = 14.sp,
+ modifier = Modifier.padding(start = 3.dp)
+ )
+ }
+}
+
+
+@Composable
+fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
+ when (status) {
+ is CIStatus.SndSent -> {
+ Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
+ }
+ is CIStatus.SndErrorAuth -> {
+ Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
+ }
+ is CIStatus.SndError -> {
+ Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
+ }
+ is CIStatus.RcvNew -> {
+ Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
+ }
+ else -> {}
+ }
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaView() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
+ )
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewUnread() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
+ status = CIStatus.RcvNew()
+ )
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewSendFailed() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
+ status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX()))
+ )
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewSendNoAuth() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
+ )
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewSendSent() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
+ )
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewEdited() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
+ itemEdited = true
+ )
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewEditedUnread() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
+ itemEdited = true,
+ status=CIStatus.RcvNew()
+ )
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewEditedSent() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
+ itemEdited = true,
+ status=CIStatus.SndSent()
+ )
+ )
+}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewDeletedContent() {
+ CIMetaView(
+ chatItem = ChatItem.getDeletedContentSampleData()
+ )
+}
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..7e21417cd7
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
@@ -0,0 +1,244 @@
+package chat.simplex.app.views.chat.item
+
+import android.content.*
+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.R
+import chat.simplex.app.model.*
+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
+
+@Composable
+fun ChatItemView(
+ user: User,
+ cInfo: ChatInfo,
+ cItem: ChatItem,
+ composeState: MutableState,
+ cxt: Context,
+ uriHandler: UriHandler? = null,
+ showMember: Boolean = false,
+ chatModelIncognito: Boolean,
+ useLinkPreviews: Boolean,
+ deleteMessage: (Long, CIDeleteMode) -> Unit,
+ receiveFile: (Long) -> Unit,
+ joinGroup: (Long) -> Unit,
+ acceptCall: (Contact) -> Unit
+) {
+ val context = LocalContext.current
+ val sent = cItem.chatDir.sent
+ val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
+ val showMenu = remember { mutableStateOf(false) }
+ val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
+ Box(
+ modifier = Modifier
+ .padding(bottom = 4.dp)
+ .fillMaxWidth(),
+ contentAlignment = alignment,
+ ) {
+ Column(
+ Modifier
+ .clip(RoundedCornerShape(18.dp))
+ .combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
+ ) {
+ @Composable fun ContentItem() {
+ if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
+ EmojiItemView(cItem)
+ } else {
+ val onLinkLongClick = { _: String -> showMenu.value = true }
+ FramedItemView(cInfo, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick)
+ }
+ DropdownMenu(
+ expanded = showMenu.value,
+ onDismissRequest = { showMenu.value = false },
+ Modifier.width(220.dp)
+ ) {
+ 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 = {
+ shareText(cxt, cItem.content.text)
+ showMenu.value = false
+ })
+ ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
+ copyText(cxt, cItem.content.text)
+ showMenu.value = false
+ })
+ if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
+ 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)
+ else -> {}
+ }
+ showMenu.value = false
+ })
+ }
+ }
+ if (cItem.meta.editable) {
+ ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
+ composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
+ showMenu.value = false
+ })
+ }
+ ItemAction(
+ stringResource(R.string.delete_verb),
+ Icons.Outlined.Delete,
+ onClick = {
+ showMenu.value = false
+ deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
+ },
+ color = Color.Red
+ )
+ }
+ }
+
+ @Composable fun DeletedItem() {
+ DeletedItemView(cItem, showMember = showMember)
+ DropdownMenu(
+ expanded = showMenu.value,
+ onDismissRequest = { showMenu.value = false },
+ Modifier.width(220.dp)
+ ) {
+ ItemAction(
+ stringResource(R.string.delete_verb),
+ Icons.Outlined.Delete,
+ onClick = {
+ showMenu.value = false
+ deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
+ },
+ color = Color.Red
+ )
+ }
+ }
+
+ @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, 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 -> CIGroupEventView(cItem)
+ is CIContent.SndGroupEventContent -> CIGroupEventView(cItem)
+ }
+ }
+ }
+}
+
+@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, deleteMessage: (Long, CIDeleteMode) -> Unit) {
+ AlertManager.shared.showAlertDialogButtons(
+ title = generalGetString(R.string.delete_message__question),
+ text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
+ 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)) }
+ }
+ }
+ }
+ )
+}
+
+@Preview
+@Composable
+fun PreviewChatItemView() {
+ SimpleXTheme {
+ ChatItemView(
+ User.sampleData,
+ ChatInfo.Direct.sampleData,
+ ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
+ ),
+ useLinkPreviews = true,
+ composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
+ cxt = LocalContext.current,
+ chatModelIncognito = false,
+ deleteMessage = { _, _ -> },
+ receiveFile = {},
+ joinGroup = {},
+ acceptCall = { _ -> }
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PreviewChatItemViewDeletedContent() {
+ SimpleXTheme {
+ ChatItemView(
+ User.sampleData,
+ ChatInfo.Direct.sampleData,
+ ChatItem.getDeletedContentSampleData(),
+ useLinkPreviews = true,
+ composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
+ cxt = LocalContext.current,
+ chatModelIncognito = false,
+ deleteMessage = { _, _ -> },
+ receiveFile = {},
+ joinGroup = {},
+ acceptCall = { _ -> }
+ )
+ }
+}
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 51%
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..5ed2f39f2b 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 androidx.compose.foundation.layout.*
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
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) {
- val sent = ci.chatDir.sent
- val sentColor = MaterialTheme.appColors.sentMessage
- val receivedColor = MaterialTheme.appColors.receivedMessage
+fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
Surface(
shape = RoundedCornerShape(18.dp),
- color = if (sent) sentColor else receivedColor,
- contentColor = LocalContentColor.current
+ color = ReceivedColorLight,
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
@@ -31,28 +29,27 @@ 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)
}
}
}
-@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
+ ChatItem.getDeletedContentSampleData()
)
}
}
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..7ac5e0ca7e 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) {
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)
}
}
@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 54%
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..1b6ddba94f 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,136 +1,71 @@
-package chat.simplex.common.views.chat.item
+package chat.simplex.app.views.chat.item
+import CIImageView
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.InsertDriveFile
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.layout.*
-import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.UriHandler
-import dev.icerock.moko.resources.compose.painterResource
-import dev.icerock.moko.resources.compose.stringResource
-import androidx.compose.ui.text.*
-import androidx.compose.ui.text.font.FontStyle
-import androidx.compose.ui.text.style.TextOverflow
-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.res.stringResource
+import androidx.compose.ui.text.TextStyle
+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.ChatItemLinkView
+import chat.simplex.app.views.helpers.base64ToBitmap
+import kotlinx.datetime.Clock
+
+val SentColorLight = Color(0x1E45B8FF)
+val ReceivedColorLight = Color(0x20B1B0B5)
+val SentQuoteColorLight = Color(0x2545B8FF)
+val ReceivedQuoteColorLight = Color(0x25B1B0B5)
@Composable
fun FramedItemView(
chatInfo: ChatInfo,
ci: ChatItem,
uriHandler: UriHandler? = null,
- imageProvider: (() -> ImageGalleryProvider)? = null,
- linkMode: SimplexLinkMode,
- showViaProxy: Boolean,
+ showMember: Boolean = false,
showMenu: MutableState,
- showTimestamp: Boolean,
- tailVisible: Boolean = false,
receiveFile: (Long) -> Unit,
- onLinkLongClick: (link: String) -> Unit = {},
- scrollToItem: (Long) -> Unit = {},
- scrollToQuotedItemFromItem: (Long) -> Unit = {},
+ onLinkLongClick: (link: String) -> Unit = {}
) {
val sent = ci.chatDir.sent
- val chatTTL = chatInfo.timedMessagesTTL
fun membership(): GroupMember? {
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)
- }
- }
- }
-
- @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
- Row(
- Modifier
- .background(if (sent) sentColor else receivedColor)
- .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),
- 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
- )
- }
- Text(
- buildAnnotatedString {
- withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) {
- append(caption)
- }
- },
- style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
+ MarkdownText(
+ qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
+ style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
)
}
}
@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()
) {
when (qi.content) {
@@ -138,33 +73,21 @@ fun FramedItemView(
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),
+ contentDescription = stringResource(R.string.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),
- contentScale = ContentScale.Crop,
- modifier = Modifier.size(68.dp).clipToBounds()
- )
- }
- is MsgContent.MCFile, is MsgContent.MCVoice -> {
+ is MsgContent.MCFile -> {
Box(Modifier.fillMaxWidth().weight(1f)) {
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),
+ Icons.Filled.InsertDriveFile,
+ stringResource(R.string.icon_descr_file),
Modifier
.padding(top = 6.dp, end = 4.dp)
.size(22.dp),
@@ -176,170 +99,57 @@ fun FramedItemView(
}
}
- @Composable
- fun ciFileView(ci: ChatItem, text: String) {
- CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile)
- if (text != "" || ci.meta.isLive) {
- CIMarkdownText(ci, chatInfo, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
- }
- }
-
- 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 sentColor = MaterialTheme.appColors.sentMessage
- val receivedColor = MaterialTheme.appColors.receivedMessage
- Box(Modifier
- .clipChatItem(ci, tailVisible, revealed = true)
- .background(
- when {
- transparentBackground -> Color.Transparent
- sent -> sentColor
- else -> receivedColor
- }
- )) {
- var metaColor = MaterialTheme.colors.secondary
+ Surface(
+ shape = RoundedCornerShape(18.dp),
+ color = if (sent) SentColorLight else ReceivedColorLight
+ ) {
+ 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)
- ) {
- 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.width(IntrinsicSize.Max)) {
+ val qi = ci.quotedItem
+ if (qi != null) {
+ ciQuoteView(qi)
+ }
+ if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
+ Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
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 }
+ .padding(bottom = 2.dp)
+ .fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
) {
- 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)
+ EmojiText(ci.content.text)
+ Text("")
}
}
- if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
- Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
- Column(
- Modifier
- .padding(bottom = 2.dp)
- .fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- EmojiText(ci.content.text)
- Text("")
- }
- }
- } else {
+ } else {
+ Column(Modifier.fillMaxWidth()) {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
- CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile)
- if (mc.text == "" && !ci.meta.isLive) {
+ CIImageView(image = mc.image, file = ci.file, showMenu, receiveFile)
+ if (mc.text == "") {
metaColor = Color.White
} else {
- CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
+ CIMarkdownText(ci, showMember, uriHandler)
}
}
- 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)
- }
- }
- 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)
+ is MsgContent.MCFile -> {
+ CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (mc.text != "") {
- CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
+ CIMarkdownText(ci, showMember, 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)
- } 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, showMember, 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, showMember, 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, metaColor)
}
}
}
@@ -348,185 +158,19 @@ fun FramedItemView(
@Composable
fun CIMarkdownText(
ci: ChatItem,
- chatInfo: ChatInfo,
- chatTTL: Int?,
- linkMode: SimplexLinkMode,
+ showMember: Boolean,
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)) {
- val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
+ Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
MarkdownText(
- text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
- 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
+ ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
+ metaText = ci.timestampText, edited = ci.meta.itemEdited,
+ 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()
-
-@Composable
-fun PriorityLayout(
- modifier: Modifier = Modifier,
- priorityLayoutId: String,
- content: @Composable () -> Unit
-) {
- Layout(
- content = content,
- modifier = modifier
- ) { measureable, constraints ->
- // 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 {
- if (it.layoutId == priorityLayoutId)
- imagePlaceable!!
- else
- it.measure(constraints.copy(maxWidth = imagePlaceable?.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
- ) {
- var y = 0
- placeables.forEach {
- it.place(0, y)
- y += it.measuredHeight
- }
- }
- }
-}
-
-@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 {
override val values = listOf(false, true).asSequence()
}
@@ -541,7 +185,6 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
),
- linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -558,7 +201,6 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
),
- linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -579,7 +221,6 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
"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.",
itemEdited = edited
),
- linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -601,7 +242,6 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
- linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -623,7 +263,6 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
- linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -652,7 +291,6 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
quotedItem = ciQuote,
itemEdited = edited
),
- linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -681,7 +319,6 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
quotedItem = ciQuote,
itemEdited = edited
),
- linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -709,10 +346,8 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
quotedItem = ciQuote,
itemEdited = edited
),
- linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
}
}
-*/
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt
new file mode 100644
index 0000000000..13608278b9
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt
@@ -0,0 +1,80 @@
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.*
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.*
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import chat.simplex.app.R
+import coil.ImageLoader
+import coil.compose.rememberAsyncImagePainter
+import coil.decode.GifDecoder
+import coil.decode.ImageDecoderDecoder
+import coil.request.ImageRequest
+import coil.size.Size
+
+@Composable
+fun ImageFullScreenView(imageBitmap: Bitmap, uri: Uri, close: () -> Unit) {
+ BackHandler(onBack = close)
+ Column(
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .clickable(onClick = close)
+ ) {
+ var scale by remember { mutableStateOf(1f) }
+ var translationX by remember { mutableStateOf(0f) }
+ var translationY by remember { mutableStateOf(0f) }
+ // I'm making a new instance of imageLoader here because if I use one instance in multiple places
+ // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
+ val imageLoader = ImageLoader.Builder(LocalContext.current)
+ .components {
+ if (Build.VERSION.SDK_INT >= 28) {
+ add(ImageDecoderDecoder.Factory())
+ } else {
+ add(GifDecoder.Factory())
+ }
+ }
+ .build()
+ Image(
+ rememberAsyncImagePainter(
+ ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
+ placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
+ imageLoader = imageLoader
+ ),
+ contentDescription = stringResource(R.string.image_descr),
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .graphicsLayer(
+ scaleX = scale,
+ scaleY = scale,
+ translationX = translationX,
+ translationY = translationY,
+ )
+ .pointerInput(Unit) {
+ detectTransformGestures(
+ onGesture = { _, pan, gestureZoom, _ ->
+ scale = (scale * gestureZoom).coerceIn(1f, 20f)
+ if (scale > 1) {
+ translationX += pan.x * scale
+ translationY += pan.y * scale
+ } else {
+ translationX = 0f
+ translationY = 0f
+ }
+ }
+ )
+ }
+ .fillMaxSize(),
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt
new file mode 100644
index 0000000000..ccafc6f2aa
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt
@@ -0,0 +1,65 @@
+package chat.simplex.app.views.chat.item
+
+import android.content.res.Configuration
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.text.*
+import androidx.compose.ui.text.font.FontStyle
+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.ChatItem
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.helpers.AlertManager
+import chat.simplex.app.views.helpers.generalGetString
+
+@Composable
+fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
+ Surface(
+ Modifier.clickable(onClick = {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.alert_title_skipped_messages),
+ text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
+ )
+ }),
+ shape = RoundedCornerShape(18.dp),
+ color = ReceivedColorLight,
+ ) {
+ Row(
+ Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Text(
+ buildAnnotatedString {
+ appendSender(this, if (showMember) ci.memberDisplayName else null, true)
+ withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
+ },
+ style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ CIMetaView(ci)
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ name = "Dark Mode"
+)
+@Composable
+fun IntegrityErrorItemViewPreview() {
+ SimpleXTheme {
+ IntegrityErrorItemView(
+ ChatItem.getDeletedContentSampleData()
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
new file mode 100644
index 0000000000..7216716031
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
@@ -0,0 +1,150 @@
+package chat.simplex.app.views.chat.item
+
+import androidx.compose.foundation.text.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.text.*
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.*
+import chat.simplex.app.model.*
+import chat.simplex.app.views.helpers.detectGesture
+
+val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
+val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
+
+fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
+ if (chatItem.chatDir is CIDirection.GroupRcv) {
+ val name = chatItem.chatDir.groupMember.memberProfile.displayName
+ if (groupMemberBold) b.withStyle(boldFont) { append(name) }
+ else b.append(name)
+ b.append(": ")
+ }
+}
+
+fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) {
+ if (sender != null) {
+ if (senderBold) b.withStyle(boldFont) { append(sender) }
+ else b.append(sender)
+ b.append(": ")
+ }
+}
+
+@Composable
+fun MarkdownText (
+ text: String,
+ formattedText: List? = null,
+ sender: String? = null,
+ metaText: String? = null,
+ edited: Boolean = false,
+ style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
+ maxLines: Int = Int.MAX_VALUE,
+ overflow: TextOverflow = TextOverflow.Clip,
+ uriHandler: UriHandler? = null,
+ senderBold: Boolean = false,
+ modifier: Modifier = Modifier,
+ onLinkLongClick: (link: String) -> Unit = {}
+) {
+ val reserve = if (edited) " " else " "
+ if (formattedText == null) {
+ val annotatedText = buildAnnotatedString {
+ appendSender(this, sender, senderBold)
+ append(text)
+ if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
+ }
+ Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
+ } else {
+ var hasLinks = false
+ val annotatedText = buildAnnotatedString {
+ appendSender(this, sender, senderBold)
+ for (ft in formattedText) {
+ if (ft.format == null) append(ft.text)
+ else {
+ val link = ft.link
+ if (link != null) {
+ hasLinks = true
+ val ftStyle = ft.format.style
+ withAnnotation(tag = "URL", annotation = link) {
+ withStyle(ftStyle) { append(ft.text) }
+ }
+ } else {
+ withStyle(ft.format.style) { append(ft.text) }
+ }
+ }
+ }
+ if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
+ }
+ if (hasLinks && uriHandler != null) {
+ ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
+ onLongClick = { offset ->
+ annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
+ .firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
+ },
+ onClick = { offset ->
+ annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
+ .firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
+ },
+ shouldConsumeEvent = { offset ->
+ annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
+ }
+ )
+ } else {
+ Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
+ }
+ }
+}
+
+@Composable
+fun ClickableText(
+ text: AnnotatedString,
+ modifier: Modifier = Modifier,
+ style: TextStyle = TextStyle.Default,
+ softWrap: Boolean = true,
+ overflow: TextOverflow = TextOverflow.Clip,
+ maxLines: Int = Int.MAX_VALUE,
+ onTextLayout: (TextLayoutResult) -> Unit = {},
+ onClick: (Int) -> Unit,
+ onLongClick: (Int) -> Unit = {},
+ shouldConsumeEvent: (Int) -> Boolean
+) {
+ val layoutResult = remember { mutableStateOf(null) }
+ val pressIndicator = Modifier.pointerInput(onClick, onLongClick) {
+ detectGesture(onLongPress = { pos ->
+ layoutResult.value?.let { layoutResult ->
+ onLongClick(layoutResult.getOffsetForPosition(pos))
+ }
+ }, onPress = { pos ->
+ layoutResult.value?.let { layoutResult ->
+ val res = tryAwaitRelease()
+ if (res) {
+ onClick(layoutResult.getOffsetForPosition(pos))
+ }
+ }
+ }, shouldConsumeEvent = { pos ->
+ var consume = false
+ layoutResult.value?.let { layoutResult ->
+ consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
+ }
+ consume
+ }
+ )
+ }
+
+ BasicText(
+ text = text,
+ modifier = modifier.then(pressIndicator),
+ style = style,
+ softWrap = softWrap,
+ overflow = overflow,
+ maxLines = maxLines,
+ onTextLayout = {
+ layoutResult.value = it
+ onTextLayout(it)
+ }
+ )
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt
new file mode 100644
index 0000000000..ff10bbbfeb
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt
@@ -0,0 +1,93 @@
+package chat.simplex.app.views.chatlist
+
+import android.content.res.Configuration
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.PersonAdd
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+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.R
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.helpers.annotatedStringResource
+import chat.simplex.app.views.usersettings.simplexTeamUri
+
+val bold = SpanStyle(fontWeight = FontWeight.Bold)
+
+@Composable
+fun ChatHelpView(addContact: (() -> Unit)? = null) {
+ Column(
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ val uriHandler = LocalUriHandler.current
+
+ Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp)
+ Text(
+ annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
+ modifier = Modifier.clickable(onClick = {
+ uriHandler.openUri(simplexTeamUri)
+ }),
+ lineHeight = 22.sp
+ )
+
+ Column(
+ Modifier.padding(top = 24.dp),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Text(
+ stringResource(R.string.to_start_a_new_chat_help_header),
+ style = MaterialTheme.typography.h2,
+ lineHeight = 22.sp
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(stringResource(R.string.chat_help_tap_button))
+ Icon(
+ Icons.Outlined.PersonAdd,
+ stringResource(R.string.add_contact),
+ modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
+ )
+ Text(stringResource(R.string.above_then_preposition_continuation))
+ }
+ Text(annotatedStringResource(R.string.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp)
+ Text(annotatedStringResource(R.string.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp)
+ }
+
+ Column(
+ Modifier.padding(top = 24.dp),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
+ Text(stringResource(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp)
+ Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
+ Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatHelpLayout() {
+ SimpleXTheme {
+ ChatHelpView {}
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt
new file mode 100644
index 0000000000..f4087ab63e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt
@@ -0,0 +1,586 @@
+package chat.simplex.app.views.chatlist
+
+import android.content.res.Configuration
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+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.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.*
+import chat.simplex.app.views.chat.*
+import chat.simplex.app.views.chat.group.deleteGroupDialog
+import chat.simplex.app.views.chat.group.leaveGroupDialog
+import chat.simplex.app.views.chat.item.ItemAction
+import chat.simplex.app.views.helpers.*
+import kotlinx.coroutines.delay
+import kotlinx.datetime.Clock
+
+@Composable
+fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
+ val showMenu = remember { mutableStateOf(false) }
+ var showMarkRead by remember { mutableStateOf(false) }
+ val stopped = chatModel.chatRunning.value == false
+ LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) {
+ showMenu.value = false
+ delay(500L)
+ showMarkRead = chat.chatStats.unreadCount > 0
+ }
+ when (chat.chatInfo) {
+ is ChatInfo.Direct ->
+ ChatListNavLinkLayout(
+ chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
+ click = { directChatAction(chat.chatInfo, chatModel) },
+ dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
+ showMenu,
+ stopped
+ )
+ is ChatInfo.Group ->
+ ChatListNavLinkLayout(
+ chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
+ click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
+ dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
+ showMenu,
+ stopped
+ )
+ is ChatInfo.ContactRequest ->
+ ChatListNavLinkLayout(
+ chatLinkPreview = { ContactRequestView(chatModel.incognito.value, chat.chatInfo) },
+ click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
+ dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
+ showMenu,
+ stopped
+ )
+ is ChatInfo.ContactConnection ->
+ ChatListNavLinkLayout(
+ chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
+ click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) },
+ dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
+ showMenu,
+ stopped
+ )
+ }
+}
+
+fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) {
+ if (chatInfo.ready) {
+ withApi { openChat(chatInfo, chatModel) }
+ } else {
+ pendingContactAlertDialog(chatInfo, chatModel)
+ }
+}
+
+fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
+ when (groupInfo.membership.memberStatus) {
+ GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel)
+ GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert()
+ else -> withApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
+ }
+}
+
+suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
+ val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
+ if (chat != null) {
+ chatModel.chatItems.clear()
+ chatModel.chatItems.addAll(chat.chatItems)
+ chatModel.chatId.value = chatInfo.id
+ }
+}
+
+suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) {
+ val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT)
+ val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
+ chatModel.chatItems.addAll(0, chat.chatItems)
+}
+
+suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) {
+ val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return
+ chatModel.chatItems.clear()
+ chatModel.chatItems.addAll(0, chat.chatItems)
+}
+
+suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
+ val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId)
+ chatModel.groupMembers.clear()
+ chatModel.groupMembers.addAll(groupMembers)
+}
+
+@Composable
+fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) {
+ if (showMarkRead) {
+ MarkReadChatAction(chat, chatModel, showMenu)
+ }
+ ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
+ ClearChatAction(chat, chatModel, showMenu)
+ DeleteContactAction(chat, chatModel, showMenu)
+}
+
+@Composable
+fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) {
+ when (groupInfo.membership.memberStatus) {
+ GroupMemberStatus.MemInvited -> {
+ JoinGroupAction(chat, groupInfo, chatModel, showMenu)
+ if (groupInfo.canDelete) {
+ DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
+ }
+ }
+ else -> {
+ if (showMarkRead) {
+ MarkReadChatAction(chat, chatModel, showMenu)
+ }
+ ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
+ ClearChatAction(chat, chatModel, showMenu)
+ if (groupInfo.membership.memberCurrent) {
+ LeaveGroupAction(groupInfo, chatModel, showMenu)
+ }
+ if (groupInfo.canDelete) {
+ DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
+ }
+ }
+ }
+}
+
+@Composable
+fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) {
+ ItemAction(
+ stringResource(R.string.mark_read),
+ Icons.Outlined.Check,
+ onClick = {
+ markChatRead(chat, chatModel)
+ chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
+ showMenu.value = false
+ }
+ )
+}
+
+@Composable
+fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState) {
+ ItemAction(
+ if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
+ if (ntfsEnabled) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
+ onClick = {
+ changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
+ showMenu.value = false
+ }
+ )
+}
+
+@Composable
+fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) {
+ ItemAction(
+ stringResource(R.string.clear_verb),
+ Icons.Outlined.Restore,
+ onClick = {
+ clearChatDialog(chat.chatInfo, chatModel)
+ showMenu.value = false
+ },
+ color = WarningOrange
+ )
+}
+
+@Composable
+fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) {
+ ItemAction(
+ stringResource(R.string.delete_verb),
+ Icons.Outlined.Delete,
+ onClick = {
+ deleteContactDialog(chat.chatInfo, chatModel)
+ showMenu.value = false
+ },
+ color = Color.Red
+ )
+}
+
+@Composable
+fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) {
+ ItemAction(
+ stringResource(R.string.delete_verb),
+ Icons.Outlined.Delete,
+ onClick = {
+ deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
+ showMenu.value = false
+ },
+ color = Color.Red
+ )
+}
+
+@Composable
+fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) {
+ val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
+ ItemAction(
+ if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button),
+ if (chat.chatInfo.incognito) Icons.Filled.TheaterComedy else Icons.Outlined.Login,
+ color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground,
+ onClick = {
+ joinGroup()
+ showMenu.value = false
+ }
+ )
+}
+
+@Composable
+fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) {
+ ItemAction(
+ stringResource(R.string.leave_group_button),
+ Icons.Outlined.Logout,
+ onClick = {
+ leaveGroupDialog(groupInfo, chatModel)
+ showMenu.value = false
+ },
+ color = Color.Red
+ )
+}
+
+@Composable
+fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) {
+ ItemAction(
+ if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button),
+ if (chatModel.incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.Check,
+ color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
+ onClick = {
+ acceptContactRequest(chatInfo, chatModel)
+ showMenu.value = false
+ }
+ )
+ ItemAction(
+ stringResource(R.string.reject_contact_button),
+ Icons.Outlined.Close,
+ onClick = {
+ rejectContactRequest(chatInfo, chatModel)
+ showMenu.value = false
+ },
+ color = Color.Red
+ )
+}
+
+@Composable
+fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState) {
+ ItemAction(
+ stringResource(R.string.delete_verb),
+ Icons.Outlined.Delete,
+ onClick = {
+ deleteContactConnectionAlert(chatInfo.contactConnection, chatModel)
+ showMenu.value = false
+ },
+ color = Color.Red
+ )
+}
+
+fun markChatRead(chat: Chat, chatModel: ChatModel) {
+ // Just to be sure
+ if (chat.chatStats.unreadCount == 0) return
+
+ val minUnreadItemId = chat.chatStats.minUnreadItemId
+ chatModel.markChatItemsRead(chat.chatInfo)
+ withApi {
+ chatModel.controller.apiChatRead(
+ chat.chatInfo.chatType,
+ chat.chatInfo.apiId,
+ CC.ItemRange(minUnreadItemId, chat.chatItems.last().id)
+ )
+ }
+}
+
+fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.accept_connection_request__question),
+ text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
+ confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button),
+ onConfirm = { acceptContactRequest(contactRequest, chatModel) },
+ dismissText = generalGetString(R.string.reject_contact_button),
+ onDismiss = { rejectContactRequest(contactRequest, chatModel) }
+ )
+}
+
+fun acceptContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
+ withApi {
+ val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
+ if (contact != null) {
+ val chat = Chat(ChatInfo.Direct(contact), listOf())
+ chatModel.replaceChat(contactRequest.id, chat)
+ }
+ }
+}
+
+fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
+ withApi {
+ chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
+ chatModel.removeChat(contactRequest.id)
+ }
+}
+
+fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
+ AlertManager.shared.showAlertDialogButtons(
+ title = generalGetString(
+ if (connection.initiated) R.string.you_invited_your_contact
+ else R.string.you_accepted_connection
+ ),
+ text = generalGetString(
+ if (connection.viaContactUri) R.string.you_will_be_connected_when_your_connection_request_is_accepted
+ else R.string.you_will_be_connected_when_your_contacts_device_is_online
+ ),
+ buttons = {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(onClick = {
+ AlertManager.shared.hideAlert()
+ deleteContactConnectionAlert(connection, chatModel)
+ }) {
+ Text(stringResource(R.string.delete_verb))
+ }
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ TextButton(onClick = { AlertManager.shared.hideAlert() }) {
+ Text(stringResource(R.string.ok))
+ }
+ }
+ }
+ )
+}
+
+fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.delete_pending_connection__question),
+ text = generalGetString(
+ if (connection.initiated) R.string.contact_you_shared_link_with_wont_be_able_to_connect
+ else R.string.connection_you_accepted_will_be_cancelled
+ ),
+ confirmText = generalGetString(R.string.delete_verb),
+ onConfirm = {
+ withApi {
+ AlertManager.shared.hideAlert()
+ if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) {
+ chatModel.removeChat(connection.id)
+ }
+ }
+ }
+ )
+}
+
+fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.alert_title_contact_connection_pending),
+ text = generalGetString(R.string.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry),
+ confirmText = generalGetString(R.string.button_delete_contact),
+ onConfirm = {
+ withApi {
+ val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
+ if (r) {
+ chatModel.removeChat(chatInfo.id)
+ chatModel.chatId.value = null
+ }
+ }
+ },
+ dismissText = generalGetString(R.string.cancel_verb),
+ )
+}
+
+fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.join_group_question),
+ text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members),
+ confirmText = if (groupInfo.membership.memberIncognito) generalGetString(R.string.join_group_incognito_button) else generalGetString(R.string.join_group_button),
+ onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } },
+ dismissText = generalGetString(R.string.delete_verb),
+ onDismiss = { deleteGroup(groupInfo, chatModel) }
+ )
+}
+
+fun cantInviteIncognitoAlert() {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.alert_title_cant_invite_contacts),
+ text = generalGetString(R.string.alert_title_cant_invite_contacts_descr),
+ confirmText = generalGetString(R.string.ok),
+ )
+}
+
+fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
+ withApi {
+ val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId)
+ if (r) {
+ chatModel.removeChat(groupInfo.id)
+ chatModel.chatId.value = null
+ chatModel.controller.ntfManager.cancelNotificationsForChat(groupInfo.id)
+ }
+ }
+}
+
+fun groupInvitationAcceptedAlert() {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.joining_group),
+ generalGetString(R.string.youve_accepted_group_invitation_connecting_to_inviting_group_member)
+ )
+}
+
+fun changeNtfsStatePerChat(enabled: Boolean, currentState: MutableState, chat: Chat, chatModel: ChatModel) {
+ val newChatInfo = when(chat.chatInfo) {
+ is ChatInfo.Direct -> with (chat.chatInfo) {
+ ChatInfo.Direct(contact.copy(chatSettings = contact.chatSettings.copy(enableNtfs = enabled)))
+ }
+ is ChatInfo.Group -> with(chat.chatInfo) {
+ ChatInfo.Group(groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(enableNtfs = enabled)))
+ }
+ else -> null
+ }
+ withApi {
+ val res = when (newChatInfo) {
+ is ChatInfo.Direct -> with(newChatInfo) {
+ chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings)
+ }
+ is ChatInfo.Group -> with(newChatInfo) {
+ chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings)
+ }
+ else -> false
+ }
+ if (res && newChatInfo != null) {
+ chatModel.updateChatInfo(newChatInfo)
+ if (!enabled) {
+ chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
+ }
+ currentState.value = enabled
+ }
+ }
+}
+
+@Composable
+fun ChatListNavLinkLayout(
+ chatLinkPreview: @Composable () -> Unit,
+ click: () -> Unit,
+ dropdownMenuItems: (@Composable () -> Unit)?,
+ showMenu: MutableState,
+ stopped: Boolean
+) {
+ var modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp)
+ if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
+ Surface(modifier) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp)
+ .padding(start = 8.dp)
+ .padding(end = 12.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ chatLinkPreview()
+ }
+ if (dropdownMenuItems != null) {
+ Box(Modifier.padding(horizontal = 16.dp)) {
+ DropdownMenu(
+ expanded = showMenu.value,
+ onDismissRequest = { showMenu.value = false },
+ Modifier.width(220.dp)
+ ) {
+ dropdownMenuItems()
+ }
+ }
+ }
+ }
+ Divider(Modifier.padding(horizontal = 8.dp))
+}
+
+@Preview
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatListNavLinkDirect() {
+ SimpleXTheme {
+ ChatListNavLinkLayout(
+ chatLinkPreview = {
+ ChatPreviewView(
+ chat = Chat(
+ chatInfo = ChatInfo.Direct.sampleData,
+ chatItems = listOf(
+ ChatItem.getSampleData(
+ 1,
+ CIDirection.DirectSnd(),
+ Clock.System.now(),
+ "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."
+ )
+ ),
+ chatStats = Chat.ChatStats()
+ ),
+ false,
+ null,
+ stopped = false
+ )
+ },
+ click = {},
+ dropdownMenuItems = null,
+ showMenu = remember { mutableStateOf(false) },
+ stopped = false
+ )
+ }
+}
+
+@Preview
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatListNavLinkGroup() {
+ SimpleXTheme {
+ ChatListNavLinkLayout(
+ chatLinkPreview = {
+ ChatPreviewView(
+ Chat(
+ chatInfo = ChatInfo.Group.sampleData,
+ chatItems = listOf(
+ ChatItem.getSampleData(
+ 1,
+ CIDirection.DirectSnd(),
+ Clock.System.now(),
+ "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."
+ )
+ ),
+ chatStats = Chat.ChatStats()
+ ),
+ false,
+ null,
+ stopped = false
+ )
+ },
+ click = {},
+ dropdownMenuItems = null,
+ showMenu = remember { mutableStateOf(false) },
+ stopped = false
+ )
+ }
+}
+
+@Preview
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatListNavLinkContactRequest() {
+ SimpleXTheme {
+ ChatListNavLinkLayout(
+ chatLinkPreview = {
+ ContactRequestView(false, ChatInfo.ContactRequest.sampleData)
+ },
+ click = {},
+ dropdownMenuItems = null,
+ showMenu = remember { mutableStateOf(false) },
+ stopped = false
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
new file mode 100644
index 0000000000..0b8d2f7d45
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
@@ -0,0 +1,251 @@
+package chat.simplex.app.views.chatlist
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+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.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.graphics.Path
+import androidx.compose.ui.platform.LocalUriHandler
+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.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.Indigo
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.newchat.NewChatSheet
+import chat.simplex.app.views.usersettings.SettingsView
+import chat.simplex.app.views.usersettings.simplexTeamUri
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class ScaffoldController(val scope: CoroutineScope) {
+ lateinit var state: BottomSheetScaffoldState
+ val expanded = mutableStateOf(false)
+
+ fun expand() {
+ expanded.value = true
+ scope.launch { state.bottomSheetState.expand() }
+ }
+
+ fun collapse() {
+ expanded.value = false
+ scope.launch { state.bottomSheetState.collapse() }
+ }
+
+ fun toggleSheet() {
+ if (state.bottomSheetState.isExpanded) collapse() else expand()
+ }
+
+ fun toggleDrawer() = scope.launch {
+ state.drawerState.apply { if (isClosed) open() else close() }
+ }
+}
+
+@Composable
+fun scaffoldController(): ScaffoldController {
+ val ctrl = ScaffoldController(scope = rememberCoroutineScope())
+ val bottomSheetState = rememberBottomSheetState(
+ BottomSheetValue.Collapsed,
+ confirmStateChange = {
+ ctrl.expanded.value = it == BottomSheetValue.Expanded
+ true
+ }
+ )
+ ctrl.state = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+ return ctrl
+}
+
+@Composable
+fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
+ val scaffoldCtrl = scaffoldController()
+ LaunchedEffect(chatModel.clearOverlays.value) {
+ if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
+ }
+ var searchInList by rememberSaveable { mutableStateOf("") }
+ BottomSheetScaffold(
+ topBar = { ChatListToolbar(chatModel, scaffoldCtrl, stopped) { searchInList = it.trim() } },
+ scaffoldState = scaffoldCtrl.state,
+ drawerContent = { SettingsView(chatModel, setPerformLA) },
+ sheetPeekHeight = 0.dp,
+ sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = {
+ if (!stopped) {
+ if (!scaffoldCtrl.expanded.value) scaffoldCtrl.expand() else scaffoldCtrl.collapse()
+ }
+ },
+ Modifier.padding(bottom = 90.dp),
+ elevation = FloatingActionButtonDefaults.elevation(
+ defaultElevation = 0.dp,
+ pressedElevation = 0.dp,
+ hoveredElevation = 0.dp,
+ focusedElevation = 0.dp,
+ ),
+ backgroundColor = if (!stopped) MaterialTheme.colors.primary else HighOrLowlight,
+ contentColor = Color.White
+ ) {
+ Icon(Icons.Default.Edit, stringResource(R.string.add_contact_or_create_group))
+ }
+ },
+ sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
+ ) {
+ Box {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colors.background)
+ ) {
+ if (chatModel.chats.isNotEmpty()) {
+ ChatList(chatModel, search = searchInList)
+ } else {
+ if (!stopped) {
+ OnboardingButtons(scaffoldCtrl)
+ }
+ }
+ }
+ if (scaffoldCtrl.expanded.value) {
+ Surface(
+ Modifier
+ .fillMaxSize()
+ .clickable { scaffoldCtrl.collapse() },
+ color = Color.Black.copy(alpha = 0.12F)
+ ) {}
+ }
+ }
+ }
+}
+
+@Composable
+private fun OnboardingButtons(scaffoldCtrl: ScaffoldController) {
+ Box {
+ Column(Modifier.fillMaxSize().padding(6.dp), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
+ val uriHandler = LocalUriHandler.current
+ ConnectButton(generalGetString(R.string.chat_with_developers)) {
+ uriHandler.openUri(simplexTeamUri)
+ }
+ Spacer(Modifier.height(10.dp))
+ ConnectButton(generalGetString(R.string.tap_to_start_new_chat)) {
+ scaffoldCtrl.toggleSheet()
+ }
+ val color = MaterialTheme.colors.primary
+ Canvas(modifier = Modifier.width(46.dp).height(10.dp), onDraw = {
+ val trianglePath = Path().apply {
+ moveTo(0.dp.toPx(), 0f)
+ lineTo(16.dp.toPx(), 0.dp.toPx())
+ lineTo(8.dp.toPx(), 10.dp.toPx())
+ lineTo(0.dp.toPx(), 0.dp.toPx())
+ }
+ drawPath(
+ color = color,
+ path = trianglePath
+ )
+ })
+ Spacer(Modifier.height(80.dp))
+ }
+ Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
+ }
+}
+
+@Composable
+private fun ConnectButton(text: String, onClick: () -> Unit) {
+ Box(
+ Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colors.primary)
+ .clickable { onClick() }
+ .padding(vertical = 10.dp, horizontal = 20.dp),
+ ) {
+ Text(text, color = Color.White)
+ }
+}
+
+@Composable
+fun ChatListToolbar(chatModel: ChatModel, scaffoldCtrl: ScaffoldController, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
+ var showSearch by rememberSaveable { mutableStateOf(false) }
+ val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
+ if (showSearch) {
+ BackHandler(onBack = hideSearchOnBack)
+ }
+ val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
+ if (chatModel.chats.size >= 8) {
+ barButtons.add {
+ IconButton({ showSearch = true }) {
+ Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
+ }
+ }
+ }
+ if (stopped) {
+ barButtons.add {
+ IconButton(onClick = {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.chat_is_stopped_indication),
+ generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)
+ )
+ }) {
+ Icon(
+ Icons.Filled.Report,
+ generalGetString(R.string.chat_is_stopped_indication),
+ tint = Color.Red,
+ )
+ }
+ }
+ }
+
+ DefaultTopAppBar(
+ navigationButton = { if (showSearch) NavigationButtonBack(hideSearchOnBack) else NavigationButtonMenu { scaffoldCtrl.toggleDrawer() } },
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ stringResource(R.string.your_chats),
+ color = MaterialTheme.colors.onBackground,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (chatModel.incognito.value) {
+ Icon(
+ Icons.Filled.TheaterComedy,
+ stringResource(R.string.incognito),
+ tint = Indigo,
+ modifier = Modifier.padding(10.dp).size(26.dp)
+ )
+ }
+ }
+ },
+ onTitleClick = null,
+ showSearch = showSearch,
+ onSearchValueChanged = onSearchValueChanged,
+ buttons = barButtons
+ )
+ Divider()
+}
+
+@Composable
+fun ChatList(chatModel: ChatModel, search: String) {
+ val filter: (Chat) -> Boolean = { chat: Chat ->
+ chat.chatInfo.chatViewName.lowercase().contains(search.lowercase())
+ }
+ val chats by remember(search) { derivedStateOf { if (search.isEmpty()) chatModel.chats else chatModel.chats.filter(filter) } }
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ items(chats) { chat ->
+ ChatListNavLinkView(chat, chatModel)
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt
new file mode 100644
index 0000000000..accbffb2e1
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt
@@ -0,0 +1,230 @@
+package chat.simplex.app.views.chatlist
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+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.Cancel
+import androidx.compose.material.icons.filled.NotificationsOff
+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 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.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.item.MarkdownText
+import chat.simplex.app.views.helpers.*
+
+@Composable
+fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) {
+ val cInfo = chat.chatInfo
+
+ @Composable
+ fun groupInactiveIcon() {
+ Icon(
+ Icons.Filled.Cancel,
+ stringResource(R.string.icon_descr_group_inactive),
+ Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
+ tint = HighOrLowlight
+ )
+ }
+
+ @Composable
+ fun chatPreviewImageOverlayIcon() {
+ if (cInfo is ChatInfo.Group) {
+ when (cInfo.groupInfo.membership.memberStatus) {
+ GroupMemberStatus.MemLeft -> groupInactiveIcon()
+ GroupMemberStatus.MemRemoved -> groupInactiveIcon()
+ GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon()
+ else -> {}
+ }
+ }
+ }
+
+ @Composable
+ fun chatPreviewTitleText(color: Color = Color.Unspecified) {
+ Text(
+ cInfo.chatViewName,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.h3,
+ fontWeight = FontWeight.Bold,
+ color = color
+ )
+ }
+
+ @Composable
+ fun chatPreviewTitle() {
+ when (cInfo) {
+ is ChatInfo.Direct ->
+ chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
+ is ChatInfo.Group ->
+ when (cInfo.groupInfo.membership.memberStatus) {
+ GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
+ GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight)
+ else -> chatPreviewTitleText()
+ }
+ else -> chatPreviewTitleText()
+ }
+ }
+
+ @Composable
+ fun chatPreviewText(chatModelIncognito: Boolean) {
+ val ci = chat.chatItems.lastOrNull()
+ if (ci != null) {
+ MarkdownText(
+ ci.text, ci.formattedText, ci.memberDisplayName,
+ metaText = ci.timestampText,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
+ )
+ } else {
+ when (cInfo) {
+ is ChatInfo.Direct ->
+ if (!cInfo.ready) {
+ Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
+ }
+ is ChatInfo.Group ->
+ when (cInfo.groupInfo.membership.memberStatus) {
+ GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
+ GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight)
+ else -> {}
+ }
+ else -> {}
+ }
+ }
+ }
+
+ Row {
+ Box(contentAlignment = Alignment.BottomEnd) {
+ ChatInfoImage(cInfo, size = 72.dp)
+ Box(Modifier.padding(end = 6.dp, bottom = 6.dp)) {
+ chatPreviewImageOverlayIcon()
+ }
+ }
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .weight(1F)
+ ) {
+ chatPreviewTitle()
+ chatPreviewText(chatModelIncognito)
+ }
+ val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
+
+ Box(
+ contentAlignment = Alignment.TopEnd
+ ) {
+ Text(
+ ts,
+ color = HighOrLowlight,
+ style = MaterialTheme.typography.body2,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ val n = chat.chatStats.unreadCount
+ val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group)
+ if (n > 0) {
+ Box(
+ Modifier.padding(top = 24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ unreadCountStr(n),
+ color = MaterialTheme.colors.onPrimary,
+ fontSize = 11.sp,
+ modifier = Modifier
+ .background(if (stopped || showNtfsIcon) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
+ .badgeLayout()
+ .padding(horizontal = 3.dp)
+ .padding(vertical = 1.dp)
+ )
+ }
+ } else if (showNtfsIcon) {
+ Box(
+ Modifier.padding(top = 24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Filled.NotificationsOff,
+ contentDescription = generalGetString(R.string.notifications),
+ tint = HighOrLowlight,
+ modifier = Modifier
+ .padding(horizontal = 3.dp)
+ .padding(vertical = 1.dp)
+ .size(17.dp)
+ )
+ }
+ }
+ if (cInfo is ChatInfo.Direct) {
+ Box(
+ Modifier.padding(top = 52.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ ChatStatusImage(chat)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
+ return if (groupInfo.membership.memberIncognito)
+ String.format(stringResource(R.string.group_preview_join_as), groupInfo.membership.memberProfile.displayName)
+ else if (chatModelIncognito)
+ String.format(stringResource(R.string.group_preview_join_as), currentUserProfileDisplayName ?: "")
+ else
+ stringResource(R.string.group_preview_you_are_invited)
+}
+
+@Composable
+fun unreadCountStr(n: Int): String {
+ return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation)
+}
+
+@Composable
+fun ChatStatusImage(chat: Chat) {
+ val s = chat.serverInfo.networkStatus
+ val descr = s.statusString
+ if (s is Chat.NetworkStatus.Error) {
+ Icon(
+ Icons.Outlined.ErrorOutline,
+ contentDescription = descr,
+ tint = HighOrLowlight,
+ modifier = Modifier
+ .size(19.dp)
+ )
+ } else if (s !is Chat.NetworkStatus.Connected) {
+ CircularProgressIndicator(
+ Modifier
+ .padding(horizontal = 2.dp)
+ .size(15.dp),
+ color = HighOrLowlight,
+ strokeWidth = 1.5.dp
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatPreviewView() {
+ SimpleXTheme {
+ ChatPreviewView(Chat.sampleData, false, "", stopped = false)
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt
new file mode 100644
index 0000000000..2ee852aeb3
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt
@@ -0,0 +1,54 @@
+package chat.simplex.app.views.chatlist
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AddLink
+import androidx.compose.material.icons.outlined.Link
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.model.PendingContactConnection
+import chat.simplex.app.model.getTimestampText
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.ProfileImage
+
+@Composable
+fun ContactConnectionView(contactConnection: PendingContactConnection) {
+ Row {
+ Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
+ ProfileImage(size = 54.dp, null, if (contactConnection.initiated) Icons.Outlined.AddLink else Icons.Outlined.Link)
+ }
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .weight(1F)
+ ) {
+ Text(
+ contactConnection.displayName,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.h3,
+ fontWeight = FontWeight.Bold,
+ color = HighOrLowlight
+ )
+ Text(contactConnection.description, maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
+ }
+ val ts = getTimestampText(contactConnection.updatedAt)
+ Column(
+ Modifier.fillMaxHeight(),
+ verticalArrangement = Arrangement.Top
+ ) {
+ Text(
+ ts,
+ color = HighOrLowlight,
+ style = MaterialTheme.typography.body2,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt
new file mode 100644
index 0000000000..70b91e634e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt
@@ -0,0 +1,49 @@
+package chat.simplex.app.views.chatlist
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+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.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.ChatInfoImage
+
+@Composable
+fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) {
+ Row {
+ ChatInfoImage(contactRequest, size = 72.dp)
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .weight(1F)
+ ) {
+ Text(
+ contactRequest.chatViewName,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.h3,
+ fontWeight = FontWeight.Bold,
+ color = if (chatModelIncognito) Indigo else MaterialTheme.colors.primary
+ )
+ Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
+ }
+ val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
+ Column(
+ Modifier.fillMaxHeight(),
+ verticalArrangement = Arrangement.Top
+ ) {
+ Text(
+ ts,
+ color = HighOrLowlight,
+ style = MaterialTheme.typography.body2,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt
new file mode 100644
index 0000000000..9bc80a4a9a
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt
@@ -0,0 +1,146 @@
+package chat.simplex.app.views.database
+
+import SectionDivider
+import SectionTextFooter
+import SectionView
+import android.content.Context
+import android.content.res.Configuration
+import android.net.Uri
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+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.platform.LocalContext
+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.TAG
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.usersettings.*
+import kotlinx.datetime.*
+import java.io.BufferedOutputStream
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+
+@Composable
+fun ChatArchiveView(m: ChatModel, title: String, archiveName: String, archiveTime: Instant) {
+ val context = LocalContext.current
+ val archivePath = "${getFilesDirectory(context)}/$archiveName"
+ val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, archivePath)
+ ChatArchiveLayout(
+ title,
+ archiveTime,
+ saveArchive = { saveArchiveLauncher.launch(archivePath.substringAfterLast("/")) },
+ deleteArchiveAlert = { deleteArchiveAlert(m, archivePath) }
+ )
+}
+
+@Composable
+fun ChatArchiveLayout(
+ title: String,
+ archiveTime: Instant,
+ saveArchive: () -> Unit,
+ deleteArchiveAlert: () -> Unit
+) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ Text(
+ title,
+ Modifier.padding(start = 16.dp, bottom = 24.dp),
+ style = MaterialTheme.typography.h1
+ )
+
+ SectionView(stringResource(R.string.chat_archive_section)) {
+ SettingsActionItem(
+ Icons.Outlined.IosShare,
+ stringResource(R.string.save_archive),
+ saveArchive,
+ textColor = MaterialTheme.colors.primary
+ )
+ SectionDivider()
+ SettingsActionItem(
+ Icons.Outlined.Delete,
+ stringResource(R.string.delete_archive),
+ deleteArchiveAlert,
+ textColor = Color.Red
+ )
+ }
+ val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
+ SectionTextFooter(
+ String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
+ )
+ }
+}
+
+@Composable
+private fun rememberSaveArchiveLauncher(cxt: Context, chatArchivePath: String): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument(),
+ onResult = { destination ->
+ try {
+ destination?.let {
+ val contentResolver = cxt.contentResolver
+ contentResolver.openOutputStream(destination)?.let { stream ->
+ val outputStream = BufferedOutputStream(stream)
+ val file = File(chatArchivePath)
+ outputStream.write(file.readBytes())
+ outputStream.close()
+ Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
+ }
+ }
+ } catch (e: Error) {
+ Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
+ Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e")
+ }
+ }
+ )
+
+private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.delete_chat_archive_question),
+ confirmText = generalGetString(R.string.delete_verb),
+ onConfirm = {
+ val fileDeleted = File(archivePath).delete()
+ if (fileDeleted) {
+ m.controller.appPrefs.chatArchiveName.set(null)
+ m.controller.appPrefs.chatArchiveTime.set(null)
+ ModalManager.shared.closeModal()
+ } else {
+ Log.e(TAG, "deleteArchiveAlert delete() error")
+ }
+ }
+ )
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatArchiveLayout() {
+ SimpleXTheme {
+ ChatArchiveLayout(
+ title = "New database archive",
+ archiveTime = Clock.System.now(),
+ saveArchive = {},
+ deleteArchiveAlert = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt
new file mode 100644
index 0000000000..e2fceae5bd
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt
@@ -0,0 +1,512 @@
+package chat.simplex.app.views.database
+
+import SectionItemView
+import SectionItemViewSpaceBetween
+import SectionTextFooter
+import SectionView
+import androidx.compose.foundation.*
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.ZeroCornerSize
+import androidx.compose.foundation.text.*
+import androidx.compose.material.*
+import androidx.compose.material.TextFieldDefaults.indicatorLine
+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.*
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.*
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.*
+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.SimplexApp
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+import kotlinx.datetime.Clock
+import kotlin.math.log2
+
+@Composable
+fun DatabaseEncryptionView(m: ChatModel) {
+ val progressIndicator = remember { mutableStateOf(false) }
+ val prefs = m.controller.appPrefs
+ val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
+ val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
+ val storedKey = remember { val key = DatabaseUtils.getDatabaseKey(); mutableStateOf(key != null && key != "") }
+ // Do not do rememberSaveable on current key to prevent saving it on disk in clear text
+ val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.getDatabaseKey() ?: "" else "") }
+ val newKey = rememberSaveable { mutableStateOf("") }
+ val confirmNewKey = rememberSaveable { mutableStateOf("") }
+
+ Box(
+ Modifier.fillMaxSize(),
+ ) {
+ DatabaseEncryptionLayout(
+ useKeychain,
+ prefs,
+ m.chatDbEncrypted.value,
+ currentKey,
+ newKey,
+ confirmNewKey,
+ storedKey,
+ initialRandomDBPassphrase,
+ progressIndicator,
+ onConfirmEncrypt = {
+ progressIndicator.value = true
+ withApi {
+ try {
+ prefs.encryptionStartedAt.set(Clock.System.now())
+ val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
+ prefs.encryptionStartedAt.set(null)
+ val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
+ when {
+ sqliteError is SQLiteError.ErrorNotADatabase -> {
+ operationEnded(m, progressIndicator) {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.wrong_passphrase_title),
+ generalGetString(R.string.enter_correct_current_passphrase)
+ )
+ }
+ }
+ error != null -> {
+ operationEnded(m, progressIndicator) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database),
+ "failed to set storage encryption: ${error.responseType} ${error.details}"
+ )
+ }
+ }
+ else -> {
+ prefs.initialRandomDBPassphrase.set(false)
+ initialRandomDBPassphrase.value = false
+ if (useKeychain.value) {
+ DatabaseUtils.setDatabaseKey(newKey.value)
+ }
+ resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
+ operationEnded(m, progressIndicator) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.database_encrypted))
+ }
+ }
+ }
+ } catch (e: Exception) {
+ operationEnded(m, progressIndicator) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database), e.stackTraceToString())
+ }
+ }
+ }
+ }
+ )
+ if (progressIndicator.value) {
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ Modifier
+ .padding(horizontal = 2.dp)
+ .size(30.dp),
+ color = HighOrLowlight,
+ strokeWidth = 2.5.dp
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun DatabaseEncryptionLayout(
+ useKeychain: MutableState,
+ prefs: AppPreferences,
+ chatDbEncrypted: Boolean?,
+ currentKey: MutableState,
+ newKey: MutableState,
+ confirmNewKey: MutableState,
+ storedKey: MutableState,
+ initialRandomDBPassphrase: MutableState,
+ progressIndicator: MutableState,
+ onConfirmEncrypt: () -> Unit,
+) {
+ Column(
+ Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ Text(
+ stringResource(R.string.database_passphrase),
+ Modifier.padding(start = 16.dp, bottom = 24.dp),
+ style = MaterialTheme.typography.h1
+ )
+
+ SectionView(null) {
+ SavePassphraseSetting(useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, progressIndicator.value) { checked ->
+ if (checked) {
+ setUseKeychain(true, useKeychain, prefs)
+ } else if (storedKey.value) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.remove_passphrase_from_keychain),
+ text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
+ confirmText = generalGetString(R.string.remove_passphrase),
+ onConfirm = {
+ DatabaseUtils.removeDatabaseKey()
+ setUseKeychain(false, useKeychain, prefs)
+ storedKey.value = false
+ },
+ destructive = true,
+ )
+ } else {
+ setUseKeychain(false, useKeychain, prefs)
+ }
+ }
+
+ if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
+ DatabaseKeyField(
+ currentKey,
+ generalGetString(R.string.current_passphrase),
+ modifier = Modifier.padding(start = 8.dp),
+ isValid = ::validKey,
+ keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
+ )
+ }
+
+ DatabaseKeyField(
+ newKey,
+ generalGetString(R.string.new_passphrase),
+ modifier = Modifier.padding(start = 8.dp),
+ showStrength = true,
+ isValid = ::validKey,
+ keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
+ )
+ val onClickUpdate = {
+ // Don't do things concurrently. Shouldn't be here concurrently, just in case
+ if (!progressIndicator.value) {
+ if (currentKey.value == "") {
+ if (useKeychain.value)
+ encryptDatabaseSavedAlert(onConfirmEncrypt)
+ else
+ encryptDatabaseAlert(onConfirmEncrypt)
+ } else {
+ if (useKeychain.value)
+ changeDatabaseKeySavedAlert(onConfirmEncrypt)
+ else
+ changeDatabaseKeyAlert(onConfirmEncrypt)
+ }
+ }
+ }
+ val disabled = currentKey.value == newKey.value ||
+ newKey.value != confirmNewKey.value ||
+ newKey.value.isEmpty() ||
+ !validKey(currentKey.value) ||
+ !validKey(newKey.value) ||
+ progressIndicator.value
+
+ DatabaseKeyField(
+ confirmNewKey,
+ generalGetString(R.string.confirm_new_passphrase),
+ modifier = Modifier.padding(start = 8.dp),
+ isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
+ keyboardActions = KeyboardActions(onDone = {
+ if (!disabled) onClickUpdate()
+ defaultKeyboardAction(ImeAction.Done)
+ }),
+ )
+
+ SectionItemViewSpaceBetween(onClickUpdate, padding = PaddingValues(start = 8.dp, end = 12.dp), disabled = disabled) {
+ Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
+ }
+ }
+
+ Column {
+ if (chatDbEncrypted == false) {
+ SectionTextFooter(generalGetString(R.string.database_is_not_encrypted))
+ } else if (useKeychain.value) {
+ if (storedKey.value) {
+ SectionTextFooter(generalGetString(R.string.keychain_is_storing_securely))
+ if (initialRandomDBPassphrase.value) {
+ SectionTextFooter(generalGetString(R.string.encrypted_with_random_passphrase))
+ } else {
+ SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
+ }
+ } else {
+ SectionTextFooter(generalGetString(R.string.keychain_allows_to_receive_ntfs))
+ }
+ } else {
+ SectionTextFooter(generalGetString(R.string.you_have_to_enter_passphrase_every_time))
+ SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
+ }
+ }
+ }
+}
+
+fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.encrypt_database_question),
+ text = generalGetString(R.string.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
+ confirmText = generalGetString(R.string.encrypt_database),
+ onConfirm = onConfirm,
+ destructive = false,
+ )
+}
+
+fun encryptDatabaseAlert(onConfirm: () -> Unit) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.encrypt_database_question),
+ text = generalGetString(R.string.database_will_be_encrypted) +"\n" + storeSecurelyDanger(),
+ confirmText = generalGetString(R.string.encrypt_database),
+ onConfirm = onConfirm,
+ destructive = true,
+ )
+}
+
+fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.change_database_passphrase_question),
+ text = generalGetString(R.string.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(),
+ confirmText = generalGetString(R.string.update_database),
+ onConfirm = onConfirm,
+ destructive = false,
+ )
+}
+
+fun changeDatabaseKeyAlert(onConfirm: () -> Unit) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.change_database_passphrase_question),
+ text = generalGetString(R.string.database_passphrase_will_be_updated) + "\n" + storeSecurelyDanger(),
+ confirmText = generalGetString(R.string.update_database),
+ onConfirm = onConfirm,
+ destructive = true,
+ )
+}
+
+@Composable
+fun SavePassphraseSetting(
+ useKeychain: Boolean,
+ initialRandomDBPassphrase: Boolean,
+ storedKey: Boolean,
+ progressIndicator: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ SectionItemView() {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff,
+ stringResource(R.string.save_passphrase_in_keychain),
+ tint = if (storedKey) SimplexGreen else HighOrLowlight
+ )
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ Text(
+ stringResource(R.string.save_passphrase_in_keychain),
+ Modifier.padding(end = 24.dp),
+ color = Color.Unspecified
+ )
+ Spacer(Modifier.fillMaxWidth().weight(1f))
+ Switch(
+ checked = useKeychain,
+ onCheckedChange = onCheckedChange,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colors.primary,
+ uncheckedThumbColor = HighOrLowlight
+ ),
+ enabled = !initialRandomDBPassphrase && !progressIndicator
+ )
+ }
+ }
+}
+
+fun resetFormAfterEncryption(
+ m: ChatModel,
+ initialRandomDBPassphrase: MutableState,
+ currentKey: MutableState,
+ newKey: MutableState,
+ confirmNewKey: MutableState,
+ storedKey: MutableState,
+ stored: Boolean = false,
+) {
+ m.chatDbEncrypted.value = true
+ initialRandomDBPassphrase.value = false
+ m.controller.appPrefs.initialRandomDBPassphrase.set(false)
+ currentKey.value = ""
+ newKey.value = ""
+ confirmNewKey.value = ""
+ storedKey.value = stored
+}
+
+fun setUseKeychain(value: Boolean, useKeychain: MutableState, prefs: AppPreferences) {
+ useKeychain.value = value
+ prefs.storeDBPassphrase.set(value)
+}
+
+fun storeSecurelySaved() = generalGetString(R.string.store_passphrase_securely)
+
+fun storeSecurelyDanger() = generalGetString(R.string.store_passphrase_securely_without_recover)
+
+private fun operationEnded(m: ChatModel, progressIndicator: MutableState, alert: () -> Unit) {
+ m.chatDbChanged.value = true
+ progressIndicator.value = false
+ alert.invoke()
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun DatabaseKeyField(
+ key: MutableState,
+ placeholder: String,
+ modifier: Modifier = Modifier,
+ showStrength: Boolean = false,
+ isValid: (String) -> Boolean,
+ keyboardActions: KeyboardActions = KeyboardActions(),
+) {
+ var valid by remember { mutableStateOf(validKey(key.value)) }
+ var showKey by remember { mutableStateOf(false) }
+ val icon = if (valid) {
+ if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
+ } else Icons.Outlined.Error
+ val iconColor = if (valid) {
+ if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else HighOrLowlight
+ } else Color.Red
+ val keyboard = LocalSoftwareKeyboardController.current
+ val keyboardOptions = KeyboardOptions(
+ imeAction = if (keyboardActions.onNext != null) ImeAction.Next else ImeAction.Done,
+ autoCorrect = false,
+ keyboardType = KeyboardType.Password
+ )
+ val state = remember {
+ mutableStateOf(TextFieldValue(key.value))
+ }
+ val enabled = true
+ val colors = TextFieldDefaults.textFieldColors(
+ backgroundColor = Color.Unspecified,
+ textColor = MaterialTheme.colors.onBackground,
+ focusedIndicatorColor = Color.Unspecified,
+ unfocusedIndicatorColor = Color.Unspecified,
+ )
+ val color = MaterialTheme.colors.onBackground
+ val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
+ val interactionSource = remember { MutableInteractionSource() }
+ BasicTextField(
+ value = state.value,
+ modifier = modifier
+ .fillMaxWidth()
+ .background(colors.backgroundColor(enabled).value, shape)
+ .indicatorLine(enabled, false, interactionSource, colors)
+ .defaultMinSize(
+ minWidth = TextFieldDefaults.MinWidth,
+ minHeight = TextFieldDefaults.MinHeight
+ ),
+ onValueChange = {
+ state.value = it
+ key.value = it.text
+ valid = isValid(it.text)
+ },
+ cursorBrush = SolidColor(colors.cursorColor(false).value),
+ visualTransformation = if (showKey)
+ VisualTransformation.None
+ else
+ VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) },
+ keyboardOptions = keyboardOptions,
+ keyboardActions = KeyboardActions(onDone = {
+ keyboard?.hide()
+ keyboardActions.onDone?.invoke(this)
+ }),
+ singleLine = true,
+ textStyle = TextStyle.Default.copy(
+ color = color,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp
+ ),
+ interactionSource = interactionSource,
+ decorationBox = @Composable { innerTextField ->
+ TextFieldDefaults.TextFieldDecorationBox(
+ value = state.value.text,
+ innerTextField = innerTextField,
+ placeholder = { Text(placeholder, color = HighOrLowlight) },
+ singleLine = true,
+ enabled = enabled,
+ isError = !valid,
+ trailingIcon = {
+ IconButton({ showKey = !showKey }) {
+ Icon(icon, null, tint = iconColor)
+ }
+ },
+ interactionSource = interactionSource,
+ contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
+ visualTransformation = VisualTransformation.None,
+ colors = colors
+ )
+ }
+ )
+}
+
+// based on https://generatepasswords.org/how-to-calculate-entropy/
+private fun passphraseEntropy(s: String): Double {
+ var hasDigits = false
+ var hasUppercase = false
+ var hasLowercase = false
+ var hasSymbols = false
+ for (c in s) {
+ if (c.isDigit()) {
+ hasDigits = true
+ } else if (c.isLetter()) {
+ if (c.isUpperCase()) {
+ hasUppercase = true
+ } else {
+ hasLowercase = true
+ }
+ } else if (c.isASCII()) {
+ hasSymbols = true
+ }
+ }
+ val poolSize = (if (hasDigits) 10 else 0) + (if (hasUppercase) 26 else 0) + (if (hasLowercase) 26 else 0) + (if (hasSymbols) 32 else 0)
+ return s.length * log2(poolSize.toDouble())
+}
+
+private enum class PassphraseStrength(val color: Color) {
+ VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
+
+ companion object {
+ fun check(s: String) = with(passphraseEntropy(s)) {
+ when {
+ this > 100 -> STRONG
+ this > 70 -> REASONABLE
+ this > 40 -> WEAK
+ else -> VERY_WEAK
+ }
+ }
+ }
+}
+
+fun validKey(s: String): Boolean {
+ for (c in s) {
+ if (c.isWhitespace() || !c.isASCII()) {
+ return false
+ }
+ }
+ return true
+}
+
+private fun Char.isASCII() = code in 32..126
+
+@Preview
+@Composable
+fun PreviewDatabaseEncryptionLayout() {
+ SimpleXTheme {
+ DatabaseEncryptionLayout(
+ useKeychain = remember { mutableStateOf(true) },
+ prefs = AppPreferences(SimplexApp.context),
+ chatDbEncrypted = true,
+ currentKey = remember { mutableStateOf("") },
+ newKey = remember { mutableStateOf("") },
+ confirmNewKey = remember { mutableStateOf("") },
+ storedKey = remember { mutableStateOf(true) },
+ initialRandomDBPassphrase = remember { mutableStateOf(true) },
+ progressIndicator = remember { mutableStateOf(false) },
+ onConfirmEncrypt = {},
+ )
+ }
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt
new file mode 100644
index 0000000000..9abd0c6984
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt
@@ -0,0 +1,254 @@
+package chat.simplex.app.views.database
+
+import SectionSpacer
+import SectionView
+import android.content.Context
+import android.util.Log
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+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.AppPreferences
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.usersettings.NotificationsMode
+import kotlinx.coroutines.*
+import kotlinx.datetime.Clock
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import kotlin.io.path.Path
+
+@Composable
+fun DatabaseErrorView(
+ chatDbStatus: State,
+ appPreferences: AppPreferences,
+) {
+ val progressIndicator = remember { mutableStateOf(false) }
+ val dbKey = remember { mutableStateOf("") }
+ var storedDBKey by remember { mutableStateOf(DatabaseUtils.getDatabaseKey()) }
+ var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
+ val context = LocalContext.current
+ val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
+ val saveAndRunChatOnClick: () -> Unit = {
+ DatabaseUtils.setDatabaseKey(dbKey.value)
+ storedDBKey = dbKey.value
+ appPreferences.storeDBPassphrase.set(true)
+ useKeychain = true
+ appPreferences.initialRandomDBPassphrase.set(false)
+ runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
+ }
+ val title = when (chatDbStatus.value) {
+ is DBMigrationResult.OK -> ""
+ is DBMigrationResult.ErrorNotADatabase -> if (useKeychain && !storedDBKey.isNullOrEmpty())
+ generalGetString(R.string.wrong_passphrase)
+ else
+ generalGetString(R.string.encrypted_database)
+ is DBMigrationResult.Error -> generalGetString(R.string.database_error)
+ is DBMigrationResult.ErrorKeychain -> generalGetString(R.string.keychain_error)
+ is DBMigrationResult.Unknown -> generalGetString(R.string.database_error)
+ null -> "" // should never be here
+ }
+
+ Column(
+ Modifier.fillMaxWidth().fillMaxHeight().verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ title,
+ Modifier.padding(start = 16.dp, top = 16.dp, bottom = 24.dp),
+ style = MaterialTheme.typography.h1
+ )
+ SectionView(null) {
+ Column(
+ Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
+ ) {
+ val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
+ when (val status = chatDbStatus.value) {
+ is DBMigrationResult.ErrorNotADatabase -> {
+ if (useKeychain && !storedDBKey.isNullOrEmpty()) {
+ Text(generalGetString(R.string.passphrase_is_different))
+ DatabaseKeyField(dbKey, buttonEnabled) {
+ saveAndRunChatOnClick()
+ }
+ SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
+ SectionSpacer()
+ Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
+ } else {
+ Text(generalGetString(R.string.database_passphrase_is_required))
+ DatabaseKeyField(dbKey, buttonEnabled) {
+ if (useKeychain) saveAndRunChatOnClick() else runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
+ }
+ if (useKeychain) {
+ SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
+ } else {
+ OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) }
+ }
+ }
+ }
+ is DBMigrationResult.Error -> {
+ Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
+ Text(String.format(generalGetString(R.string.error_with_info), status.migrationError))
+ }
+ is DBMigrationResult.ErrorKeychain -> {
+ Text(generalGetString(R.string.cannot_access_keychain))
+ }
+ is DBMigrationResult.Unknown -> {
+ Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
+ }
+ is DBMigrationResult.OK -> {
+ }
+ null -> {
+ }
+ }
+ if (restoreDbFromBackup.value) {
+ SectionSpacer()
+ Text(generalGetString(R.string.database_backup_can_be_restored))
+ Spacer(Modifier.size(16.dp))
+ RestoreDbButton {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.restore_database_alert_title),
+ text = generalGetString(R.string.restore_database_alert_desc),
+ confirmText = generalGetString(R.string.restore_database_alert_confirm),
+ onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
+ destructive = true,
+ )
+ }
+ }
+ }
+ }
+ }
+ if (progressIndicator.value) {
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ Modifier
+ .padding(horizontal = 2.dp)
+ .size(30.dp),
+ color = HighOrLowlight,
+ strokeWidth = 2.5.dp
+ )
+ }
+ }
+}
+
+private fun runChat(
+ dbKey: String,
+ chatDbStatus: State,
+ progressIndicator: MutableState,
+ prefs: AppPreferences
+) = CoroutineScope(Dispatchers.Default).launch {
+ // Don't do things concurrently. Shouldn't be here concurrently, just in case
+ if (progressIndicator.value) return@launch
+ progressIndicator.value = true
+ try {
+ SimplexApp.context.initChatController(dbKey)
+ } catch (e: Exception) {
+ Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
+ }
+ progressIndicator.value = false
+ when (val status = chatDbStatus.value) {
+ is DBMigrationResult.OK -> {
+ SimplexService.cancelPassphraseNotification()
+ when (prefs.notificationsMode.get()) {
+ NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) }
+ NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
+ }
+ }
+ is DBMigrationResult.ErrorNotADatabase -> {
+ AlertManager.shared.showAlertMsg( generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
+ }
+ is DBMigrationResult.Error -> {
+ AlertManager.shared.showAlertMsg( generalGetString(R.string.database_error), status.migrationError)
+ }
+ is DBMigrationResult.ErrorKeychain -> {
+ AlertManager.shared.showAlertMsg( generalGetString(R.string.keychain_error))
+ }
+ is DBMigrationResult.Unknown -> {
+ AlertManager.shared.showAlertMsg( generalGetString(R.string.unknown_error), status.json)
+ }
+ null -> {}
+ }
+}
+
+private fun shouldShowRestoreDbButton(prefs: AppPreferences, context: Context): Boolean {
+ val startedAt = prefs.encryptionStartedAt.get() ?: return false
+ /** Just in case there is any small difference between reported Java's [Clock.System.now] and Linux's time on a file */
+ val safeDiffInTime = 10_000L
+ val filesChat = File(context.dataDir.absolutePath + File.separator + "files_chat.db.bak")
+ val filesAgent = File(context.dataDir.absolutePath + File.separator + "files_agent.db.bak")
+ return filesChat.exists() &&
+ filesAgent.exists() &&
+ startedAt.toEpochMilliseconds() - safeDiffInTime <= filesChat.lastModified() &&
+ startedAt.toEpochMilliseconds() - safeDiffInTime <= filesAgent.lastModified()
+}
+
+private fun restoreDb(restoreDbFromBackup: MutableState, prefs: AppPreferences, context: Context) {
+ val filesChatBase = context.dataDir.absolutePath + File.separator + "files_chat.db"
+ val filesAgentBase = context.dataDir.absolutePath + File.separator + "files_agent.db"
+ try {
+ Files.copy(Path("$filesChatBase.bak"), Path(filesChatBase), StandardCopyOption.REPLACE_EXISTING)
+ Files.copy(Path("$filesAgentBase.bak"), Path(filesAgentBase), StandardCopyOption.REPLACE_EXISTING)
+ restoreDbFromBackup.value = false
+ prefs.encryptionStartedAt.set(null)
+ } catch (e: Exception) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.database_restore_error), e.stackTraceToString())
+ }
+}
+
+@Composable
+private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onClick: (() -> Unit)? = null) {
+ DatabaseKeyField(
+ text,
+ generalGetString(R.string.enter_passphrase),
+ isValid = ::validKey,
+ keyboardActions = KeyboardActions(onDone = if (enabled) {
+ { onClick?.invoke() }
+ } else null
+ )
+ )
+}
+
+@Composable
+private fun ColumnScope.SaveAndOpenButton(enabled: Boolean, onClick: () -> Unit) {
+ TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
+ Text(generalGetString(R.string.save_passphrase_and_open_chat))
+ }
+}
+
+@Composable
+private fun ColumnScope.OpenChatButton(enabled: Boolean, onClick: () -> Unit) {
+ TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
+ Text(generalGetString(R.string.open_chat))
+ }
+}
+
+@Composable
+private fun ColumnScope.RestoreDbButton(onClick: () -> Unit) {
+ TextButton(onClick, Modifier.align(Alignment.CenterHorizontally)) {
+ Text(generalGetString(R.string.restore_database), color = MaterialTheme.colors.error)
+ }
+}
+
+@Preview
+@Composable
+fun PreviewChatInfoLayout() {
+ SimpleXTheme {
+ DatabaseErrorView(
+ remember { mutableStateOf(DBMigrationResult.ErrorNotADatabase("simplex_v1_chat.db")) },
+ AppPreferences(SimplexApp.context)
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt
new file mode 100644
index 0000000000..5e9a661188
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt
@@ -0,0 +1,588 @@
+package chat.simplex.app.views.database
+
+import SectionDivider
+import SectionTextFooter
+import SectionItemView
+import SectionSpacer
+import SectionView
+import android.content.Context
+import android.content.res.Configuration
+import android.net.Uri
+import android.os.FileUtils
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+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.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.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.FragmentActivity
+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.*
+import chat.simplex.app.views.usersettings.*
+import kotlinx.coroutines.*
+import kotlinx.datetime.*
+import java.io.*
+import java.text.SimpleDateFormat
+import java.util.*
+
+@Composable
+fun DatabaseView(
+ m: ChatModel,
+ showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
+) {
+ val context = LocalContext.current
+ val progressIndicator = remember { mutableStateOf(false) }
+ val runChat = remember { mutableStateOf(m.chatRunning.value ?: true) }
+ val prefs = m.controller.appPrefs
+ val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
+ val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
+ val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) }
+ val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) }
+ val chatArchiveFile = remember { mutableStateOf(null) }
+ val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, chatArchiveFile)
+ val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? ->
+ if (uri != null) {
+ importArchiveAlert(m, context, uri, progressIndicator)
+ }
+ }
+ val chatDbDeleted = remember { m.chatDbDeleted }
+ val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory(context))) }
+ LaunchedEffect(m.chatRunning) {
+ runChat.value = m.chatRunning.value ?: true
+ }
+ Box(
+ Modifier.fillMaxSize(),
+ ) {
+ DatabaseLayout(
+ progressIndicator.value,
+ runChat.value,
+ useKeychain.value,
+ m.chatDbEncrypted.value,
+ m.controller.appPrefs.initialRandomDBPassphrase,
+ importArchiveLauncher,
+ chatArchiveName,
+ chatArchiveTime,
+ chatLastStart,
+ chatDbDeleted.value,
+ appFilesCountAndSize,
+ startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
+ stopChatAlert = { stopChatAlert(m, runChat, context) },
+ exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
+ deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
+ deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(context, appFilesCountAndSize) },
+ showSettingsModal
+ )
+ if (progressIndicator.value) {
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ Modifier
+ .padding(horizontal = 2.dp)
+ .size(30.dp),
+ color = HighOrLowlight,
+ strokeWidth = 2.5.dp
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun DatabaseLayout(
+ progressIndicator: Boolean,
+ runChat: Boolean,
+ useKeyChain: Boolean,
+ chatDbEncrypted: Boolean?,
+ initialRandomDBPassphrase: Preference,
+ importArchiveLauncher: ManagedActivityResultLauncher,
+ chatArchiveName: MutableState,
+ chatArchiveTime: MutableState,
+ chatLastStart: MutableState,
+ chatDbDeleted: Boolean,
+ appFilesCountAndSize: MutableState>,
+ startChat: () -> Unit,
+ stopChatAlert: () -> Unit,
+ exportArchive: () -> Unit,
+ deleteChatAlert: () -> Unit,
+ deleteAppFilesAndMedia: () -> Unit,
+ showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
+) {
+ val stopped = !runChat
+ val operationsDisabled = !stopped || progressIndicator
+
+ Column(
+ Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ Text(
+ stringResource(R.string.your_chat_database),
+ Modifier.padding(start = 16.dp, bottom = 24.dp),
+ style = MaterialTheme.typography.h1
+ )
+
+ SectionView(stringResource(R.string.run_chat_section)) {
+ RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
+ }
+ SectionSpacer()
+
+ SectionView(stringResource(R.string.chat_database_section)) {
+ val unencrypted = chatDbEncrypted == false
+ SettingsActionItem(
+ if (unencrypted) Icons.Outlined.LockOpen else if (useKeyChain) Icons.Filled.VpnKey else Icons.Outlined.Lock,
+ stringResource(R.string.database_passphrase),
+ click = showSettingsModal { DatabaseEncryptionView(it) },
+ iconColor = if (unencrypted) WarningOrange else HighOrLowlight,
+ disabled = operationsDisabled
+ )
+ SectionDivider()
+ SettingsActionItem(
+ Icons.Outlined.IosShare,
+ stringResource(R.string.export_database),
+ click = {
+ if (initialRandomDBPassphrase.get()) {
+ exportProhibitedAlert()
+ } else {
+ exportArchive()
+ }
+ },
+ textColor = MaterialTheme.colors.primary,
+ disabled = operationsDisabled
+ )
+ SectionDivider()
+ SettingsActionItem(
+ Icons.Outlined.FileDownload,
+ stringResource(R.string.import_database),
+ { importArchiveLauncher.launch("application/zip") },
+ textColor = Color.Red,
+ disabled = operationsDisabled
+ )
+ SectionDivider()
+ val chatArchiveNameVal = chatArchiveName.value
+ val chatArchiveTimeVal = chatArchiveTime.value
+ val chatLastStartVal = chatLastStart.value
+ if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
+ val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
+ SettingsActionItem(
+ Icons.Outlined.Inventory2,
+ title,
+ click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
+ disabled = operationsDisabled
+ )
+ SectionDivider()
+ }
+ SettingsActionItem(
+ Icons.Outlined.DeleteForever,
+ stringResource(R.string.delete_database),
+ deleteChatAlert,
+ textColor = Color.Red,
+ disabled = operationsDisabled
+ )
+ }
+ SectionTextFooter(
+ if (stopped) {
+ stringResource(R.string.you_must_use_the_most_recent_version_of_database)
+ } else {
+ stringResource(R.string.stop_chat_to_enable_database_actions)
+ }
+ )
+ SectionSpacer()
+
+ SectionView(stringResource(R.string.files_section)) {
+ val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
+ SectionItemView(
+ deleteAppFilesAndMedia,
+ disabled = deleteFilesDisabled
+ ) {
+ Text(
+ stringResource(R.string.delete_files_and_media),
+ color = if (deleteFilesDisabled) HighOrLowlight else Color.Red
+ )
+ }
+ }
+ val (count, size) = appFilesCountAndSize.value
+ SectionTextFooter(
+ if (count == 0) {
+ stringResource(R.string.no_received_app_files)
+ } else {
+ String.format(stringResource(R.string.total_files_count_and_size), count, formatBytes(size))
+ }
+ )
+ }
+}
+
+@Composable
+fun RunChatSetting(
+ runChat: Boolean,
+ stopped: Boolean,
+ chatDbDeleted: Boolean,
+ startChat: () -> Unit,
+ stopChatAlert: () -> Unit
+) {
+ SectionItemView() {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
+ Icon(
+ if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow,
+ chatRunningText,
+ tint = if (stopped) Color.Red else MaterialTheme.colors.primary
+ )
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ Text(
+ chatRunningText,
+ Modifier.padding(end = 24.dp)
+ )
+ Spacer(Modifier.fillMaxWidth().weight(1f))
+ Switch(
+ enabled= !chatDbDeleted,
+ checked = runChat,
+ onCheckedChange = { runChatSwitch ->
+ if (runChatSwitch) {
+ startChat()
+ } else {
+ stopChatAlert()
+ }
+ },
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colors.primary,
+ uncheckedThumbColor = HighOrLowlight
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
+ return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
+}
+
+private fun startChat(m: ChatModel, runChat: MutableState, chatLastStart: MutableState, chatDbChanged: MutableState) {
+ withApi {
+ try {
+ if (chatDbChanged.value) {
+ SimplexApp.context.initChatController()
+ chatDbChanged.value = false
+ }
+ if (m.chatDbStatus.value !is DBMigrationResult.OK) {
+ /** Hide current view and show [DatabaseErrorView] */
+ ModalManager.shared.closeModals()
+ return@withApi
+ }
+ m.controller.apiStartChat()
+ runChat.value = true
+ m.chatRunning.value = true
+ val ts = Clock.System.now()
+ m.controller.appPrefs.chatLastStart.set(ts)
+ chatLastStart.value = ts
+ when (m.controller.appPrefs.notificationsMode.get()) {
+ NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) }
+ NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
+ }
+ } catch (e: Error) {
+ runChat.value = false
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
+ }
+ }
+}
+
+private fun stopChatAlert(m: ChatModel, runChat: MutableState, context: Context) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.stop_chat_question),
+ text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
+ confirmText = generalGetString(R.string.stop_chat_confirmation),
+ onConfirm = { authStopChat(m, runChat, context) },
+ onDismiss = { runChat.value = true }
+ )
+}
+
+private fun exportProhibitedAlert() {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.set_password_to_export),
+ text = generalGetString(R.string.set_password_to_export_desc),
+ )
+}
+
+private fun authStopChat(m: ChatModel, runChat: MutableState, context: Context) {
+ if (m.controller.appPrefs.performLA.get()) {
+ authenticate(
+ generalGetString(R.string.auth_stop_chat),
+ generalGetString(R.string.auth_log_in_using_credential),
+ context as FragmentActivity,
+ completed = { laResult ->
+ when (laResult) {
+ LAResult.Success, LAResult.Unavailable -> {
+ stopChat(m, runChat, context)
+ }
+ is LAResult.Error -> {
+ }
+ LAResult.Failed -> {
+ runChat.value = true
+ }
+ }
+ }
+ )
+ } else {
+ stopChat(m, runChat, context)
+ }
+}
+
+private fun stopChat(m: ChatModel, runChat: MutableState, context: Context) {
+ withApi {
+ try {
+ m.controller.apiStopChat()
+ runChat.value = false
+ m.chatRunning.value = false
+ SimplexService.stop(context)
+ MessagesFetcherWorker.cancelAll()
+ } catch (e: Error) {
+ runChat.value = true
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
+ }
+ }
+}
+
+private fun exportArchive(
+ context: Context,
+ m: ChatModel,
+ progressIndicator: MutableState,
+ chatArchiveName: MutableState,
+ chatArchiveTime: MutableState,
+ chatArchiveFile: MutableState,
+ saveArchiveLauncher: ManagedActivityResultLauncher
+) {
+ progressIndicator.value = true
+ withApi {
+ try {
+ val archiveFile = exportChatArchive(m, context, chatArchiveName, chatArchiveTime, chatArchiveFile)
+ chatArchiveFile.value = archiveFile
+ saveArchiveLauncher.launch(archiveFile.substringAfterLast("/"))
+ progressIndicator.value = false
+ } catch (e: Error) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.error_exporting_chat_database), e.toString())
+ progressIndicator.value = false
+ }
+ }
+}
+
+private suspend fun exportChatArchive(
+ m: ChatModel,
+ context: Context,
+ chatArchiveName: MutableState,
+ chatArchiveTime: MutableState,
+ chatArchiveFile: MutableState
+): String {
+ val archiveTime = Clock.System.now()
+ val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
+ val archiveName = "simplex-chat.$ts.zip"
+ val archivePath = "${getFilesDirectory(context)}/$archiveName"
+ val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
+ m.controller.apiExportArchive(config)
+ deleteOldArchive(m, context)
+ m.controller.appPrefs.chatArchiveName.set(archiveName)
+ chatArchiveName.value = archiveName
+ m.controller.appPrefs.chatArchiveTime.set(archiveTime)
+ chatArchiveTime.value = archiveTime
+ chatArchiveFile.value = archivePath
+ return archivePath
+}
+
+private fun deleteOldArchive(m: ChatModel, context: Context) {
+ val chatArchiveName = m.controller.appPrefs.chatArchiveName.get()
+ if (chatArchiveName != null) {
+ val file = File("${getFilesDirectory(context)}/$chatArchiveName")
+ val fileDeleted = file.delete()
+ if (fileDeleted) {
+ m.controller.appPrefs.chatArchiveName.set(null)
+ m.controller.appPrefs.chatArchiveTime.set(null)
+ } else {
+ Log.e(TAG, "deleteOldArchive file.delete() error")
+ }
+ }
+}
+
+@Composable
+private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableState): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument(),
+ onResult = { destination ->
+ try {
+ destination?.let {
+ val filePath = chatArchiveFile.value
+ if (filePath != null) {
+ val contentResolver = cxt.contentResolver
+ contentResolver.openOutputStream(destination)?.let { stream ->
+ val outputStream = BufferedOutputStream(stream)
+ val file = File(filePath)
+ outputStream.write(file.readBytes())
+ outputStream.close()
+ Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
+ }
+ }
+ } catch (e: Error) {
+ Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
+ Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e")
+ } finally {
+ chatArchiveFile.value = null
+ }
+ }
+ )
+
+private fun importArchiveAlert(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.import_database_question),
+ text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
+ confirmText = generalGetString(R.string.import_database_confirmation),
+ onConfirm = { importArchive(m, context, importedArchiveUri, progressIndicator) }
+ )
+}
+
+private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState) {
+ progressIndicator.value = true
+ val archivePath = saveArchiveFromUri(context, importedArchiveUri)
+ if (archivePath != null) {
+ withApi {
+ try {
+ m.controller.apiDeleteStorage()
+ try {
+ val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
+ m.controller.apiImportArchive(config)
+ DatabaseUtils.removeDatabaseKey()
+ operationEnded(m, progressIndicator) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
+ }
+ } catch (e: Error) {
+ operationEnded(m, progressIndicator) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.error_importing_database), e.toString())
+ }
+ }
+ } catch (e: Error) {
+ operationEnded(m, progressIndicator) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString())
+ }
+ } finally {
+ File(archivePath).delete()
+ }
+ }
+ }
+}
+
+private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): String? {
+ return try {
+ val inputStream = context.contentResolver.openInputStream(importedArchiveUri)
+ val archiveName = getFileName(context, importedArchiveUri)
+ if (inputStream != null && archiveName != null) {
+ val archivePath = "${context.cacheDir}/$archiveName"
+ val destFile = File(archivePath)
+ FileUtils.copy(inputStream, FileOutputStream(destFile))
+ archivePath
+ } else {
+ Log.e(TAG, "saveArchiveFromUri null inputStream")
+ null
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "saveArchiveFromUri error: ${e.message}")
+ null
+ }
+}
+
+private fun deleteChatAlert(m: ChatModel, progressIndicator: MutableState) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.delete_chat_profile_question),
+ text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning),
+ confirmText = generalGetString(R.string.delete_verb),
+ onConfirm = { deleteChat(m, progressIndicator) }
+ )
+}
+
+private fun deleteChat(m: ChatModel, progressIndicator: MutableState) {
+ progressIndicator.value = true
+ withApi {
+ try {
+ m.controller.apiDeleteStorage()
+ m.chatDbDeleted.value = true
+ DatabaseUtils.removeDatabaseKey()
+ m.controller.appPrefs.storeDBPassphrase.set(true)
+ operationEnded(m, progressIndicator) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
+ }
+ } catch (e: Error) {
+ operationEnded(m, progressIndicator) {
+ AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString())
+ }
+ }
+ }
+}
+
+private fun deleteFilesAndMediaAlert(context: Context, appFilesCountAndSize: MutableState>) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.delete_files_and_media_question),
+ text = generalGetString(R.string.delete_files_and_media_desc),
+ confirmText = generalGetString(R.string.delete_verb),
+ onConfirm = { deleteFiles(appFilesCountAndSize, context) },
+ destructive = true
+ )
+}
+
+private fun deleteFiles(appFilesCountAndSize: MutableState>, context: Context) {
+ deleteAppFiles(context)
+ appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
+}
+
+private fun operationEnded(m: ChatModel, progressIndicator: MutableState, alert: () -> Unit) {
+ m.chatDbChanged.value = true
+ progressIndicator.value = false
+ alert.invoke()
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewDatabaseLayout() {
+ SimpleXTheme {
+ DatabaseLayout(
+ progressIndicator = false,
+ runChat = true,
+ useKeyChain = false,
+ chatDbEncrypted = false,
+ initialRandomDBPassphrase = Preference({ true }, {}),
+ importArchiveLauncher = rememberGetContentLauncher {},
+ chatArchiveName = remember { mutableStateOf("dummy_archive") },
+ chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
+ chatLastStart = remember { mutableStateOf(Clock.System.now()) },
+ chatDbDeleted = false,
+ appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
+ startChat = {},
+ stopChatAlert = {},
+ exportArchive = {},
+ deleteChatAlert = {},
+ deleteAppFilesAndMedia = {},
+ showSettingsModal = { {} }
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt
new file mode 100644
index 0000000000..9ec7d974f1
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt
@@ -0,0 +1,103 @@
+package chat.simplex.app.views.helpers
+
+import android.util.Log
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import chat.simplex.app.R
+import chat.simplex.app.TAG
+
+class AlertManager {
+ var alertView = mutableStateOf<(@Composable () -> Unit)?>(null)
+ var presentAlert = mutableStateOf(false)
+
+ fun showAlert(alert: @Composable () -> Unit) {
+ Log.d(TAG, "AlertManager.showAlert")
+ alertView.value = alert
+ presentAlert.value = true
+ }
+
+ fun hideAlert() {
+ presentAlert.value = false
+ alertView.value = null
+ }
+
+ fun showAlertDialogButtons(
+ title: String,
+ text: String? = null,
+ buttons: @Composable () -> Unit,
+ ) {
+ val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
+ showAlert {
+ AlertDialog(
+ onDismissRequest = this::hideAlert,
+ title = { Text(title) },
+ text = alertText,
+ buttons = buttons
+ )
+ }
+ }
+
+ fun showAlertDialog(
+ title: String,
+ text: String? = null,
+ confirmText: String = generalGetString(R.string.ok),
+ onConfirm: (() -> Unit)? = null,
+ dismissText: String = generalGetString(R.string.cancel_verb),
+ onDismiss: (() -> Unit)? = null,
+ onDismissRequest: (() -> Unit)? = null,
+ destructive: Boolean = false
+ ) {
+ val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
+ showAlert {
+ AlertDialog(
+ onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
+ title = { Text(title) },
+ text = alertText,
+ confirmButton = {
+ TextButton(onClick = {
+ onConfirm?.invoke()
+ hideAlert()
+ }) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ onDismiss?.invoke()
+ hideAlert()
+ }) { Text(dismissText) }
+ }
+ )
+ }
+ }
+
+ fun showAlertMsg(
+ title: String, text: String? = null,
+ confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
+ ) {
+ val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
+ showAlert {
+ AlertDialog(
+ onDismissRequest = this::hideAlert,
+ title = { Text(title) },
+ text = alertText,
+ confirmButton = {
+ TextButton(onClick = {
+ onConfirm?.invoke()
+ hideAlert()
+ }) { Text(confirmText) }
+ }
+ )
+ }
+ }
+
+ @Composable
+ fun showInView() {
+ if (presentAlert.value) alertView.value?.invoke()
+ }
+
+ companion object {
+ val shared = AlertManager()
+ }
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
new file mode 100644
index 0000000000..f1d6fa9caa
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
@@ -0,0 +1,80 @@
+package chat.simplex.app.views.helpers
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.model.ChatInfo
+import chat.simplex.app.ui.theme.SimpleXTheme
+
+@Composable
+fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
+ val icon =
+ if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
+ else Icons.Filled.AccountCircle
+ ProfileImage(size, chatInfo.image, icon, iconColor)
+}
+
+@Composable
+fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
+ Box(Modifier.size(size)) {
+ Icon(
+ Icons.Filled.TheaterComedy, stringResource(R.string.incognito),
+ modifier = Modifier.size(size).padding(size / 12),
+ iconColor
+ )
+ }
+}
+
+@Composable
+fun ProfileImage(
+ size: Dp,
+ image: String? = null,
+ icon: ImageVector = Icons.Filled.AccountCircle,
+ color: Color = MaterialTheme.colors.secondary
+) {
+ Box(Modifier.size(size)) {
+ if (image == null) {
+ Icon(
+ icon,
+ contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
+ tint = color,
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ val imageBitmap = base64ToBitmap(image).asImageBitmap()
+ Image(
+ imageBitmap,
+ stringResource(R.string.image_descr_profile_image),
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(size).padding(size / 12).clip(CircleShape)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewChatInfoImage() {
+ SimpleXTheme {
+ ChatInfoImage(
+ chatInfo = ChatInfo.Direct.sampleData,
+ size = 55.dp
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt
new file mode 100644
index 0000000000..f437e382bd
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt
@@ -0,0 +1,54 @@
+package chat.simplex.app.views.helpers
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.views.newchat.ActionButton
+
+sealed class AttachmentOption {
+ object TakePhoto: AttachmentOption()
+ object PickImage: AttachmentOption()
+ object PickFile: AttachmentOption()
+}
+
+@Composable
+fun ChooseAttachmentView(
+ attachmentOption: MutableState,
+ hide: () -> Unit
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .onFocusChanged { focusState ->
+ if (!focusState.hasFocus) hide()
+ }
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 30.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
+ attachmentOption.value = AttachmentOption.TakePhoto
+ hide()
+ }
+ ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
+ attachmentOption.value = AttachmentOption.PickImage
+ hide()
+ }
+ ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
+ attachmentOption.value = AttachmentOption.PickFile
+ hide()
+ }
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt
new file mode 100644
index 0000000000..c0de98ad65
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt
@@ -0,0 +1,48 @@
+package chat.simplex.app.views.helpers
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+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.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.ui.theme.SimpleXTheme
+
+@Composable
+fun CloseSheetBar(close: () -> Unit) {
+ Row (
+ Modifier
+ .fillMaxWidth()
+ .height(60.dp),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = close) {
+ Icon(
+ Icons.Outlined.Close,
+ stringResource(R.string.icon_descr_close_button),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(10.dp)
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewCloseSheetBar() {
+ SimpleXTheme {
+ CloseSheetBar(close = {})
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DataClasses.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DataClasses.kt
similarity index 57%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DataClasses.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/helpers/DataClasses.kt
index 5eba757a25..9fdf2b3966 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DataClasses.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DataClasses.kt
@@ -1,6 +1,4 @@
-package chat.simplex.common.views.helpers
-
-import androidx.compose.ui.text.AnnotatedString
+package chat.simplex.app.views.helpers
interface ValueTitle {
val value: T
@@ -10,5 +8,5 @@ interface ValueTitle {
data class ValueTitleDesc (
override val value: T,
override val title: String,
- val description: AnnotatedString
+ val description: String
): ValueTitle
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt
new file mode 100644
index 0000000000..0409789e1f
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt
@@ -0,0 +1,73 @@
+package chat.simplex.app.views.helpers
+
+import android.util.Log
+import chat.simplex.app.*
+import chat.simplex.app.model.AppPreferences
+import chat.simplex.app.views.usersettings.Cryptor
+import kotlinx.serialization.*
+import java.io.File
+import java.security.SecureRandom
+
+object DatabaseUtils {
+ private val cryptor = Cryptor()
+
+ private val appPreferences: AppPreferences by lazy {
+ AppPreferences(SimplexApp.context)
+ }
+
+ private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
+
+ private fun hasDatabase(rootDir: String): Boolean =
+ File(rootDir + File.separator + "files_chat.db").exists() && File(rootDir + File.separator + "files_agent.db").exists()
+
+ fun getDatabaseKey(): String? {
+ return cryptor.decryptData(
+ appPreferences.encryptedDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
+ appPreferences.initializationVectorDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
+ DATABASE_PASSWORD_ALIAS,
+ )
+ }
+
+ fun setDatabaseKey(key: String) {
+ val data = cryptor.encryptText(key, DATABASE_PASSWORD_ALIAS)
+ appPreferences.encryptedDBPassphrase.set(data.first.toBase64String())
+ appPreferences.initializationVectorDBPassphrase.set(data.second.toBase64String())
+ }
+
+ fun removeDatabaseKey() {
+ cryptor.deleteKey(DATABASE_PASSWORD_ALIAS)
+ appPreferences.encryptedDBPassphrase.set(null)
+ appPreferences.initializationVectorDBPassphrase.set(null)
+ }
+
+ fun useDatabaseKey(): String {
+ Log.d(TAG, "useDatabaseKey ${appPreferences.storeDBPassphrase.get()}")
+ var dbKey = ""
+ val useKeychain = appPreferences.storeDBPassphrase.get()
+ if (useKeychain) {
+ if (!hasDatabase(SimplexApp.context.dataDir.absolutePath)) {
+ dbKey = randomDatabasePassword()
+ setDatabaseKey(dbKey)
+ appPreferences.initialRandomDBPassphrase.set(true)
+ } else {
+ dbKey = getDatabaseKey() ?: ""
+ }
+ }
+ return dbKey
+ }
+
+ private fun randomDatabasePassword(): String {
+ val s = ByteArray(32)
+ SecureRandom().nextBytes(s)
+ return s.toBase64String().replace("\n", "")
+ }
+}
+
+@Serializable
+sealed class DBMigrationResult {
+ @Serializable @SerialName("ok") object OK: DBMigrationResult()
+ @Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult()
+ @Serializable @SerialName("error") class Error(val dbFile: String, val migrationError: String): DBMigrationResult()
+ @Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult()
+ @Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult()
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt
new file mode 100644
index 0000000000..31de2f5212
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt
@@ -0,0 +1,110 @@
+package chat.simplex.app.views.helpers
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.shape.ZeroCornerSize
+import androidx.compose.foundation.text.*
+import androidx.compose.material.*
+import androidx.compose.material.TextFieldDefaults.indicatorLine
+import androidx.compose.runtime.*
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.*
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.*
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun DefaultBasicTextField(
+ modifier: Modifier,
+ initialValue: String,
+ placeholder: (@Composable () -> Unit)? = null,
+ focus: Boolean = false,
+ color: Color = MaterialTheme.colors.onBackground,
+ textStyle: TextStyle = TextStyle.Default,
+ selectTextOnFocus: Boolean = false,
+ keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions: KeyboardActions = KeyboardActions(),
+ onValueChange: (String) -> Unit,
+) {
+ val state = remember {
+ mutableStateOf(TextFieldValue(initialValue))
+ }
+ val focusRequester = remember { FocusRequester() }
+ val keyboard = LocalSoftwareKeyboardController.current
+
+ LaunchedEffect(Unit) {
+ if (!focus) return@LaunchedEffect
+ focusRequester.requestFocus()
+ delay(200)
+ keyboard?.show()
+ }
+ val enabled = true
+ val colors = TextFieldDefaults.textFieldColors(
+ backgroundColor = Color.Unspecified,
+ textColor = MaterialTheme.colors.onBackground,
+ focusedIndicatorColor = Color.Unspecified,
+ unfocusedIndicatorColor = Color.Unspecified,
+ )
+ val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
+ val interactionSource = remember { MutableInteractionSource() }
+ BasicTextField(
+ value = state.value,
+ modifier = modifier
+ .background(colors.backgroundColor(enabled).value, shape)
+ .indicatorLine(enabled, false, interactionSource, colors)
+ .focusRequester(focusRequester)
+ .onFocusChanged { focusState ->
+ if (focusState.isFocused && selectTextOnFocus) {
+ val text = state.value.text
+ state.value = state.value.copy(
+ selection = TextRange(0, text.length)
+ )
+ }
+ }
+ .defaultMinSize(
+ minWidth = TextFieldDefaults.MinWidth,
+ minHeight = TextFieldDefaults.MinHeight
+ ),
+ onValueChange = {
+ state.value = it
+ onValueChange(it.text)
+ },
+ cursorBrush = SolidColor(colors.cursorColor(false).value),
+ visualTransformation = VisualTransformation.None,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = KeyboardActions(onDone = {
+ keyboard?.hide()
+ keyboardActions.onDone?.invoke(this)
+ }),
+ singleLine = true,
+ textStyle = textStyle.copy(
+ color = color,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp
+ ),
+ interactionSource = interactionSource,
+ decorationBox = @Composable { innerTextField ->
+ TextFieldDefaults.TextFieldDecorationBox(
+ value = state.value.text,
+ innerTextField = innerTextField,
+ placeholder = placeholder,
+ singleLine = true,
+ enabled = enabled,
+ interactionSource = interactionSource,
+ contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
+ visualTransformation = VisualTransformation.None,
+ colors = colors
+ )
+ }
+ )
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt
new file mode 100644
index 0000000000..a4962dc7a8
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt
@@ -0,0 +1,123 @@
+package chat.simplex.app.views.helpers
+
+import chat.simplex.app.R
+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.ArrowBackIos
+import androidx.compose.material.icons.outlined.Menu
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.*
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.ui.theme.*
+
+@Composable
+fun DefaultTopAppBar(
+ navigationButton: @Composable RowScope.() -> Unit,
+ title: @Composable () -> Unit,
+ onTitleClick: (() -> Unit)? = null,
+ showSearch: Boolean,
+ onSearchValueChanged: (String) -> Unit,
+ buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
+) {
+ // If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
+ val modifier = if (!showSearch) {
+ Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { })
+ } else Modifier
+
+ TopAppBar(
+ modifier = modifier,
+ title = {
+ if (!showSearch) {
+ title()
+ } else {
+ SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
+ }
+ },
+ backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
+ navigationIcon = navigationButton,
+ buttons = if (!showSearch) buttons else emptyList(),
+ centered = !showSearch
+ )
+}
+
+@Composable
+fun NavigationButtonBack(onButtonClicked: () -> Unit) {
+ IconButton(onButtonClicked) {
+ Icon(
+ Icons.Outlined.ArrowBackIos, stringResource(R.string.back), tint = MaterialTheme.colors.primary
+ )
+ }
+}
+
+@Composable
+fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
+ IconButton(onClick = onButtonClicked) {
+ Icon(
+ Icons.Outlined.Menu,
+ stringResource(R.string.icon_descr_settings),
+ tint = MaterialTheme.colors.primary,
+ )
+ }
+}
+
+@Composable
+private fun TopAppBar(
+ title: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ navigationIcon: @Composable (RowScope.() -> Unit)? = null,
+ buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
+ backgroundColor: Color = MaterialTheme.colors.primarySurface,
+ centered: Boolean,
+) {
+ Box(
+ modifier
+ .fillMaxWidth()
+ .height(AppBarHeight)
+ .background(backgroundColor)
+ .padding(horizontal = 4.dp),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ if (navigationIcon != null) {
+ Row(
+ Modifier
+ .fillMaxHeight()
+ .width(TitleInsetWithIcon - AppBarHorizontalPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ content = navigationIcon
+ )
+ }
+
+ Row(
+ Modifier
+ .fillMaxHeight()
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ buttons.forEach { it() }
+ }
+ val startPadding = if (navigationIcon != null) TitleInsetWithIcon else TitleInsetWithoutIcon
+ val endPadding = (buttons.size * 50f).dp
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(
+ start = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else startPadding,
+ end = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else endPadding
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ title()
+ }
+ }
+}
+
+val AppBarHeight = 56.dp
+private val AppBarHorizontalPadding = 4.dp
+private val TitleInsetWithoutIcon = 16.dp - AppBarHorizontalPadding
+private val TitleInsetWithIcon = 72.dp
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ExposedDropDownSettingRow.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ExposedDropDownSettingRow.kt
new file mode 100644
index 0000000000..89ba9669ea
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ExposedDropDownSettingRow.kt
@@ -0,0 +1,95 @@
+package chat.simplex.app.views.helpers
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ExpandLess
+import androidx.compose.material.icons.outlined.ExpandMore
+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.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.ui.theme.HighOrLowlight
+
+@Composable
+fun ExposedDropDownSettingRow(
+ title: String,
+ values: List>,
+ selection: State,
+ label: String? = null,
+ icon: ImageVector? = null,
+ iconTint: Color = HighOrLowlight,
+ enabled: State = mutableStateOf(true),
+ onSelected: (T) -> Unit
+) {
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ var expanded by remember { mutableStateOf(false) }
+
+ if (icon != null) {
+ Icon(
+ icon,
+ "",
+ Modifier.padding(end = 8.dp),
+ tint = iconTint
+ )
+ }
+ Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
+
+ Spacer(Modifier.fillMaxWidth().weight(1f))
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = {
+ expanded = !expanded && enabled.value
+ }
+ ) {
+ Row(
+ Modifier.padding(start = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End
+ ) {
+ Text(
+ values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = HighOrLowlight
+ )
+ Spacer(Modifier.size(12.dp))
+ Icon(
+ if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
+ generalGetString(R.string.icon_descr_more_button),
+ tint = HighOrLowlight
+ )
+ }
+ ExposedDropdownMenu(
+ modifier = Modifier.widthIn(min = 200.dp),
+ expanded = expanded,
+ onDismissRequest = {
+ expanded = false
+ }
+ ) {
+ values.forEach { selectionOption ->
+ DropdownMenuItem(
+ onClick = {
+ onSelected(selectionOption.first)
+ expanded = false
+ }
+ ) {
+ Text(
+ selectionOption.second + (if (label != null) " $label" else ""),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt
similarity index 60%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt
index 9252e8b032..e19f00f971 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt
@@ -14,25 +14,37 @@
* limitations under the License.
*/
-package chat.simplex.common.views.helpers
+package chat.simplex.app.views.helpers
-import chat.simplex.common.platform.Log
+import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.gestures.*
-import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.interaction.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.input.pointer.*
+import androidx.compose.ui.input.pointer.AwaitPointerEventScope
+import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
+import androidx.compose.ui.input.pointer.PointerEvent
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.changedToDown
+import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
+import androidx.compose.ui.input.pointer.changedToUp
+import androidx.compose.ui.input.pointer.consumeAllChanges
+import androidx.compose.ui.input.pointer.consumeDownChange
+import androidx.compose.ui.input.pointer.isOutOfBounds
+import androidx.compose.ui.input.pointer.positionChangeConsumed
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.Density
-import chat.simplex.common.platform.TAG
+import androidx.compose.ui.util.fastAll
+import androidx.compose.ui.util.fastAny
+import androidx.compose.ui.util.fastForEach
+import chat.simplex.app.TAG
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
-import kotlin.math.PI
-import kotlin.math.abs
/**
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
@@ -77,27 +89,10 @@ suspend fun PointerInputScope.detectGesture(
pressScope.release()
}
} catch (_: PointerEventTimeoutCancellationException) {
- if (onLongPress != null) {
- onLongPress(down.position)
- if (shouldConsume)
- consumeUntilUp()
- pressScope.cancel()
- } else {
- if (shouldConsume)
- consumeUntilUp()
- pressScope.release()
- }
- }
- }
- }
-}
-
-suspend fun PointerInputScope.detectCursorMove(onMove: (Offset) -> Unit = {},) = coroutineScope {
- forEachGesture {
- awaitPointerEventScope {
- val event = awaitPointerEvent()
- if (event.type == PointerEventType.Move) {
- onMove(event.changes[0].position)
+ onLongPress?.invoke(down.position)
+ if (shouldConsume)
+ consumeUntilUp()
+ pressScope.release()
}
}
}
@@ -106,8 +101,8 @@ suspend fun PointerInputScope.detectCursorMove(onMove: (Offset) -> Unit = {},) =
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
do {
val event = awaitPointerEvent()
- event.changes.forEach { it.consumeAllChanges() }
- } while (event.changes.any { it.pressed })
+ event.changes.fastForEach { it.consumeAllChanges() }
+ } while (event.changes.fastAny { it.pressed })
}
suspend fun AwaitPointerEventScope.awaitFirstDown(
@@ -123,7 +118,7 @@ internal suspend fun AwaitPointerEventScope.awaitFirstDownOnPass(
do {
event = awaitPointerEvent(pass)
} while (
- !event.changes.all {
+ !event.changes.fastAll {
if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
}
)
@@ -133,18 +128,18 @@ internal suspend fun AwaitPointerEventScope.awaitFirstDownOnPass(
suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange? {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
- if (event.changes.all { it.changedToUp() }) {
+ if (event.changes.fastAll { it.changedToUp() }) {
return event.changes[0]
}
- if (event.changes.any {
+ if (event.changes.fastAny {
it.consumed.downChange || it.isOutOfBounds(size, extendedTouchPadding)
}
) {
return null
}
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
- if (consumeCheck.changes.any { it.positionChangeConsumed() }) {
+ if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
return null
}
}
@@ -226,84 +221,3 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
}
return interactionSource
}
-
-@Composable
-fun interactionSourceWithTapDetection(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
- val interactionSource = remember { MutableInteractionSource() }
- LaunchedEffect(interactionSource) {
- var firstTapTime = 0L
- interactionSource.interactions.collect { interaction ->
- when (interaction) {
- is PressInteraction.Press -> {
- firstTapTime = System.currentTimeMillis(); onPress()
- }
- is PressInteraction.Release -> if (firstTapTime + 1000L < System.currentTimeMillis()) onRelease() else onClick()
- is PressInteraction.Cancel -> onCancel()
- }
- }
- }
- return interactionSource
-}
-
-suspend fun PointerInputScope.detectTransformGestures(
- allowIntercept: () -> Boolean,
- panZoomLock: Boolean = false,
- onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
-) {
- var zoom = 1f
- forEachGesture {
- awaitPointerEventScope {
- var rotation = 0f
- var pan = Offset.Zero
- var pastTouchSlop = false
- val touchSlop = viewConfiguration.touchSlop
- var lockedToPanZoom = false
-
- awaitFirstDown(requireUnconsumed = false)
- do {
- val event = awaitPointerEvent()
- val canceled = event.changes.any { it.isConsumed }
- if (!canceled) {
- val zoomChange = event.calculateZoom()
- val rotationChange = event.calculateRotation()
- val panChange = event.calculatePan()
-
- if (!pastTouchSlop) {
- zoom *= zoomChange
- rotation += rotationChange
- pan += panChange
-
- val centroidSize = event.calculateCentroidSize(useCurrent = false)
- val zoomMotion = abs(1 - zoom) * centroidSize
- val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f)
- val panMotion = pan.getDistance()
-
- if (zoomMotion > touchSlop ||
- rotationMotion > touchSlop ||
- panMotion > touchSlop
- ) {
- pastTouchSlop = true
- lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
- }
- }
-
- if (pastTouchSlop) {
- val centroid = event.calculateCentroid(useCurrent = false)
- val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
- if (effectiveRotation != 0f ||
- zoomChange != 1f ||
- panChange != Offset.Zero
- ) {
- onGesture(centroid, panChange, zoomChange, effectiveRotation)
- }
- event.changes.forEach {
- if (it.positionChanged() && zoom != 1f && allowIntercept()) {
- it.consume()
- }
- }
- }
- }
- } while (!canceled && event.changes.any { it.pressed })
- }
- }
-}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt
new file mode 100644
index 0000000000..5117bd453e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt
@@ -0,0 +1,221 @@
+package chat.simplex.app.views.helpers
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.*
+import android.net.Uri
+import android.provider.MediaStore
+import android.util.Base64
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.CallSuper
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Collections
+import androidx.compose.material.icons.outlined.PhotoCamera
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import chat.simplex.app.*
+import chat.simplex.app.R
+import chat.simplex.app.views.newchat.ActionButton
+import java.io.ByteArrayOutputStream
+import java.io.File
+import kotlin.math.min
+import kotlin.math.sqrt
+
+// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
+fun cropToSquare(image: Bitmap): Bitmap {
+ var xOffset = 0
+ var yOffset = 0
+ val side = min(image.height, image.width)
+ if (image.height < image.width) {
+ xOffset = (image.width - side) / 2
+ } else {
+ yOffset = (image.height - side) / 2
+ }
+ return Bitmap.createBitmap(image, xOffset, yOffset, side, side)
+}
+
+fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String {
+ var img = image
+ var str = compressImageStr(img)
+ while (str.length > maxDataSize) {
+ val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble())
+ val clippedRatio = min(ratio, 2.0)
+ val width = (img.width.toDouble() / clippedRatio).toInt()
+ val height = img.height * width / img.width
+ img = Bitmap.createScaledBitmap(img, width, height, true)
+ str = compressImageStr(img)
+ }
+ return str
+}
+
+private fun compressImageStr(bitmap: Bitmap): String {
+ return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP)
+}
+
+fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream {
+ var img = image
+ var stream = compressImageData(img)
+ while (stream.size() > maxDataSize) {
+ val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble())
+ val clippedRatio = min(ratio, 2.0)
+ val width = (img.width.toDouble() / clippedRatio).toInt()
+ val height = img.height * width / img.width
+ img = Bitmap.createScaledBitmap(img, width, height, true)
+ stream = compressImageData(img)
+ }
+ return stream
+}
+
+private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
+ val stream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream)
+ return stream
+}
+
+val errorBitmapBytes = Base64.decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg==", Base64.NO_WRAP)
+val errorBitmap: Bitmap = BitmapFactory.decodeByteArray(errorBitmapBytes, 0, errorBitmapBytes.size)
+
+fun base64ToBitmap(base64ImageString: String): Bitmap {
+ val imageString = base64ImageString
+ .removePrefix("data:image/png;base64,")
+ .removePrefix("data:image/jpg;base64,")
+ try {
+ val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
+ return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
+ } catch (e: Exception) {
+ Log.e(TAG, "base64ToBitmap error: $e")
+ return errorBitmap
+ }
+}
+
+class CustomTakePicturePreview: ActivityResultContract() {
+ private var uri: Uri? = null
+ private var tmpFile: File? = null
+ lateinit var externalContext: Context
+
+ @CallSuper
+ override fun createIntent(context: Context, input: Void?): Intent {
+ externalContext = context
+ tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
+ uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
+ return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ .putExtra(MediaStore.EXTRA_OUTPUT, uri)
+ }
+
+ override fun getSynchronousResult(
+ context: Context,
+ input: Void?
+ ): SynchronousResult? = null
+
+ override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
+ return if (resultCode == Activity.RESULT_OK && uri != null) {
+ val source = ImageDecoder.createSource(externalContext.contentResolver, uri!!)
+ val bitmap = ImageDecoder.decodeBitmap(source)
+ tmpFile?.delete()
+ bitmap
+ } else {
+ Log.e(TAG, "Getting image from camera cancelled or failed.")
+ tmpFile?.delete()
+ null
+ }
+ }
+}
+//class GetGalleryContent: ActivityResultContracts.GetContent() {
+// override fun createIntent(context: Context, input: String): Intent {
+// super.createIntent(context, input)
+// return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
+// }
+//}
+//@Composable
+//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher =
+// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
+@Composable
+fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
+
+@Composable
+fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb)
+
+@Composable
+fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
+
+@Composable
+fun GetImageBottomSheet(
+ imageBitmap: MutableState,
+ onImageChange: (Bitmap) -> Unit,
+ hideBottomSheet: () -> Unit
+) {
+ val context = LocalContext.current
+ val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
+ if (uri != null) {
+ val source = ImageDecoder.createSource(context.contentResolver, uri)
+ val bitmap = ImageDecoder.decodeBitmap(source)
+ imageBitmap.value = bitmap
+ onImageChange(bitmap)
+ }
+ }
+ val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
+ if (bitmap != null) {
+ imageBitmap.value = bitmap
+ onImageChange(bitmap)
+ }
+ }
+ val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
+ if (isGranted) {
+ cameraLauncher.launch(null)
+ hideBottomSheet()
+ } else {
+ Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .onFocusChanged { focusState ->
+ if (!focusState.hasFocus) hideBottomSheet()
+ }
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 30.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
+ when (PackageManager.PERMISSION_GRANTED) {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
+ cameraLauncher.launch(null)
+ hideBottomSheet()
+ }
+ else -> {
+ permissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+ }
+ ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
+ galleryLauncher.launch("image/*")
+ hideBottomSheet()
+ }
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt
new file mode 100644
index 0000000000..f101b7c916
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt
@@ -0,0 +1,163 @@
+package chat.simplex.app.views.helpers
+
+import android.content.res.Configuration
+import android.graphics.BitmapFactory
+import androidx.compose.foundation.Image
+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.outlined.Close
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+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.LinkPreview
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.chat.item.SentColorLight
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.jsoup.Jsoup
+
+private const val OG_SELECT_QUERY = "meta[property^=og:]"
+
+suspend fun getLinkPreview(url: String): LinkPreview? {
+ return withContext(Dispatchers.IO) {
+ try {
+ val response = Jsoup.connect(url)
+ .ignoreContentType(true)
+ .timeout(10000)
+ .followRedirects(true)
+ .execute()
+ val doc = response.parse()
+ val ogTags = doc.select(OG_SELECT_QUERY)
+ val imageUri = ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content")
+ if (imageUri != null) {
+ try {
+ val stream = java.net.URL(imageUri).openStream()
+ val image = resizeImageToStrSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000)
+// TODO add once supported in iOS
+// val description = ogTags.firstOrNull {
+// it.attr("property") == "og:description"
+// }?.attr("content") ?: ""
+ val title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content")
+ if (title != null) {
+ return@withContext LinkPreview(url, title, description = "", image)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ return@withContext null
+ }
+}
+
+
+
+@Composable
+fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
+ Row(
+ Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (linkPreview == null) {
+ Box(
+ Modifier.fillMaxWidth().weight(1f).height(60.dp).padding(start = 16.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ CircularProgressIndicator(
+ Modifier.size(16.dp),
+ color = HighOrLowlight,
+ strokeWidth = 2.dp
+ )
+ }
+ } else {
+ val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap()
+ Image(
+ imageBitmap,
+ stringResource(R.string.image_descr_link_preview),
+ modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp)
+ )
+ Column(Modifier.fillMaxWidth().weight(1F)) {
+ Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(
+ linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.body2
+ )
+ }
+ }
+ IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.icon_descr_cancel_link_preview),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(10.dp)
+ )
+ }
+ }
+}
+
+@Composable
+fun ChatItemLinkView(linkPreview: LinkPreview) {
+ Column {
+ Image(
+ base64ToBitmap(linkPreview.image).asImageBitmap(),
+ stringResource(R.string.image_descr_link_preview),
+ modifier = Modifier.fillMaxWidth(),
+ contentScale = ContentScale.FillWidth,
+ )
+ Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
+ Text(linkPreview.title, maxLines = 3, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp, modifier = Modifier.padding(bottom = 4.dp))
+ if (linkPreview.description != "") {
+ Text(linkPreview.description, maxLines = 12, overflow = TextOverflow.Ellipsis, fontSize = 14.sp, lineHeight = 20.sp)
+ }
+ Text(linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, color = HighOrLowlight)
+ }
+ }
+}
+
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "ChatItemLinkView (Dark Mode)"
+)
+@Composable
+fun PreviewChatItemLinkView() {
+ SimpleXTheme {
+ ChatItemLinkView(LinkPreview.sampleData)
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "ComposeLinkView (Dark Mode)"
+)
+@Composable
+fun PreviewComposeLinkView() {
+ SimpleXTheme {
+ ComposeLinkView(LinkPreview.sampleData) { -> }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewComposeLinkViewLoading() {
+ SimpleXTheme {
+ ComposeLinkView(null) { -> }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt
new file mode 100644
index 0000000000..75f97be140
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt
@@ -0,0 +1,112 @@
+package chat.simplex.app.views.helpers
+
+import android.content.Context
+import android.os.Build.VERSION.SDK_INT
+import android.widget.Toast
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricManager.Authenticators.*
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentActivity
+import chat.simplex.app.R
+
+sealed class LAResult {
+ object Success: LAResult()
+ class Error(val errString: CharSequence): LAResult()
+ object Failed: LAResult()
+ object Unavailable: LAResult()
+}
+
+fun authenticate(
+ promptTitle: String,
+ promptSubtitle: String,
+ activity: FragmentActivity,
+ completed: (LAResult) -> Unit
+) {
+ when {
+ SDK_INT in 28..29 ->
+ // KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
+ authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
+ SDK_INT > 29 ->
+ authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
+ else ->
+ completed(LAResult.Unavailable)
+ }
+}
+
+private fun authenticateWithBiometricManager(
+ promptTitle: String,
+ promptSubtitle: String,
+ activity: FragmentActivity,
+ completed: (LAResult) -> Unit,
+ authenticators: Int
+) {
+ val biometricManager = BiometricManager.from(activity)
+ when (biometricManager.canAuthenticate(authenticators)) {
+ BiometricManager.BIOMETRIC_SUCCESS -> {
+ val executor = ContextCompat.getMainExecutor(activity)
+ val biometricPrompt = BiometricPrompt(
+ activity,
+ executor,
+ object: BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(
+ errorCode: Int,
+ errString: CharSequence
+ ) {
+ super.onAuthenticationError(errorCode, errString)
+ completed(LAResult.Error(errString))
+ }
+
+ override fun onAuthenticationSucceeded(
+ result: BiometricPrompt.AuthenticationResult
+ ) {
+ super.onAuthenticationSucceeded(result)
+ completed(LAResult.Success)
+ }
+
+ override fun onAuthenticationFailed() {
+ super.onAuthenticationFailed()
+ completed(LAResult.Failed)
+ }
+ }
+ )
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(promptTitle)
+ .setSubtitle(promptSubtitle)
+ .setAllowedAuthenticators(authenticators)
+ .setConfirmationRequired(false)
+ .build()
+ biometricPrompt.authenticate(promptInfo)
+ }
+ else -> {
+ completed(LAResult.Unavailable)
+ }
+ }
+}
+
+fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.auth_simplex_lock_turned_on),
+ generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
+)
+
+fun laErrorToast(context: Context, errString: CharSequence) = Toast.makeText(
+ context,
+ if (errString.isNotEmpty()) String.format(generalGetString(R.string.auth_error_w_desc), errString) else generalGetString(R.string.auth_error),
+ Toast.LENGTH_SHORT
+).show()
+
+fun laFailedToast(context: Context) = Toast.makeText(
+ context,
+ generalGetString(R.string.auth_failed),
+ Toast.LENGTH_SHORT
+).show()
+
+fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.auth_unavailable),
+ generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
+)
+
+fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.auth_unavailable),
+ generalGetString(R.string.auth_device_authentication_is_disabled_turning_off)
+)
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt
similarity index 78%
rename from apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt
index 9264ca6abf..17b856017d 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt
@@ -1,13 +1,10 @@
-package chat.simplex.app
+package chat.simplex.app.views.helpers
import android.content.Context
import android.util.Log
import androidx.work.*
+import chat.simplex.app.*
import chat.simplex.app.SimplexService.Companion.showPassphraseNotification
-import chat.simplex.common.model.ChatController
-import chat.simplex.common.platform.*
-import chat.simplex.common.views.helpers.DBMigrationResult
-import chat.simplex.common.views.helpers.DatabaseUtils
import kotlinx.coroutines.*
import java.util.Date
import java.util.concurrent.TimeUnit
@@ -29,14 +26,12 @@ object MessagesFetcherWorker {
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
- SimplexApp.context.getWorkManagerInstance().enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest)
+ WorkManager.getInstance(SimplexApp.context).enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest)
}
- fun cancelAll(withLog: Boolean = true) {
- if (withLog) {
- Log.d(TAG, "Worker: canceled all tasks")
- }
- SimplexApp.context.getWorkManagerInstance().cancelUniqueWork(UNIQUE_WORK_TAG)
+ fun cancelAll() {
+ Log.d(TAG, "Worker: canceled all tasks")
+ WorkManager.getInstance(SimplexApp.context).cancelUniqueWork(UNIQUE_WORK_TAG)
}
}
@@ -59,13 +54,8 @@ class MessagesFetcherWork(
val durationSeconds = inputData.getInt(INPUT_DATA_DURATION, 60)
var shouldReschedule = true
try {
- // In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here
- if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) {
- initChatControllerOnStart()
- }
withTimeout(durationSeconds * 1000L) {
- val chatController = ChatController
- SimplexService.waitDbMigrationEnds(chatController)
+ val chatController = (applicationContext as SimplexApp).chatController
val chatDbStatus = chatController.chatModel.chatDbStatus.value
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(TAG, "Worker: problem with the database: $chatDbStatus")
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt
new file mode 100644
index 0000000000..f417fb3d93
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt
@@ -0,0 +1,65 @@
+package chat.simplex.app.views.helpers
+
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.TAG
+
+@Composable
+fun ModalView(
+ close: () -> Unit,
+ background: Color = MaterialTheme.colors.background,
+ modifier: Modifier = Modifier.padding(horizontal = 16.dp),
+ content: @Composable () -> Unit,
+) {
+ BackHandler(onBack = close)
+ Surface(Modifier.fillMaxSize()) {
+ Column(Modifier.background(background)) {
+ CloseSheetBar(close)
+ Box(modifier) { content() }
+ }
+ }
+}
+
+class ModalManager {
+ private val modalViews = arrayListOf<(@Composable (close: () -> Unit) -> Unit)?>()
+ private val modalCount = mutableStateOf(0)
+
+ fun showModal(content: @Composable () -> Unit) {
+ showCustomModal { close -> ModalView(close, content = content) }
+ }
+
+ fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
+ Log.d(TAG, "ModalManager.showModal")
+ modalViews.add(modal)
+ modalCount.value = modalViews.count()
+ }
+
+ fun closeModal() {
+ if (modalViews.isNotEmpty()) {
+ modalViews.removeAt(modalViews.count() - 1)
+ }
+ modalCount.value = modalViews.count()
+ }
+
+ fun closeModals() {
+ while (modalViews.isNotEmpty()) closeModal()
+ }
+
+ @Composable
+ fun showInView() {
+ if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal)
+ }
+
+ companion object {
+ val shared = ModalManager()
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Modifiers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt
similarity index 94%
rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Modifiers.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt
index 8ad877d879..f60d644a88 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Modifiers.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt
@@ -1,4 +1,4 @@
-package chat.simplex.common.views.helpers
+package chat.simplex.app.views.helpers
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.offset
@@ -37,7 +37,7 @@ fun SwipeToDismissModifier(
return Modifier.swipeable(
state = state,
anchors = anchors,
- thresholds = { _, _ -> FractionalThreshold(0.99f) },
+ thresholds = { _, _ -> FractionalThreshold(0.5f) },
orientation = Orientation.Horizontal,
reverseDirection = isRtl,
).offset { IntOffset(state.offset.value.roundToInt(), 0) }
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt
new file mode 100644
index 0000000000..09967ca0b6
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt
@@ -0,0 +1,97 @@
+package chat.simplex.app.views.helpers
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.shape.ZeroCornerSize
+import androidx.compose.foundation.text.*
+import androidx.compose.material.*
+import androidx.compose.material.TextFieldDefaults.indicatorLine
+import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.*
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.*
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import kotlinx.coroutines.delay
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) {
+ var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
+ val focusRequester = remember { FocusRequester() }
+ val keyboard = LocalSoftwareKeyboardController.current
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ delay(200)
+ keyboard?.show()
+ }
+
+ val enabled = true
+ val colors = TextFieldDefaults.textFieldColors(
+ backgroundColor = Color.Unspecified,
+ textColor = MaterialTheme.colors.onBackground,
+ focusedIndicatorColor = Color.Unspecified,
+ unfocusedIndicatorColor = Color.Unspecified,
+ )
+ val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
+ val interactionSource = remember { MutableInteractionSource() }
+ BasicTextField(
+ value = searchText,
+ modifier = modifier
+ .background(colors.backgroundColor(enabled).value, shape)
+ .indicatorLine(enabled, false, interactionSource, colors)
+ .focusRequester(focusRequester)
+ .defaultMinSize(
+ minWidth = TextFieldDefaults.MinWidth,
+ minHeight = TextFieldDefaults.MinHeight
+ ),
+ onValueChange = {
+ searchText = it
+ onValueChange(it.text)
+ },
+ cursorBrush = SolidColor(colors.cursorColor(false).value),
+ visualTransformation = VisualTransformation.None,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ singleLine = true,
+ textStyle = TextStyle(
+ color = MaterialTheme.colors.onBackground,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp
+ ),
+ interactionSource = interactionSource,
+ decorationBox = @Composable { innerTextField ->
+ TextFieldDefaults.TextFieldDecorationBox(
+ value = searchText.text,
+ innerTextField = innerTextField,
+ placeholder = {
+ Text(placeholder)
+ },
+ trailingIcon = if (searchText.text.isNotEmpty()) {{
+ IconButton({ searchText = TextFieldValue(""); onValueChange("") }) {
+ Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
+ }
+ }} else null,
+ singleLine = true,
+ enabled = enabled,
+ interactionSource = interactionSource,
+ contentPadding = textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
+ visualTransformation = VisualTransformation.None,
+ colors = colors
+ )
+ }
+ )
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt
new file mode 100644
index 0000000000..3d416a70e7
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt
@@ -0,0 +1,199 @@
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Check
+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.platform.LocalConfiguration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.ValueTitleDesc
+import chat.simplex.app.views.helpers.ValueTitle
+
+@Composable
+fun SectionView(title: String? = null, content: (@Composable () -> Unit)) {
+ Column {
+ if (title != null) {
+ Text(
+ title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
+ modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
+ )
+ }
+ Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
+ Column(Modifier.padding(horizontal = 6.dp).fillMaxWidth()) { content() }
+ }
+ }
+}
+
+@Composable
+fun SectionViewSelectable(
+ title: String?,
+ currentValue: MutableState,
+ values: List>,
+ onSelected: (T) -> Unit,
+) {
+ SectionView(title) {
+ LazyColumn(
+ Modifier.padding(horizontal = 8.dp)
+ ) {
+ items(values.size) { index ->
+ val item = values[index]
+ SectionItemViewSpaceBetween({ onSelected(item.value) }, padding = PaddingValues()) {
+ Text(item.title)
+ if (currentValue.value == item.value) {
+ Icon(Icons.Outlined.Check, item.title, tint = HighOrLowlight)
+ }
+ }
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ }
+ }
+ }
+ SectionTextFooter(values.first { it.value == currentValue.value }.description)
+}
+
+@Composable
+fun SectionItemView(click: (() -> Unit)? = null, minHeight: Dp = 46.dp, disabled: Boolean = false, content: (@Composable RowScope.() -> Unit)) {
+ val modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth()
+ .sizeIn(minHeight = minHeight)
+ Row(
+ if (click == null || disabled) modifier else modifier.clickable(onClick = click),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ content()
+ }
+}
+
+@Composable
+fun SectionItemViewSpaceBetween(
+ click: (() -> Unit)? = null,
+ minHeight: Dp = 46.dp,
+ padding: PaddingValues = PaddingValues(horizontal = 8.dp),
+ disabled: Boolean = false,
+ content: (@Composable () -> Unit)
+) {
+ val modifier = Modifier
+ .padding(padding)
+ .fillMaxWidth()
+ .sizeIn(minHeight = minHeight)
+ Row(
+ if (click == null || disabled) modifier else modifier.clickable(onClick = click),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ content()
+ }
+}
+
+@Composable
+fun SectionItemWithValue(
+ title: String,
+ currentValue: State,
+ values: List>,
+ label: String? = null,
+ icon: ImageVector? = null,
+ iconTint: Color = HighOrLowlight,
+ enabled: State = mutableStateOf(true),
+ onSelected: () -> Unit
+) {
+ SectionItemView(click = if (enabled.value) onSelected else null) {
+ if (icon != null) {
+ Icon(
+ icon,
+ title,
+ Modifier.padding(end = 8.dp),
+ tint = iconTint
+ )
+ }
+ Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
+
+ Spacer(Modifier.fillMaxWidth().weight(1f))
+
+ Row(
+ Modifier.padding(start = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End
+ ) {
+ Text(
+ values.first { it.value == currentValue.value }.title + (if (label != null) " $label" else ""),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = HighOrLowlight
+ )
+ }
+ }
+}
+
+@Composable
+fun SectionTextFooter(text: String) {
+ Text(
+ text,
+ Modifier.padding(horizontal = 16.dp).padding(top = 8.dp).fillMaxWidth(0.9F),
+ color = HighOrLowlight,
+ lineHeight = 18.sp,
+ fontSize = 14.sp
+ )
+}
+
+@Composable
+fun SectionCustomFooter(padding: PaddingValues = PaddingValues(start = 16.dp, end = 16.dp, top = 5.dp), content: (@Composable () -> Unit)) {
+ Row(
+ Modifier.padding(padding)
+ ) {
+ content()
+ }
+}
+
+@Composable
+fun SectionDivider() {
+ Divider(Modifier.padding(horizontal = 8.dp))
+}
+
+@Composable
+fun SectionSpacer() {
+ Spacer(Modifier.height(30.dp))
+}
+
+@Composable
+fun InfoRow(title: String, value: String) {
+ SectionItemView {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(title)
+ Text(value, color = HighOrLowlight)
+ }
+ }
+}
+
+@Composable
+fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) {
+ SectionItemView {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val configuration = LocalConfiguration.current
+ Text(title)
+ Text(value,
+ Modifier
+ .padding(start = 10.dp)
+ .widthIn(max = (configuration.screenWidthDp / 2).dp)
+ .clickable(onClick = onClick),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = HighOrLowlight
+ )
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt
new file mode 100644
index 0000000000..2b46110d58
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt
@@ -0,0 +1,86 @@
+package chat.simplex.app.views.helpers
+
+import android.content.*
+import android.net.Uri
+import android.provider.MediaStore
+import android.widget.Toast
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.core.content.ContextCompat
+import chat.simplex.app.R
+import chat.simplex.app.model.CIFile
+import java.io.BufferedOutputStream
+import java.io.File
+
+fun shareText(cxt: Context, text: String) {
+ val sendIntent: Intent = Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra(Intent.EXTRA_TEXT, text)
+ type = "text/plain"
+ }
+ val shareIntent = Intent.createChooser(sendIntent, null)
+ cxt.startActivity(shareIntent)
+}
+
+fun copyText(cxt: Context, text: String) {
+ val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java)
+ clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
+}
+
+@Composable
+fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument(),
+ onResult = { destination ->
+ destination?.let {
+ val filePath = getLoadedFilePath(cxt, ciFile)
+ if (filePath != null) {
+ val contentResolver = cxt.contentResolver
+ contentResolver.openOutputStream(destination)?.let { stream ->
+ val outputStream = BufferedOutputStream(stream)
+ val file = File(filePath)
+ outputStream.write(file.readBytes())
+ outputStream.close()
+ Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ )
+
+fun saveImage(cxt: Context, ciFile: CIFile?) {
+ val filePath = getLoadedFilePath(cxt, ciFile)
+ val fileName = ciFile?.fileName
+ if (filePath != null && fileName != null) {
+ val values = ContentValues()
+ val lowercaseName = fileName.lowercase()
+ val mimeType = when {
+ lowercaseName.endsWith(".png") -> "image/png"
+ lowercaseName.endsWith(".gif") -> "image/gif"
+ lowercaseName.endsWith(".webp") -> "image/webp"
+ lowercaseName.endsWith(".avif") -> "image/avif"
+ lowercaseName.endsWith(".svg") -> "image/svg+xml"
+ else -> "image/jpeg"
+ }
+ values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
+ values.put(MediaStore.Images.Media.MIME_TYPE, mimeType)
+ values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
+ values.put(MediaStore.MediaColumns.TITLE, fileName)
+ val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
+ uri?.let {
+ cxt.contentResolver.openOutputStream(uri)?.let { stream ->
+ val outputStream = BufferedOutputStream(stream)
+ val file = File(filePath)
+ outputStream.write(file.readBytes())
+ outputStream.close()
+ Toast.makeText(cxt, generalGetString(R.string.image_saved), Toast.LENGTH_SHORT).show()
+ }
+ }
+ } else {
+ Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt
new file mode 100644
index 0000000000..392da7086f
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt
@@ -0,0 +1,48 @@
+package chat.simplex.app.ui.theme
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun SimpleButton(text: String, icon: ImageVector,
+ color: Color = MaterialTheme.colors.primary,
+ click: () -> Unit) {
+ SimpleButtonFrame(click) {
+ Icon(
+ icon, text, tint = color,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Text(text, style = MaterialTheme.typography.caption, color = color)
+ }
+}
+
+@Composable
+fun SimpleButtonFrame(click: () -> Unit, disabled: Boolean = false, content: @Composable () -> Unit) {
+ Surface(shape = RoundedCornerShape(20.dp)) {
+ val modifier = if (disabled) Modifier else Modifier.clickable { click() }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.padding(8.dp)
+ ) { content() }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewCloseSheetBar() {
+ SimpleXTheme {
+ SimpleButton(text = "Share", icon = Icons.Outlined.Share, click = {})
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt
new file mode 100644
index 0000000000..c02bdfb6a4
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt
@@ -0,0 +1,58 @@
+package chat.simplex.app.views.helpers
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+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.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.ui.theme.HighOrLowlight
+
+@Composable
+fun TextEditor(modifier: Modifier, text: MutableState) {
+ BasicTextField(
+ value = text.value,
+ onValueChange = { text.value = it },
+ textStyle = TextStyle(
+ fontFamily = FontFamily.Monospace, fontSize = 14.sp,
+ color = MaterialTheme.colors.onBackground
+ ),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrect = false
+ ),
+ modifier = modifier,
+ cursorBrush = SolidColor(HighOrLowlight),
+ decorationBox = { innerTextField ->
+ Surface(
+ shape = RoundedCornerShape(10.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
+ ) {
+ Row(
+ Modifier.background(MaterialTheme.colors.background),
+ verticalAlignment = Alignment.Top
+ ) {
+ Box(
+ Modifier
+ .weight(1f)
+ .padding(vertical = 5.dp, horizontal = 7.dp)
+ ) {
+ innerTextField()
+ }
+ }
+ }
+ }
+ )
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
new file mode 100644
index 0000000000..374355582e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
@@ -0,0 +1,434 @@
+package chat.simplex.app.views.helpers
+
+import android.content.Context
+import android.content.res.Resources
+import android.graphics.*
+import android.graphics.Typeface
+import android.net.Uri
+import android.os.FileUtils
+import android.provider.OpenableColumns
+import android.text.Spanned
+import android.text.SpannedString
+import android.text.style.*
+import android.util.Base64
+import android.util.Log
+import android.view.ViewTreeObserver
+import androidx.annotation.StringRes
+import androidx.compose.runtime.*
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.*
+import androidx.compose.ui.text.*
+import androidx.compose.ui.text.font.*
+import androidx.compose.ui.text.style.BaselineShift
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.*
+import androidx.core.content.FileProvider
+import androidx.core.text.HtmlCompat
+import chat.simplex.app.*
+import chat.simplex.app.model.CIFile
+import kotlinx.coroutines.*
+import java.io.*
+import java.text.SimpleDateFormat
+import java.util.*
+import kotlin.math.*
+
+fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalScope, action)
+
+fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
+ scope.launch { withContext(Dispatchers.Main, action) }
+
+enum class KeyboardState {
+ Opened, Closed
+}
+
+@Composable
+fun getKeyboardState(): State {
+ val keyboardState = remember { mutableStateOf(KeyboardState.Closed) }
+ val view = LocalView.current
+ DisposableEffect(view) {
+ val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
+ val rect = Rect()
+ view.getWindowVisibleDisplayFrame(rect)
+ val screenHeight = view.rootView.height
+ val keypadHeight = screenHeight - rect.bottom
+ keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
+ KeyboardState.Opened
+ } else {
+ KeyboardState.Closed
+ }
+ }
+ view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
+
+ onDispose {
+ view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
+ }
+ }
+
+ return keyboardState
+}
+
+// Resource to annotated string from
+// https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources
+fun generalGetString(id: Int): String {
+ // prefer stringResource in Composable items to retain preview abilities
+ return SimplexApp.context.getString(id)
+}
+
+@Composable
+@ReadOnlyComposable
+private fun resources(): Resources {
+ LocalConfiguration.current
+ return LocalContext.current.resources
+}
+
+fun Spanned.toHtmlWithoutParagraphs(): String {
+ return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE)
+ .substringAfter("").substringBeforeLast("
")
+}
+
+fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence {
+ val escapedArgs = args.map {
+ if (it is Spanned) it.toHtmlWithoutParagraphs() else it
+ }.toTypedArray()
+ val resource = SpannedString(getText(id))
+ val htmlResource = resource.toHtmlWithoutParagraphs()
+ val formattedHtml = String.format(htmlResource, *escapedArgs)
+ return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
+}
+
+@Composable
+fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
+ val resources = resources()
+ val density = LocalDensity.current
+ return remember(id) {
+ val text = resources.getText(id)
+ spannableStringToAnnotatedString(text, density)
+ }
+}
+
+private fun spannableStringToAnnotatedString(
+ text: CharSequence,
+ density: Density,
+): AnnotatedString {
+ return if (text is Spanned) {
+ with(density) {
+ buildAnnotatedString {
+ append((text.toString()))
+ text.getSpans(0, text.length, Any::class.java).forEach {
+ val start = text.getSpanStart(it)
+ val end = text.getSpanEnd(it)
+ when (it) {
+ is StyleSpan -> when (it.style) {
+ Typeface.NORMAL -> addStyle(
+ SpanStyle(
+ fontWeight = FontWeight.Normal,
+ fontStyle = FontStyle.Normal,
+ ),
+ start,
+ end
+ )
+ Typeface.BOLD -> addStyle(
+ SpanStyle(
+ fontWeight = FontWeight.Bold,
+ fontStyle = FontStyle.Normal
+ ),
+ start,
+ end
+ )
+ Typeface.ITALIC -> addStyle(
+ SpanStyle(
+ fontWeight = FontWeight.Normal,
+ fontStyle = FontStyle.Italic
+ ),
+ start,
+ end
+ )
+ Typeface.BOLD_ITALIC -> addStyle(
+ SpanStyle(
+ fontWeight = FontWeight.Bold,
+ fontStyle = FontStyle.Italic
+ ),
+ start,
+ end
+ )
+ }
+ is TypefaceSpan -> addStyle(
+ SpanStyle(
+ fontFamily = when (it.family) {
+ FontFamily.SansSerif.name -> FontFamily.SansSerif
+ FontFamily.Serif.name -> FontFamily.Serif
+ FontFamily.Monospace.name -> FontFamily.Monospace
+ FontFamily.Cursive.name -> FontFamily.Cursive
+ else -> FontFamily.Default
+ }
+ ),
+ start,
+ end
+ )
+ is AbsoluteSizeSpan -> addStyle(
+ SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()),
+ start,
+ end
+ )
+ is RelativeSizeSpan -> addStyle(
+ SpanStyle(fontSize = it.sizeChange.em),
+ start,
+ end
+ )
+ is StrikethroughSpan -> addStyle(
+ SpanStyle(textDecoration = TextDecoration.LineThrough),
+ start,
+ end
+ )
+ is UnderlineSpan -> addStyle(
+ SpanStyle(textDecoration = TextDecoration.Underline),
+ start,
+ end
+ )
+ is SuperscriptSpan -> addStyle(
+ SpanStyle(baselineShift = BaselineShift.Superscript),
+ start,
+ end
+ )
+ is SubscriptSpan -> addStyle(
+ SpanStyle(baselineShift = BaselineShift.Subscript),
+ start,
+ end
+ )
+ is ForegroundColorSpan -> addStyle(
+ SpanStyle(color = Color(it.foregroundColor)),
+ start,
+ end
+ )
+ else -> addStyle(SpanStyle(color = Color.White), start, end)
+ }
+ }
+ }
+ }
+ } else {
+ AnnotatedString(text.toString())
+ }
+}
+
+// maximum image file size to be auto-accepted
+const val MAX_IMAGE_SIZE: Long = 236700
+const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
+const val MAX_FILE_SIZE: Long = 8000000
+
+fun getFilesDirectory(context: Context): String {
+ return context.filesDir.toString()
+}
+
+fun getAppFilesDirectory(context: Context): String {
+ return "${getFilesDirectory(context)}/app_files"
+}
+
+fun getAppFilePath(context: Context, fileName: String): String {
+ return "${getAppFilesDirectory(context)}/$fileName"
+}
+
+fun getLoadedFilePath(context: Context, file: CIFile?): String? {
+ return if (file?.filePath != null && file.loaded) {
+ val filePath = getAppFilePath(context, file.filePath)
+ if (File(filePath).exists()) filePath else null
+ } else {
+ null
+ }
+}
+
+// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
+fun getLoadedImage(context: Context, file: CIFile?): Bitmap? {
+ val filePath = getLoadedFilePath(context, file)
+ return if (filePath != null) {
+ try {
+ val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
+ val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r")
+ val fileDescriptor = parcelFileDescriptor?.fileDescriptor
+ val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000)
+ parcelFileDescriptor?.close()
+ image
+ } catch (e: Exception) {
+ null
+ }
+ } else {
+ null
+ }
+}
+
+// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
+private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap {
+ // First decode with inJustDecodeBounds=true to check dimensions
+ return BitmapFactory.Options().run {
+ inJustDecodeBounds = true
+ BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
+ // Calculate inSampleSize
+ inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
+ // Decode bitmap with inSampleSize set
+ inJustDecodeBounds = false
+
+ BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
+ }
+}
+
+private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
+ // Raw height and width of image
+ val (height: Int, width: Int) = options.run { outHeight to outWidth }
+ var inSampleSize = 1
+
+ if (height > reqHeight || width > reqWidth) {
+ val halfHeight: Int = height / 2
+ val halfWidth: Int = width / 2
+ // Calculate the largest inSampleSize value that is a power of 2 and keeps both
+ // height and width larger than the requested height and width.
+ while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
+ inSampleSize *= 2
+ }
+ }
+
+ return inSampleSize
+}
+
+fun getFileName(context: Context, uri: Uri): String? {
+ return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ cursor.moveToFirst()
+ cursor.getString(nameIndex)
+ }
+}
+
+fun getFileSize(context: Context, uri: Uri): Long? {
+ return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
+ cursor.moveToFirst()
+ cursor.getLong(sizeIndex)
+ }
+}
+
+fun saveImage(context: Context, image: Bitmap): String? {
+ return try {
+ val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val fileToSave = uniqueCombine(context, "IMG_${timestamp}.jpg")
+ val file = File(getAppFilePath(context, fileToSave))
+ val output = FileOutputStream(file)
+ dataResized.writeTo(output)
+ output.flush()
+ output.close()
+ fileToSave
+ } catch (e: Exception) {
+ Log.e(chat.simplex.app.TAG, "Util.kt saveImage error: ${e.message}")
+ null
+ }
+}
+
+fun saveAnimImage(context: Context, uri: Uri): String? {
+ return try {
+ val filename = getFileName(context, uri)?.lowercase()
+ var ext = when {
+ // remove everything but extension
+ filename?.contains(".") == true -> filename.replaceBeforeLast('.', "").replace(".", "")
+ else -> "gif"
+ }
+ // Just in case the image has a strange extension
+ if (ext.length < 3 || ext.length > 4) ext = "gif"
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext")
+ val file = File(getAppFilePath(context, fileToSave))
+ val output = FileOutputStream(file)
+ context.contentResolver.openInputStream(uri)!!.use { input ->
+ output.use { output ->
+ input.copyTo(output)
+ }
+ }
+ fileToSave
+ } catch (e: Exception) {
+ Log.e(chat.simplex.app.TAG, "Util.kt saveAnimImage error: ${e.message}")
+ null
+ }
+}
+
+fun saveFileFromUri(context: Context, uri: Uri): String? {
+ return try {
+ val inputStream = context.contentResolver.openInputStream(uri)
+ val fileToSave = getFileName(context, uri)
+ if (inputStream != null && fileToSave != null) {
+ val destFileName = uniqueCombine(context, fileToSave)
+ val destFile = File(getAppFilePath(context, destFileName))
+ FileUtils.copy(inputStream, FileOutputStream(destFile))
+ destFileName
+ } else {
+ Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
+ null
+ }
+ } catch (e: Exception) {
+ Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri error: ${e.message}")
+ null
+ }
+}
+
+fun uniqueCombine(context: Context, fileName: String): String {
+ fun tryCombine(fileName: String, n: Int): String {
+ val name = File(fileName).nameWithoutExtension
+ val ext = File(fileName).extension
+ val suffix = if (n == 0) "" else "_$n"
+ val f = "$name$suffix.$ext"
+ return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f
+ }
+ return tryCombine(fileName, 0)
+}
+
+fun formatBytes(bytes: Long): String {
+ if (bytes == 0.toLong()) {
+ return "0 bytes"
+ }
+ val bytesDouble = bytes.toDouble()
+ val k = 1000.toDouble()
+ val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
+ val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
+ val size = bytesDouble / k.pow(i)
+ val unit = units[i.toInt()]
+
+ return if (i <= 1) {
+ String.format("%.0f %s", size, unit)
+ } else {
+ String.format("%.2f %s", size, unit)
+ }
+}
+
+fun removeFile(context: Context, fileName: String): Boolean {
+ val file = File(getAppFilePath(context, fileName))
+ val fileDeleted = file.delete()
+ if (!fileDeleted) {
+ Log.e(chat.simplex.app.TAG, "Util.kt removeFile error")
+ }
+ return fileDeleted
+}
+
+fun deleteAppFiles(context: Context) {
+ val dir = File(getAppFilesDirectory(context))
+ try {
+ dir.list()?.forEach {
+ removeFile(context, it)
+ }
+ } catch (e: java.lang.Exception) {
+ Log.e(TAG, "Util deleteAppFiles error: ${e.stackTraceToString()}")
+ }
+}
+
+fun directoryFileCountAndSize(dir: String): Pair { // count, size in bytes
+ var fileCount = 0
+ var bytes = 0L
+ try {
+ File(dir).listFiles()?.forEach {
+ fileCount++
+ bytes += it.length()
+ }
+ } catch (e: java.lang.Exception) {
+ Log.e(TAG, "Util directoryFileCountAndSize error: ${e.stackTraceToString()}")
+ }
+ return fileCount to bytes
+}
+
+fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
+
+fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
new file mode 100644
index 0000000000..4acb441a79
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
@@ -0,0 +1,142 @@
+package chat.simplex.app.views.newchat
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.TheaterComedy
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Share
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.R
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+
+@Composable
+fun AddContactView(chatModel: ChatModel, connReqInvitation: String) {
+ val cxt = LocalContext.current
+ AddContactLayout(
+ chatModelIncognito = chatModel.incognito.value,
+ connReq = connReqInvitation,
+ share = { shareText(cxt, connReqInvitation) }
+ )
+}
+
+@Composable
+fun AddContactLayout(chatModelIncognito: Boolean, connReq: String, share: () -> Unit) {
+ BoxWithConstraints {
+ val screenHeight = maxHeight
+ Column(
+ Modifier.verticalScroll(rememberScrollState()).padding(bottom = 16.dp),
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ stringResource(R.string.add_contact),
+ Modifier.padding(bottom = 16.dp),
+ style = MaterialTheme.typography.h1,
+ )
+ Text(
+ stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
+ )
+ Row {
+ InfoAboutIncognito(
+ chatModelIncognito,
+ true,
+ generalGetString(R.string.incognito_random_profile_description),
+ generalGetString(R.string.your_profile_will_be_sent)
+ )
+ }
+ if (connReq.isNotEmpty()) {
+ QRCode(
+ connReq, Modifier
+ .aspectRatio(1f)
+ .padding(vertical = 3.dp)
+ )
+ } else {
+ CircularProgressIndicator(
+ Modifier
+ .size(36.dp)
+ .padding(4.dp)
+ .align(Alignment.CenterHorizontally),
+ color = HighOrLowlight,
+ strokeWidth = 3.dp
+ )
+ }
+ Text(
+ annotatedStringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
+ lineHeight = 22.sp,
+ modifier = Modifier
+ .padding(bottom = if (screenHeight > 600.dp) 8.dp else 0.dp)
+ )
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share)
+ }
+ }
+ }
+}
+
+@Composable
+fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String) {
+ if (chatModelIncognito) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ if (supportedIncognito) Icons.Filled.TheaterComedy else Icons.Outlined.Info,
+ stringResource(R.string.incognito),
+ tint = if (supportedIncognito) Indigo else WarningOrange,
+ modifier = Modifier.padding(end = 10.dp).size(20.dp)
+ )
+ Text(onText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2)
+ }
+ } else {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Info,
+ stringResource(R.string.incognito),
+ tint = HighOrLowlight,
+ modifier = Modifier.padding(end = 10.dp).size(20.dp)
+ )
+ Text(offText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2)
+ }
+ }
+}
+
+@Preview
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewAddContactView() {
+ SimpleXTheme {
+ AddContactLayout(
+ chatModelIncognito = false,
+ connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
+ share = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt
new file mode 100644
index 0000000000..f18ccecbcd
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt
@@ -0,0 +1,185 @@
+package chat.simplex.app.views.newchat
+
+import android.graphics.Bitmap
+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.outlined.ArrowForwardIos
+import androidx.compose.runtime.*
+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.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.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.ProfileNameField
+import chat.simplex.app.views.chat.group.AddGroupMembersView
+import chat.simplex.app.views.chatlist.setGroupMembers
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.isValidDisplayName
+import chat.simplex.app.views.onboarding.ReadableText
+import chat.simplex.app.views.usersettings.DeleteImageButton
+import chat.simplex.app.views.usersettings.EditImageButton
+import com.google.accompanist.insets.ProvideWindowInsets
+import com.google.accompanist.insets.navigationBarsWithImePadding
+import kotlinx.coroutines.launch
+
+@Composable
+fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
+ AddGroupLayout(
+ chatModel.incognito.value,
+ createGroup = { groupProfile ->
+ withApi {
+ val groupInfo = chatModel.controller.apiNewGroup(groupProfile)
+ if (groupInfo != null) {
+ chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf()))
+ chatModel.chatItems.clear()
+ chatModel.chatId.value = groupInfo.id
+ setGroupMembers(groupInfo, chatModel)
+ close.invoke()
+ ModalManager.shared.showCustomModal { close ->
+ ModalView(
+ close = close, modifier = Modifier,
+ background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
+ ) {
+ AddGroupMembersView(groupInfo, chatModel, close)
+ }
+ }
+ }
+ }
+ },
+ close
+ )
+}
+
+@Composable
+fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
+ val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
+ val scope = rememberCoroutineScope()
+ val displayName = remember { mutableStateOf("") }
+ val fullName = remember { mutableStateOf("") }
+ val profileImage = remember { mutableStateOf(null) }
+ val chosenImage = remember { mutableStateOf(null) }
+ 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) {
+ Surface(Modifier.background(MaterialTheme.colors.onBackground).fillMaxSize()) {
+ Column(
+ Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(bottom = 16.dp),
+ ) {
+ Text(
+ stringResource(R.string.create_secret_group_title),
+ style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
+ modifier = Modifier.padding(vertical = 5.dp)
+ )
+ Text(stringResource(R.string.group_is_decentralized))
+ InfoAboutIncognito(
+ chatModelIncognito,
+ false,
+ generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
+ generalGetString(R.string.group_main_profile_sent)
+ )
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(contentAlignment = Alignment.TopEnd) {
+ Box(contentAlignment = Alignment.Center) {
+ ProfileImage(size = 192.dp, image = 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(8.dp))
+ val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
+ if (enabled) {
+ CreateGroupButton(MaterialTheme.colors.primary, Modifier
+ .clickable { createGroup(GroupProfile(displayName.value, fullName.value, profileImage.value)) }
+ .padding(8.dp))
+ } else {
+ CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp))
+ }
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CreateGroupButton(color: Color, modifier: Modifier) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ Surface(shape = RoundedCornerShape(20.dp)) {
+ Row(modifier, verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = color)
+ Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = color)
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewAddGroupLayout() {
+ SimpleXTheme {
+ AddGroupLayout(
+ chatModelIncognito = false,
+ createGroup = {},
+ close = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectViaLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectViaLinkView.kt
new file mode 100644
index 0000000000..892ff5672f
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectViaLinkView.kt
@@ -0,0 +1,73 @@
+package chat.simplex.app.views.newchat
+
+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.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.usersettings.UserAddressView
+
+enum class ConnectViaLinkTab {
+ SCAN, PASTE
+}
+
+@Composable
+fun ConnectViaLinkView(m: ChatModel) {
+ val selection = remember {
+ mutableStateOf(
+ runCatching { ConnectViaLinkTab.valueOf(m.controller.appPrefs.connectViaLinkTab.get()!!) }.getOrDefault(ConnectViaLinkTab.SCAN)
+ )
+ }
+ val tabTitles = ConnectViaLinkTab.values().map {
+ when (it) {
+ ConnectViaLinkTab.SCAN -> stringResource(R.string.scan_QR_code)
+ ConnectViaLinkTab.PASTE -> stringResource(R.string.paste_the_link_you_received)
+ }
+ }
+ Column(
+ Modifier.fillMaxHeight(),
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(Modifier.weight(1f)) {
+ when (selection.value) {
+ ConnectViaLinkTab.SCAN -> {
+ ScanToConnectView(m)
+ }
+ ConnectViaLinkTab.PASTE -> {
+ PasteToConnectView(m)
+ }
+ }
+ }
+ TabRow(
+ selectedTabIndex = selection.value.ordinal,
+ backgroundColor = MaterialTheme.colors.background,
+ contentColor = MaterialTheme.colors.primary,
+ ) {
+ tabTitles.forEachIndexed { index, it ->
+ Tab(
+ selected = selection.value.ordinal == index,
+ onClick = {
+ selection.value = ConnectViaLinkTab.values()[index]
+ m.controller.appPrefs.connectViaLinkTab.set(selection.value .name)
+ },
+ text = { Text(it, fontSize = 13.sp) },
+ icon = {
+ Icon(
+ if (ConnectViaLinkTab.SCAN.ordinal == index) Icons.Outlined.QrCode else Icons.Outlined.Article,
+ it
+ )
+ },
+ selectedContentColor = MaterialTheme.colors.primary,
+ unselectedContentColor = HighOrLowlight,
+ )
+ }
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/CreateLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/CreateLinkView.kt
new file mode 100644
index 0000000000..0908aca35b
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/CreateLinkView.kt
@@ -0,0 +1,89 @@
+package chat.simplex.app.views.newchat
+
+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.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.usersettings.UserAddressView
+
+enum class CreateLinkTab {
+ ONE_TIME, LONG_TERM
+}
+
+@Composable
+fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
+ val selection = remember { mutableStateOf(initialSelection) }
+ val connReqInvitation = remember { mutableStateOf("") }
+ val creatingConnReq = remember { mutableStateOf(false) }
+ LaunchedEffect(selection.value) {
+ if (selection.value == CreateLinkTab.ONE_TIME && connReqInvitation.value.isEmpty() && !creatingConnReq.value) {
+ createInvitation(m, creatingConnReq, connReqInvitation)
+ }
+ }
+ val tabTitles = CreateLinkTab.values().map {
+ when {
+ it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isEmpty() -> stringResource(R.string.create_one_time_link)
+ it == CreateLinkTab.ONE_TIME -> stringResource(R.string.one_time_link)
+ it == CreateLinkTab.LONG_TERM -> stringResource(R.string.your_contact_address)
+ else -> ""
+ }
+ }
+ Column(
+ Modifier.fillMaxHeight(),
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(Modifier.weight(1f)) {
+ when (selection.value) {
+ CreateLinkTab.ONE_TIME -> {
+ AddContactView(m, connReqInvitation.value)
+ }
+ CreateLinkTab.LONG_TERM -> {
+ UserAddressView(m)
+ }
+ }
+ }
+ TabRow(
+ selectedTabIndex = selection.value.ordinal,
+ backgroundColor = MaterialTheme.colors.background,
+ contentColor = MaterialTheme.colors.primary,
+ ) {
+ tabTitles.forEachIndexed { index, it ->
+ Tab(
+ selected = selection.value.ordinal == index,
+ onClick = {
+ selection.value = CreateLinkTab.values()[index]
+ },
+ text = { Text(it, fontSize = 13.sp) },
+ icon = {
+ Icon(
+ if (CreateLinkTab.ONE_TIME.ordinal == index) Icons.Outlined.RepeatOne else Icons.Outlined.AllInclusive,
+ it
+ )
+ },
+ selectedContentColor = MaterialTheme.colors.primary,
+ unselectedContentColor = HighOrLowlight,
+ )
+ }
+ }
+ }
+}
+
+private fun createInvitation(m: ChatModel, creatingConnReq: MutableState